inital commit (v0.3)
|
@ -0,0 +1,9 @@
|
||||||
|
index
|
||||||
|
.github
|
||||||
|
target
|
||||||
|
*.csv
|
||||||
|
.git
|
||||||
|
|
||||||
|
.vscode/*
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
|
@ -0,0 +1,9 @@
|
||||||
|
/target
|
||||||
|
/index
|
||||||
|
|
||||||
|
*.csv
|
||||||
|
/release
|
||||||
|
/zlib-searcher
|
||||||
|
|
||||||
|
.vscode
|
||||||
|
index_0.6.zip
|
|
@ -0,0 +1,33 @@
|
||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
"crates/zlib-searcher",
|
||||||
|
"crates/zlib-searcher-core",
|
||||||
|
"crates/zlib-searcher-desktop",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["zu1k <i@zu1k.com>"]
|
||||||
|
description = "search z-library index."
|
||||||
|
homepage = "https://github.com/zlib-searcher/zlib-searcher"
|
||||||
|
repository = "https://github.com/zlib-searcher/zlib-searcher"
|
||||||
|
license = "MIT"
|
||||||
|
exclude = [".github/", "index/", "frontend/"]
|
||||||
|
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
strip = true
|
||||||
|
lto = true
|
||||||
|
opt-level = 3
|
||||||
|
codegen-units = 1
|
||||||
|
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
anyhow = "1.0"
|
||||||
|
env_logger = "0.10"
|
||||||
|
log = "0.4"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_with = "2.0"
|
||||||
|
|
||||||
|
zlib-searcher-core = { path = "crates/zlib-searcher-core" }
|
|
@ -0,0 +1,10 @@
|
||||||
|
[build.env]
|
||||||
|
passthrough = [
|
||||||
|
"RUSTFLAGS"
|
||||||
|
]
|
||||||
|
|
||||||
|
[target.mips-unknown-linux-musl]
|
||||||
|
image = "rustembedded/cross:mips-unknown-linux-musl-0.2.1"
|
||||||
|
|
||||||
|
[target.mipsel-unknown-linux-musl]
|
||||||
|
image = "rustembedded/cross:mipsel-unknown-linux-musl-0.2.1"
|
|
@ -0,0 +1,16 @@
|
||||||
|
FROM node:19-bullseye as frontend
|
||||||
|
|
||||||
|
COPY . /source
|
||||||
|
RUN cd /source/frontend && npm install && npm run build
|
||||||
|
|
||||||
|
FROM rust:1.65-buster as backend
|
||||||
|
|
||||||
|
COPY . /source
|
||||||
|
COPY --from=frontend /source/frontend/dist /source/frontend/dist
|
||||||
|
RUN cd /source && cargo build --release -p zlib-searcher
|
||||||
|
|
||||||
|
FROM ubuntu:22.04
|
||||||
|
|
||||||
|
COPY --from=backend /source/target/release/zlib-searcher /zlib-searcher
|
||||||
|
|
||||||
|
CMD ["/zlib-searcher", "run", "-b", "0.0.0.0:7070"]
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2022 zlib-searcher's authors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
|
@ -0,0 +1,27 @@
|
||||||
|
NAME=zlib-searcher
|
||||||
|
|
||||||
|
PREFIX ?= /usr/local/bin
|
||||||
|
TARGET ?= debug
|
||||||
|
|
||||||
|
.PHONY: all frontend_preinstall frontend build clean
|
||||||
|
all: build
|
||||||
|
|
||||||
|
frontend_preinstall:
|
||||||
|
pnpm -C frontend install
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
pnpm -C frontend run build
|
||||||
|
|
||||||
|
build: frontend
|
||||||
|
ifeq (${TARGET}, release)
|
||||||
|
cargo build -p zlib-searcher --release
|
||||||
|
else
|
||||||
|
cargo build -p zlib-searcher
|
||||||
|
endif
|
||||||
|
|
||||||
|
clean:
|
||||||
|
cargo clean
|
||||||
|
rm -rf release
|
||||||
|
|
||||||
|
releases:
|
||||||
|
cd scripts && ./build_release.sh -a a
|
|
@ -0,0 +1,131 @@
|
||||||
|
# Millelibri project
|
||||||
|
|
||||||
|
This is a fork from zlib-searcher project. Future goals:
|
||||||
|
|
||||||
|
- improve search indexes (language)
|
||||||
|
- add books
|
||||||
|
- expand file types
|
||||||
|
|
||||||
|
# zlib(libgen) searcher
|
||||||
|
|
||||||
|
[![GitHub stars](https://img.shields.io/github/stars/zlib-searcher/zlib-searcher)](https://github.com/zlib-searcher/zlib-searcher/stargazers)
|
||||||
|
[![GitHub forks](https://img.shields.io/github/forks/zlib-searcher/zlib-searcher)](https://github.com/zlib-searcher/zlib-searcher/network)
|
||||||
|
[![Release](https://img.shields.io/github/release/zlib-searcher/zlib-searcher)](https://github.com/zlib-searcher/zlib-searcher/releases)
|
||||||
|
[![GitHub issues](https://img.shields.io/github/issues/zlib-searcher/zlib-searcher)](https://github.com/zlib-searcher/zlib-searcher/issues)
|
||||||
|
[![GitHub license](https://img.shields.io/github/license/zlib-searcher/zlib-searcher)](https://github.com/zlib-searcher/zlib-searcher/blob/master/LICENSE)
|
||||||
|
|
||||||
|
Search `zlib`/`libgen` index to get `ipfs_cid`.
|
||||||
|
|
||||||
|
We don't save and provide files, we provide search.
|
||||||
|
|
||||||
|
I hope everyone have a copy of the index locally, so that no need to rely on any centralized service.
|
||||||
|
|
||||||
|
## Deploy with Docker
|
||||||
|
|
||||||
|
```
|
||||||
|
git clone https://github.com/zlib-searcher/zlib-searcher.git && cd zlib-searcher
|
||||||
|
wget https://github.com/zlib-searcher/zlib-searcher/releases/download/0.6.0/index_0.6.zip && unzip index_0.6.zip
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Now `zlib-searcher` it will listen to `0.0.0.0:7070`.
|
||||||
|
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### 1. Download the pre-compiled binary from [Release](https://github.com/zlib-searcher/zlib-searcher/releases).
|
||||||
|
|
||||||
|
Or you can compile by yourself. Refer to [Build from source](#build-from-source) for instructions.
|
||||||
|
|
||||||
|
### 2. Download the `index` file that has been created.
|
||||||
|
|
||||||
|
We will give the corresponding `index` download links for each version in the release page.
|
||||||
|
|
||||||
|
Or you can make your own via `zlib-searcher index`.
|
||||||
|
|
||||||
|
Extract the `index` folder to the same level as the program, it should look like the following:
|
||||||
|
|
||||||
|
```
|
||||||
|
zlib_searcher_dir
|
||||||
|
├── index
|
||||||
|
│ ├── some index files...
|
||||||
|
│ └── meta.json
|
||||||
|
└── zlib-searcher
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run `zlib-searcher run`, it will listen to `127.0.0.1:7070`.
|
||||||
|
|
||||||
|
Access http://127.0.0.1:7070/ to use webui, or you can use the original api.
|
||||||
|
|
||||||
|
#### original search api
|
||||||
|
|
||||||
|
You can search by the following fields:
|
||||||
|
|
||||||
|
- title
|
||||||
|
- author
|
||||||
|
- publisher
|
||||||
|
- extension
|
||||||
|
- language
|
||||||
|
- isbn
|
||||||
|
- zlib_id
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- `http://127.0.0.1:7070/search?limit=30&query=余华`
|
||||||
|
- `http://127.0.0.1:7070/search?limit=30&query=title:机器学习 extension:azw3 publisher:清华`
|
||||||
|
- `http://127.0.0.1:7070/search?limit=30&query=zlib_id:18557063`
|
||||||
|
- `http://127.0.0.1:7070/search?limit=30&query=isbn:9787302423287`
|
||||||
|
|
||||||
|
## Build from source
|
||||||
|
|
||||||
|
### 1. Build `zlib-searcher`
|
||||||
|
|
||||||
|
First build frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make frontend_preinstall frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
Then build zlib-searcher
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TARGET=release make
|
||||||
|
|
||||||
|
# move the compiled binary to the project root directory
|
||||||
|
mv target/release/zlib-searcher .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Build `index`
|
||||||
|
|
||||||
|
Download `zlib_index_books.csv.zip` and `libgen_index_books.csv.zip` and extract the `csv` files to the project root directory.
|
||||||
|
|
||||||
|
Then run `zlib-searcher index`. You may need to `rm index/*` first.
|
||||||
|
|
||||||
|
If you have other csv files, you can run `zlib-searcher index -f *.csv` to index them.
|
||||||
|
|
||||||
|
The finally folder structure should look like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
zlib_searcher_dir // in the example above, it is project root directory.
|
||||||
|
├── index
|
||||||
|
│ ├── some index files...
|
||||||
|
│ └── meta.json
|
||||||
|
└── zlib-searcher
|
||||||
|
```
|
||||||
|
|
||||||
|
## Raw data
|
||||||
|
|
||||||
|
We downloaded `libgen` sql and `zlib` sql and exported the necessary data from them.
|
||||||
|
|
||||||
|
```
|
||||||
|
id, title, author, publisher, extension, filesize, language, year, pages, isbn, ipfs_cid
|
||||||
|
```
|
||||||
|
|
||||||
|
This raw data is used to generate our `index`, you can download the raw data from here:
|
||||||
|
|
||||||
|
- [zlib_index_books.csv.zip](https://github.com/zlib-searcher/zlib-searcher/releases/download/0.4.0/zlib_index_books.csv.zip)
|
||||||
|
- [libgen_index_books.csv.zip](https://github.com/zlib-searcher/zlib-searcher/releases/download/0.4.0/libgen_index_books.csv.zip)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
**zlib-searcher** © [zlib-searcher's authors](https://github.com/zlib-searcher/zlib-searcher/graphs/contributors), Released under the [MIT](./LICENSE) License.
|
|
@ -0,0 +1,31 @@
|
||||||
|
[package]
|
||||||
|
name = "zlib-searcher-core"
|
||||||
|
version = "0.7.0"
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
description.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
env_logger = { workspace = true }
|
||||||
|
log = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_with = { workspace = true }
|
||||||
|
|
||||||
|
tantivy = { version = "0.18", default-features = false, features = ["mmap"] }
|
||||||
|
cang-jie = "0.14"
|
||||||
|
jieba-rs = { version = "0.6", features = ["default-dict"] }
|
||||||
|
|
||||||
|
csv = "1.1"
|
||||||
|
indicatif = "0.17"
|
||||||
|
sysinfo = { version = "0.27", default-features = false }
|
||||||
|
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["best-size"]
|
||||||
|
best-size = ["tantivy/brotli-compression"]
|
||||||
|
best-speed = ["tantivy/lz4-compression"]
|
|
@ -0,0 +1,101 @@
|
||||||
|
use crate::{Book, Searcher};
|
||||||
|
use indicatif::{ProgressBar, ProgressIterator, ProgressStyle};
|
||||||
|
use log::info;
|
||||||
|
use std::{
|
||||||
|
fs::File,
|
||||||
|
io::{BufRead, BufReader},
|
||||||
|
path::Path,
|
||||||
|
};
|
||||||
|
use sysinfo::{System, SystemExt};
|
||||||
|
use tantivy::doc;
|
||||||
|
|
||||||
|
fn get_memory_arena_num_bytes() -> usize {
|
||||||
|
let sys = System::new_all();
|
||||||
|
let available_memory = sys.available_memory() as usize;
|
||||||
|
let cpu_num = sys.cpus().len();
|
||||||
|
info!("Your system has cpu {cpu_num} cores and {available_memory} Bytes available");
|
||||||
|
|
||||||
|
let chunk_size = 1024 * 1024 * 1024; // 1GB
|
||||||
|
let total_num_chunk = available_memory / chunk_size;
|
||||||
|
|
||||||
|
let s = if total_num_chunk < 2 {
|
||||||
|
// <2G
|
||||||
|
available_memory - 100 * 1024 * 1024 // available_memory-100MB
|
||||||
|
} else {
|
||||||
|
// >2G
|
||||||
|
available_memory * (total_num_chunk - 1) // available_memory-1GB
|
||||||
|
};
|
||||||
|
|
||||||
|
let num_threads = std::cmp::min(cpu_num, 8);
|
||||||
|
let s = std::cmp::min(s, num_threads * 4293967294);
|
||||||
|
|
||||||
|
info!("Using {num_threads} threads and {s} Bytes to do index");
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Searcher {
|
||||||
|
pub fn index(&mut self, csv_file: impl AsRef<Path>) {
|
||||||
|
let mut writer = self.index.writer(get_memory_arena_num_bytes()).unwrap();
|
||||||
|
|
||||||
|
let file = File::open(&csv_file).unwrap();
|
||||||
|
let reader = BufReader::new(file);
|
||||||
|
|
||||||
|
let mut rdr = csv::ReaderBuilder::new()
|
||||||
|
.has_headers(false)
|
||||||
|
.from_reader(reader);
|
||||||
|
|
||||||
|
let line_count = BufReader::new(File::open(&csv_file).unwrap())
|
||||||
|
.lines()
|
||||||
|
.count();
|
||||||
|
let style = ProgressStyle::default_bar()
|
||||||
|
.template("[{elapsed_precise}] {bar:40.cyan/blue} {pos:>7}/{len:7} {msg}")
|
||||||
|
.unwrap();
|
||||||
|
let bar = ProgressBar::new(line_count as u64)
|
||||||
|
.with_message(format!("Indexing {}", csv_file.as_ref().to_str().unwrap()))
|
||||||
|
.with_style(style);
|
||||||
|
for result in rdr.deserialize::<Book>().progress_with(bar) {
|
||||||
|
match result {
|
||||||
|
Ok(item) => {
|
||||||
|
if let Err(err) = writer.add_document(doc!(
|
||||||
|
self.id => item.id,
|
||||||
|
self.title => item.title,
|
||||||
|
self.author => item.author,
|
||||||
|
self.publisher => item.publisher,
|
||||||
|
self.extension => item.extension,
|
||||||
|
self.filesize => item.filesize,
|
||||||
|
self.language => item.language,
|
||||||
|
self.year => item.year,
|
||||||
|
self.pages => item.pages,
|
||||||
|
self.isbn => item.isbn,
|
||||||
|
self.ipfs_cid => item.ipfs_cid,
|
||||||
|
)) {
|
||||||
|
println!("{err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
println!("{err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.commit().unwrap();
|
||||||
|
writer.wait_merging_threads().expect("merge complete");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_csv_der() {
|
||||||
|
let file = File::open("zlib_index_books.csv").unwrap();
|
||||||
|
let reader = BufReader::new(file);
|
||||||
|
|
||||||
|
let mut rdr = csv::ReaderBuilder::new()
|
||||||
|
.has_headers(false)
|
||||||
|
.from_reader(reader);
|
||||||
|
for result in rdr.records() {
|
||||||
|
if let Err(err) = result {
|
||||||
|
println!("{err:?}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("{:?}", rdr.position());
|
||||||
|
}
|
|
@ -0,0 +1,152 @@
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_with::{serde_as, DefaultOnError, DefaultOnNull};
|
||||||
|
use tantivy::{schema::*, store::Compressor, Index};
|
||||||
|
use tokenizer::{get_tokenizer, META_DATA_TOKENIZER};
|
||||||
|
|
||||||
|
pub mod index;
|
||||||
|
pub mod search;
|
||||||
|
mod tokenizer;
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Book {
|
||||||
|
pub id: u64,
|
||||||
|
|
||||||
|
pub title: String,
|
||||||
|
#[serde_as(deserialize_as = "DefaultOnNull")]
|
||||||
|
pub author: String,
|
||||||
|
#[serde_as(deserialize_as = "DefaultOnNull")]
|
||||||
|
pub publisher: String,
|
||||||
|
#[serde_as(deserialize_as = "DefaultOnNull")]
|
||||||
|
pub extension: String,
|
||||||
|
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||||
|
pub filesize: u64,
|
||||||
|
#[serde_as(deserialize_as = "DefaultOnNull")]
|
||||||
|
pub language: String,
|
||||||
|
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||||
|
pub year: u64,
|
||||||
|
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||||
|
pub pages: u64,
|
||||||
|
#[serde_as(deserialize_as = "DefaultOnNull")]
|
||||||
|
pub isbn: String,
|
||||||
|
#[serde_as(deserialize_as = "DefaultOnNull")]
|
||||||
|
pub ipfs_cid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<(&Schema, Document)> for Book {
|
||||||
|
fn from((schema, doc): (&Schema, Document)) -> Self {
|
||||||
|
macro_rules! get_field_text {
|
||||||
|
($field:expr) => {
|
||||||
|
doc.get_first(schema.get_field($field).unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.as_text()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_owned()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! get_field_u64 {
|
||||||
|
($field:expr) => {
|
||||||
|
doc.get_first(schema.get_field($field).unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.as_u64()
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Book {
|
||||||
|
id: get_field_u64!("id"),
|
||||||
|
title: get_field_text!("title"),
|
||||||
|
author: get_field_text!("author"),
|
||||||
|
publisher: get_field_text!("publisher"),
|
||||||
|
extension: get_field_text!("extension"),
|
||||||
|
filesize: get_field_u64!("filesize"),
|
||||||
|
language: get_field_text!("language"),
|
||||||
|
year: get_field_u64!("year"),
|
||||||
|
pages: get_field_u64!("pages"),
|
||||||
|
isbn: get_field_text!("isbn"),
|
||||||
|
ipfs_cid: get_field_text!("ipfs_cid"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Searcher {
|
||||||
|
index: Index,
|
||||||
|
schema: Schema,
|
||||||
|
|
||||||
|
// fields
|
||||||
|
id: Field,
|
||||||
|
title: Field,
|
||||||
|
author: Field,
|
||||||
|
publisher: Field,
|
||||||
|
extension: Field,
|
||||||
|
filesize: Field,
|
||||||
|
language: Field,
|
||||||
|
year: Field,
|
||||||
|
pages: Field,
|
||||||
|
isbn: Field,
|
||||||
|
ipfs_cid: Field,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Searcher {
|
||||||
|
pub fn new(index_dir: impl AsRef<Path>) -> Self {
|
||||||
|
let text_indexing = TextFieldIndexing::default()
|
||||||
|
.set_tokenizer(META_DATA_TOKENIZER)
|
||||||
|
.set_index_option(IndexRecordOption::WithFreqsAndPositions);
|
||||||
|
let text_options = TextOptions::default()
|
||||||
|
.set_indexing_options(text_indexing)
|
||||||
|
.set_stored();
|
||||||
|
|
||||||
|
let mut schema_builder = Schema::builder();
|
||||||
|
let id = schema_builder.add_u64_field("id", INDEXED | STORED);
|
||||||
|
let title = schema_builder.add_text_field("title", text_options.clone());
|
||||||
|
let author = schema_builder.add_text_field("author", text_options.clone());
|
||||||
|
let publisher = schema_builder.add_text_field("publisher", text_options);
|
||||||
|
let extension = schema_builder.add_text_field("extension", STRING | STORED);
|
||||||
|
let filesize = schema_builder.add_u64_field("filesize", STORED);
|
||||||
|
let language = schema_builder.add_text_field("language", TEXT | STORED);
|
||||||
|
let year = schema_builder.add_u64_field("year", STORED);
|
||||||
|
let pages = schema_builder.add_u64_field("pages", STORED);
|
||||||
|
let isbn = schema_builder.add_text_field("isbn", TEXT | STORED);
|
||||||
|
let ipfs_cid = schema_builder.add_text_field("ipfs_cid", STORED);
|
||||||
|
let schema = schema_builder.build();
|
||||||
|
|
||||||
|
// open or create index
|
||||||
|
let index_dir = index_dir.as_ref();
|
||||||
|
let mut index = Index::open_in_dir(index_dir).unwrap_or_else(|_| {
|
||||||
|
std::fs::create_dir_all(index_dir).expect("create index directory");
|
||||||
|
Index::create_in_dir(index_dir, schema.clone()).unwrap()
|
||||||
|
});
|
||||||
|
#[cfg(feature = "best-size")]
|
||||||
|
{
|
||||||
|
index.settings_mut().docstore_compression = Compressor::Brotli; // size: 2.1G, size is best
|
||||||
|
}
|
||||||
|
#[cfg(feature = "best-speed")]
|
||||||
|
{
|
||||||
|
index.settings_mut().docstore_compression = Compressor::Lz4; // size: 3.1G, speed is best
|
||||||
|
}
|
||||||
|
|
||||||
|
index
|
||||||
|
.tokenizers()
|
||||||
|
.register(META_DATA_TOKENIZER, get_tokenizer());
|
||||||
|
_ = index.set_default_multithread_executor();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
index,
|
||||||
|
schema,
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
author,
|
||||||
|
publisher,
|
||||||
|
extension,
|
||||||
|
filesize,
|
||||||
|
language,
|
||||||
|
year,
|
||||||
|
pages,
|
||||||
|
isbn,
|
||||||
|
ipfs_cid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
use crate::{Book, Searcher};
|
||||||
|
use tantivy::{collector::TopDocs, query::QueryParser};
|
||||||
|
|
||||||
|
impl Searcher {
|
||||||
|
pub fn search(&self, query: &str, limit: usize) -> Vec<Book> {
|
||||||
|
let reader = self.index.reader().unwrap();
|
||||||
|
let searcher = reader.searcher();
|
||||||
|
|
||||||
|
let mut query_parser = QueryParser::for_index(
|
||||||
|
&self.index,
|
||||||
|
vec![self.title, self.author, self.publisher, self.isbn],
|
||||||
|
);
|
||||||
|
query_parser.set_conjunction_by_default();
|
||||||
|
let query = query_parser.parse_query(query).unwrap();
|
||||||
|
|
||||||
|
let top_docs = searcher
|
||||||
|
.search(&query, &TopDocs::with_limit(limit))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
top_docs
|
||||||
|
.iter()
|
||||||
|
.map(|d| {
|
||||||
|
let doc = searcher.doc(d.1).unwrap();
|
||||||
|
let item: Book = (&self.schema, doc).into();
|
||||||
|
item
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use cang_jie::{CangJieTokenizer, TokenizerOption};
|
||||||
|
use jieba_rs::Jieba;
|
||||||
|
use tantivy::tokenizer::{AsciiFoldingFilter, LowerCaser, RemoveLongFilter, TextAnalyzer};
|
||||||
|
|
||||||
|
pub const META_DATA_TOKENIZER: &str = "meta_data_tokenizer";
|
||||||
|
|
||||||
|
pub fn get_tokenizer() -> TextAnalyzer {
|
||||||
|
let cangjie = CangJieTokenizer {
|
||||||
|
worker: Arc::new(Jieba::new()),
|
||||||
|
option: TokenizerOption::ForSearch { hmm: false },
|
||||||
|
};
|
||||||
|
TextAnalyzer::from(cangjie)
|
||||||
|
.filter(RemoveLongFilter::limit(20))
|
||||||
|
.filter(AsciiFoldingFilter)
|
||||||
|
.filter(LowerCaser)
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
/index/
|
|
@ -0,0 +1,38 @@
|
||||||
|
[package]
|
||||||
|
name = "zlib-searcher-desktop"
|
||||||
|
version = "0.7.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["Wybxc <wybxc@qq.com>", "zu1k <i@zu1k.com>"]
|
||||||
|
description = "search z-library index."
|
||||||
|
homepage = "https://github.com/zlib-searcher/zlib-searcher"
|
||||||
|
repository = "https://github.com/zlib-searcher/zlib-searcher"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "1.2.1", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
zlib-searcher-core = { workspace = true }
|
||||||
|
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
env_logger = { workspace = true }
|
||||||
|
log = { workspace = true }
|
||||||
|
|
||||||
|
serde_json = "1.0"
|
||||||
|
tauri = { version = "1.2.1", features = ["dialog-open", "shell-open"] }
|
||||||
|
|
||||||
|
tokio = { version = "1", features = ["sync", "parking_lot"] }
|
||||||
|
|
||||||
|
confy = "0.5"
|
||||||
|
dunce = "1.0"
|
||||||
|
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# by default Tauri runs in production mode
|
||||||
|
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
|
||||||
|
default = ["custom-protocol"]
|
||||||
|
# this feature is used for production builds where `devPath` points to the filesystem
|
||||||
|
# DO NOT remove this
|
||||||
|
custom-protocol = ["tauri/custom-protocol"]
|
|
@ -0,0 +1,3 @@
|
||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
After Width: | Height: | Size: 3.4 KiB |
After Width: | Height: | Size: 5.7 KiB |
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 3.4 KiB |
After Width: | Height: | Size: 4.7 KiB |
After Width: | Height: | Size: 4.9 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 1.7 KiB |
After Width: | Height: | Size: 2.3 KiB |
After Width: | Height: | Size: 2.8 KiB |
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 6.2 KiB |
|
@ -0,0 +1,121 @@
|
||||||
|
#![cfg_attr(
|
||||||
|
all(not(debug_assertions), target_os = "windows"),
|
||||||
|
windows_subsystem = "windows"
|
||||||
|
)]
|
||||||
|
|
||||||
|
use log::info;
|
||||||
|
use std::{error::Error, path::PathBuf};
|
||||||
|
use zlib_searcher_core::{Book, Searcher};
|
||||||
|
|
||||||
|
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::State;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
struct AppConfig {
|
||||||
|
pub index_dir: PathBuf,
|
||||||
|
pub ipfs_gateways: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_dir(name: &str) -> Option<PathBuf> {
|
||||||
|
let dir = std::env::current_exe().ok()?.parent()?.join(name);
|
||||||
|
std::fs::create_dir_all(&dir).ok()?;
|
||||||
|
let dir = dunce::canonicalize(dir).ok()?;
|
||||||
|
Some(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
let index_dir = get_dir("index").unwrap_or_else(|| PathBuf::from("index"));
|
||||||
|
Self {
|
||||||
|
index_dir,
|
||||||
|
ipfs_gateways: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
const APP_NAME: &'static str = "zlib-searcher-desktop";
|
||||||
|
|
||||||
|
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
|
let config = confy::load(Self::APP_NAME, None)?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
confy::store(Self::APP_NAME, None, self)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn configuration_file_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||||
|
Ok(confy::get_configuration_file_path(Self::APP_NAME, None)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_config(config: State<'_, Mutex<AppConfig>>) -> Result<AppConfig, String> {
|
||||||
|
Ok(config.lock().await.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn set_config(
|
||||||
|
new_config: AppConfig,
|
||||||
|
config: State<'_, Mutex<AppConfig>>,
|
||||||
|
searcher: tauri::State<'_, Mutex<Searcher>>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut config = config.lock().await;
|
||||||
|
|
||||||
|
// reload searcher if index_dir changed
|
||||||
|
if config.index_dir != new_config.index_dir {
|
||||||
|
info!("index_dir changed, reloading searcher");
|
||||||
|
let mut searcher = searcher.lock().await;
|
||||||
|
*searcher = Searcher::new(new_config.index_dir.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
*config = new_config;
|
||||||
|
config.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
info!("Config saved: {:?}", config);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn search(
|
||||||
|
searcher: tauri::State<'_, Mutex<Searcher>>,
|
||||||
|
query: String,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<Book>, ()> {
|
||||||
|
info!("Search: {}", query);
|
||||||
|
Ok(searcher.lock().await.search(&query, limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn version() -> String {
|
||||||
|
VERSION.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
env_logger::init();
|
||||||
|
|
||||||
|
let config = AppConfig::load()?;
|
||||||
|
let searcher = Mutex::new(Searcher::new(&config.index_dir));
|
||||||
|
let config = Mutex::new(config);
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"load config from {:?}",
|
||||||
|
AppConfig::configuration_file_path()?
|
||||||
|
);
|
||||||
|
|
||||||
|
tauri::Builder::default()
|
||||||
|
.manage(config)
|
||||||
|
.manage(searcher)
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
version, search, get_config, set_config
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
|
@ -0,0 +1,71 @@
|
||||||
|
{
|
||||||
|
"build": {
|
||||||
|
"beforeBuildCommand": "cd ../frontend && pnpm run build",
|
||||||
|
"beforeDevCommand": "cd ../frontend && pnpm run dev",
|
||||||
|
"devPath": "http://localhost:5173/",
|
||||||
|
"distDir": "../../frontend/dist"
|
||||||
|
},
|
||||||
|
"package": {
|
||||||
|
"productName": "zLib Searcher",
|
||||||
|
"version": "0.1.0"
|
||||||
|
},
|
||||||
|
"tauri": {
|
||||||
|
"allowlist": {
|
||||||
|
"all": false,
|
||||||
|
"shell": {
|
||||||
|
"open": true
|
||||||
|
},
|
||||||
|
"dialog": {
|
||||||
|
"open": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"category": "DeveloperTool",
|
||||||
|
"copyright": "",
|
||||||
|
"deb": {
|
||||||
|
"depends": []
|
||||||
|
},
|
||||||
|
"externalBin": [],
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
|
"identifier": "com.github.zlib-searcher",
|
||||||
|
"longDescription": "",
|
||||||
|
"macOS": {
|
||||||
|
"entitlements": null,
|
||||||
|
"exceptionDomain": "",
|
||||||
|
"frameworks": [],
|
||||||
|
"providerShortName": null,
|
||||||
|
"signingIdentity": null
|
||||||
|
},
|
||||||
|
"resources": [],
|
||||||
|
"shortDescription": "",
|
||||||
|
"targets": "all",
|
||||||
|
"windows": {
|
||||||
|
"certificateThumbprint": null,
|
||||||
|
"digestAlgorithm": "sha256",
|
||||||
|
"timestampUrl": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
},
|
||||||
|
"updater": {
|
||||||
|
"active": false
|
||||||
|
},
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"fullscreen": false,
|
||||||
|
"height": 900,
|
||||||
|
"resizable": true,
|
||||||
|
"title": "zLib Searcher",
|
||||||
|
"width": 1500
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
[package]
|
||||||
|
name = "zlib-searcher"
|
||||||
|
version = "0.7.0"
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
description.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
default-run = "zlib-searcher"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
zlib-searcher-core = { workspace = true }
|
||||||
|
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
env_logger = { workspace = true }
|
||||||
|
log = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_with = { workspace = true }
|
||||||
|
|
||||||
|
actix-web = "4"
|
||||||
|
actix-web-static-files = "4.0"
|
||||||
|
static-files = "0.2"
|
||||||
|
|
||||||
|
csv = "1.1"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
static-files = "0.2"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["best-size"]
|
||||||
|
best-size = ["zlib-searcher-core/best-size"]
|
||||||
|
best-speed = ["zlib-searcher-core/best-speed"]
|
|
@ -0,0 +1,6 @@
|
||||||
|
use static_files::resource_dir;
|
||||||
|
|
||||||
|
fn main() -> std::io::Result<()> {
|
||||||
|
println!("cargo:rerun-if-changed=../../frontend/dist");
|
||||||
|
resource_dir("../../frontend/dist").build()
|
||||||
|
}
|
|
@ -0,0 +1,61 @@
|
||||||
|
use std::{fs::File, io::BufReader};
|
||||||
|
use zlib_searcher_core::Book;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let mut writer = csv::Writer::from_path("zlib_libgen_chinese_books.csv").unwrap();
|
||||||
|
|
||||||
|
let mut filter_csv = |path: &str| {
|
||||||
|
let file = File::open(path).unwrap();
|
||||||
|
let reader = BufReader::new(file);
|
||||||
|
|
||||||
|
let mut rdr = csv::ReaderBuilder::new()
|
||||||
|
.has_headers(false)
|
||||||
|
.from_reader(reader);
|
||||||
|
for result in rdr.deserialize::<Book>() {
|
||||||
|
match result {
|
||||||
|
Ok(ref book) => {
|
||||||
|
if is_chinese_title(book) {
|
||||||
|
if let Err(err) = writer.serialize(book) {
|
||||||
|
println!("err: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
println!("{err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("{:?}", rdr.position());
|
||||||
|
};
|
||||||
|
|
||||||
|
filter_csv("zlib_index_books.csv");
|
||||||
|
filter_csv("libgen_index_books.csv");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_chinese_title(book: &Book) -> bool {
|
||||||
|
let chinese_char_count = book.title.matches(is_chinese_char).count();
|
||||||
|
chinese_char_count as f32 / book.title.len() as f32 > 0.3
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline(always)]
|
||||||
|
const fn is_chinese_char(c: char) -> bool {
|
||||||
|
matches!(c as u32,
|
||||||
|
0x4E00..=0x9FA5 |
|
||||||
|
0x9FA6..=0x9FFF |
|
||||||
|
0x3400..=0x4DB5 |
|
||||||
|
0x20000..=0x2A6D6 |
|
||||||
|
0x2A700..=0x2B734 |
|
||||||
|
0x2B740..=0x2B81D |
|
||||||
|
0x2F00..=0x2FD5 |
|
||||||
|
0x2E80..=0x2EF3 |
|
||||||
|
0xF900..=0xFAD9 |
|
||||||
|
0x2F800..=0x2FA1D |
|
||||||
|
0xE815..=0xE86F |
|
||||||
|
0xE400..=0xE5E8 |
|
||||||
|
0xE600..=0xE6CF |
|
||||||
|
0x31C0..=0x31E3 |
|
||||||
|
0x2FF0..=0x2FFB |
|
||||||
|
0x3105..=0x3120 |
|
||||||
|
0x31A0..=0x31BA
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,141 @@
|
||||||
|
use actix_web::{
|
||||||
|
get, http::header, middleware::Logger, web, App, HttpResponse, HttpServer, Responder,
|
||||||
|
};
|
||||||
|
use actix_web_static_files::ResourceFiles;
|
||||||
|
use clap::Parser;
|
||||||
|
use log::{info, LevelFilter};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{path::PathBuf, sync::Arc};
|
||||||
|
use zlib_searcher_core::{Book, Searcher};
|
||||||
|
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/generated.rs"));
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct AppState {
|
||||||
|
searcher: Arc<Searcher>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn init(index_dir: &str) -> Self {
|
||||||
|
info!("AppState init!");
|
||||||
|
AppState {
|
||||||
|
searcher: Arc::new(Searcher::new(index_dir)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_limit() -> usize {
|
||||||
|
30
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SearchQuery {
|
||||||
|
query: String,
|
||||||
|
#[serde(default = "default_limit")]
|
||||||
|
limit: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SearchResult {
|
||||||
|
books: Vec<Book>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/search")]
|
||||||
|
async fn search(query: web::Query<SearchQuery>, state: web::Data<AppState>) -> impl Responder {
|
||||||
|
let books = state.searcher.search(&query.query, query.limit);
|
||||||
|
let result = SearchResult { books };
|
||||||
|
|
||||||
|
return HttpResponse::Ok()
|
||||||
|
.insert_header(header::ContentType::json())
|
||||||
|
.insert_header((header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"))
|
||||||
|
.json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[clap(author, version, about, long_about)]
|
||||||
|
struct AppOpts {
|
||||||
|
#[clap(subcommand)]
|
||||||
|
subcmd: SubCommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
enum SubCommand {
|
||||||
|
/// run search webserver
|
||||||
|
Run(Run),
|
||||||
|
/// index the raw data
|
||||||
|
Index(Index),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
struct Run {
|
||||||
|
#[clap(
|
||||||
|
short,
|
||||||
|
long,
|
||||||
|
default_value = "127.0.0.1:7070",
|
||||||
|
help = "webserver bind address"
|
||||||
|
)]
|
||||||
|
bind: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
struct Index {
|
||||||
|
#[clap(short, long, num_args=1.., help = "specify csv file to be indexed")]
|
||||||
|
file: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
env_logger::builder().filter_level(LevelFilter::Info).init();
|
||||||
|
|
||||||
|
let args = AppOpts::parse();
|
||||||
|
match args.subcmd {
|
||||||
|
SubCommand::Run(opts) => run(opts).unwrap(),
|
||||||
|
SubCommand::Index(opts) => index(opts),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::main]
|
||||||
|
async fn run(opts: Run) -> std::io::Result<()> {
|
||||||
|
info!("zlib-searcher webserver started!");
|
||||||
|
|
||||||
|
let index_dir = std::env::current_exe()
|
||||||
|
.unwrap()
|
||||||
|
.parent()
|
||||||
|
.unwrap()
|
||||||
|
.join("index")
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
let app_state = AppState::init(&index_dir);
|
||||||
|
|
||||||
|
HttpServer::new(move || {
|
||||||
|
let generated = generate();
|
||||||
|
App::new()
|
||||||
|
.wrap(Logger::default())
|
||||||
|
.app_data(web::Data::new(app_state.clone()))
|
||||||
|
.service(search)
|
||||||
|
.service(ResourceFiles::new("/", generated))
|
||||||
|
})
|
||||||
|
.bind(opts.bind)?
|
||||||
|
.run()
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index(opts: Index) {
|
||||||
|
let index_dir = std::env::current_exe()
|
||||||
|
.unwrap()
|
||||||
|
.parent()
|
||||||
|
.unwrap()
|
||||||
|
.join("index")
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
let mut searcher = Searcher::new(&index_dir);
|
||||||
|
|
||||||
|
if opts.file.is_empty() {
|
||||||
|
vec!["zlib_index_books.csv", "libgen_index_books.csv"]
|
||||||
|
.iter()
|
||||||
|
.for_each(|file| searcher.index(file));
|
||||||
|
} else {
|
||||||
|
opts.file.iter().for_each(|file| searcher.index(file));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
services:
|
||||||
|
zlib:
|
||||||
|
image: lamacchinadesiderante/millelibri:latest
|
||||||
|
|
||||||
|
# image: millelibri:v0.2
|
||||||
|
|
||||||
|
# image: millelibri
|
||||||
|
|
||||||
|
# build:
|
||||||
|
# context: .
|
||||||
|
# dockerfile: ./Dockerfile
|
||||||
|
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "7070:7070"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- ./index:/index
|
|
@ -0,0 +1,54 @@
|
||||||
|
import time
|
||||||
|
import pandas as pd
|
||||||
|
import hanlp
|
||||||
|
import torch
|
||||||
|
import random
|
||||||
|
import os
|
||||||
|
import numpy as np
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
|
||||||
|
def seed_everything(seed=2022):
|
||||||
|
'''
|
||||||
|
设置整个开发环境的seed
|
||||||
|
'''
|
||||||
|
random.seed(seed)
|
||||||
|
os.environ['PYTHONHASHSEED'] = str(seed)
|
||||||
|
np.random.seed(seed)
|
||||||
|
torch.manual_seed(seed)
|
||||||
|
torch.cuda.manual_seed(seed)
|
||||||
|
torch.cuda.manual_seed_all(seed)
|
||||||
|
# some cudnn methods can be random even after fixing the seed
|
||||||
|
# unless you tell it to be deterministic
|
||||||
|
torch.backends.cudnn.deterministic = True
|
||||||
|
torch.backends.cudnn.benchmark = False
|
||||||
|
|
||||||
|
seed_everything()
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
专业级:本地模型
|
||||||
|
"""
|
||||||
|
def Pro_tokenize():
|
||||||
|
mul_tokenizer = hanlp.load(hanlp.pretrained.tok.UD_TOK_MMINILMV2L12)
|
||||||
|
df_zlib = pd.read_csv('zlib_index_books.csv', header=None)
|
||||||
|
df_title = df_zlib.iloc[:, :2].astype(str)
|
||||||
|
df_title.columns = ["id", "title"]
|
||||||
|
df_title["title_token"] = None
|
||||||
|
print("分词中……\n")
|
||||||
|
# df_title = df_title.head()
|
||||||
|
total = len(df_title)
|
||||||
|
for i in tqdm(range(total)):
|
||||||
|
try:
|
||||||
|
title = df_title["title"][i]
|
||||||
|
batch_token_lis = mul_tokenizer(title)
|
||||||
|
df_title["title_token"][i] = batch_token_lis
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
print(len(df_title))
|
||||||
|
df_title.to_csv("title_token.csv")
|
||||||
|
print("分词结果保存完成……")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
Pro_tokenize()
|
|
@ -0,0 +1,4 @@
|
||||||
|
*.log
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
|
@ -0,0 +1,2 @@
|
||||||
|
# .env.production
|
||||||
|
VITE_BACKEND_BASE_API = 'http://127.0.0.1:7070/'
|
|
@ -0,0 +1,2 @@
|
||||||
|
# .env.production
|
||||||
|
VITE_BACKEND_BASE_API = ''
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
components.d.ts
|
|
@ -0,0 +1,4 @@
|
||||||
|
singleQuote: true
|
||||||
|
semi: true
|
||||||
|
printWidth: 100
|
||||||
|
trailingComma: none
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Millelibri / zlib searcher</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,49 @@
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.2.0",
|
||||||
|
"type": "module",
|
||||||
|
"repository": "https://github.com/lamacchinadesiderante/millelibri",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@chakra-ui/react": "^2.4.6",
|
||||||
|
"@chakra-ui/skip-nav": "^2.0.13",
|
||||||
|
"@chakra-ui/system": "^2.3.7",
|
||||||
|
"@emotion/react": "^11.10.5",
|
||||||
|
"@emotion/styled": "^11.10.5",
|
||||||
|
"@tanstack/react-table": "^8.7.4",
|
||||||
|
"@tanstack/table-core": "^8.7.4",
|
||||||
|
"@tauri-apps/api": "^1.2.0",
|
||||||
|
"ahooks": "^3.7.4",
|
||||||
|
"axios": "^1.2.2",
|
||||||
|
"filesize": "^10.0.6",
|
||||||
|
"framer-motion": "^7.10.3",
|
||||||
|
"i18next": "^22.4.6",
|
||||||
|
"i18next-browser-languagedetector": "^7.0.1",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-hook-form": "^7.41.3",
|
||||||
|
"react-i18next": "^12.1.1",
|
||||||
|
"react-icons": "^4.7.1",
|
||||||
|
"react-intersection-observer": "^9.4.1",
|
||||||
|
"react-responsive": "^9.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.20.7",
|
||||||
|
"@darkobits/vite-plugin-favicons": "^0.1.8",
|
||||||
|
"@types/lodash": "^4.14.191",
|
||||||
|
"@types/node": "^18.11.18",
|
||||||
|
"@types/react": "^18.0.26",
|
||||||
|
"@types/react-dom": "^18.0.10",
|
||||||
|
"@vitejs/plugin-react": "^2.2.0",
|
||||||
|
"prettier": "^2.8.1",
|
||||||
|
"typescript": "^4.9.4",
|
||||||
|
"vite": "^3.2.5",
|
||||||
|
"vite-plugin-top-level-await": "^1.2.2"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { Flex, HStack, Icon, IconButton, Spacer } from '@chakra-ui/react';
|
||||||
|
import React, { Suspense, useState } from 'react';
|
||||||
|
import { SkipNavContent, SkipNavLink } from '@chakra-ui/skip-nav';
|
||||||
|
|
||||||
|
import { Book } from './scripts/searcher';
|
||||||
|
import BooksView from './components/BooksView';
|
||||||
|
import ColorModeSwitch from './components/ColorModeSwitch';
|
||||||
|
import ExternalLink from './components/ExternalLink';
|
||||||
|
import { FaGithub } from 'react-icons/fa';
|
||||||
|
import Footer from './components/Footer';
|
||||||
|
import Header from './components/Header';
|
||||||
|
import LanguageSwitch from './components/LanguageSwitch';
|
||||||
|
import Search from './components/Search';
|
||||||
|
import { repository } from '../package.json';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const Main: React.FC = () => {
|
||||||
|
const [books, setBooks] = useState<Book[]>([]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SkipNavContent />
|
||||||
|
<Search setBooks={setBooks} />
|
||||||
|
<BooksView books={books} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Settings =
|
||||||
|
import.meta.env.VITE_TAURI === '1'
|
||||||
|
? React.lazy(() => import('./components/Settings-tauri'))
|
||||||
|
: React.lazy(() => import('./components/Settings'));
|
||||||
|
|
||||||
|
const App: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" minH="100vh">
|
||||||
|
<SkipNavLink>Skip to content</SkipNavLink>
|
||||||
|
<Header title="Millelibri">
|
||||||
|
<HStack spacing={{ base: 1, md: 2 }}>
|
||||||
|
<IconButton
|
||||||
|
as={ExternalLink}
|
||||||
|
aria-label={t('nav.repository')}
|
||||||
|
title={t('nav.repository') ?? ''}
|
||||||
|
href={repository}
|
||||||
|
variant="ghost"
|
||||||
|
icon={<Icon as={FaGithub} boxSize={5} />}
|
||||||
|
/>
|
||||||
|
<LanguageSwitch />
|
||||||
|
<ColorModeSwitch />
|
||||||
|
<Suspense>
|
||||||
|
<Settings />
|
||||||
|
</Suspense>
|
||||||
|
</HStack>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<Main />
|
||||||
|
|
||||||
|
<Spacer />
|
||||||
|
<Footer>
|
||||||
|
<a href='https://www.lamacchinadesiderante.org'>lamacchinadesiderante.org</a>
|
||||||
|
</Footer>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { Card, CardHeader, Heading, Divider, CardBody, CardFooter, GridItem, SimpleGrid, Text, Button, Flex, Icon } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import React, { useContext } from 'react';
|
||||||
|
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { ipfsGateways } from '../scripts/ipfs';
|
||||||
|
|
||||||
|
import { Book, Row } from '../scripts/searcher';
|
||||||
|
|
||||||
|
import { filesize as formatFileSize } from 'filesize';
|
||||||
|
import { TbChevronUp } from 'react-icons/tb';
|
||||||
|
import ExternalLink from './ExternalLink';
|
||||||
|
import Description from './Description';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
row: Row
|
||||||
|
}
|
||||||
|
|
||||||
|
const BookDetailsCard: React.FC<IProps> = (props) => {
|
||||||
|
|
||||||
|
// const downloadLinkFromIPFS = (gateway: string, book: Book) => {
|
||||||
|
// return (
|
||||||
|
// `https://${gateway}/ipfs/${book.ipfs_cid}?filename=` +
|
||||||
|
// encodeURIComponent(`${book.title}_${book.author}.${book.extension}`)
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { row } = props
|
||||||
|
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
author,
|
||||||
|
publisher,
|
||||||
|
extension,
|
||||||
|
filesize,
|
||||||
|
language,
|
||||||
|
year,
|
||||||
|
pages,
|
||||||
|
isbn,
|
||||||
|
ipfs_cid
|
||||||
|
} = row.original;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card mt={{ base: 1, md: 2 }} mb={{ base: 2, md: 4 }} mx={{ base: 4, md: 8 }}>
|
||||||
|
<CardHeader>
|
||||||
|
<Heading as="h3" fontSize="xl">
|
||||||
|
{title}
|
||||||
|
</Heading>
|
||||||
|
</CardHeader>
|
||||||
|
<Divider />
|
||||||
|
<CardBody>
|
||||||
|
<SimpleGrid columns={{ sm: 1, md: 3, lg: 4 }} spacing={{ base: 2, md: 4 }}>
|
||||||
|
<Description name={`${t('book.id') ?? 'zlib/libgen id'}: `}>{id}</Description>
|
||||||
|
<GridItem colSpan={{ sm: 1, md: 2, lg: 3 }}>
|
||||||
|
<Description name={`${t('book.ipfs_cid') ?? 'IPFS CID'}: `}>
|
||||||
|
{ipfs_cid}
|
||||||
|
</Description>
|
||||||
|
</GridItem>
|
||||||
|
<Description name={`${t('book.author') ?? 'Author'}: `}>{author}</Description>
|
||||||
|
<Description name={`${t('book.publisher') ?? 'Publisher'}: `}>
|
||||||
|
{publisher || t('book.unknown') || 'Unknown'}
|
||||||
|
</Description>
|
||||||
|
<Description name={`${t('book.extension') ?? 'Extension'}: `}>
|
||||||
|
{extension}
|
||||||
|
</Description>
|
||||||
|
<Description name={`${t('book.filesize') ?? 'Filesize'}: `}>
|
||||||
|
{formatFileSize(filesize) as string}
|
||||||
|
</Description>
|
||||||
|
<Description name={`${t('book.language') ?? 'Language'}: `}>
|
||||||
|
<Text as="span" textTransform="capitalize">
|
||||||
|
{language}
|
||||||
|
</Text>
|
||||||
|
</Description>
|
||||||
|
<Description name={`${t('book.year') ?? 'Year'}: `}>
|
||||||
|
{year || t('book.unknown') || 'Unknown'}
|
||||||
|
</Description>
|
||||||
|
<Description name={`${t('book.pages') ?? 'Pages'}: `}>
|
||||||
|
{pages || t('book.unknown') || 'Unknown'}
|
||||||
|
</Description>
|
||||||
|
<Description name={`${t('book.isbn') ?? 'ISBN'}: `}>
|
||||||
|
{isbn || t('book.unknown') || 'Unknown'}
|
||||||
|
</Description>
|
||||||
|
</SimpleGrid>
|
||||||
|
</CardBody>
|
||||||
|
<CardFooter flexDirection="column">
|
||||||
|
|
||||||
|
{/* <SimpleGrid columns={{ sm: 2, md: 3, lg: 4, xl: 5 }} spacing={{ base: 2, md: 4 }}>
|
||||||
|
{ipfsGateways.map((gateway) => (
|
||||||
|
<Button
|
||||||
|
as={ExternalLink}
|
||||||
|
href={downloadLinkFromIPFS(gateway, row.original)}
|
||||||
|
key={gateway}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{gateway}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</SimpleGrid> */}
|
||||||
|
|
||||||
|
<Flex><Text fontWeight={'bold'}>{t('disclaimer.nolink_warning')}</Text></Flex>
|
||||||
|
|
||||||
|
<Flex justify="flex-end">
|
||||||
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
|
onClick={() => row.toggleExpanded(false)}
|
||||||
|
color="gray.500"
|
||||||
|
mt={2}
|
||||||
|
mb={-2}
|
||||||
|
>
|
||||||
|
{t('table.collapse')}
|
||||||
|
<Icon as={TbChevronUp} boxSize={4} position="relative" top={0.5} left={1} />
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BookDetailsCard;
|
|
@ -0,0 +1,30 @@
|
||||||
|
import MediaQuery from 'react-responsive'
|
||||||
|
|
||||||
|
import { Book } from '../scripts/searcher';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import MobileDataList from './MobileDataList';
|
||||||
|
import DesktopDataList from './DesktopDataList';
|
||||||
|
|
||||||
|
import { MEDIA_QUERY_DESKTOP_STARTS, MEDIA_QUERY_MOBILE_ENDS } from '../constants/mediaquery';
|
||||||
|
|
||||||
|
export interface BooksViewProps {
|
||||||
|
books: Book[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const BooksView: React.FC<BooksViewProps> = ({ books }) => {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MediaQuery minWidth={MEDIA_QUERY_DESKTOP_STARTS}>
|
||||||
|
<DesktopDataList books={books} />
|
||||||
|
</MediaQuery>
|
||||||
|
<MediaQuery maxWidth={MEDIA_QUERY_MOBILE_ENDS}>
|
||||||
|
<MobileDataList books={books} />
|
||||||
|
</MediaQuery>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BooksView;
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { Icon, IconButton, useColorMode } from '@chakra-ui/react';
|
||||||
|
import { TbMoon, TbSun } from 'react-icons/tb';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const ColorModeSwitch: React.FC = () => {
|
||||||
|
const { colorMode, toggleColorMode } = useColorMode();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
aria-label={colorMode === 'light' ? t('nav.toggle_dark') : t('nav.toggle_light')}
|
||||||
|
title={(colorMode === 'light' ? t('nav.toggle_dark') : t('nav.toggle_light')) ?? ''}
|
||||||
|
icon={
|
||||||
|
colorMode === 'light' ? <Icon as={TbSun} boxSize={5} /> : <Icon as={TbMoon} boxSize={5} />
|
||||||
|
}
|
||||||
|
onClick={toggleColorMode}
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ColorModeSwitch;
|
|
@ -0,0 +1,70 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import MediaQuery from 'react-responsive'
|
||||||
|
|
||||||
|
import { Button, Card, Flex, Box } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { BookFilterElement } from '../constants/book';
|
||||||
|
import { MEDIA_QUERY_DESKTOP_STARTS, MEDIA_QUERY_MOBILE_ENDS } from '../constants/mediaquery';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
setSortingElement(element: string): void
|
||||||
|
activeFilter: string
|
||||||
|
currentDirection: string
|
||||||
|
elements: BookFilterElement[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const DataFilter: React.FC<IProps> = (props) => {
|
||||||
|
|
||||||
|
const { setSortingElement, activeFilter, currentDirection, elements } = props
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const getSortingArrow = (sortButton: string) => {
|
||||||
|
if (sortButton !== activeFilter) {
|
||||||
|
return ''
|
||||||
|
} else {
|
||||||
|
return currentDirection == 'asc' ? <span> ↓</span> : <span> ↑</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBox = (elem: BookFilterElement, key: number) => {
|
||||||
|
return (
|
||||||
|
<Box key={key} flex={elem.flex} p='4'>
|
||||||
|
<Button
|
||||||
|
size={'xs'}
|
||||||
|
justifyContent={'space-between'}
|
||||||
|
onClick={() => { elem.sortable && setSortingElement(elem.field) }}>
|
||||||
|
{t(`book.${elem.field}`)} {getSortingArrow(elem.field)}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFlexWrapper = () => {
|
||||||
|
return (
|
||||||
|
<Flex justifyContent={'space-between'}>
|
||||||
|
{elements.map((elem, key) => {
|
||||||
|
return getBox(elem, key)
|
||||||
|
})}
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MediaQuery minWidth={MEDIA_QUERY_DESKTOP_STARTS}>
|
||||||
|
<Card backgroundColor={'transparent'} mt={{ base: 0, md: 4 }} mb={{ base: 0, md: 4 }} mx={{ base: 4, md: 8 }}>
|
||||||
|
{getFlexWrapper()}
|
||||||
|
</Card>
|
||||||
|
</MediaQuery>
|
||||||
|
<MediaQuery maxWidth={MEDIA_QUERY_MOBILE_ENDS}>
|
||||||
|
{getFlexWrapper()}
|
||||||
|
</MediaQuery>
|
||||||
|
</>
|
||||||
|
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DataFilter
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { Text } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
interface DescriptionProps {
|
||||||
|
name: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Description: React.FC<DescriptionProps> = ({ name, children }) => {
|
||||||
|
return (
|
||||||
|
<Text whiteSpace="normal" wordBreak="break-all">
|
||||||
|
<Text as="span" fontWeight="bold">
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
<Text as="span">{children}</Text>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Description;
|
|
@ -0,0 +1,104 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { bookDisplayDesktopElements } from '../constants/book';
|
||||||
|
import { MAX_RESULTS_PER_PAGE } from '../constants/pagination';
|
||||||
|
|
||||||
|
import { Book } from '../scripts/searcher';
|
||||||
|
|
||||||
|
import DataFilter from './DataFilter';
|
||||||
|
|
||||||
|
import DesktopDataListElement from './DesktopDataListElement';
|
||||||
|
import Paginator from './Paginator';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
books: Book[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const DesktopDataList: React.FC<IProps> = (props) => {
|
||||||
|
const { books } = props;
|
||||||
|
|
||||||
|
const [sortBy, setSortBy] = useState<string>('title')
|
||||||
|
const [direction, setDirection] = useState<string>('asc')
|
||||||
|
|
||||||
|
const [currentIndex, setCurrentIndex] = useState<number>(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentIndex(0)
|
||||||
|
}, [books])
|
||||||
|
|
||||||
|
const getMaxIndex = (): number => {
|
||||||
|
const maxIndex = String(books.length / MAX_RESULTS_PER_PAGE)
|
||||||
|
const res = books.length > MAX_RESULTS_PER_PAGE ? parseInt(maxIndex) : 1
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
const setPreviousIndex = () => {
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
setCurrentIndex(currentIndex - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const setNextIndex = () => {
|
||||||
|
if (currentIndex < getMaxIndex()) {
|
||||||
|
setCurrentIndex(currentIndex + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrentSliceStart = ():number => {
|
||||||
|
return currentIndex == 0 ? 0 : currentIndex * MAX_RESULTS_PER_PAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrentSliceEnd = ():number => {
|
||||||
|
const maxIndex = getMaxIndex()
|
||||||
|
return currentIndex == maxIndex ? books.length - 1 : (currentIndex + 1) * MAX_RESULTS_PER_PAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSorting = (sortingElement: string) => {
|
||||||
|
if (sortBy == sortingElement) {
|
||||||
|
setDirection(direction == 'asc' ? 'desc' : 'asc')
|
||||||
|
} else {
|
||||||
|
setSortBy(sortingElement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortBooksFunction = (a: any, b: any) => {
|
||||||
|
var nameA = a[sortBy].toString().toUpperCase(); // ignore upper and lowercase
|
||||||
|
var nameB = b[sortBy].toString().toUpperCase(); // ignore upper and lowercase
|
||||||
|
if (nameA < nameB) {
|
||||||
|
return -1; //nameA comes first
|
||||||
|
}
|
||||||
|
if (nameA > nameB) {
|
||||||
|
return 1; // nameB comes first
|
||||||
|
}
|
||||||
|
return 0; // names must be equal
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSortedBooks = (): Book[] => {
|
||||||
|
return (direction == 'asc') ? books.sort(sortBooksFunction) : books.sort(sortBooksFunction).reverse()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{books.length > 0 &&
|
||||||
|
<DataFilter
|
||||||
|
elements={bookDisplayDesktopElements}
|
||||||
|
activeFilter={sortBy}
|
||||||
|
currentDirection={direction}
|
||||||
|
setSortingElement={handleSorting} />}
|
||||||
|
|
||||||
|
{getSortedBooks().slice(getCurrentSliceStart(), getCurrentSliceEnd()).map((book, key) => {
|
||||||
|
return <DesktopDataListElement key={key} book={book} />
|
||||||
|
})}
|
||||||
|
|
||||||
|
{books.length > 0 &&
|
||||||
|
<Paginator
|
||||||
|
maxIndex={getMaxIndex()}
|
||||||
|
setIndex={setCurrentIndex}
|
||||||
|
setPrevious={setPreviousIndex}
|
||||||
|
setNext={setNextIndex}
|
||||||
|
currentIndex={currentIndex} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DesktopDataList;
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { Card, Flex, Box, Text, Tag } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { colorSchemes } from '../constants/color';
|
||||||
|
|
||||||
|
import { Book } from '../scripts/searcher';
|
||||||
|
import BookDetailsCard from './BooksDetailsCard';
|
||||||
|
|
||||||
|
import { filesize as formatFileSize } from 'filesize';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
book: Book
|
||||||
|
}
|
||||||
|
|
||||||
|
const DesktopDataListElement: React.FC<IProps> = (props) => {
|
||||||
|
const { book } = props
|
||||||
|
|
||||||
|
const languageColorScheme = colorSchemes[book.language.length % colorSchemes.length];
|
||||||
|
const extensionColorScheme = colorSchemes[book.extension.charCodeAt(0) % colorSchemes.length];
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState<boolean>(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div onClick={() => { setIsOpen(true) }}>
|
||||||
|
|
||||||
|
<Card cursor={'pointer'} backgroundColor={'transparent'} mt={{ base: 0, md: 2 }} mb={{ base: 2, md: 4 }} mx={{ base: 4, md: 8 }}>
|
||||||
|
|
||||||
|
<Flex justifyContent={'space-between'} alignItems={'center'}>
|
||||||
|
<Box flex={10} p='4'>
|
||||||
|
<Text marginBottom={1} fontSize="lg">
|
||||||
|
{book.title}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flex={6} p='4'>
|
||||||
|
<Text marginBottom={1} color={'gray.600'} fontSize="xs">
|
||||||
|
{book.author}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flex={4} p='4'>
|
||||||
|
<Text marginBottom={1} color={'gray.400'} fontSize="xs">
|
||||||
|
{book.publisher}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flex={2} p='4'>
|
||||||
|
<Tag colorScheme={extensionColorScheme}>{book.extension}</Tag>
|
||||||
|
</Box>
|
||||||
|
<Box flex={3} p='4'>
|
||||||
|
<Text marginBottom={1} color={'gray.400'} fontSize="xs">
|
||||||
|
{formatFileSize(book.filesize) as string}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flex={2} p='4'>
|
||||||
|
<Tag colorScheme={languageColorScheme} textTransform="capitalize">{book.language}</Tag>
|
||||||
|
</Box>
|
||||||
|
<Box flex={2} p='4'>
|
||||||
|
<Text marginBottom={1} color={'gray.400'} fontSize="xs">
|
||||||
|
{book.year}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flex={2} p='4'>
|
||||||
|
<Text marginBottom={1} color={'gray.400'} fontSize="xs">
|
||||||
|
{book.pages}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen &&
|
||||||
|
<BookDetailsCard row={{
|
||||||
|
original: book,
|
||||||
|
toggleExpanded: setIsOpen
|
||||||
|
}} />}
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DesktopDataListElement;
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { Link, LinkProps } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { open } from '@tauri-apps/api/shell';
|
||||||
|
|
||||||
|
const ExternalLink = React.forwardRef<HTMLAnchorElement, LinkProps>((props, ref) => {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
props.href && open(props.href);
|
||||||
|
}}
|
||||||
|
></Link>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ExternalLink;
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { Link, LinkProps } from '@chakra-ui/react';
|
||||||
|
import React, { Suspense } from 'react';
|
||||||
|
|
||||||
|
const ExternalLinkInner =
|
||||||
|
import.meta.env.VITE_TAURI === '1'
|
||||||
|
? React.lazy(() => import('./ExternalLink-tauri'))
|
||||||
|
: React.Fragment;
|
||||||
|
|
||||||
|
const ExternalLink = React.forwardRef<HTMLAnchorElement, LinkProps>((props, ref) => {
|
||||||
|
if (import.meta.env.VITE_TAURI === '1')
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<Link {...props} ref={ref} isExternal></Link>}>
|
||||||
|
<ExternalLinkInner {...props} ref={ref} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
return <Link {...props} ref={ref} isExternal></Link>;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ExternalLink;
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { Box } from '@chakra-ui/react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export interface FooterProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Footer: React.FC<FooterProps> = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<Box mt={2} mb={6} w="full" textAlign="center">
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { Box, Flex, Heading, Spacer, useColorMode } from '@chakra-ui/react';
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { useInView } from 'react-intersection-observer';
|
||||||
|
|
||||||
|
export interface HeaderProps {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Header: React.FC<HeaderProps> = ({ title, children }) => {
|
||||||
|
const { ref, inView } = useInView({ threshold: 0 });
|
||||||
|
const [bgColor, setBgColor] = React.useState('transparent');
|
||||||
|
const { colorMode } = useColorMode();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inView) {
|
||||||
|
setBgColor(colorMode === 'light' ? 'white' : 'blue.900');
|
||||||
|
} else {
|
||||||
|
setBgColor('transparent');
|
||||||
|
}
|
||||||
|
}, [inView, colorMode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Flex
|
||||||
|
px={{base: 4, md: 8}}
|
||||||
|
py={3}
|
||||||
|
mb={2}
|
||||||
|
w="full"
|
||||||
|
position="sticky"
|
||||||
|
top={0}
|
||||||
|
zIndex="sticky"
|
||||||
|
transition="background-color 0.2s ease-in-out"
|
||||||
|
bgColor={bgColor}
|
||||||
|
boxShadow={!inView ? 'sm' : 'none'}
|
||||||
|
alignItems={'center'}
|
||||||
|
>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<Spacer />
|
||||||
|
<Box>{children}</Box>
|
||||||
|
</Flex>
|
||||||
|
<Box ref={ref} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
|
@ -0,0 +1,44 @@
|
||||||
|
import {
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Menu,
|
||||||
|
MenuButton,
|
||||||
|
MenuItem,
|
||||||
|
MenuItemOption,
|
||||||
|
MenuList,
|
||||||
|
MenuOptionGroup
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { IoLanguage } from 'react-icons/io5';
|
||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const LanguageSwitch: React.FC = () => {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu>
|
||||||
|
<MenuButton
|
||||||
|
as={IconButton}
|
||||||
|
aria-label={t('nav.toggle_language') ?? ''}
|
||||||
|
title={t('nav.toggle_language') ?? ''}
|
||||||
|
icon={<Icon as={IoLanguage} boxSize={5} />}
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
<MenuList>
|
||||||
|
<MenuOptionGroup
|
||||||
|
defaultValue={i18n.language}
|
||||||
|
type="radio"
|
||||||
|
onChange={(value) => i18n.changeLanguage(value as string)}
|
||||||
|
>
|
||||||
|
<MenuItemOption value="en">English</MenuItemOption>
|
||||||
|
<MenuItemOption value="zh-CN">简体中文</MenuItemOption>
|
||||||
|
<MenuItemOption value="fr">French</MenuItemOption>
|
||||||
|
<MenuItemOption value="it">Italian</MenuItemOption>
|
||||||
|
</MenuOptionGroup>
|
||||||
|
</MenuList>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LanguageSwitch;
|
|
@ -0,0 +1,99 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
import { bookDisplayMobileElements } from '../constants/book';
|
||||||
|
import { MAX_RESULTS_PER_PAGE } from '../constants/pagination';
|
||||||
|
import { Book } from '../scripts/searcher';
|
||||||
|
|
||||||
|
import DataFilter from './DataFilter';
|
||||||
|
import MobileDataListElement from './MobileDataListElement';
|
||||||
|
import Paginator from './Paginator';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
books: Book[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const MobileDataList: React.FC<IProps> = (props) => {
|
||||||
|
const { books } = props;
|
||||||
|
|
||||||
|
const [sortBy, setSortBy] = useState<string>('title')
|
||||||
|
const [direction, setDirection] = useState<string>('asc')
|
||||||
|
|
||||||
|
const [currentIndex, setCurrentIndex] = useState<number>(0)
|
||||||
|
|
||||||
|
const getMaxIndex = (): number => {
|
||||||
|
const maxIndex = String(books.length / MAX_RESULTS_PER_PAGE)
|
||||||
|
const res = books.length > MAX_RESULTS_PER_PAGE ? parseInt(maxIndex) : 1
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
const setPreviousIndex = () => {
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
setCurrentIndex(currentIndex - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const setNextIndex = () => {
|
||||||
|
if (currentIndex < getMaxIndex()) {
|
||||||
|
setCurrentIndex(currentIndex + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrentSliceStart = (): number => {
|
||||||
|
return currentIndex == 0 ? 0 : currentIndex * MAX_RESULTS_PER_PAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrentSliceEnd = (): number => {
|
||||||
|
const maxIndex = getMaxIndex()
|
||||||
|
return currentIndex == maxIndex ? books.length - 1 : (currentIndex + 1) * MAX_RESULTS_PER_PAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const handleSorting = (sortingElement: string) => {
|
||||||
|
if (sortBy == sortingElement) {
|
||||||
|
setDirection(direction == 'asc' ? 'desc' : 'asc')
|
||||||
|
} else {
|
||||||
|
setSortBy(sortingElement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortBooksFunction = (a: Book, b: Book) => {
|
||||||
|
var nameA = a[sortBy == 'title' ? 'title' : 'author'].toUpperCase(); // ignore upper and lowercase
|
||||||
|
var nameB = b[sortBy == 'title' ? 'title' : 'author'].toUpperCase(); // ignore upper and lowercase
|
||||||
|
if (nameA < nameB) {
|
||||||
|
return -1; //nameA comes first
|
||||||
|
}
|
||||||
|
if (nameA > nameB) {
|
||||||
|
return 1; // nameB comes first
|
||||||
|
}
|
||||||
|
return 0; // names must be equal
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSortedBooks = (): Book[] => {
|
||||||
|
return (direction == 'asc') ? books.sort(sortBooksFunction) : books.sort(sortBooksFunction).reverse()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{books.length > 0 &&
|
||||||
|
<DataFilter
|
||||||
|
elements={bookDisplayMobileElements}
|
||||||
|
activeFilter={sortBy}
|
||||||
|
currentDirection={direction}
|
||||||
|
setSortingElement={handleSorting} />}
|
||||||
|
|
||||||
|
{getSortedBooks().slice(getCurrentSliceStart(), getCurrentSliceEnd()).map((book, key) => {
|
||||||
|
return <MobileDataListElement key={key} book={book} />
|
||||||
|
})}
|
||||||
|
|
||||||
|
{books.length > 0 &&
|
||||||
|
<Paginator
|
||||||
|
maxIndex={getMaxIndex()}
|
||||||
|
setIndex={setCurrentIndex}
|
||||||
|
setPrevious={setPreviousIndex}
|
||||||
|
setNext={setNextIndex}
|
||||||
|
currentIndex={currentIndex} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MobileDataList;
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { Card, CardHeader, Tag, Text } from '@chakra-ui/react';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { colorSchemes } from '../constants/color';
|
||||||
|
|
||||||
|
import { Book } from '../scripts/searcher';
|
||||||
|
import BookDetailsCard from './BooksDetailsCard';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
book: Book
|
||||||
|
}
|
||||||
|
|
||||||
|
const MobileDataListElement: React.FC<IProps> = (props) => {
|
||||||
|
const { book } = props
|
||||||
|
|
||||||
|
const languageColorScheme = colorSchemes[book.language.length % colorSchemes.length];
|
||||||
|
const extensionColorScheme = colorSchemes[book.extension.charCodeAt(0) % colorSchemes.length];
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState<boolean>(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div onClick={() => { setIsOpen(true) }}>
|
||||||
|
|
||||||
|
<Card backgroundColor={'transparent'} mt={{ base: 0, md: 2 }} mb={{ base: 2, md: 4 }} mx={{ base: 4, md: 8 }}>
|
||||||
|
<CardHeader>
|
||||||
|
<Text marginBottom={1} fontSize="lg">
|
||||||
|
{book.title}
|
||||||
|
</Text>
|
||||||
|
<Text marginBottom={1} color={'gray.400'} fontSize="xs">
|
||||||
|
{book.author}
|
||||||
|
</Text>
|
||||||
|
<div>
|
||||||
|
<Tag colorScheme={languageColorScheme} textTransform="capitalize">{book.language}</Tag>
|
||||||
|
{" "}
|
||||||
|
<Tag colorScheme={extensionColorScheme}>{book.extension}</Tag>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen &&
|
||||||
|
<BookDetailsCard row={{
|
||||||
|
original: book,
|
||||||
|
toggleExpanded: setIsOpen
|
||||||
|
}} />}
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MobileDataListElement;
|
|
@ -0,0 +1,81 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Flex, IconButton, Icon, Text, IconButtonProps } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { TbChevronLeft, TbChevronRight, TbChevronsLeft, TbChevronsRight } from 'react-icons/tb';
|
||||||
|
|
||||||
|
export interface IProps {
|
||||||
|
currentIndex: number
|
||||||
|
maxIndex: number
|
||||||
|
setIndex(index: number): void
|
||||||
|
setPrevious():void
|
||||||
|
setNext():void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Paginator: React.FC<IProps> = (props) => {
|
||||||
|
|
||||||
|
const { currentIndex, maxIndex, setIndex, setPrevious, setNext } = props
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex w="full" mt={4} pr={8} justify="flex-end" wrap="wrap">
|
||||||
|
<IconButton
|
||||||
|
aria-label={t('table.first_page')}
|
||||||
|
title={t('table.first_page') ?? ''}
|
||||||
|
icon={<Icon as={TbChevronsLeft} />}
|
||||||
|
mr={1}
|
||||||
|
display={{ base: 'none', md: 'inline-flex' }}
|
||||||
|
onClick={() => setIndex(0)}
|
||||||
|
disabled={currentIndex == 0}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
aria-label={t('table.previous_page')}
|
||||||
|
title={t('table.previous_page') ?? ''}
|
||||||
|
icon={<Icon as={TbChevronLeft} />}
|
||||||
|
mr={1}
|
||||||
|
onClick={() => setPrevious()}
|
||||||
|
disabled={currentIndex == 0}
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
Array.from(Array(maxIndex).keys()).map((pageIndex) => {
|
||||||
|
const title = t('table.page', { page: pageIndex + 1 });
|
||||||
|
const disabled = currentIndex === pageIndex;
|
||||||
|
const style: Partial<IconButtonProps> = disabled ? { colorScheme: 'blue' } : {};
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
aria-label={title}
|
||||||
|
title={title}
|
||||||
|
key={pageIndex}
|
||||||
|
icon={<Text>{pageIndex + 1}</Text>}
|
||||||
|
mr={1}
|
||||||
|
onClick={() => setIndex(pageIndex)}
|
||||||
|
disabled={disabled}
|
||||||
|
{...style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
<IconButton
|
||||||
|
aria-label={t('table.next_page')}
|
||||||
|
title={t('table.next_page') ?? ''}
|
||||||
|
icon={<Icon as={TbChevronRight} />}
|
||||||
|
mr={{ base: 0, md: 1 }}
|
||||||
|
onClick={() => setNext()}
|
||||||
|
disabled={currentIndex + 1 == maxIndex}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
aria-label={t('table.last_page')}
|
||||||
|
title={t('table.last_page') ?? ''}
|
||||||
|
icon={<Icon as={TbChevronsRight} />}
|
||||||
|
display={{ base: 'none', md: 'inline-flex' }}
|
||||||
|
onClick={() => setIndex(maxIndex - 1)}
|
||||||
|
disabled={currentIndex + 1 == maxIndex}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Paginator;
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { GridItem, Icon, SimpleGrid } from '@chakra-ui/react';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
TbBook2,
|
||||||
|
TbBuilding,
|
||||||
|
TbFileDescription,
|
||||||
|
TbHash,
|
||||||
|
TbReportSearch,
|
||||||
|
TbUserCircle
|
||||||
|
} from 'react-icons/tb';
|
||||||
|
import search, { Book } from '../scripts/searcher';
|
||||||
|
|
||||||
|
import { IoLanguage } from 'react-icons/io5';
|
||||||
|
import SearchInput from './SearchInput';
|
||||||
|
import { useDebounceEffect } from 'ahooks';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import SearchLanguage from './SearchLanguage';
|
||||||
|
|
||||||
|
function constructQuery(parts: Record<string, string>): string {
|
||||||
|
return Object.keys(parts)
|
||||||
|
.map((key) =>
|
||||||
|
parts[key]
|
||||||
|
.split(' ')
|
||||||
|
.filter((s) => s !== '')
|
||||||
|
.map((s) => `${key}:"${s}"`)
|
||||||
|
)
|
||||||
|
.flat()
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchProps {
|
||||||
|
setBooks: (books: Book[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Search: React.FC<SearchProps> = ({ setBooks }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [title, setTitle] = useState<string>('');
|
||||||
|
const [author, setAuthor] = useState<string>('');
|
||||||
|
const [publisher, setPublisher] = useState<string>('');
|
||||||
|
const [extension, setExtension] = useState<string>('');
|
||||||
|
const [language, setLanguage] = useState<string>('');
|
||||||
|
const [isbn, setISBN] = useState<string>('');
|
||||||
|
const [complexQuery, setComplexQuery] = useState<string>('');
|
||||||
|
const [showLanguageDropdown, setShowLanguageDropdown] = useState<boolean>(true)
|
||||||
|
|
||||||
|
const handleLanguageChange = (language: string) => {
|
||||||
|
if (language == 'input') {
|
||||||
|
setShowLanguageDropdown(false)
|
||||||
|
} else {
|
||||||
|
setLanguage(language)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLanguageReset = () => {
|
||||||
|
setShowLanguageDropdown(true)
|
||||||
|
setLanguage('')
|
||||||
|
}
|
||||||
|
|
||||||
|
useDebounceEffect(
|
||||||
|
() => {
|
||||||
|
const query = complexQuery
|
||||||
|
? complexQuery
|
||||||
|
: constructQuery({ title, author, publisher, extension, language, isbn });
|
||||||
|
|
||||||
|
search(query, 100).then((books) => {
|
||||||
|
setBooks(books);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[title, author, publisher, extension, language, isbn, complexQuery],
|
||||||
|
{ wait: 300 }
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SimpleGrid
|
||||||
|
columns={{ sm: 1, md: 2, lg: 3 }}
|
||||||
|
spacing={{ base: 2, md: 4 }}
|
||||||
|
px={{ base: 4, md: 8 }}
|
||||||
|
>
|
||||||
|
<SearchInput
|
||||||
|
icon={<Icon as={TbBook2} />}
|
||||||
|
placeholder={t('book.title')}
|
||||||
|
value={title}
|
||||||
|
onChange={setTitle}
|
||||||
|
/>
|
||||||
|
<SearchInput
|
||||||
|
icon={<Icon as={TbUserCircle} />}
|
||||||
|
placeholder={t('book.author')}
|
||||||
|
value={author}
|
||||||
|
onChange={setAuthor}
|
||||||
|
/>
|
||||||
|
<SearchInput
|
||||||
|
icon={<Icon as={TbBuilding} />}
|
||||||
|
placeholder={t('book.publisher')}
|
||||||
|
value={publisher}
|
||||||
|
onChange={setPublisher}
|
||||||
|
/>
|
||||||
|
<SearchInput
|
||||||
|
icon={<Icon as={TbFileDescription} />}
|
||||||
|
placeholder={t('book.extension')}
|
||||||
|
value={extension}
|
||||||
|
onChange={setExtension}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!showLanguageDropdown && (<SearchInput
|
||||||
|
icon={<Icon as={IoLanguage} />}
|
||||||
|
placeholder={t('book.language')}
|
||||||
|
value={language}
|
||||||
|
onChange={handleLanguageChange}
|
||||||
|
onClear={handleLanguageReset}
|
||||||
|
/>)}
|
||||||
|
|
||||||
|
{showLanguageDropdown && (<SearchLanguage
|
||||||
|
icon={<Icon as={IoLanguage} />}
|
||||||
|
placeholder={t('book.language')}
|
||||||
|
value={language}
|
||||||
|
onChange={handleLanguageChange}
|
||||||
|
/>)}
|
||||||
|
|
||||||
|
<SearchInput
|
||||||
|
icon={<Icon as={TbHash} />}
|
||||||
|
placeholder={t('book.isbn')}
|
||||||
|
value={isbn}
|
||||||
|
onChange={setISBN}
|
||||||
|
/>
|
||||||
|
<GridItem colSpan={{ sm: 1, md: 2, lg: 3 }}>
|
||||||
|
<SearchInput
|
||||||
|
icon={<Icon as={TbReportSearch} />}
|
||||||
|
placeholder={t('search.complex')}
|
||||||
|
value={complexQuery}
|
||||||
|
onChange={setComplexQuery}
|
||||||
|
/>
|
||||||
|
</GridItem>
|
||||||
|
</SimpleGrid>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Search;
|
|
@ -0,0 +1,60 @@
|
||||||
|
import {
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
InputGroup,
|
||||||
|
InputLeftElement,
|
||||||
|
InputRightElement,
|
||||||
|
useControllableState
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { TbCircleX } from 'react-icons/tb';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface SearchInputProps {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
placeholder: string;
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
onClear?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchInput: React.FC<SearchInputProps> = ({ placeholder, icon, value, onChange, onClear }) => {
|
||||||
|
const [controlledValue, setControlledValue] = useControllableState({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
defaultValue: ''
|
||||||
|
});
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InputGroup>
|
||||||
|
<InputLeftElement pointerEvents="none" children={icon} />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
aria-label={placeholder}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={controlledValue}
|
||||||
|
onChange={(e) => setControlledValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
<InputRightElement>
|
||||||
|
{value === '' ? null : (
|
||||||
|
<IconButton
|
||||||
|
aria-label={t('input.clear')}
|
||||||
|
tabIndex={-1}
|
||||||
|
title={t('input.clear') ?? ''}
|
||||||
|
icon={<Icon as={TbCircleX} color="GrayText" />}
|
||||||
|
variant="unstyled"
|
||||||
|
onClick={() => {
|
||||||
|
setControlledValue('')
|
||||||
|
onClear && onClear()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</InputRightElement>
|
||||||
|
</InputGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchInput;
|
|
@ -0,0 +1,53 @@
|
||||||
|
import {
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
InputGroup,
|
||||||
|
InputLeftElement,
|
||||||
|
InputRightElement,
|
||||||
|
Select,
|
||||||
|
useControllableState
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { TbCircleX } from 'react-icons/tb';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface SearchInputProps {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
placeholder: string;
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchLanguage: React.FC<SearchInputProps> = ({ placeholder, icon, value, onChange }) => {
|
||||||
|
const [controlledValue, setControlledValue] = useControllableState({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
defaultValue: ''
|
||||||
|
});
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InputGroup>
|
||||||
|
|
||||||
|
<InputLeftElement pointerEvents="none" children={icon} />
|
||||||
|
|
||||||
|
<Select defaultValue={''} className={value !== '' ? 'active' : ''} placeholder={placeholder} onChange={(e) => setControlledValue(e.target.value)}>
|
||||||
|
<option value={"Chinese"}>{t('languages.chinese')}</option>
|
||||||
|
<option value={"English"}>{t('languages.english')}</option>
|
||||||
|
<option value={"French"}>{t('languages.french')}</option>
|
||||||
|
<option value={"German"}>{t('languages.german')}</option>
|
||||||
|
<option value={"Japanese"}>{t('languages.japanese')}</option>
|
||||||
|
<option value={"Italian"}>{t('languages.italian')}</option>
|
||||||
|
<option value={"Portuguese"}>{t('languages.portuguese')}</option>
|
||||||
|
<option value={"Spanish"}>{t('languages.spanish')}</option>
|
||||||
|
<option value={"Other"}>{t('languages.other')}</option>
|
||||||
|
<option value={"input"}>{t('languages.input')}</option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
</InputGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchLanguage;
|
|
@ -0,0 +1,153 @@
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Drawer,
|
||||||
|
DrawerBody,
|
||||||
|
DrawerCloseButton,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerOverlay,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
InputRightElement,
|
||||||
|
Stack,
|
||||||
|
Textarea,
|
||||||
|
useDisclosure
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { TbFolder, TbHelp, TbSettings } from 'react-icons/tb';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api';
|
||||||
|
import { open } from '@tauri-apps/api/dialog';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { SettingsItem } from './SettingsItem';
|
||||||
|
import RootContext from '../store';
|
||||||
|
import { parseIpfsGateways } from '../scripts/ipfs';
|
||||||
|
|
||||||
|
interface Config {
|
||||||
|
index_dir: string;
|
||||||
|
ipfs_gateways: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Settings: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||||
|
const btnRef = React.useRef<HTMLButtonElement | null>(null);
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
formState: { errors }
|
||||||
|
} = useForm<Config>();
|
||||||
|
const [submitting, setSubmitting] = React.useState(false);
|
||||||
|
const rootContext = React.useContext(RootContext);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
isOpen &&
|
||||||
|
invoke('get_config').then((conf) => {
|
||||||
|
const config = conf as {
|
||||||
|
index_dir: string;
|
||||||
|
ipfs_gateways: string[];
|
||||||
|
};
|
||||||
|
setValue('index_dir', config.index_dir, { shouldValidate: true });
|
||||||
|
setValue('ipfs_gateways', config.ipfs_gateways.join('\n'), { shouldValidate: true });
|
||||||
|
});
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const onSubmit = async (newConfig: Config) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
const ipfsGateways: string[]= parseIpfsGateways(newConfig.ipfs_gateways);
|
||||||
|
const config = {
|
||||||
|
index_dir: newConfig.index_dir,
|
||||||
|
ipfs_gateways: ipfsGateways
|
||||||
|
};
|
||||||
|
await invoke('set_config', { newConfig: config });
|
||||||
|
rootContext.ipfs_gateways = ipfsGateways;
|
||||||
|
onClose();
|
||||||
|
setSubmitting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
ref={btnRef}
|
||||||
|
aria-label={t('settings.title')}
|
||||||
|
title={t('settings.title') ?? ''}
|
||||||
|
icon={<Icon as={TbSettings} boxSize={5} />}
|
||||||
|
onClick={onOpen}
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
<Drawer isOpen={isOpen} placement="right" size="md" onClose={onClose} finalFocusRef={btnRef}>
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerContent>
|
||||||
|
<DrawerCloseButton />
|
||||||
|
<DrawerHeader>{t('settings.title')}</DrawerHeader>
|
||||||
|
|
||||||
|
<DrawerBody>
|
||||||
|
<form id="settings-form" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<SettingsItem
|
||||||
|
label={t('settings.index_dir')}
|
||||||
|
help={t('settings.index_dir_help') ?? undefined}
|
||||||
|
error={errors.index_dir?.message}
|
||||||
|
element={
|
||||||
|
<Input
|
||||||
|
{...register('index_dir', { required: t('settings.index_dir_required') ?? true })}
|
||||||
|
aria-invalid={errors.index_dir ? 'true' : 'false'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
rightElement={
|
||||||
|
<InputRightElement>
|
||||||
|
<IconButton
|
||||||
|
aria-label={t('settings.index_dir_browse')}
|
||||||
|
title={t('settings.index_dir_browse') ?? ''}
|
||||||
|
tabIndex={-1}
|
||||||
|
icon={<Icon as={TbFolder} />}
|
||||||
|
variant="unstyled"
|
||||||
|
pt={1}
|
||||||
|
onClick={async () => {
|
||||||
|
const selected = (await open({
|
||||||
|
defaultPath: watch('index_dir'),
|
||||||
|
directory: true,
|
||||||
|
multiple: false
|
||||||
|
})) as string | null;
|
||||||
|
if (selected) setValue('index_dir', selected, { shouldValidate: true });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</InputRightElement>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingsItem
|
||||||
|
label={t('settings.ipfs_gateways')}
|
||||||
|
help={t('settings.ipfs_gateways_help') ?? undefined}
|
||||||
|
error={errors.ipfs_gateways?.message}
|
||||||
|
element={
|
||||||
|
<Textarea
|
||||||
|
{...register('ipfs_gateways')}
|
||||||
|
aria-invalid={errors.ipfs_gateways ? 'true' : 'false'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</DrawerBody>
|
||||||
|
|
||||||
|
<DrawerFooter>
|
||||||
|
<Button variant="outline" mr={3} onClick={onClose}>
|
||||||
|
{t('settings.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button colorScheme="blue" type="submit" form="settings-form" isLoading={submitting}>
|
||||||
|
{t('settings.save')}
|
||||||
|
</Button>
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Settings;
|
|
@ -0,0 +1,109 @@
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Drawer,
|
||||||
|
DrawerBody,
|
||||||
|
DrawerCloseButton,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerOverlay,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
Textarea,
|
||||||
|
useDisclosure
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { TbSettings } from 'react-icons/tb';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { SettingsItem } from './SettingsItem';
|
||||||
|
import RootContext from '../store';
|
||||||
|
import { parseIpfsGateways } from '../scripts/ipfs';
|
||||||
|
|
||||||
|
interface Config {
|
||||||
|
ipfs_gateways: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Settings: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||||
|
const btnRef = React.useRef<HTMLButtonElement | null>(null);
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
formState: { errors }
|
||||||
|
} = useForm<Config>();
|
||||||
|
const [submitting, setSubmitting] = React.useState(false);
|
||||||
|
const rootContext = React.useContext(RootContext);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
const ipfsGateways: string[] = JSON.parse(localStorage.getItem('ipfs_gateways') || '[]');
|
||||||
|
setValue('ipfs_gateways', ipfsGateways.join('\n'));
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const onSubmit = async (newConfig: Config) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
const ipfsGateways: string[] = parseIpfsGateways(newConfig.ipfs_gateways);
|
||||||
|
localStorage.setItem('ipfs_gateways', JSON.stringify(ipfsGateways));
|
||||||
|
rootContext.ipfs_gateways = ipfsGateways;
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
setSubmitting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
ref={btnRef}
|
||||||
|
aria-label={t('settings.title')}
|
||||||
|
title={t('settings.title') ?? ''}
|
||||||
|
icon={<Icon as={TbSettings} boxSize={5} />}
|
||||||
|
onClick={onOpen}
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
<Drawer isOpen={isOpen} placement="right" size="md" onClose={onClose} finalFocusRef={btnRef}>
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerContent>
|
||||||
|
<DrawerCloseButton />
|
||||||
|
<DrawerHeader>{t('settings.title')}</DrawerHeader>
|
||||||
|
|
||||||
|
<DrawerBody>
|
||||||
|
<form id="settings-form" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<SettingsItem
|
||||||
|
label={t('settings.ipfs_gateways')}
|
||||||
|
help={t('settings.ipfs_gateways_help') ?? undefined}
|
||||||
|
error={errors.ipfs_gateways?.message}
|
||||||
|
element={
|
||||||
|
<Textarea
|
||||||
|
{...register('ipfs_gateways')}
|
||||||
|
aria-invalid={errors.ipfs_gateways ? 'true' : 'false'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</DrawerBody>
|
||||||
|
|
||||||
|
<DrawerFooter>
|
||||||
|
<Button variant="outline" mr={3} onClick={onClose}>
|
||||||
|
{t('settings.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button colorScheme="blue" type="submit" form="settings-form" isLoading={submitting}>
|
||||||
|
{t('settings.save')}
|
||||||
|
</Button>
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Settings;
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { FormControl, FormErrorMessage, FormLabel, Icon, InputGroup, InputProps, Tooltip, Text } from "@chakra-ui/react";
|
||||||
|
import React from "react";
|
||||||
|
import { TbHelp } from "react-icons/tb";
|
||||||
|
|
||||||
|
export interface SettingsItemProps extends InputProps {
|
||||||
|
label: string;
|
||||||
|
help?: string;
|
||||||
|
error?: string;
|
||||||
|
element: React.ReactNode;
|
||||||
|
leftElement?: React.ReactNode;
|
||||||
|
rightElement?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsItem: React.FC<SettingsItemProps> =
|
||||||
|
({ label, help, error, element, leftElement, rightElement }) => {
|
||||||
|
console.log(label, error);
|
||||||
|
return (
|
||||||
|
<FormControl isInvalid={error ? true : false}>
|
||||||
|
<FormLabel>
|
||||||
|
{label}{' '}
|
||||||
|
{help && (
|
||||||
|
<Tooltip hasArrow label={help}>
|
||||||
|
<Text as="span">
|
||||||
|
<Icon as={TbHelp}></Icon>
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<InputGroup>
|
||||||
|
{leftElement}
|
||||||
|
{element}
|
||||||
|
{rightElement}
|
||||||
|
</InputGroup>
|
||||||
|
{error && <FormErrorMessage>{error}</FormErrorMessage>}
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
;
|
|
@ -0,0 +1,61 @@
|
||||||
|
export interface BookFilterElement {
|
||||||
|
field: string,
|
||||||
|
flex: number,
|
||||||
|
sortable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bookDisplayDesktopElements: BookFilterElement[] = [
|
||||||
|
{
|
||||||
|
field: 'title',
|
||||||
|
flex: 10,
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'author',
|
||||||
|
flex: 6,
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'publisher',
|
||||||
|
flex: 4,
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'extension',
|
||||||
|
flex: 2,
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'filesize',
|
||||||
|
flex: 3,
|
||||||
|
sortable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'language',
|
||||||
|
flex: 2,
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'year',
|
||||||
|
flex: 2,
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'pages',
|
||||||
|
flex: 2,
|
||||||
|
sortable: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const bookDisplayMobileElements: BookFilterElement[] = [
|
||||||
|
{
|
||||||
|
field: 'title',
|
||||||
|
flex: 0,
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'author',
|
||||||
|
flex: 0,
|
||||||
|
sortable: true
|
||||||
|
}
|
||||||
|
]
|
|
@ -0,0 +1,12 @@
|
||||||
|
export const colorSchemes = [
|
||||||
|
'red',
|
||||||
|
'orange',
|
||||||
|
'yellow',
|
||||||
|
'green',
|
||||||
|
'teal',
|
||||||
|
'blue',
|
||||||
|
'cyan',
|
||||||
|
'purple',
|
||||||
|
'pink',
|
||||||
|
'gray'
|
||||||
|
];
|
|
@ -0,0 +1,2 @@
|
||||||
|
export const MEDIA_QUERY_MOBILE_ENDS = 899
|
||||||
|
export const MEDIA_QUERY_DESKTOP_STARTS = 900
|
|
@ -0,0 +1 @@
|
||||||
|
export const MAX_RESULTS_PER_PAGE = 20
|
|
@ -0,0 +1,62 @@
|
||||||
|
{
|
||||||
|
"en": {
|
||||||
|
"translation": {
|
||||||
|
"settings": {
|
||||||
|
"title": "Settings",
|
||||||
|
"index_dir": "Index directory",
|
||||||
|
"index_dir_help": "The directory where the index is stored.",
|
||||||
|
"index_dir_required": "The index directory is required",
|
||||||
|
"index_dir_browse": "Browse",
|
||||||
|
"ipfs_gateways": "IPFS Gateways",
|
||||||
|
"ipfs_gateways_help": "IPFS Gateways List, line break",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-CN": {
|
||||||
|
"translation": {
|
||||||
|
"settings": {
|
||||||
|
"title": "设置",
|
||||||
|
"index_dir": "索引目录",
|
||||||
|
"index_dir_help": "存储索引的目录。",
|
||||||
|
"index_dir_required": "索引目录不能为空",
|
||||||
|
"index_dir_browse": "浏览",
|
||||||
|
"ipfs_gateways": "IPFS 网关",
|
||||||
|
"ipfs_gateways_help": "IPFS 网关列表,一行一个",
|
||||||
|
"cancel": "取消",
|
||||||
|
"save": "保存"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr": {
|
||||||
|
"translation": {
|
||||||
|
"settings": {
|
||||||
|
"title": "Paramètres",
|
||||||
|
"index_dir": "Répertoire de l'index",
|
||||||
|
"index_dir_help": "Le répertoire où l'index est stocké.",
|
||||||
|
"index_dir_required": "Le répertoire de l'index est requis",
|
||||||
|
"index_dir_browse": "Parcourir",
|
||||||
|
"ipfs_gateways": "Passerelles IPFS",
|
||||||
|
"ipfs_gateways_help": "Liste des passerelles IPFS, saut de ligne",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"save": "Enregistrer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"it": {
|
||||||
|
"translation": {
|
||||||
|
"settings": {
|
||||||
|
"title": "Impostazioni",
|
||||||
|
"index_dir": "Directory del file index",
|
||||||
|
"index_dir_help": "La directory dove è salvato il file index",
|
||||||
|
"index_dir_required": "Il file index è obbligatorio",
|
||||||
|
"index_dir_browse": "Cerca",
|
||||||
|
"ipfs_gateways": "Gateway IPFS",
|
||||||
|
"ipfs_gateways_help": "Elenco dei gateway IPFS, interruzione di riga",
|
||||||
|
"cancel": "Annulla",
|
||||||
|
"save": "Salva"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,232 @@
|
||||||
|
{
|
||||||
|
"en": {
|
||||||
|
"translation": {
|
||||||
|
"nav": {
|
||||||
|
"repository": "GitHub Repository",
|
||||||
|
"toggle_dark": "Toggle to Dark Mode",
|
||||||
|
"toggle_light": "Toggle to Light Mode",
|
||||||
|
"toggle_language": "Toggle Language"
|
||||||
|
},
|
||||||
|
"input": {
|
||||||
|
"clear": "Clear"
|
||||||
|
},
|
||||||
|
"book": {
|
||||||
|
"id": "zlib/libgen id",
|
||||||
|
"title": "Title",
|
||||||
|
"author": "Author",
|
||||||
|
"publisher": "Publisher",
|
||||||
|
"extension": "Extension",
|
||||||
|
"filesize": "Filesize",
|
||||||
|
"language": "Language",
|
||||||
|
"year": "Year",
|
||||||
|
"pages": "Pages",
|
||||||
|
"isbn": "ISBN",
|
||||||
|
"ipfs_cid": "IPFS CID",
|
||||||
|
"unknown": "Unknown"
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"sort_asc": "Sort ascending",
|
||||||
|
"sort_desc": "Sort descending",
|
||||||
|
"not_sorted": "Not sorted",
|
||||||
|
"filter": "Filter",
|
||||||
|
"no_data": "No data",
|
||||||
|
"first_page": "First page",
|
||||||
|
"last_page": "Last page",
|
||||||
|
"next_page": "Next page",
|
||||||
|
"previous_page": "Previous page",
|
||||||
|
"page": "Page {{page}}",
|
||||||
|
"collapse": "Collapse"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"complex": "Complex search"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Settings",
|
||||||
|
"ipfs_gateways": "IPFS Gateways",
|
||||||
|
"ipfs_gateways_help": "IPFS Gateways List, line break",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save"
|
||||||
|
},
|
||||||
|
"languages": {
|
||||||
|
"chinese": "Chinese",
|
||||||
|
"english": "English",
|
||||||
|
"french": "French",
|
||||||
|
"german": "German",
|
||||||
|
"japanese": "Japanese",
|
||||||
|
"italian": "Italian",
|
||||||
|
"portuguese": "Portuguese",
|
||||||
|
"spanish": "Spanish",
|
||||||
|
"other": "Other / Unknown",
|
||||||
|
"input": "Input..."
|
||||||
|
},
|
||||||
|
"disclaimer": {
|
||||||
|
"nolink_warning": "WARNING: This platform does not host any kind of link to copyrighted material. It just displays CID related to IPFS resources"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-CN": {
|
||||||
|
"translation": {
|
||||||
|
"nav": {
|
||||||
|
"repository": "GitHub 仓库",
|
||||||
|
"toggle_dark": "切换到暗黑模式",
|
||||||
|
"toggle_light": "切换到亮色模式",
|
||||||
|
"toggle_language": "切换语言"
|
||||||
|
},
|
||||||
|
"input": {
|
||||||
|
"clear": "清空"
|
||||||
|
},
|
||||||
|
"book": {
|
||||||
|
"id": "zlib/libgen id",
|
||||||
|
"title": "书名",
|
||||||
|
"author": "作者",
|
||||||
|
"publisher": "出版社",
|
||||||
|
"extension": "扩展名",
|
||||||
|
"filesize": "文件大小",
|
||||||
|
"language": "语言",
|
||||||
|
"year": "年份",
|
||||||
|
"pages": "页数",
|
||||||
|
"isbn": "ISBN",
|
||||||
|
"ipfs_cid": "IPFS CID",
|
||||||
|
"unknown": "未知"
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"sort_asc": "升序排序",
|
||||||
|
"sort_desc": "降序排序",
|
||||||
|
"not_sorted": "未排序",
|
||||||
|
"filter": "过滤",
|
||||||
|
"no_data": "无数据",
|
||||||
|
"first_page": "第一页",
|
||||||
|
"last_page": "最后一页",
|
||||||
|
"next_page": "下一页",
|
||||||
|
"previous_page": "上一页",
|
||||||
|
"page": "第 {{page}} 页",
|
||||||
|
"collapse": "收起"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"complex": "复杂搜索"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "设置",
|
||||||
|
"ipfs_gateways": "IPFS 网关",
|
||||||
|
"ipfs_gateways_help": "IPFS 网关列表,一行一个",
|
||||||
|
"cancel": "取消",
|
||||||
|
"save": "保存"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr": {
|
||||||
|
"translation": {
|
||||||
|
"nav": {
|
||||||
|
"repository": "Dépôt GitHub",
|
||||||
|
"toggle_dark": "Basculer en mode sombre",
|
||||||
|
"toggle_light": "Basculer en mode clair",
|
||||||
|
"toggle_language": "Basculer la langue"
|
||||||
|
},
|
||||||
|
"input": {
|
||||||
|
"clear": "Effacer"
|
||||||
|
},
|
||||||
|
"book": {
|
||||||
|
"id": "ID zlib/libgen",
|
||||||
|
"title": "Titre",
|
||||||
|
"author": "Auteur",
|
||||||
|
"publisher": "Éditeur",
|
||||||
|
"extension": "Extension",
|
||||||
|
"filesize": "Taille du fichier",
|
||||||
|
"language": "Langue",
|
||||||
|
"year": "Année",
|
||||||
|
"pages": "Pages",
|
||||||
|
"isbn": "ISBN",
|
||||||
|
"ipfs_cid": "CID IPFS",
|
||||||
|
"unknown": "Inconnu"
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"sort_asc": "Trier par ordre croissant",
|
||||||
|
"sort_desc": "Trier par ordre décroissant",
|
||||||
|
"not_sorted": "Non trié",
|
||||||
|
"filter": "Filtrer",
|
||||||
|
"no_data": "Aucune donnée",
|
||||||
|
"first_page": "Première page",
|
||||||
|
"last_page": "Dernière page",
|
||||||
|
"next_page": "Page suivante",
|
||||||
|
"previous_page": "Page précédente",
|
||||||
|
"page": "Page {{page}}",
|
||||||
|
"collapse": "Replier"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"complex": "Recherche complexe"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Paramètres",
|
||||||
|
"ipfs_gateways": "Passerelles IPFS",
|
||||||
|
"ipfs_gateways_help": "Liste des passerelles IPFS, saut de ligne",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"save": "Enregistrer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"it": {
|
||||||
|
"translation": {
|
||||||
|
"nav": {
|
||||||
|
"repository": "Repo GitHub",
|
||||||
|
"toggle_dark": "Modalità scura",
|
||||||
|
"toggle_light": "Modalità chiara",
|
||||||
|
"toggle_language": "Lingua"
|
||||||
|
},
|
||||||
|
"input": {
|
||||||
|
"clear": "pulisci"
|
||||||
|
},
|
||||||
|
"book": {
|
||||||
|
"id": "ID zlib/libgen",
|
||||||
|
"title": "Titolo",
|
||||||
|
"author": "Autore",
|
||||||
|
"publisher": "Editore",
|
||||||
|
"extension": "Estensione",
|
||||||
|
"filesize": "Dimensione file",
|
||||||
|
"language": "Lingua",
|
||||||
|
"year": "Anno",
|
||||||
|
"pages": "Pagina",
|
||||||
|
"isbn": "ISBN",
|
||||||
|
"ipfs_cid": "CID IPFS",
|
||||||
|
"unknown": "Sconosciuto"
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"sort_asc": "Ordine crescente",
|
||||||
|
"sort_desc": "Ordine decrescente",
|
||||||
|
"not_sorted": "Non ordinato",
|
||||||
|
"filter": "Filtra",
|
||||||
|
"no_data": "Nessun risultato",
|
||||||
|
"first_page": "Prima pagina",
|
||||||
|
"last_page": "Ultima pagina",
|
||||||
|
"next_page": "Pagina seguente",
|
||||||
|
"previous_page": "Pagina precedente",
|
||||||
|
"page": "Pagina {{page}}",
|
||||||
|
"collapse": "Richiudi"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"complex": "Ricerca dettagliata"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Impostazioni",
|
||||||
|
"ipfs_gateways": "Gateway IPFS",
|
||||||
|
"ipfs_gateways_help": "Elenco dei gateway IPFS, interruzione di riga",
|
||||||
|
"cancel": "Annulla",
|
||||||
|
"save": "Salva"
|
||||||
|
},
|
||||||
|
"languages": {
|
||||||
|
"chinese": "Cinese",
|
||||||
|
"english": "Inglese",
|
||||||
|
"french": "Francese",
|
||||||
|
"german": "Tedesco",
|
||||||
|
"italian": "Italiano",
|
||||||
|
"japanese": "Giapponese",
|
||||||
|
"portuguese": "Portoghese",
|
||||||
|
"spanish": "Spagnolo",
|
||||||
|
"other": "Altro / Sconosciuto",
|
||||||
|
"input": "Scrivi..."
|
||||||
|
},
|
||||||
|
"disclaimer": {
|
||||||
|
"nolink_warning": "IMPORTANTE: Questa piattaforma non ospita nessun tipo di link a materiale protetto da copyright. Solo CID relativi a risorse IPFS."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
|
import * as ReactDOM from 'react-dom/client';
|
||||||
|
|
||||||
|
import App from './App';
|
||||||
|
import { ChakraProvider } from '@chakra-ui/react';
|
||||||
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
|
import React from 'react';
|
||||||
|
import i18n from 'i18next';
|
||||||
|
import i18nResource from './i18n.json';
|
||||||
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
import merge from 'lodash/merge';
|
||||||
|
import theme from './theme';
|
||||||
|
import RootContext, {initRootContext} from './store';
|
||||||
|
|
||||||
|
const resources =
|
||||||
|
import.meta.env.VITE_TAURI === '1'
|
||||||
|
? merge(i18nResource, (await import('./i18n-tauri.json')).default)
|
||||||
|
: i18nResource;
|
||||||
|
|
||||||
|
i18n
|
||||||
|
.use(LanguageDetector)
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
resources,
|
||||||
|
fallbackLng: 'en',
|
||||||
|
lng: 'it', // default language
|
||||||
|
// debug: true,
|
||||||
|
interpolation: { escapeValue: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('app')!;
|
||||||
|
ReactDOM.createRoot(rootElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ChakraProvider theme={theme}>
|
||||||
|
<RootContext.Provider value={initRootContext}>
|
||||||
|
<App />
|
||||||
|
</RootContext.Provider>
|
||||||
|
</ChakraProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
|
@ -0,0 +1,30 @@
|
||||||
|
interface TauriConfig {
|
||||||
|
index_dir: string;
|
||||||
|
ipfs_gateways: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ipfsGateways: string[] = [
|
||||||
|
'cloudflare-ipfs.com',
|
||||||
|
'dweb.link',
|
||||||
|
'ipfs.io',
|
||||||
|
'gateway.pinata.cloud'
|
||||||
|
];
|
||||||
|
|
||||||
|
export default async function getIpfsGateways() {
|
||||||
|
if (import.meta.env.VITE_TAURI === '1') {
|
||||||
|
import('@tauri-apps/api').then(api => {
|
||||||
|
api.invoke('get_config').then((conf) => {
|
||||||
|
const config = conf as TauriConfig;
|
||||||
|
return config.ipfs_gateways;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
return <string[]>[];
|
||||||
|
} else {
|
||||||
|
const ipfsGateways: string[] = JSON.parse(localStorage.getItem('ipfs_gateways') || '[]');
|
||||||
|
return ipfsGateways;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseIpfsGateways(text: string) {
|
||||||
|
return text.split('\n').filter(g => g.length);
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
import type { Book } from './searcher';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const http = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_BACKEND_BASE_API,
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function search(query: string, limit: number) {
|
||||||
|
const response = await http.get(`search?limit=${limit}&query=${query}`);
|
||||||
|
return response.data.books as Book[];
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { Book } from './searcher';
|
||||||
|
import { invoke } from '@tauri-apps/api';
|
||||||
|
|
||||||
|
export default async function search(query: string, limit: number) {
|
||||||
|
const response = await invoke('search', { query, limit });
|
||||||
|
return response as Book[];
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
export interface Book {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
publisher?: string;
|
||||||
|
extension: string;
|
||||||
|
filesize: number;
|
||||||
|
language: string;
|
||||||
|
year?: number;
|
||||||
|
pages?: number;
|
||||||
|
isbn: string;
|
||||||
|
ipfs_cid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Row {
|
||||||
|
original: Book;
|
||||||
|
toggleExpanded: (arg0: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function search(query: string, limit: number) {
|
||||||
|
if (import.meta.env.VITE_TAURI === '1') {
|
||||||
|
return await import('./searcher-tauri').then(({ default: search }) => search(query, limit));
|
||||||
|
} else {
|
||||||
|
return await import('./searcher-browser').then(({ default: search }) => search(query, limit));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export const initRootContext = {
|
||||||
|
ipfs_gateways: <string[]>[]
|
||||||
|
};
|
||||||
|
|
||||||
|
const RootContext = React.createContext(initRootContext);
|
||||||
|
|
||||||
|
export default RootContext;
|
|
@ -0,0 +1,41 @@
|
||||||
|
#app {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
select.chakra-select {
|
||||||
|
padding-inline-start: var(--chakra-space-9);
|
||||||
|
color: var(--chakra-colors-chakra-placeholder-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
select.chakra-select.active {
|
||||||
|
padding-inline-start: var(--chakra-space-9);
|
||||||
|
color: var(--chakra-colors-whiteAlpha-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chakra-ui-light select.chakra-select {
|
||||||
|
color: var(--chakra-colors-gray-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chakra-ui-light select.chakra-select.active {
|
||||||
|
color: var(--chakra-colors-gray-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chakra-heading {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Lemon Yellow Sun';
|
||||||
|
src: url("./font/LemonYellowSun.otf") format("opentype");
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-family: 'Lemon Yellow Sun' !important;
|
||||||
|
font-size: 32px !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { extendTheme, type ThemeConfig } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
const config: ThemeConfig = {
|
||||||
|
initialColorMode: 'light',
|
||||||
|
useSystemColorMode: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const theme = extendTheme({ config });
|
||||||
|
|
||||||
|
export default theme;
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": [
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable",
|
||||||
|
"ESNext"
|
||||||
|
],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": false,
|
||||||
|
"esModuleInterop": false,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.d.ts",
|
||||||
|
"src/**/*.tsx"
|
||||||
|
],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import faviconsPlugin from '@darkobits/vite-plugin-favicons';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import topLevelAwait from 'vite-plugin-top-level-await';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig(() => {
|
||||||
|
if (process.env.TAURI_PLATFORM) {
|
||||||
|
process.env.VITE_TAURI = '1';
|
||||||
|
} else {
|
||||||
|
process.env.VITE_TAURI = '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [
|
||||||
|
process.env.VITE_TAURI === '1' ? topLevelAwait() : null,
|
||||||
|
react(),
|
||||||
|
process.env.VITE_TAURI === '0'
|
||||||
|
? faviconsPlugin({
|
||||||
|
icons: { favicons: { source: '../crates/zlib-searcher-desktop/icons/icon.png' } }
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
],
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
'chakra-ui': ['@chakra-ui/react']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
|
@ -0,0 +1,3 @@
|
||||||
|
[toolchain]
|
||||||
|
channel = "nightly"
|
||||||
|
components = ["rustfmt", "clippy"]
|
|
@ -0,0 +1,4 @@
|
||||||
|
version = "Two"
|
||||||
|
|
||||||
|
indent_style = "Block"
|
||||||
|
imports_granularity = "Crate"
|
|
@ -0,0 +1,20 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
NAME=zlib-searcher
|
||||||
|
|
||||||
|
targets=(x86_64-unknown-linux-gnu x86_64-unknown-linux-musl)
|
||||||
|
targets_win=(x86_64-pc-windows-gnu)
|
||||||
|
|
||||||
|
for target in "${targets[@]}"
|
||||||
|
do
|
||||||
|
echo $target
|
||||||
|
cargo build --release --target $target -p zlib-searcher
|
||||||
|
pushd target/$target/release/ && zip zlib-searcher-$target.zip $NAME && mv zlib-searcher-$target.zip ../../ && popd
|
||||||
|
done
|
||||||
|
|
||||||
|
for target in "${targets_win[@]}"
|
||||||
|
do
|
||||||
|
echo $target
|
||||||
|
cargo build --release --target $target -p zlib-searcher
|
||||||
|
pushd target/$target/release/ && zip zlib-searcher-$target.zip $NAME.exe && mv zlib-searcher-$target.zip ../../ && popd
|
||||||
|
done
|
|
@ -0,0 +1,2 @@
|
||||||
|
#!/bin/bash
|
||||||
|
cargo build --release --no-default-features --features best-speed --target x86_64-unknown-linux-musl -p zlib-searcher
|
|
@ -0,0 +1,119 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
CUR_DIR=$( cd $( dirname $0 ) && pwd )
|
||||||
|
|
||||||
|
project=zlib-searcher
|
||||||
|
targets=()
|
||||||
|
features=()
|
||||||
|
|
||||||
|
ALL_TARGETS="
|
||||||
|
i686-unknown-linux-musl
|
||||||
|
x86_64-pc-windows-gnu
|
||||||
|
x86_64-unknown-linux-gnu
|
||||||
|
x86_64-unknown-linux-musl
|
||||||
|
armv7-unknown-linux-musleabihf
|
||||||
|
armv7-unknown-linux-gnueabihf
|
||||||
|
arm-unknown-linux-gnueabi
|
||||||
|
arm-unknown-linux-gnueabihf
|
||||||
|
arm-unknown-linux-musleabi
|
||||||
|
arm-unknown-linux-musleabihf
|
||||||
|
aarch64-unknown-linux-gnu
|
||||||
|
aarch64-unknown-linux-musl
|
||||||
|
"
|
||||||
|
|
||||||
|
while getopts "p:t:a:f" opt; do
|
||||||
|
case $opt in
|
||||||
|
p)
|
||||||
|
project=($OPTARG)
|
||||||
|
;;
|
||||||
|
t)
|
||||||
|
targets+=($OPTARG)
|
||||||
|
;;
|
||||||
|
a)
|
||||||
|
targets+=($ALL_TARGETS)
|
||||||
|
;;
|
||||||
|
f)
|
||||||
|
features+=($OPTARG)
|
||||||
|
;;
|
||||||
|
?)
|
||||||
|
echo "Usage: $(basename $0) [-t <target-triple>] [-f features]"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
features+=${EXTRA_FEATURES}
|
||||||
|
|
||||||
|
if [[ "${#targets[@]}" == "0" ]]; then
|
||||||
|
echo "Specifying compile target with -t <target-triple>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
function build() {
|
||||||
|
cd "$CUR_DIR/.."
|
||||||
|
|
||||||
|
TARGET=$1
|
||||||
|
|
||||||
|
RELEASE_DIR="target/${TARGET}/release"
|
||||||
|
TARGET_FEATURES="${features[@]}"
|
||||||
|
|
||||||
|
if [[ "${TARGET_FEATURES}" != "" ]]; then
|
||||||
|
echo "* Building ${project} package for ${TARGET} with features \"${TARGET_FEATURES}\" ..."
|
||||||
|
|
||||||
|
cross build --target "${TARGET}" \
|
||||||
|
--default-features=false
|
||||||
|
--features "${TARGET_FEATURES}" \
|
||||||
|
-p zlib-searcher \
|
||||||
|
--release
|
||||||
|
else
|
||||||
|
echo "* Building ${project} package for ${TARGET} ..."
|
||||||
|
|
||||||
|
cross build --target "${TARGET}" \
|
||||||
|
-p zlib-searcher \
|
||||||
|
--release
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $? != "0" ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PKG_DIR="${CUR_DIR}/../release"
|
||||||
|
mkdir -p "${PKG_DIR}"
|
||||||
|
|
||||||
|
if [[ "$TARGET" == *"-windows-"* ]]; then
|
||||||
|
PKG_NAME="${project}-${TARGET}.zip"
|
||||||
|
PKG_PATH="${PKG_DIR}/${PKG_NAME}"
|
||||||
|
|
||||||
|
echo "* Packaging ZIP in ${PKG_PATH} ..."
|
||||||
|
cd ${RELEASE_DIR}
|
||||||
|
zip ${PKG_PATH} ${project}.exe
|
||||||
|
|
||||||
|
if [[ $? != "0" ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${PKG_DIR}"
|
||||||
|
shasum -a 256 "${PKG_NAME}" > "${PKG_NAME}.sha256"
|
||||||
|
else
|
||||||
|
PKG_NAME="${project}-${TARGET}.tar.gz"
|
||||||
|
PKG_PATH="${PKG_DIR}/${PKG_NAME}"
|
||||||
|
|
||||||
|
cd ${RELEASE_DIR}
|
||||||
|
|
||||||
|
echo "* Packaging gz in ${PKG_PATH} ..."
|
||||||
|
tar -czf ${PKG_PATH} ${project}
|
||||||
|
|
||||||
|
if [[ $? != "0" ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${PKG_DIR}"
|
||||||
|
shasum -a 256 "${PKG_NAME}" > "${PKG_NAME}.sha256"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "* Done build package ${PKG_NAME}"
|
||||||
|
}
|
||||||
|
|
||||||
|
for target in "${targets[@]}"; do
|
||||||
|
cargo clean;
|
||||||
|
build "$target";
|
||||||
|
done
|