feat: rewrite frontend in Chakra UI
parent
d04472b60b
commit
e79a592cfe
|
@ -2441,6 +2441,17 @@ dependencies = [
|
|||
"objc_exception",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc-foundation"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
|
||||
dependencies = [
|
||||
"block",
|
||||
"objc",
|
||||
"objc_id",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc_exception"
|
||||
version = "0.1.2"
|
||||
|
@ -2474,6 +2485,16 @@ dependencies = [
|
|||
"loom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2078c0039e6a54a0c42c28faa984e115fb4c2d5bf2208f77d1961002df8576f8"
|
||||
dependencies = [
|
||||
"pathdiff",
|
||||
"windows-sys 0.42.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.43"
|
||||
|
@ -2618,6 +2639,12 @@ version = "0.1.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "498a099351efa4becc6a19c72aa9270598e8fd274ca47052e37455241c88b696"
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.2.0"
|
||||
|
@ -3114,6 +3141,30 @@ dependencies = [
|
|||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0149778bd99b6959285b0933288206090c50e2327f47a9c463bfdbf45c8823ea"
|
||||
dependencies = [
|
||||
"block",
|
||||
"dispatch",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"objc",
|
||||
"objc-foundation",
|
||||
"objc_id",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows 0.37.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-stemmers"
|
||||
version = "1.2.0"
|
||||
|
@ -3793,9 +3844,12 @@ dependencies = [
|
|||
"ignore",
|
||||
"objc",
|
||||
"once_cell",
|
||||
"open",
|
||||
"percent-encoding",
|
||||
"rand 0.8.5",
|
||||
"raw-window-handle",
|
||||
"regex",
|
||||
"rfd",
|
||||
"semver 1.0.14",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
@ -3847,6 +3901,7 @@ dependencies = [
|
|||
"png",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"semver 1.0.14",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
@ -4559,6 +4614,19 @@ dependencies = [
|
|||
"windows_x86_64_msvc 0.32.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57b543186b344cc61c85b5aab0d2e3adf4e0f99bc076eff9aa5927bcc0b8a647"
|
||||
dependencies = [
|
||||
"windows_aarch64_msvc 0.37.0",
|
||||
"windows_i686_gnu 0.37.0",
|
||||
"windows_i686_msvc 0.37.0",
|
||||
"windows_x86_64_gnu 0.37.0",
|
||||
"windows_x86_64_msvc 0.37.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.39.0"
|
||||
|
@ -4651,6 +4719,12 @@ version = "0.36.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2623277cb2d1c216ba3b578c0f3cf9cdebeddb6e66b1b218bb33596ea7769c3a"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.39.0"
|
||||
|
@ -4675,6 +4749,12 @@ version = "0.36.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3925fd0b0b804730d44d4b6278c50f9699703ec49bcd628020f46f4ba07d9e1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.39.0"
|
||||
|
@ -4699,6 +4779,12 @@ version = "0.36.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce907ac74fe331b524c1298683efbf598bb031bc84d5e274db2083696d07c57c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.39.0"
|
||||
|
@ -4723,6 +4809,12 @@ version = "0.36.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2babfba0828f2e6b32457d5341427dcbb577ceef556273229959ac23a10af33d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.39.0"
|
||||
|
@ -4753,6 +4845,12 @@ version = "0.36.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4dd6dc7df2d84cf7b33822ed5b86318fb1781948e9663bacd047fc9dd52259d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.39.0"
|
||||
|
|
|
@ -21,7 +21,7 @@ env_logger = { workspace = true }
|
|||
log = { workspace = true }
|
||||
|
||||
serde_json = "1.0"
|
||||
tauri = { version = "1.2.1", features = [] }
|
||||
tauri = { version = "1.2.1", features = ["dialog-open", "shell-open"] }
|
||||
|
||||
tokio = { version = "1", features = ["sync", "parking_lot"] }
|
||||
|
||||
|
|
|
@ -11,7 +11,13 @@
|
|||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
"all": false
|
||||
"all": false,
|
||||
"shell": {
|
||||
"open": true
|
||||
},
|
||||
"dialog": {
|
||||
"open": true
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
|
|
|
@ -47,6 +47,7 @@ async fn search(query: web::Query<SearchQuery>, state: web::Data<AppState>) -> i
|
|||
|
||||
return HttpResponse::Ok()
|
||||
.insert_header(header::ContentType::json())
|
||||
.insert_header((header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"))
|
||||
.json(result);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
// Generated by 'unplugin-auto-import'
|
||||
export {}
|
||||
declare global {
|
||||
|
||||
}
|
|
@ -7,6 +7,6 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -3,27 +3,44 @@
|
|||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/zu1k/zlib-searcher",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^6.1.0",
|
||||
"@chakra-ui/react": "^2.4.2",
|
||||
"@emotion/react": "^11.10.5",
|
||||
"@emotion/styled": "^11.10.5",
|
||||
"@tanstack/react-table": "^8.7.0",
|
||||
"@tanstack/table-core": "^8.7.0",
|
||||
"@tauri-apps/api": "^1.2.0",
|
||||
"ant-design-vue": "^3.2.15",
|
||||
"ahooks": "^3.7.2",
|
||||
"axios": "^1.2.0",
|
||||
"filesize": "^10.0.5",
|
||||
"vue": "^3.2.41"
|
||||
"framer-motion": "^7.6.18",
|
||||
"i18next": "^22.0.6",
|
||||
"i18next-browser-languagedetector": "^7.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.40.0",
|
||||
"react-i18next": "^12.0.0",
|
||||
"react-icons": "^4.7.1",
|
||||
"react-intersection-observer": "^9.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.5",
|
||||
"@darkobits/vite-plugin-favicons": "^0.1.8",
|
||||
"@types/lodash": "^4.14.191",
|
||||
"@types/node": "^18.11.10",
|
||||
"@vitejs/plugin-vue": "^3.2.0",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.9",
|
||||
"@vitejs/plugin-react": "^2.2.0",
|
||||
"prettier": "^2.8.0",
|
||||
"typescript": "^4.6.4",
|
||||
"unplugin-auto-import": "^0.12.0",
|
||||
"unplugin-vue-components": "^0.22.11",
|
||||
"vite": "^3.2.3",
|
||||
"vue-tsc": "^1.0.9"
|
||||
"typescript": "^4.9.3",
|
||||
"vite": "^3.2.4",
|
||||
"vite-plugin-top-level-await": "^1.2.2"
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,66 @@
|
|||
import { Flex, HStack, Icon, IconButton, Link, Spacer } from '@chakra-ui/react';
|
||||
import React, { Suspense, useState } from 'react';
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Search setBooks={setBooks} />
|
||||
<BooksView books={books} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Settings =
|
||||
import.meta.env.VITE_TAURI === '1'
|
||||
? React.lazy(() => import('./components/Settings-tauri'))
|
||||
: React.Fragment;
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Flex direction="column" h="full">
|
||||
<Header title="zLib Searcher">
|
||||
<HStack spacing={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 />
|
||||
{import.meta.env.VITE_TAURI === '1' && (
|
||||
<Suspense>
|
||||
<Settings />
|
||||
</Suspense>
|
||||
)}
|
||||
</HStack>
|
||||
</Header>
|
||||
|
||||
<Main />
|
||||
|
||||
<Spacer />
|
||||
<Footer>
|
||||
zLib Searcher ©2022 | <ExternalLink href={repository}>Source Code</ExternalLink>
|
||||
</Footer>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
|
@ -1,24 +0,0 @@
|
|||
<template>
|
||||
<a-layout class="layout">
|
||||
<a-page-header style="padding: 10px 30px" title="zLib Searcher">
|
||||
<template #extra>
|
||||
<Settings />
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<a-layout-content style="padding: 0 20px">
|
||||
<Search />
|
||||
</a-layout-content>
|
||||
|
||||
<a-layout-footer style="text-align: center">
|
||||
zLib Searcher ©2022 | <a href="https://github.com/zu1k/zlib-searcher" id="source">Source Code</a>
|
||||
</a-layout-footer>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Search from './components/Search.vue';
|
||||
import Settings from './platform/__platform__/Settings.vue';
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
|
@ -0,0 +1,279 @@
|
|||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
Divider,
|
||||
GridItem,
|
||||
Heading,
|
||||
Link,
|
||||
SimpleGrid,
|
||||
TableContainer,
|
||||
Tag,
|
||||
Text
|
||||
} from '@chakra-ui/react';
|
||||
import { FilterFn, createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { Book } from '../scripts/searcher';
|
||||
import DataTable from './DataTable';
|
||||
import ExternalLink from './ExternalLink';
|
||||
import React from 'react';
|
||||
import { filesize as formatFileSize } from 'filesize';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const columnHelper = createColumnHelper<Book>();
|
||||
|
||||
const colorSchemes = [
|
||||
'red',
|
||||
'orange',
|
||||
'yellow',
|
||||
'green',
|
||||
'teal',
|
||||
'blue',
|
||||
'cyan',
|
||||
'purple',
|
||||
'pink',
|
||||
'gray'
|
||||
];
|
||||
|
||||
const arrFilter: FilterFn<Book> = (row, columnId, filterValue: string[]) => {
|
||||
if (!filterValue.length) return true;
|
||||
const value: string = row.getValue(columnId);
|
||||
return filterValue.includes(value);
|
||||
};
|
||||
|
||||
const ipfsGateways: string[] = [
|
||||
'cloudflare-ipfs.com',
|
||||
'dweb.link',
|
||||
'ipfs.io',
|
||||
'gateway.pinata.cloud'
|
||||
];
|
||||
|
||||
function downloadLinkFromIPFS(gateway: string, book: Book, schema: string = 'https') {
|
||||
return (
|
||||
`${schema}://${gateway}/ipfs/${book.ipfs_cid}?filename=` +
|
||||
encodeURIComponent(`${book.title}_${book.author}.${book.extension}`)
|
||||
);
|
||||
}
|
||||
|
||||
interface DescriptionProps {
|
||||
name: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
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 interface BooksViewProps {
|
||||
books: Book[];
|
||||
}
|
||||
|
||||
const BooksView: React.FC<BooksViewProps> = ({ books }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const columns = React.useMemo(
|
||||
() => [
|
||||
columnHelper.accessor('title', {
|
||||
header: t('book.title') ?? 'Title',
|
||||
sortingFn: 'text',
|
||||
enableColumnFilter: false,
|
||||
meta: { width: '20%' }
|
||||
}),
|
||||
columnHelper.accessor('author', {
|
||||
header: t('book.author') ?? 'Author',
|
||||
sortingFn: 'text',
|
||||
enableColumnFilter: false,
|
||||
meta: { width: '20%' }
|
||||
}),
|
||||
columnHelper.accessor('publisher', {
|
||||
header: t('book.publisher') ?? 'Publisher',
|
||||
sortingFn: 'text',
|
||||
sortUndefined: 1,
|
||||
enableColumnFilter: false,
|
||||
meta: { width: '20%', breakpoint: 'md' }
|
||||
}),
|
||||
columnHelper.accessor(
|
||||
'extension',
|
||||
(() => {
|
||||
const renderer = (value: string) => {
|
||||
const extension = value;
|
||||
const colorScheme = colorSchemes[extension.charCodeAt(0) % colorSchemes.length];
|
||||
return <Tag colorScheme={colorScheme}>{extension}</Tag>;
|
||||
};
|
||||
return {
|
||||
header: t('book.extension') ?? 'Extension',
|
||||
cell: (cell) => renderer(cell.getValue()),
|
||||
enableSorting: false,
|
||||
filterFn: arrFilter,
|
||||
meta: { breakpoint: 'lg', filterRenderer: renderer }
|
||||
};
|
||||
})()
|
||||
),
|
||||
columnHelper.accessor('filesize', {
|
||||
header: t('book.filesize') ?? 'Filesize',
|
||||
cell: (cell) => {
|
||||
const filesize = cell.getValue();
|
||||
return <Box>{formatFileSize(filesize) as string}</Box>;
|
||||
},
|
||||
enableColumnFilter: false,
|
||||
meta: { breakpoint: 'lg' }
|
||||
}),
|
||||
columnHelper.accessor(
|
||||
'language',
|
||||
(() => {
|
||||
const renderer = (value: string) => {
|
||||
const language = value.toLocaleLowerCase().trim();
|
||||
const colorScheme = colorSchemes[language.length % colorSchemes.length];
|
||||
return (
|
||||
<Tag colorScheme={colorScheme} textTransform="capitalize">
|
||||
{language}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
return {
|
||||
header: t('book.language') ?? 'Language',
|
||||
cell: (cell) => renderer(cell.getValue()),
|
||||
enableSorting: false,
|
||||
filterFn: arrFilter,
|
||||
meta: { breakpoint: 'lg', filterRenderer: renderer }
|
||||
};
|
||||
})()
|
||||
),
|
||||
columnHelper.accessor('year', {
|
||||
header: t('book.year') ?? 'Year',
|
||||
cell: (cell) => {
|
||||
const year = cell.getValue();
|
||||
return year ? year : '';
|
||||
},
|
||||
sortUndefined: 1,
|
||||
enableColumnFilter: false,
|
||||
meta: { breakpoint: 'xl' }
|
||||
}),
|
||||
columnHelper.accessor('pages', {
|
||||
header: t('book.pages') ?? 'Pages',
|
||||
cell: (cell) => {
|
||||
const pages = cell.getValue();
|
||||
return pages ? pages : '';
|
||||
},
|
||||
sortUndefined: 1,
|
||||
enableColumnFilter: false,
|
||||
meta: { breakpoint: 'xl' }
|
||||
})
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const data = books.map((book) => ({
|
||||
...book,
|
||||
publisher: book.publisher ? book.publisher : undefined,
|
||||
year: book.year ? book.year : undefined,
|
||||
pages: book.pages ? book.pages : undefined
|
||||
}));
|
||||
|
||||
const extensions = [...new Set(books.map((book) => book.extension.toLowerCase()))].sort();
|
||||
const languages = [...new Set(books.map((book) => book.language.toLowerCase()))].sort();
|
||||
|
||||
return (
|
||||
<TableContainer px={8} my={4} overflowY="unset">
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
pageSize={20}
|
||||
filterSchema={{ extension: extensions, language: languages }}
|
||||
sx={{ tableLayout: 'fixed' }}
|
||||
renderSubComponent={(row) => {
|
||||
const {
|
||||
id,
|
||||
title,
|
||||
author,
|
||||
publisher,
|
||||
extension,
|
||||
filesize,
|
||||
language,
|
||||
year,
|
||||
pages,
|
||||
isbn,
|
||||
ipfs_cid
|
||||
} = row.original;
|
||||
return (
|
||||
<Card mt={2} mb={4} mx={8}>
|
||||
<CardHeader>
|
||||
<Heading as="h3" fontSize="xl">
|
||||
{title}
|
||||
</Heading>
|
||||
</CardHeader>
|
||||
<Divider />
|
||||
<CardBody>
|
||||
<SimpleGrid columns={{ sm: 1, md: 3, lg: 4 }} spacing={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>
|
||||
<SimpleGrid columns={{ sm: 2, md: 3, lg: 4, xl: 5 }} spacing={4}>
|
||||
{ipfsGateways.map((gateway) => (
|
||||
<Button
|
||||
as={ExternalLink}
|
||||
href={downloadLinkFromIPFS(gateway, row.original)}
|
||||
key={gateway}
|
||||
variant="outline"
|
||||
>
|
||||
{gateway}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
as={ExternalLink}
|
||||
href={downloadLinkFromIPFS('localhost:8080', row.original, 'http')}
|
||||
variant="outline"
|
||||
>
|
||||
localhost:8080
|
||||
</Button>
|
||||
</SimpleGrid>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
||||
|
||||
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,295 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import {
|
||||
Collapse,
|
||||
Flex,
|
||||
Icon,
|
||||
IconButton,
|
||||
IconButtonProps,
|
||||
Spacer,
|
||||
Table,
|
||||
TableProps,
|
||||
Tbody,
|
||||
Td,
|
||||
Text,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useBreakpoint,
|
||||
MenuButton,
|
||||
Menu,
|
||||
MenuList,
|
||||
Portal,
|
||||
MenuOptionGroup,
|
||||
MenuItemOption,
|
||||
useColorMode,
|
||||
Box
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
type ColumnDef,
|
||||
type PaginationState,
|
||||
type Row,
|
||||
type RowData,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
getFilteredRowModel
|
||||
} from '@tanstack/react-table';
|
||||
import {
|
||||
TbArrowNarrowDown,
|
||||
TbArrowNarrowUp,
|
||||
TbArrowsSort,
|
||||
TbChevronLeft,
|
||||
TbChevronRight,
|
||||
TbChevronsLeft,
|
||||
TbChevronsRight,
|
||||
TbFilter
|
||||
} from 'react-icons/tb';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
declare module '@tanstack/table-core' {
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
isNumeric?: boolean;
|
||||
width?: number | string;
|
||||
filterRenderer?: (value: string) => JSX.Element;
|
||||
breakpoint?: 'base' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
}
|
||||
}
|
||||
|
||||
const breakpoints = ['base', 'sm', 'md', 'lg', 'xl', '2xl'];
|
||||
|
||||
function compareBreakpoints(a: string, b: string) {
|
||||
return breakpoints.indexOf(a) >= breakpoints.indexOf(b);
|
||||
}
|
||||
|
||||
export interface DataTableProps<Data extends object> extends TableProps {
|
||||
data: Data[];
|
||||
columns: ColumnDef<Data, any>[];
|
||||
pageSize?: number;
|
||||
filterSchema?: { [K in keyof Data]?: Data[K][] };
|
||||
renderSubComponent: (row: Row<Data>) => React.ReactNode;
|
||||
}
|
||||
|
||||
export default function DataTable<Data extends object>({
|
||||
data,
|
||||
columns,
|
||||
pageSize = 20,
|
||||
filterSchema = {},
|
||||
renderSubComponent,
|
||||
...props
|
||||
}: DataTableProps<Data>) {
|
||||
const { t } = useTranslation();
|
||||
const { colorMode } = useColorMode();
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageSize,
|
||||
pageIndex: 0
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
data,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
state: {
|
||||
pagination
|
||||
},
|
||||
|
||||
enableHiding: true,
|
||||
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
|
||||
enableExpanding: true,
|
||||
getRowCanExpand: () => true,
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: setPagination
|
||||
});
|
||||
|
||||
const breakpoint = useBreakpoint();
|
||||
|
||||
React.useEffect(() => {
|
||||
table.getAllColumns().forEach((column) => {
|
||||
const meta = column.columnDef.meta;
|
||||
if (meta && compareBreakpoints(breakpoint, meta.breakpoint ?? 'base'))
|
||||
column.toggleVisibility(true);
|
||||
else column.toggleVisibility(false);
|
||||
});
|
||||
}, [breakpoint]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table {...props}>
|
||||
<Thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<Tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const meta = header.column.columnDef.meta;
|
||||
return header.column.getIsVisible() ? (
|
||||
<Th
|
||||
key={header.id}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
w={meta?.width ?? 'auto'}
|
||||
isNumeric={meta?.isNumeric ?? false}
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
|
||||
{header.column.getCanSort() ? (
|
||||
<Text as="span" pl={2} position="relative" top={0.5}>
|
||||
{header.column.getIsSorted() ? (
|
||||
header.column.getIsSorted() === 'desc' ? (
|
||||
<Icon
|
||||
aria-label={t('table.sort_desc') ?? ''}
|
||||
title={t('table.sort_desc') ?? ''}
|
||||
as={TbArrowNarrowDown}
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
aria-label={t('table.sort_asc') ?? ''}
|
||||
title={t('table.sort_asc') ?? ''}
|
||||
as={TbArrowNarrowUp}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Icon
|
||||
aria-label={t('table.not_sorted') ?? ''}
|
||||
title={t('table.not_sorted') ?? ''}
|
||||
as={TbArrowsSort}
|
||||
/>
|
||||
)}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{header.column.getCanFilter() ? (
|
||||
<Text as="span" pl={2} position="relative" top={0.5}>
|
||||
<Menu closeOnSelect={false}>
|
||||
<MenuButton
|
||||
aria-label={t('table.filter') ?? ''}
|
||||
title={t('table.filter') ?? ''}
|
||||
type="button"
|
||||
>
|
||||
<Icon as={TbFilter} />
|
||||
</MenuButton>
|
||||
<Portal>
|
||||
<MenuList>
|
||||
<MenuOptionGroup
|
||||
type="checkbox"
|
||||
onChange={header.column.setFilterValue}
|
||||
>
|
||||
{filterSchema[header.column.id as keyof Data]?.map((val, i) => {
|
||||
const value = `${val}`;
|
||||
return (
|
||||
<MenuItemOption value={value} key={i}>
|
||||
{meta?.filterRenderer ? meta.filterRenderer(value) : value}
|
||||
</MenuItemOption>
|
||||
);
|
||||
})}
|
||||
</MenuOptionGroup>
|
||||
</MenuList>
|
||||
</Portal>
|
||||
</Menu>
|
||||
</Text>
|
||||
) : null}
|
||||
</Th>
|
||||
) : null;
|
||||
})}
|
||||
</Tr>
|
||||
))}
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<React.Fragment key={row.id}>
|
||||
<Tr onClick={row.getToggleExpandedHandler()}>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const meta = cell.column.columnDef.meta;
|
||||
return cell.column.getIsVisible() ? (
|
||||
<Td
|
||||
key={cell.id}
|
||||
isNumeric={meta?.isNumeric ?? false}
|
||||
borderBottom="none"
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
title={(cell.getValue() as any)?.toString()}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</Td>
|
||||
) : null;
|
||||
})}
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td colSpan={row.getVisibleCells().length} p={0}>
|
||||
<Collapse in={row.getIsExpanded()} animateOpacity unmountOnExit>
|
||||
{renderSubComponent(row)}
|
||||
</Collapse>
|
||||
</Td>
|
||||
</Tr>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
{data.length === 0 && (
|
||||
<Flex mt={16} mb={12}>
|
||||
<Spacer />
|
||||
<Text color={colorMode === 'light' ? 'gray.400' : 'gray.600'}>{t('table.no_data')}</Text>
|
||||
<Spacer />
|
||||
</Flex>
|
||||
)}
|
||||
<Flex mt={4}>
|
||||
<Spacer />
|
||||
<IconButton
|
||||
aria-label={t('table.first_page')}
|
||||
title={t('table.first_page') ?? ''}
|
||||
ml={1}
|
||||
icon={<Icon as={TbChevronsLeft} />}
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label={t('table.previous_page')}
|
||||
title={t('table.previous_page') ?? ''}
|
||||
ml={1}
|
||||
icon={<Icon as={TbChevronLeft} />}
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
/>
|
||||
{Array.from({ length: table.getPageCount() }, (_, i) => i).map((pageIndex) => {
|
||||
const title = t('table.page', { page: pageIndex + 1 });
|
||||
const disabled = pagination.pageIndex === pageIndex;
|
||||
const style: Partial<IconButtonProps> = disabled ? { colorScheme: 'blue' } : {};
|
||||
return (
|
||||
<IconButton
|
||||
aria-label={title}
|
||||
title={title}
|
||||
ml={1}
|
||||
key={pageIndex}
|
||||
icon={<Text>{pageIndex + 1}</Text>}
|
||||
onClick={() => table.setPageIndex(pageIndex)}
|
||||
disabled={disabled}
|
||||
{...style}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<IconButton
|
||||
aria-label={t('table.next_page')}
|
||||
title={t('table.next_page') ?? ''}
|
||||
ml={1}
|
||||
icon={<Icon as={TbChevronRight} />}
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label={t('table.last_page')}
|
||||
title={t('table.last_page') ?? ''}
|
||||
ml={1}
|
||||
icon={<Icon as={TbChevronsRight} />}
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
/>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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,49 @@
|
|||
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={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'}
|
||||
>
|
||||
<Heading as="h1" fontSize="xl" my={2}>
|
||||
{title}
|
||||
</Heading>
|
||||
<Spacer />
|
||||
<Box>{children}</Box>
|
||||
</Flex>
|
||||
<Box ref={ref} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
|
@ -0,0 +1,42 @@
|
|||
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>
|
||||
</MenuOptionGroup>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSwitch;
|
|
@ -0,0 +1,109 @@
|
|||
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';
|
||||
|
||||
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>('');
|
||||
|
||||
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={4} px={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}
|
||||
/>
|
||||
<SearchInput
|
||||
icon={<Icon as={IoLanguage} />}
|
||||
placeholder={t('book.language')}
|
||||
value={language}
|
||||
onChange={setLanguage}
|
||||
/>
|
||||
<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;
|
|
@ -1,332 +0,0 @@
|
|||
<template>
|
||||
<div id="input">
|
||||
<a-row type="flex">
|
||||
<a-col :flex="1">
|
||||
<a-input
|
||||
v-model:value="title"
|
||||
placeholder="书名"
|
||||
allow-clear
|
||||
@input="debounce(handleSearch)"
|
||||
>
|
||||
<template #prefix>
|
||||
<BookOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-col>
|
||||
<a-col :flex="1">
|
||||
<a-input
|
||||
v-model:value="author"
|
||||
placeholder="作者"
|
||||
allow-clear
|
||||
@input="debounce(handleSearch)"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-col>
|
||||
<a-col :flex="1">
|
||||
<a-input
|
||||
v-model:value="publisher"
|
||||
placeholder="出版社"
|
||||
allow-clear
|
||||
@input="debounce(handleSearch)"
|
||||
>
|
||||
<template #prefix>
|
||||
<BankOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row type="flex">
|
||||
<a-col :flex="1">
|
||||
<a-input
|
||||
v-model:value="extension"
|
||||
placeholder="扩展名"
|
||||
allow-clear
|
||||
@input="debounce(handleSearch)"
|
||||
>
|
||||
<template #prefix>
|
||||
<FileTextOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-col>
|
||||
<a-col :flex="1">
|
||||
<a-input
|
||||
v-model:value="language"
|
||||
placeholder="语言"
|
||||
allow-clear
|
||||
@input="debounce(handleSearch)"
|
||||
>
|
||||
<template #prefix>
|
||||
<TranslationOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-col>
|
||||
<a-col :flex="1">
|
||||
<a-input
|
||||
v-model:value="isbn"
|
||||
placeholder="ISBN"
|
||||
allow-clear
|
||||
@input="debounce(handleSearch)"
|
||||
>
|
||||
<template #prefix>
|
||||
<BorderlessTableOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-input
|
||||
v-model:value="complexQuery"
|
||||
placeholder="复杂查询"
|
||||
allow-clear
|
||||
@input="debounce(handleSearch)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="result" style="margin-top: 20px">
|
||||
<a-table
|
||||
style="word-break: break-all"
|
||||
:dataSource="books"
|
||||
:columns="columns"
|
||||
:rowKey="(record: any) => record.id"
|
||||
:pagination="{ defaultPageSize: 20 }"
|
||||
bordered
|
||||
expandRowByClick
|
||||
:expand-icon-column-index="-1"
|
||||
size="middle"
|
||||
@resizeColumn="handleResizeColumn"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'filesize'">
|
||||
{{ getFilesize(record.filesize) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'isbn'">
|
||||
<span>
|
||||
<a-tag v-for="isbn in record.isbn.split(',')" :key="isbn">
|
||||
{{ isbn }}
|
||||
</a-tag>
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #expandedRowRender="{ record }">
|
||||
<a-card size="small">
|
||||
<a-row>
|
||||
<a-descriptions bordered>
|
||||
<a-descriptions-item label="zlib_id | libgen_id">{{ record.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="ISBN">{{ record.isbn }}</a-descriptions-item>
|
||||
<a-descriptions-item label="ipfs_cid">{{ record.ipfs_cid }}</a-descriptions-item>
|
||||
<a-descriptions-item label="标题">{{ record.title }}</a-descriptions-item>
|
||||
<a-descriptions-item label="作者">{{ record.author }}</a-descriptions-item>
|
||||
<a-descriptions-item label="出版社">{{ record.publisher }}</a-descriptions-item>
|
||||
<a-descriptions-item label="扩展名">{{ record.extension }}</a-descriptions-item>
|
||||
<a-descriptions-item label="文件大小">{{
|
||||
getFilesize(record.filesize)
|
||||
}}</a-descriptions-item>
|
||||
<a-descriptions-item label="页数">{{ record.pages }}</a-descriptions-item>
|
||||
<a-descriptions-item label="语言">{{ record.language }}</a-descriptions-item>
|
||||
<a-descriptions-item label="发布年份">{{ record.year }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-row>
|
||||
<a-row style="margin-top: 10px; overflow-x: scroll">
|
||||
<a-space style="width: 100px">
|
||||
<a-button
|
||||
v-for="item in ipfsGateways"
|
||||
:key="item"
|
||||
@click="downloadFromIPFS(item, record)"
|
||||
>{{ item }}</a-button
|
||||
>
|
||||
<a-button @click="downloadFromIPFS('127.0.0.1:8080', record, 'http')"
|
||||
>127.0.0.1:8080</a-button
|
||||
>
|
||||
</a-space>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UserOutlined from '@ant-design/icons-vue/UserOutlined';
|
||||
import BookOutlined from '@ant-design/icons-vue/BookOutlined';
|
||||
import TranslationOutlined from '@ant-design/icons-vue/TranslationOutlined';
|
||||
import FileTextOutlined from '@ant-design/icons-vue/FileTextOutlined';
|
||||
import BankOutlined from '@ant-design/icons-vue/BankOutlined';
|
||||
import BorderlessTableOutlined from '@ant-design/icons-vue/BorderlessTableOutlined';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { filesize } from 'filesize';
|
||||
|
||||
import { Book } from '../scripts/searcher';
|
||||
import useSearcher from '../platform/__platform__/searcher';
|
||||
import { createDebounce } from '../scripts/debounce';
|
||||
|
||||
import type { TableColumnType } from 'ant-design-vue';
|
||||
|
||||
const columns: TableColumnType<Book>[] = [
|
||||
{
|
||||
title: '书名',
|
||||
key: 'title',
|
||||
dataIndex: 'title',
|
||||
width: '20%'
|
||||
},
|
||||
{
|
||||
title: '作者',
|
||||
key: 'author',
|
||||
dataIndex: 'author',
|
||||
width: '20%'
|
||||
},
|
||||
{
|
||||
title: '出版社',
|
||||
key: 'publisher',
|
||||
dataIndex: 'publisher',
|
||||
width: '20%',
|
||||
responsive: ['xxxl', 'xxl', 'xl', 'lg', 'md']
|
||||
},
|
||||
{
|
||||
title: '扩展名',
|
||||
key: 'extension',
|
||||
dataIndex: 'extension',
|
||||
width: 40,
|
||||
align: 'center',
|
||||
responsive: ['xxxl', 'xxl', 'xl', 'lg'],
|
||||
filters: [
|
||||
{
|
||||
text: 'epub',
|
||||
value: 'epub'
|
||||
},
|
||||
{
|
||||
text: 'mobi',
|
||||
value: 'mobi'
|
||||
},
|
||||
{
|
||||
text: 'azw3',
|
||||
value: 'azw3'
|
||||
},
|
||||
{
|
||||
text: 'pdf',
|
||||
value: 'pdf'
|
||||
},
|
||||
{
|
||||
text: 'txt',
|
||||
value: 'txt'
|
||||
}
|
||||
],
|
||||
onFilter: (value: string | number | boolean, record: Book) =>
|
||||
record.extension.indexOf(value as string) === 0
|
||||
},
|
||||
{
|
||||
title: '文件大小',
|
||||
key: 'filesize',
|
||||
dataIndex: 'filesize',
|
||||
width: 40,
|
||||
align: 'center',
|
||||
sorter: (a: Book, b: Book) => a.filesize - b.filesize,
|
||||
sortDirections: ['descend', 'ascend'],
|
||||
responsive: ['xxxl', 'xxl', 'xl', 'lg']
|
||||
},
|
||||
{
|
||||
title: '语言',
|
||||
key: 'language',
|
||||
dataIndex: 'language',
|
||||
width: 50,
|
||||
align: 'center',
|
||||
responsive: ['xxxl', 'xxl', 'xl', 'lg']
|
||||
},
|
||||
{
|
||||
title: '年份',
|
||||
key: 'year',
|
||||
dataIndex: 'year',
|
||||
width: 20,
|
||||
align: 'center',
|
||||
responsive: ['xxxl', 'xxl', 'xl', 'lg'],
|
||||
sorter: (a: Book, b: Book) => a.year - b.year,
|
||||
sortDirections: ['descend', 'ascend']
|
||||
},
|
||||
{
|
||||
title: '页数',
|
||||
key: 'pages',
|
||||
dataIndex: 'pages',
|
||||
width: 40,
|
||||
align: 'center',
|
||||
responsive: ['xxxl', 'xxl', 'xl', 'lg']
|
||||
},
|
||||
{
|
||||
title: 'ISBN',
|
||||
key: 'isbn',
|
||||
dataIndex: 'isbn',
|
||||
width: 70,
|
||||
responsive: ['xxxl', 'xxl', 'xl', 'lg']
|
||||
}
|
||||
];
|
||||
|
||||
const complexQuery = ref<string>('');
|
||||
const title = ref<string>('');
|
||||
const author = ref<string>('');
|
||||
const publisher = ref<string>('');
|
||||
const extension = ref<string>('');
|
||||
const language = ref<string>('');
|
||||
const isbn = ref<string>('');
|
||||
const books = ref<Book[]>([]);
|
||||
|
||||
const ipfsGateways: string[] = [
|
||||
'cloudflare-ipfs.com',
|
||||
'dweb.link',
|
||||
'ipfs.io',
|
||||
'gateway.pinata.cloud'
|
||||
];
|
||||
|
||||
const searcher = useSearcher();
|
||||
|
||||
const debounce = createDebounce();
|
||||
function handleSearch() {
|
||||
searcher
|
||||
.search(constructQuery(), 100)
|
||||
.then((data) => {
|
||||
books.value = data;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function handleResizeColumn(w: number, col: any) {
|
||||
col.width = w;
|
||||
}
|
||||
function getFilesize(s: number | null) {
|
||||
return typeof s === 'number' ? `${filesize(s)}` : '0';
|
||||
}
|
||||
function constructQuery() {
|
||||
if (complexQuery.value) {
|
||||
return complexQuery.value;
|
||||
}
|
||||
const queryParts = [
|
||||
...title.value
|
||||
?.split(' ')
|
||||
.filter((s) => s && s.trim())
|
||||
.map((e) => `title:"${e}"`),
|
||||
...author.value
|
||||
?.split(' ')
|
||||
.filter((s) => s && s.trim())
|
||||
.map((e) => `author:"${e}"`),
|
||||
...publisher.value
|
||||
?.split(' ')
|
||||
.filter((s) => s && s.trim())
|
||||
.map((e) => `publisher:"${e}"`),
|
||||
...(extension.value ? [`extension:"${extension.value}"`] : []),
|
||||
...(language.value ? [`language:"${language.value}"`] : []),
|
||||
...(isbn.value ? [`isbn:"${isbn.value}"`] : [])
|
||||
];
|
||||
const query = queryParts.join('');
|
||||
console.log(query);
|
||||
return query;
|
||||
}
|
||||
function downloadFromIPFS(gateway: string, book: Book, schema: string = 'https') {
|
||||
const downloadUrl =
|
||||
`${schema}://${gateway}/ipfs/${book.ipfs_cid}?filename=` +
|
||||
encodeURIComponent(`${book.title}_${book.author}.${book.extension}`);
|
||||
window.open(downloadUrl, '_blank');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
|
@ -0,0 +1,57 @@
|
|||
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;
|
||||
}
|
||||
|
||||
const SearchInput: React.FC<SearchInputProps> = ({ placeholder, icon, value, onChange }) => {
|
||||
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
|
||||
children={
|
||||
value === '' ? null : (
|
||||
<IconButton
|
||||
aria-label={t('input.clear')}
|
||||
title={t('input.clear') ?? ''}
|
||||
icon={<Icon as={TbCircleX} color="GrayText" />}
|
||||
variant="unstyled"
|
||||
onClick={() => setControlledValue('')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</InputGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchInput;
|
|
@ -0,0 +1,203 @@
|
|||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Icon,
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputProps,
|
||||
InputRightElement,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
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';
|
||||
|
||||
interface Config {
|
||||
index_dir: string;
|
||||
ipfs_api_url: string;
|
||||
download_path: string;
|
||||
}
|
||||
|
||||
interface SettingsItemProps extends InputProps {
|
||||
label: string;
|
||||
help?: string;
|
||||
error?: string;
|
||||
leftElement?: React.ReactNode;
|
||||
rightElement?: React.ReactNode;
|
||||
}
|
||||
|
||||
const SettingsItem = React.forwardRef<HTMLInputElement, SettingsItemProps>(
|
||||
({ label, help, error, leftElement, rightElement, ...props }, ref) => {
|
||||
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}
|
||||
<Input ref={ref} {...props} />
|
||||
{rightElement}
|
||||
</InputGroup>
|
||||
{error && <FormErrorMessage>{error}</FormErrorMessage>}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
React.useEffect(() => {
|
||||
isOpen &&
|
||||
invoke('get_config').then((conf) => {
|
||||
const config = conf as Config;
|
||||
setValue('index_dir', config.index_dir, { shouldValidate: true });
|
||||
setValue('ipfs_api_url', config.ipfs_api_url, { shouldValidate: true });
|
||||
setValue('download_path', config.download_path, { shouldValidate: true });
|
||||
});
|
||||
}, [isOpen]);
|
||||
|
||||
const onSubmit = async (newConfig: Config) => {
|
||||
setSubmitting(true);
|
||||
await invoke('set_config', { newConfig });
|
||||
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}
|
||||
{...register('index_dir', { required: t('settings.index_dir_required') ?? true })}
|
||||
aria-invalid={errors.index_dir ? 'true' : 'false'}
|
||||
error={errors.index_dir?.message}
|
||||
rightElement={
|
||||
<InputRightElement>
|
||||
<IconButton
|
||||
aria-label={t('settings.index_dir_browse')}
|
||||
title={t('settings.index_dir_browse') ?? ''}
|
||||
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_api_url')}
|
||||
help={t('settings.ipfs_api_url_help') ?? undefined}
|
||||
{...register('ipfs_api_url', {
|
||||
required: t('settings.ipfs_api_url_required') ?? true
|
||||
})}
|
||||
aria-invalid={errors.ipfs_api_url ? 'true' : 'false'}
|
||||
error={errors.ipfs_api_url?.message}
|
||||
/>
|
||||
<SettingsItem
|
||||
label={t('settings.download_path')}
|
||||
help={t('settings.download_path_help') ?? undefined}
|
||||
{...register('download_path', {
|
||||
required: t('settings.download_path_required') ?? true
|
||||
})}
|
||||
aria-invalid={errors.download_path ? 'true' : 'false'}
|
||||
error={errors.download_path?.message}
|
||||
rightElement={
|
||||
<InputRightElement>
|
||||
<IconButton
|
||||
aria-label={t('settings.download_path_browse')}
|
||||
title={t('settings.download_path_browse') ?? ''}
|
||||
icon={<Icon as={TbFolder} />}
|
||||
variant="unstyled"
|
||||
pt={1}
|
||||
onClick={async () => {
|
||||
const selected = (await open({
|
||||
defaultPath: watch('download_path'),
|
||||
directory: true,
|
||||
multiple: false
|
||||
})) as string | null;
|
||||
if (selected)
|
||||
setValue('download_path', selected, { shouldValidate: true });
|
||||
}}
|
||||
/>
|
||||
</InputRightElement>
|
||||
}
|
||||
/>
|
||||
</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,42 @@
|
|||
{
|
||||
"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_api_url": "IPFS API URL",
|
||||
"ipfs_api_url_help": "The URL of the IPFS API.",
|
||||
"ipfs_api_url_required": "The IPFS API URL is required",
|
||||
"download_path": "Download path",
|
||||
"download_path_help": "The path where the books will be downloaded.",
|
||||
"download_path_required": "The download path is required",
|
||||
"download_path_browse": "Browse",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save"
|
||||
}
|
||||
}
|
||||
},
|
||||
"zh-CN": {
|
||||
"translation": {
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"index_dir": "索引目录",
|
||||
"index_dir_help": "存储索引的目录。",
|
||||
"index_dir_required": "索引目录不能为空",
|
||||
"index_dir_browse": "浏览",
|
||||
"ipfs_api_url": "IPFS API 地址",
|
||||
"ipfs_api_url_help": "IPFS API 地址。",
|
||||
"ipfs_api_url_required": "IPFS API 地址不能为空",
|
||||
"download_path": "下载路径",
|
||||
"download_path_help": "下载书籍的保存路径。",
|
||||
"download_path_required": "下载路径不能为空",
|
||||
"download_path_browse": "浏览",
|
||||
"cancel": "取消",
|
||||
"save": "保存"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
{
|
||||
"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}}"
|
||||
},
|
||||
"search": {
|
||||
"complex": "Complex search"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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}} 页"
|
||||
},
|
||||
"search": {
|
||||
"complex": "复杂搜索"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
import { createApp } from 'vue';
|
||||
import './style.css';
|
||||
import App from './App.vue';
|
||||
|
||||
createApp(App).mount('#app');
|
|
@ -0,0 +1,40 @@
|
|||
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';
|
||||
|
||||
const resources =
|
||||
import.meta.env.VITE_TAURI === '1'
|
||||
? merge(i18nResource, (await import('./i18n-tauri.json')).default)
|
||||
: i18nResource;
|
||||
|
||||
console.log('resources', resources);
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: 'en',
|
||||
|
||||
// debug: true,
|
||||
interpolation: { escapeValue: false }
|
||||
});
|
||||
|
||||
const rootElement = document.getElementById('app')!;
|
||||
ReactDOM.createRoot(rootElement).render(
|
||||
<React.StrictMode>
|
||||
<ChakraProvider theme={theme}>
|
||||
<App />
|
||||
</ChakraProvider>
|
||||
</React.StrictMode>
|
||||
);
|
|
@ -1 +0,0 @@
|
|||
<template></template>
|
|
@ -1,5 +0,0 @@
|
|||
import type { UseSearcher } from '../../scripts/searcher';
|
||||
|
||||
// workaround for ts
|
||||
declare const useSearcher: UseSearcher;
|
||||
export { useSearcher as default };
|
|
@ -1,2 +0,0 @@
|
|||
<!-- no settings in browser -->
|
||||
<template></template>
|
|
@ -1,17 +0,0 @@
|
|||
import type { UseSearcher } from '../../scripts/searcher';
|
||||
import axios from 'axios';
|
||||
|
||||
const useSearcher: UseSearcher = () => {
|
||||
const http = axios.create({
|
||||
baseURL: import.meta.env.VITE_BACKEND_BASE_API,
|
||||
timeout: 5000
|
||||
});
|
||||
return {
|
||||
search: async (query: string, limit: number) => {
|
||||
const response = await http.get(`search?limit=${limit}&query=${query}`);
|
||||
return response.data.books;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default useSearcher;
|
|
@ -1,60 +0,0 @@
|
|||
<template>
|
||||
<a-button shape="circle" @click="showSettings">
|
||||
<template #icon><SettingOutlined /></template
|
||||
></a-button>
|
||||
<a-modal
|
||||
v-model:visible="visible"
|
||||
title="设置"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="confirmLoading"
|
||||
@ok="handleOk"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="索引文件目录">
|
||||
<a-input v-model:value="config.index_dir" />
|
||||
</a-form-item>
|
||||
<a-form-item label="IPFS RPC 地址">
|
||||
<a-input v-model:value="config.ipfs_api_url" placeholder="http://localhost:5001/" />
|
||||
</a-form-item>
|
||||
<a-form-item label="下载目录">
|
||||
<a-input v-model:value="config.download_path" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import SettingOutlined from '@ant-design/icons-vue/SettingOutlined';
|
||||
|
||||
const visible = ref<boolean>(false);
|
||||
const confirmLoading = ref<boolean>(false);
|
||||
function showSettings() {
|
||||
visible.value = true;
|
||||
}
|
||||
async function handleOk() {
|
||||
confirmLoading.value = true;
|
||||
await invoke('set_config', { newConfig: config.value });
|
||||
visible.value = false;
|
||||
confirmLoading.value = false;
|
||||
}
|
||||
|
||||
interface Config {
|
||||
index_dir: string;
|
||||
ipfs_api_url: string;
|
||||
download_path: string;
|
||||
}
|
||||
|
||||
const config = ref<Config>({
|
||||
index_dir: '',
|
||||
ipfs_api_url: '',
|
||||
download_path: ''
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await invoke('get_config');
|
||||
config.value = res as Config;
|
||||
});
|
||||
</script>
|
|
@ -1,13 +0,0 @@
|
|||
import type { Book, UseSearcher } from '../../scripts/searcher';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
|
||||
const useSearcher: UseSearcher = () => {
|
||||
return {
|
||||
search: async (query: string, limit: number) => {
|
||||
const response = await invoke('search', { query, limit });
|
||||
return response as Book[];
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default useSearcher;
|
|
@ -1,9 +0,0 @@
|
|||
export function createDebounce() {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
return function (fnc: () => void, delayMs?: number) {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
fnc();
|
||||
}, delayMs || 300);
|
||||
};
|
||||
}
|
|
@ -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[];
|
||||
}
|
|
@ -1,18 +1,21 @@
|
|||
export interface Book {
|
||||
id: number;
|
||||
title: string;
|
||||
author: string;
|
||||
publisher: string;
|
||||
publisher?: string;
|
||||
extension: string;
|
||||
filesize: number;
|
||||
language: string;
|
||||
year: number;
|
||||
pages: number;
|
||||
year?: number;
|
||||
pages?: number;
|
||||
isbn: string;
|
||||
ipfs_cid: string;
|
||||
}
|
||||
|
||||
export interface UseSearcher {
|
||||
(): {
|
||||
search: (query: string, limit: number) => Promise<Book[]>;
|
||||
};
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
.ant-layout.layout {
|
||||
min-height: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
|
|
|
@ -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;
|
|
@ -1,9 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
|
||||
declare const __platform__: string;
|
||||
|
|
|
@ -2,17 +2,32 @@
|
|||
"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",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.tsx"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,33 +1,31 @@
|
|||
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers';
|
||||
import AutoImport from 'unplugin-auto-import/vite';
|
||||
import Components from 'unplugin-vue-components/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
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: [
|
||||
{
|
||||
name: 'vite-plugin-replace',
|
||||
transform: (code: string) => {
|
||||
return code.replace(/__platform__/g, process.env.TAURI_PLATFORM ? 'tauri' : 'browser');
|
||||
},
|
||||
enforce: 'pre'
|
||||
},
|
||||
vue(),
|
||||
AutoImport({
|
||||
resolvers: [AntDesignVueResolver()]
|
||||
}),
|
||||
Components({
|
||||
resolvers: [AntDesignVueResolver()]
|
||||
})
|
||||
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: {
|
||||
antdv: ['ant-design-vue']
|
||||
'chakra-ui': ['@chakra-ui/react']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue