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
|