feat: rewrite frontend in Chakra UI

pull/34/head
忘忧北萱草 2022-12-05 01:20:38 +08:00
parent d04472b60b
commit e79a592cfe
No known key found for this signature in database
GPG Key ID: D5CC9EEE9D6D6F6A
41 changed files with 5960 additions and 1086 deletions

98
Cargo.lock generated
View File

@ -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"

View File

@ -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"] }

View File

@ -11,7 +11,13 @@
},
"tauri": {
"allowlist": {
"all": false
"all": false,
"shell": {
"open": true
},
"dialog": {
"open": true
}
},
"bundle": {
"active": true,

View File

@ -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);
}

View File

@ -1,5 +0,0 @@
// Generated by 'unplugin-auto-import'
export {}
declare global {
}

View File

@ -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>

View File

@ -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

View File

@ -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;

View File

@ -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>

View File

@ -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;

View File

@ -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;

View File

@ -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>
</>
);
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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>

View File

@ -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;

View File

@ -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;

View File

@ -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": "保存"
}
}
}
}

View File

@ -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": "复杂搜索"
}
}
}
}

View File

@ -1,5 +0,0 @@
import { createApp } from 'vue';
import './style.css';
import App from './App.vue';
createApp(App).mount('#app');

View File

@ -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>
);

View File

@ -1 +0,0 @@
<template></template>

View File

@ -1,5 +0,0 @@
import type { UseSearcher } from '../../scripts/searcher';
// workaround for ts
declare const useSearcher: UseSearcher;
export { useSearcher as default };

View File

@ -1,2 +0,0 @@
<!-- no settings in browser -->
<template></template>

View File

@ -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;

View File

@ -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>

View File

@ -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;

View File

@ -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);
};
}

View File

@ -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[];
}

View File

@ -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[];
}

View File

@ -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));
}
}

View File

@ -1,8 +1,5 @@
#app {
height: 100%;
}
.ant-layout.layout {
min-height: 100%;
min-height: 100vh;
}
::-webkit-scrollbar {

View File

@ -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;

View File

@ -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;

View File

@ -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"
}
]
}

View File

@ -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']
}
}
}