add concurrent dl button and links to copyriot gateways
This commit is contained in:
parent
16c7e6bd35
commit
1218b60224
|
@ -6,9 +6,9 @@ Un motore di ricerca libri basato su protocollo IPFS.
|
|||
|
||||
Una versione funzionante si trova [qui](https://millelibri.copyriot.xyz).
|
||||
|
||||
Il progetto è partito come un fork di [Book Searcher](https://github.com/book-searcher-org/book-searcher).
|
||||
Un tempo (fino a dicembre 2023) il progetto era sincronizzato con [Book Searcher](https://github.com/book-searcher-org/book-searcher). Poi venne la macchina editoriale e distrusse tutto:
|
||||
|
||||
L'obiettivo è trasformarlo in una piattaforma partecipativa: gli utenti avranno la possibilità di richiedere l'aggiunta di libri, oltre a fare ricerche.
|
||||
https://github.com/book-searcher-org/deleted/issues/1
|
||||
|
||||
## Installazione rapida
|
||||
|
||||
|
@ -33,7 +33,3 @@ npm install
|
|||
npm run build
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Licenza
|
||||
|
||||
Book Searcher è rilasciato sotto licenza [BSD-3-Clause](https://github.com/book-searcher-org/book-searcher/blob/master/LICENSE). Millelibri eredita la licenza da Book Searcher.
|
|
@ -4,14 +4,6 @@ services:
|
|||
zlib:
|
||||
image: lamacchinadesiderante/millelibri:latest
|
||||
|
||||
# image: millelibri:v0.4
|
||||
|
||||
# image: millelibri
|
||||
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: ./Dockerfile
|
||||
|
||||
restart: always
|
||||
|
||||
ports:
|
||||
|
|
|
@ -1,3 +1,2 @@
|
|||
# .env.production
|
||||
VITE_BACKEND_BASE_API = 'http://127.0.0.1:7070/'
|
||||
VITE_STORJ_JSON_URL = 'https://link.storjshare.io/juowot6xaa2rz4vjqeal2uo44jka/millelibri%2Findex%2Fjson%2Fmillelibri.json?download=1'
|
|
@ -1,3 +1,2 @@
|
|||
# .env.production
|
||||
VITE_BACKEND_BASE_API = ''
|
||||
VITE_STORJ_JSON_URL = 'https://link.storjshare.io/juowot6xaa2rz4vjqeal2uo44jka/millelibri%2Findex%2Fjson%2Fmillelibri.json?download=1'
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "v0.7.0",
|
||||
"version": "v0.8.0",
|
||||
"type": "module",
|
||||
"repository": "https://git.lamacchinadesiderante.org/lamacchinadesiderante/millelibri",
|
||||
"scripts": {
|
||||
|
@ -13,6 +13,7 @@
|
|||
"@chakra-ui/react": "^2.4.6",
|
||||
"@chakra-ui/skip-nav": "^2.0.13",
|
||||
"@chakra-ui/system": "^2.3.7",
|
||||
"@chakra-ui/icons": "^2.1.1",
|
||||
"@emotion/react": "^11.10.5",
|
||||
"@emotion/styled": "^11.10.5",
|
||||
"@tanstack/react-table": "^8.7.4",
|
||||
|
@ -24,6 +25,7 @@
|
|||
"framer-motion": "^7.10.3",
|
||||
"i18next": "^22.4.6",
|
||||
"i18next-browser-languagedetector": "^7.0.1",
|
||||
"js-file-download": "^0.4.12",
|
||||
"lodash": "^4.17.21",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,4 +1,4 @@
|
|||
import { Card, CardHeader, Heading, Divider, CardBody, CardFooter, GridItem, SimpleGrid, Text, Button, Flex, Icon } from '@chakra-ui/react';
|
||||
import { Box, Card, CardHeader, Heading, Divider, CardBody, CardFooter, GridItem, SimpleGrid, Text, Button, Flex, Icon } from '@chakra-ui/react';
|
||||
|
||||
import React, { useContext } from 'react';
|
||||
|
||||
|
@ -12,6 +12,7 @@ import { filesize as formatFileSize } from 'filesize';
|
|||
import { TbChevronUp } from 'react-icons/tb';
|
||||
import ExternalLink from './ExternalLink';
|
||||
import Description from './Description';
|
||||
import IpfsDownloadButton from './IpfsDownloadButton';
|
||||
|
||||
interface IProps {
|
||||
row: Row
|
||||
|
@ -19,13 +20,6 @@ interface IProps {
|
|||
|
||||
const BookDetailsCard: React.FC<IProps> = (props) => {
|
||||
|
||||
const downloadLinkFromIPFS = (gateway: string, book: Book) => {
|
||||
return (
|
||||
`https://${gateway}/ipfs/${book.ipfs_cid}?filename=` +
|
||||
encodeURIComponent(`${book.title}_${book.author}.${book.extension}`)
|
||||
);
|
||||
}
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { row } = props
|
||||
|
@ -44,12 +38,32 @@ const BookDetailsCard: React.FC<IProps> = (props) => {
|
|||
ipfs_cid
|
||||
} = row.original;
|
||||
|
||||
const searchOnAnnasArchive = () => {
|
||||
return (`https://annas-archive.org/search?q=` + encodeURIComponent(`${title} ${author}`));
|
||||
}
|
||||
|
||||
return (
|
||||
<Card mt={{ base: 1, md: 2 }} mb={{ base: 2, md: 4 }} mx={{ base: 4, md: 8 }}>
|
||||
<CardHeader>
|
||||
<Heading as="h3" fontSize="xl">
|
||||
<Flex
|
||||
align="center"
|
||||
flexWrap={{ base: 'wrap', lg: 'nowrap' }}
|
||||
justify="space-between"
|
||||
gap={{ base: '4', lg: '2' }}
|
||||
>
|
||||
<Heading as="h3" fontSize={['xl', '2xl', '2xl']} whiteSpace="break-spaces" minW="0">
|
||||
{title}
|
||||
</Heading>
|
||||
<Flex gap="2">
|
||||
<Button
|
||||
as={ExternalLink}
|
||||
minWidth="unset"
|
||||
href={searchOnAnnasArchive()}
|
||||
>
|
||||
{t('input.anna_archive')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<Divider />
|
||||
<CardBody>
|
||||
|
@ -89,7 +103,10 @@ const BookDetailsCard: React.FC<IProps> = (props) => {
|
|||
<CardFooter flexDirection="column">
|
||||
|
||||
<SimpleGrid columns={{ sm: 2, md: 3, lg: 4, xl: 5 }} spacing={{ base: 2, md: 4 }}>
|
||||
{ipfsGateways.map((gateway) => (
|
||||
|
||||
<IpfsDownloadButton book={row.original as Book} onlyIcon={false}></IpfsDownloadButton>
|
||||
|
||||
{/* {ipfsGateways.map((gateway) => (
|
||||
<Button
|
||||
as={ExternalLink}
|
||||
href={downloadLinkFromIPFS(gateway, row.original)}
|
||||
|
@ -98,11 +115,11 @@ const BookDetailsCard: React.FC<IProps> = (props) => {
|
|||
>
|
||||
{gateway}
|
||||
</Button>
|
||||
))}
|
||||
))} */}
|
||||
|
||||
</SimpleGrid>
|
||||
|
||||
{/* <Flex><Text fontWeight={'bold'}>{t('disclaimer.nolink_warning')}</Text></Flex> */}
|
||||
<Flex><Text mt={2} mb={-2} fontSize={'2xs'} fontStyle={'italic'} fontWeight={'light'}>{t('disclaimer.broken_link')}</Text></Flex>
|
||||
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
|
@ -117,7 +134,7 @@ const BookDetailsCard: React.FC<IProps> = (props) => {
|
|||
</Button>
|
||||
</Flex>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Card >
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Button, useToast } from '@chakra-ui/react';
|
||||
import { DownloadIcon } from '@chakra-ui/icons';
|
||||
import { Book } from '../scripts/searcher';
|
||||
import autoDownload from '../scripts/download';
|
||||
import { t } from 'i18next';
|
||||
|
||||
export interface IpfsDownloadButtonProps {
|
||||
book: Book;
|
||||
onlyIcon: boolean;
|
||||
}
|
||||
|
||||
const IpfsDownloadButton: React.FC<IpfsDownloadButtonProps> = ({ book, onlyIcon }) => {
|
||||
const toast = useToast();
|
||||
const [downloadProgress, setDownloadProgress] = useState(-1);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key="input.download"
|
||||
w="100%"
|
||||
zIndex={111}
|
||||
variant="outline"
|
||||
colorScheme="blue"
|
||||
leftIcon={onlyIcon ? undefined : <DownloadIcon />}
|
||||
isLoading={downloadProgress > -1}
|
||||
loadingText={`${downloadProgress}%`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
autoDownload(book, toast, setDownloadProgress);
|
||||
}}
|
||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{onlyIcon ? <DownloadIcon /> : t('input.download')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default IpfsDownloadButton;
|
|
@ -19,7 +19,6 @@ import { useTranslation } from 'react-i18next';
|
|||
import SearchLanguage from './SearchLanguage';
|
||||
import { MEDIA_QUERY_DESKTOP_STARTS, MEDIA_QUERY_MOBILE_ENDS } from '../constants/mediaquery';
|
||||
import CopyToClipboardButton from './CopyToClipboardButton';
|
||||
import { getJsonArchive } from '../scripts/searcher-browser';
|
||||
|
||||
function constructQuery(parts: Record<string, string>): string {
|
||||
return Object.keys(parts)
|
||||
|
@ -71,13 +70,6 @@ const Search: React.FC<SearchProps> = ({ setBooks }) => {
|
|||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const jsonArchive = await getJsonArchive() as Book[]
|
||||
setBooksFromJsonArchive(jsonArchive)
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const handleLanguageChange = (language: string) => {
|
||||
if (language == 'input') {
|
||||
setShowLanguageDropdown(false)
|
||||
|
|
|
@ -9,7 +9,9 @@
|
|||
},
|
||||
"input": {
|
||||
"clear": "Clear",
|
||||
"select_language": "(Select language...)"
|
||||
"select_language": "(Select language...)",
|
||||
"download": "Download",
|
||||
"anna_archive": "Anna's Archive"
|
||||
},
|
||||
"book": {
|
||||
"id": "zlib/libgen id",
|
||||
|
@ -63,107 +65,8 @@
|
|||
"input": "Input..."
|
||||
},
|
||||
"disclaimer": {
|
||||
"nolink_warning": "WARNING: This platform does not host any kind of link to copyrighted material."
|
||||
}
|
||||
}
|
||||
},
|
||||
"zh-CN": {
|
||||
"translation": {
|
||||
"nav": {
|
||||
"repository": "GitHub 仓库",
|
||||
"toggle_dark": "切换到暗黑模式",
|
||||
"toggle_light": "切换到亮色模式",
|
||||
"toggle_language": "切换语言"
|
||||
},
|
||||
"input": {
|
||||
"clear": "清空"
|
||||
},
|
||||
"book": {
|
||||
"id": "zlib/libgen id",
|
||||
"title": "书名",
|
||||
"author": "作者",
|
||||
"publisher": "出版社",
|
||||
"extension": "扩展名",
|
||||
"filesize": "文件大小",
|
||||
"language": "语言",
|
||||
"year": "年份",
|
||||
"pages": "页数",
|
||||
"isbn": "ISBN",
|
||||
"ipfs_cid": "IPFS CID",
|
||||
"unknown": "未知"
|
||||
},
|
||||
"table": {
|
||||
"sort_asc": "升序排序",
|
||||
"sort_desc": "降序排序",
|
||||
"not_sorted": "未排序",
|
||||
"filter": "过滤",
|
||||
"no_data": "无数据",
|
||||
"first_page": "第一页",
|
||||
"last_page": "最后一页",
|
||||
"next_page": "下一页",
|
||||
"previous_page": "上一页",
|
||||
"page": "第 {{page}} 页",
|
||||
"collapse": "收起"
|
||||
},
|
||||
"search": {
|
||||
"complex": "复杂搜索"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"ipfs_gateways": "IPFS 网关",
|
||||
"ipfs_gateways_help": "IPFS 网关列表,一行一个",
|
||||
"cancel": "取消",
|
||||
"save": "保存"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"translation": {
|
||||
"nav": {
|
||||
"repository": "Dépôt GitHub",
|
||||
"toggle_dark": "Basculer en mode sombre",
|
||||
"toggle_light": "Basculer en mode clair",
|
||||
"toggle_language": "Basculer la langue"
|
||||
},
|
||||
"input": {
|
||||
"clear": "Effacer"
|
||||
},
|
||||
"book": {
|
||||
"id": "ID zlib/libgen",
|
||||
"title": "Titre",
|
||||
"author": "Auteur",
|
||||
"publisher": "Éditeur",
|
||||
"extension": "Extension",
|
||||
"filesize": "Taille du fichier",
|
||||
"language": "Langue",
|
||||
"year": "Année",
|
||||
"pages": "Pages",
|
||||
"isbn": "ISBN",
|
||||
"ipfs_cid": "CID IPFS",
|
||||
"unknown": "Inconnu"
|
||||
},
|
||||
"table": {
|
||||
"sort_asc": "Trier par ordre croissant",
|
||||
"sort_desc": "Trier par ordre décroissant",
|
||||
"not_sorted": "Non trié",
|
||||
"filter": "Filtrer",
|
||||
"no_data": "Aucune donnée",
|
||||
"first_page": "Première page",
|
||||
"last_page": "Dernière page",
|
||||
"next_page": "Page suivante",
|
||||
"previous_page": "Page précédente",
|
||||
"page": "Page {{page}}",
|
||||
"collapse": "Replier"
|
||||
},
|
||||
"search": {
|
||||
"complex": "Recherche complexe"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
"ipfs_gateways": "Passerelles IPFS",
|
||||
"ipfs_gateways_help": "Liste des passerelles IPFS, saut de ligne",
|
||||
"cancel": "Annuler",
|
||||
"save": "Enregistrer"
|
||||
"nolink_warning": "WARNING: This platform does not host any kind of link to copyrighted material.",
|
||||
"broken_link": "WARNING: Links might be broken. If download doesn't start, try with Anna's Archive"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -177,7 +80,9 @@
|
|||
},
|
||||
"input": {
|
||||
"clear": "pulisci",
|
||||
"select_language": "(Seleziona lingua...)"
|
||||
"select_language": "(Seleziona lingua...)",
|
||||
"download": "Download",
|
||||
"anna_archive": "Anna's Archive"
|
||||
},
|
||||
"book": {
|
||||
"id": "ID zlib/libgen",
|
||||
|
@ -231,7 +136,8 @@
|
|||
"input": "Scrivi..."
|
||||
},
|
||||
"disclaimer": {
|
||||
"nolink_warning": "IMPORTANTE: Questa piattaforma non ospita nessun tipo di link a materiale protetto da copyright."
|
||||
"nolink_warning": "IMPORTANTE: Questa piattaforma non ospita nessun tipo di link a materiale protetto da copyright.",
|
||||
"broken_link": "IMPORTANTE: I link potrebbero non funzionare. Se il download non parte, provare con Anna's Archive."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,139 @@
|
|||
import type { Book } from './searcher';
|
||||
import getIpfsGateways, { getDownloadLinkFromIPFS, ipfsGateways } from './ipfs';
|
||||
import axios, { AxiosProgressEvent, AxiosResponse } from 'axios';
|
||||
import fileDownload from 'js-file-download';
|
||||
import { t } from 'i18next';
|
||||
|
||||
export default async function autoDownload(book: Book, toast: any, setDownloadProgress: any) {
|
||||
const filename = `${book.title}_${book.author}.${book.extension}`;
|
||||
toast({
|
||||
title: `${filename} ${t('download_start')}!`,
|
||||
status: 'info',
|
||||
position: 'bottom-right',
|
||||
isClosable: true,
|
||||
duration: 3000
|
||||
});
|
||||
|
||||
console.log('Download: ', book);
|
||||
var gateways = ipfsGateways;
|
||||
gateways = gateways.filter(function (item, pos) {
|
||||
return gateways.indexOf(item) == pos;
|
||||
});
|
||||
console.log('Try gateways:', gateways);
|
||||
const controllerMap = new Map();
|
||||
var fastedProgress = 0;
|
||||
setDownloadProgress(fastedProgress.toFixed(2));
|
||||
Promise.any(
|
||||
gateways.map((gateway) => {
|
||||
const controller = new AbortController();
|
||||
controllerMap.set(gateway, controller);
|
||||
return axios
|
||||
.get(getDownloadLinkFromIPFS(gateway, book), {
|
||||
signal: controller.signal,
|
||||
withCredentials: false,
|
||||
responseType: 'blob',
|
||||
onDownloadProgress: (e: AxiosProgressEvent) => {
|
||||
console.log('Download Progress: ', gateway, e);
|
||||
const myProgress = e.progress! * 100;
|
||||
const bar = 10;
|
||||
if (fastedProgress > bar && myProgress < bar) {
|
||||
controllerMap.get(gateway).abort();
|
||||
}
|
||||
if (myProgress > fastedProgress && myProgress != 100) {
|
||||
fastedProgress = myProgress;
|
||||
setDownloadProgress(fastedProgress.toFixed(2));
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch();
|
||||
})
|
||||
)
|
||||
.then((resp: AxiosResponse) => {
|
||||
controllerMap.forEach((c) => c.abort());
|
||||
fileDownload(resp.data, filename);
|
||||
toast({
|
||||
title: `${filename} ${t('download_success')}!`,
|
||||
status: 'success',
|
||||
position: 'bottom-right',
|
||||
isClosable: true,
|
||||
duration: 6000
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast({
|
||||
title: `${filename} ${t('download_failed')}!`,
|
||||
status: 'error',
|
||||
position: 'bottom-right',
|
||||
isClosable: true,
|
||||
duration: 6000
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setDownloadProgress(-1);
|
||||
});
|
||||
}
|
||||
|
||||
const downloadBookData = async function (book: Book, signal: AbortSignal) {
|
||||
var gateways = await getIpfsGateways();
|
||||
gateways = gateways.filter(function (item, pos) {
|
||||
return gateways.indexOf(item) == pos;
|
||||
});
|
||||
const controllerMap = new Map();
|
||||
var fastedProgress = 0;
|
||||
return Promise.any(
|
||||
gateways.map((gateway) => {
|
||||
const controller = new AbortController();
|
||||
controllerMap.set(gateway, controller);
|
||||
return axios
|
||||
.get(getDownloadLinkFromIPFS(gateway, book), {
|
||||
signal: anySignal([controller.signal, signal]),
|
||||
withCredentials: false,
|
||||
responseType: 'arraybuffer',
|
||||
onDownloadProgress: (e: AxiosProgressEvent) => {
|
||||
console.log('Download Progress: ', gateway, e);
|
||||
const myProgress = e.progress! * 100;
|
||||
const bar = 10;
|
||||
if (fastedProgress > bar && myProgress < bar) {
|
||||
controllerMap.get(gateway).abort();
|
||||
}
|
||||
if (myProgress > fastedProgress && myProgress != 100) {
|
||||
fastedProgress = myProgress;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch();
|
||||
})
|
||||
)
|
||||
.then((resp: AxiosResponse) => {
|
||||
return resp.data;
|
||||
})
|
||||
.catch()
|
||||
.finally(() => {
|
||||
controllerMap.forEach((c) => c.abort());
|
||||
});
|
||||
};
|
||||
|
||||
export { downloadBookData };
|
||||
|
||||
function anySignal(signals: AbortSignal[]) {
|
||||
const controller = new AbortController();
|
||||
|
||||
function onAbort() {
|
||||
controller.abort();
|
||||
|
||||
// Cleanup
|
||||
for (const signal of signals) {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
for (const signal of signals) {
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
break;
|
||||
}
|
||||
signal.addEventListener('abort', onAbort);
|
||||
}
|
||||
|
||||
return controller.signal;
|
||||
}
|
|
@ -1,14 +1,18 @@
|
|||
import { Book } from './searcher';
|
||||
|
||||
interface TauriConfig {
|
||||
index_dir: string;
|
||||
ipfs_gateways: string[];
|
||||
}
|
||||
|
||||
export const ipfsGateways: string[] = [
|
||||
'cloudflare-ipfs.com',
|
||||
'ipfs.2read.net',
|
||||
'dweb.link',
|
||||
'ipfs.io',
|
||||
'gateway.pinata.cloud'
|
||||
// 'cloudflare-ipfs.com',
|
||||
// 'dweb.link',
|
||||
// 'ipfs.io',
|
||||
// 'gateway.pinata.cloud',
|
||||
// 'nftstorage.link'
|
||||
'ipfs.copyriot.xyz',
|
||||
'ipfs2.copyriot.xyz'
|
||||
];
|
||||
|
||||
export default async function getIpfsGateways() {
|
||||
|
@ -29,3 +33,10 @@ export default async function getIpfsGateways() {
|
|||
export function parseIpfsGateways(text: string) {
|
||||
return text.split('\n').filter(g => g.length);
|
||||
}
|
||||
|
||||
export function getDownloadLinkFromIPFS(gateway: string, book: Book) {
|
||||
return (
|
||||
`https://${gateway}/ipfs/${book.ipfs_cid}?filename=` +
|
||||
encodeURIComponent(`${book.title}_${book.author}.${book.extension}`)
|
||||
);
|
||||
}
|
|
@ -8,39 +8,6 @@ const http = axios.create({
|
|||
timeout: 5000
|
||||
});
|
||||
|
||||
const storj = axios.create({
|
||||
baseURL: import.meta.env.VITE_STORJ_JSON_URL,
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
export const getJsonArchive = async () => {
|
||||
//@ts-ignore
|
||||
if (!window[JSON_ARCHIVE_WINDOW_KEY]) {
|
||||
|
||||
const response = await storj.get(``);
|
||||
|
||||
if (response.status == 200) {
|
||||
//@ts-ignore
|
||||
window[JSON_ARCHIVE_WINDOW_KEY] = response.data as Book[];
|
||||
} else {
|
||||
|
||||
console.log(response);
|
||||
|
||||
//@ts-ignore
|
||||
window[JSON_ARCHIVE_WINDOW_KEY] = [];
|
||||
}
|
||||
|
||||
//@ts-ignore
|
||||
return window[JSON_ARCHIVE_WINDOW_KEY] as Book[];
|
||||
|
||||
|
||||
} else {
|
||||
//@ts-ignore
|
||||
return window[JSON_ARCHIVE_WINDOW_KEY] as Book[];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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[];
|
||||
|
|
Loading…
Reference in New Issue