Feat: Vite + React (#991)
* remove test_web * integrate reactjs-vite * update lint-staged * simple cors proxy server which handles cookies and redirections * overrider global fetch with proxy call * simple examples for set cookies and fetch request * app bar * add redux * loadPlugins. error: cant load urlencode module * add ts-node for transforming * plugin search & select * plugin slice * accordion components * path item & popular novel * optional for filters * search novel * parse novel * parseChapter * update cookies * default filters * fix: import path * bundler module resolution * new json_plugins * fix: generate multisrc (missing filters) * fix: host scripts * fix: plugin code path * remove typechecking action
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import React, { forwardRef, useEffect, useState } from 'react';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Box from '@mui/material/Box';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
import {
|
||||
Avatar,
|
||||
Backdrop,
|
||||
Button,
|
||||
FormControl,
|
||||
InputAdornment,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
TextField,
|
||||
} from '@mui/material';
|
||||
import { Search, Translate } from '@mui/icons-material';
|
||||
import { searchPlugins } from '@provider/plugins';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { FixedSizeList, ListChildComponentProps } from 'react-window';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { AppState } from '@redux/store';
|
||||
import { selectPlugin } from '@redux/pluginSlice';
|
||||
|
||||
const resovleIcon = (iconPath: string) => {
|
||||
return '/icons/' + iconPath;
|
||||
};
|
||||
|
||||
export default function () {
|
||||
const [searchedPlugins, setSearchPlugins] = useState<Plugin.PluginBase[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [listVisible, setListVisible] = useState(false);
|
||||
const selectedPlugin = useSelector(
|
||||
(state: AppState) => state.plugin.selected,
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
const plugins = searchPlugins(keyword);
|
||||
setSearchPlugins(plugins);
|
||||
}, [keyword]);
|
||||
|
||||
function renderPlugin(props: ListChildComponentProps<Plugin.PluginBase[]>) {
|
||||
const { index, style, data } = props;
|
||||
const plugin = data[index];
|
||||
|
||||
return (
|
||||
<ListItem style={style} key={index} component="div" disablePadding>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
dispatch(
|
||||
selectPlugin({
|
||||
id: plugin.id,
|
||||
name: plugin.name,
|
||||
version: plugin.version,
|
||||
icon: plugin.icon,
|
||||
site: plugin.site,
|
||||
}),
|
||||
);
|
||||
setListVisible(false);
|
||||
}}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar variant="square" src={resovleIcon(plugin.icon)} />
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={plugin.name} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<AppBar position="fixed" color="inherit" elevation={0}>
|
||||
<Toolbar>
|
||||
<FormControl
|
||||
sx={{
|
||||
display: 'block',
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
variant="outlined"
|
||||
placeholder="Seach plugin"
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{ width: 434 }}
|
||||
onFocus={() => {
|
||||
setListVisible(true);
|
||||
if (searchedPlugins.length === 0)
|
||||
setSearchPlugins(searchPlugins(keyword));
|
||||
}}
|
||||
/>
|
||||
{listVisible ? (
|
||||
<FixedSizeList
|
||||
height={400}
|
||||
width={434}
|
||||
itemData={searchedPlugins}
|
||||
itemSize={46}
|
||||
itemCount={searchedPlugins.length}
|
||||
overscanCount={5}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
boxShadow: '2px 10px 20px rgba(0, 0, 0, 0.2)',
|
||||
backgroundColor: 'white',
|
||||
}}
|
||||
>
|
||||
{renderPlugin}
|
||||
</FixedSizeList>
|
||||
) : null}
|
||||
</FormControl>
|
||||
<Backdrop
|
||||
open={listVisible}
|
||||
onClick={() => setListVisible(false)}
|
||||
sx={{ zIndex: 9 }}
|
||||
invisible={true}
|
||||
></Backdrop>
|
||||
<InputAdornment position="end">
|
||||
<Button variant="contained" endIcon={<Translate />}>
|
||||
Language
|
||||
</Button>
|
||||
</InputAdornment>
|
||||
{selectedPlugin ? (
|
||||
<Box sx={{ pl: 2, display: 'flex', alignItems: 'center' }}>
|
||||
<Avatar
|
||||
sx={{ mr: 1 }}
|
||||
src={resovleIcon(selectedPlugin.icon)}
|
||||
variant="square"
|
||||
/>
|
||||
<a href={selectedPlugin.site} target="_blank">
|
||||
{selectedPlugin.name} - v{selectedPlugin.version}
|
||||
</a>
|
||||
</Box>
|
||||
) : null}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React, { ReactNode, useEffect, useState } from 'react';
|
||||
import Alert from '@mui/material/Alert';
|
||||
|
||||
export default function CopiableItem({
|
||||
content,
|
||||
children,
|
||||
}: {
|
||||
content?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [alertVisible, setAlertVisble] = useState(false);
|
||||
useEffect(() => {
|
||||
if (alertVisible) {
|
||||
setTimeout(() => setAlertVisble(false), 1000);
|
||||
}
|
||||
}, [alertVisible]);
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
if (content) {
|
||||
navigator.clipboard.writeText(content).then(() => {
|
||||
setAlertVisble(true);
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{alertVisible ? (
|
||||
<Alert
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
transform: 'translate(100%, -100%)',
|
||||
zIndex: theme => theme.zIndex.tooltip + 1,
|
||||
}}
|
||||
severity="success"
|
||||
>
|
||||
Copied
|
||||
</Alert>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Box, Button, Tooltip, withStyles } from '@mui/material';
|
||||
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import CopiableItem from './CopiableItem';
|
||||
|
||||
interface Validation {
|
||||
type?: 'error' | 'warning';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const validatePath = (path: string): Validation => {
|
||||
if (path.includes('http'))
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'Path must be relative, please remove the domain',
|
||||
};
|
||||
if (/^\/|\/$/.test(path)) {
|
||||
return {
|
||||
type: 'warning',
|
||||
message: 'your path should not contain leading or trailing "/"',
|
||||
};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
export default function PathItem({ path }: { path: string }) {
|
||||
const validation = validatePath(path);
|
||||
const errorTheme = createTheme({
|
||||
components: {
|
||||
MuiTooltip: {
|
||||
styleOverrides: {
|
||||
tooltip: {
|
||||
color: '#FFF',
|
||||
backgroundColor: '#BA000D',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const warningTheme = createTheme({
|
||||
components: {
|
||||
MuiTooltip: {
|
||||
styleOverrides: {
|
||||
tooltip: {
|
||||
color: '#FFF',
|
||||
backgroundColor: 'orange',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const ValidateWrapper = validation.type ? Tooltip : Box;
|
||||
return (
|
||||
<Tooltip arrow={true} title={path} placement="bottom">
|
||||
<Box>
|
||||
<ThemeProvider
|
||||
theme={validation.type === 'error' ? errorTheme : warningTheme}
|
||||
>
|
||||
<ValidateWrapper title={validation.message} placement="top">
|
||||
<Box>
|
||||
<CopiableItem content={path}>
|
||||
<Button
|
||||
sx={{
|
||||
width: '100%',
|
||||
borderColor:
|
||||
validation.type === 'error'
|
||||
? 'red'
|
||||
: validation.type === 'warning'
|
||||
? 'orange'
|
||||
: undefined,
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
Path
|
||||
</Button>
|
||||
</CopiableItem>
|
||||
</Box>
|
||||
</ValidateWrapper>
|
||||
</ThemeProvider>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import { styled, TextareaAutosize } from '@mui/material';
|
||||
|
||||
const blue = {
|
||||
100: '#DAECFF',
|
||||
200: '#b6daff',
|
||||
400: '#3399FF',
|
||||
500: '#007FFF',
|
||||
600: '#0072E5',
|
||||
900: '#003A75',
|
||||
};
|
||||
|
||||
const grey = {
|
||||
50: '#F3F6F9',
|
||||
100: '#E5EAF2',
|
||||
200: '#DAE2ED',
|
||||
300: '#C7D0DD',
|
||||
400: '#B0B8C4',
|
||||
500: '#9DA8B7',
|
||||
600: '#6B7A90',
|
||||
700: '#434D5B',
|
||||
800: '#303740',
|
||||
900: '#1C2025',
|
||||
};
|
||||
|
||||
const Textarea = styled(TextareaAutosize)(
|
||||
({ theme }) => `
|
||||
box-sizing: border-box;
|
||||
width: 320px;
|
||||
font-family: 'IBM Plex Sans', sans-serif;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]};
|
||||
background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'};
|
||||
border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]};
|
||||
box-shadow: 0px 2px 2px ${theme.palette.mode === 'dark' ? grey[900] : grey[50]};
|
||||
|
||||
&:hover {
|
||||
border-color: ${blue[400]};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: ${blue[400]};
|
||||
box-shadow: 0 0 0 3px ${theme.palette.mode === 'dark' ? blue[600] : blue[200]};
|
||||
}
|
||||
|
||||
// firefox
|
||||
&:focus-visible {
|
||||
outline: 0;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
export default Textarea;
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import AccordionContainer from '../components/AccordionContainer';
|
||||
import { Alert, Box, InputAdornment, TextField } from '@mui/material';
|
||||
import useDebounce from '@hooks/useDebounce';
|
||||
|
||||
export default function HeadersSection() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [cookies, setCookies] = useState('');
|
||||
const debounceCookies = useDebounce(cookies, 500);
|
||||
const [alertVisible, setAlertVisble] = useState(false);
|
||||
useEffect(() => {
|
||||
if (alertVisible) {
|
||||
setTimeout(() => setAlertVisble(false), 1000);
|
||||
}
|
||||
}, [alertVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetch('cookies', {
|
||||
method: 'POST',
|
||||
body: debounceCookies,
|
||||
})
|
||||
.then(() => setAlertVisble(true))
|
||||
.finally(() => setLoading(false));
|
||||
}, [debounceCookies]);
|
||||
return (
|
||||
<AccordionContainer title="Headers" loading={loading}>
|
||||
{alertVisible ? (
|
||||
<Alert
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
zIndex: 2,
|
||||
top: 0,
|
||||
right: 0,
|
||||
}}
|
||||
>
|
||||
Cookies updated
|
||||
</Alert>
|
||||
) : null}
|
||||
<TextField
|
||||
size="small"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">Headers</InputAdornment>
|
||||
),
|
||||
}}
|
||||
disabled
|
||||
value={navigator.userAgent}
|
||||
sx={{ width: '100%' }}
|
||||
/>
|
||||
<Box sx={{ height: 10 }} />
|
||||
<TextField
|
||||
size="small"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">Cookies</InputAdornment>
|
||||
),
|
||||
}}
|
||||
value={cookies}
|
||||
onChange={e => {
|
||||
setCookies(e.target.value.trim());
|
||||
}}
|
||||
sx={{ width: '100%' }}
|
||||
/>
|
||||
</AccordionContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Box, Button, Stack, TextField } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import AccordionContainer from '../components/AccordionContainer';
|
||||
import usePlugin from '@hooks/usePlugin';
|
||||
import Textarea from '@components/Textarea';
|
||||
export default function ParseChapter() {
|
||||
const plugin = usePlugin();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [chapterPath, setchapterPath] = useState('');
|
||||
const [chapterText, setChapterText] = useState('');
|
||||
const fetchChapter = () => {
|
||||
if (plugin) {
|
||||
setLoading(true);
|
||||
plugin
|
||||
.parseChapter(chapterPath)
|
||||
.then(res => setChapterText(res))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<AccordionContainer title="Parse Chapter" loading={loading}>
|
||||
<Stack direction={'row'} spacing={2}>
|
||||
<TextField
|
||||
value={chapterPath}
|
||||
onChange={e => setchapterPath(e.target.value)}
|
||||
size="small"
|
||||
label="Chapter path"
|
||||
/>
|
||||
<Button
|
||||
disabled={plugin === undefined}
|
||||
onClick={fetchChapter}
|
||||
variant="contained"
|
||||
>
|
||||
Fetch
|
||||
</Button>
|
||||
</Stack>
|
||||
<Box sx={{ height: 10 }} />
|
||||
{chapterText ? (
|
||||
<Textarea
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
maxRows={20}
|
||||
disabled
|
||||
defaultValue={chapterText}
|
||||
/>
|
||||
) : null}
|
||||
</AccordionContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import PathItem from '@components/PathItem';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import dayjs from 'dayjs';
|
||||
import React from 'react';
|
||||
import { TableComponents, TableVirtuoso } from 'react-virtuoso';
|
||||
|
||||
const VirtuosoTableComponents: TableComponents<Plugin.ChapterItem> = {
|
||||
Scroller: React.forwardRef<HTMLDivElement>((props, ref) => (
|
||||
<TableContainer component={Paper} {...props} ref={ref} />
|
||||
)),
|
||||
Table: props => (
|
||||
<Table
|
||||
{...props}
|
||||
sx={{ borderCollapse: 'separate', tableLayout: 'fixed' }}
|
||||
/>
|
||||
),
|
||||
// @ts-ignore
|
||||
TableHead,
|
||||
TableRow: ({ item: _item, ...props }) => <TableRow {...props} />,
|
||||
TableBody: React.forwardRef<HTMLTableSectionElement>((props, ref) => (
|
||||
<TableBody {...props} ref={ref} />
|
||||
)),
|
||||
};
|
||||
|
||||
function fixedHeaderContent() {
|
||||
return (
|
||||
<TableRow sx={{ backgroundColor: 'background.paper' }}>
|
||||
<TableCell variant="head">Index</TableCell>
|
||||
<TableCell variant="head">Name</TableCell>
|
||||
<TableCell variant="head">Path</TableCell>
|
||||
<TableCell variant="head">Release Time</TableCell>
|
||||
<TableCell variant="head">Number</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function rowContent(_index: number, row: Plugin.ChapterItem) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<TableCell>{_index}</TableCell>
|
||||
<TableCell>{row.name}</TableCell>
|
||||
<TableCell>
|
||||
<PathItem path={row.path} />
|
||||
</TableCell>
|
||||
<TableCell>{dayjs(row.releaseTime).format('YYYY/MM/DD')}</TableCell>
|
||||
<TableCell>{row.chapterNumber}</TableCell>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
export default function ChapterList({
|
||||
chapters,
|
||||
}: {
|
||||
chapters: Plugin.ChapterItem[];
|
||||
}) {
|
||||
return (
|
||||
<Paper style={{ height: 400, width: '100%', textAlign: 'left' }}>
|
||||
<TableVirtuoso
|
||||
data={chapters}
|
||||
components={VirtuosoTableComponents}
|
||||
fixedHeaderContent={fixedHeaderContent}
|
||||
itemContent={rowContent}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Chip,
|
||||
ListItem,
|
||||
Stack,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import CopiableItem from '@components/CopiableItem';
|
||||
import PathItem from '@components/PathItem';
|
||||
import Textarea from '@components/Textarea';
|
||||
|
||||
const FlexRow = ({ children }: { children: ReactNode }) => (
|
||||
<Stack direction="row" gap={1} sx={{ alignItems: 'center' }}>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default function SourceNovelCard({
|
||||
sourceNovel,
|
||||
}: {
|
||||
sourceNovel: Plugin.SourceNovel;
|
||||
}) {
|
||||
return (
|
||||
<Card sx={{ display: 'flex', p: 1, width: '100%', textAlign: 'left' }}>
|
||||
<CopiableItem content={sourceNovel.cover}>
|
||||
<Tooltip title={sourceNovel.cover}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
sx={{ width: 200, height: '100%' }}
|
||||
image={sourceNovel.cover}
|
||||
/>
|
||||
</Tooltip>
|
||||
</CopiableItem>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||
<CardContent>
|
||||
<Tooltip title={sourceNovel.name}>
|
||||
<Box>
|
||||
<CopiableItem content={sourceNovel.name}>
|
||||
<Button variant="outlined">
|
||||
<Typography
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: '5',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{sourceNovel.name}
|
||||
</Typography>
|
||||
</Button>
|
||||
</CopiableItem>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<FlexRow>
|
||||
<Typography sx={{ width: 50 }}>Status:</Typography>
|
||||
<Chip variant="outlined" label={sourceNovel.status} color="info" />
|
||||
</FlexRow>
|
||||
<FlexRow>
|
||||
<Typography sx={{ width: 50 }}>Author:</Typography>
|
||||
<Chip variant="outlined" label={sourceNovel.author} color="info" />
|
||||
</FlexRow>
|
||||
<FlexRow>
|
||||
<Typography sx={{ width: 50 }}>Artist: </Typography>
|
||||
<Chip variant="outlined" label={sourceNovel.artist} color="info" />
|
||||
</FlexRow>
|
||||
</CardContent>
|
||||
<CardContent sx={{ width: '50%' }}>
|
||||
<PathItem path={sourceNovel.path} />
|
||||
</CardContent>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<CardContent>
|
||||
<Typography>Genres</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
width: 600,
|
||||
overflowX: 'auto',
|
||||
}}
|
||||
>
|
||||
<Stack sx={{ width: 'max-content' }} direction={'row'}>
|
||||
{sourceNovel.genres?.split(/,\s*/).map((genre, index) => (
|
||||
<ListItem key={'genre_' + index}>
|
||||
<Chip label={genre} />
|
||||
</ListItem>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<Typography>Summary</Typography>
|
||||
<Textarea
|
||||
sx={{ width: '100%' }}
|
||||
maxRows={5}
|
||||
defaultValue={sourceNovel.summary}
|
||||
disabled
|
||||
/>
|
||||
</CardContent>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Pagination,
|
||||
Stack,
|
||||
TextField,
|
||||
} from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import AccordionContainer from '../components/AccordionContainer';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import usePlugin from '@hooks/usePlugin';
|
||||
import SourceNovelCard from './SourceNovelCard';
|
||||
import ChapterList from './ChaptertList';
|
||||
|
||||
export default function ParseNovel() {
|
||||
const plugin = usePlugin();
|
||||
const [sourceNovel, setSouceNovel] = useState<
|
||||
Plugin.SourceNovel | undefined
|
||||
>();
|
||||
const [chapters, setChapters] = useState<Plugin.ChapterItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [novelPath, setNovelPath] = useState('');
|
||||
const fetchNovel = () => {
|
||||
if (plugin) {
|
||||
setLoading(true);
|
||||
plugin
|
||||
.parseNovel(novelPath)
|
||||
.then(res => {
|
||||
setSouceNovel(res);
|
||||
setChapters(res.chapters || []);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
};
|
||||
const fetchPage = (page: number) => {
|
||||
if (plugin && page) {
|
||||
setLoading(true);
|
||||
(plugin as Plugin.PagePlugin)
|
||||
.parsePage(novelPath, page.toString())
|
||||
.then(res => setChapters(res.chapters))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<AccordionContainer title="Parse Novel" loading={loading}>
|
||||
<Stack direction={'row'} spacing={2}>
|
||||
<TextField
|
||||
value={novelPath}
|
||||
onChange={e => setNovelPath(e.target.value)}
|
||||
size="small"
|
||||
label="Novel path"
|
||||
/>
|
||||
<Button
|
||||
disabled={plugin === undefined}
|
||||
onClick={fetchNovel}
|
||||
variant="contained"
|
||||
>
|
||||
Fetch
|
||||
</Button>
|
||||
</Stack>
|
||||
{sourceNovel ? <SourceNovelCard sourceNovel={sourceNovel} /> : null}
|
||||
<Box sx={{ height: 20 }} />
|
||||
{chapters.length ? <ChapterList chapters={chapters} /> : null}
|
||||
<Box sx={{ height: 20 }} />
|
||||
{/* @ts-ignore */}
|
||||
{sourceNovel?.totalPages ? (
|
||||
<Pagination
|
||||
// @ts-ignore
|
||||
count={sourceNovel.totalPages}
|
||||
onChange={(e, page) => fetchPage(page)}
|
||||
variant="outlined"
|
||||
/>
|
||||
) : null}
|
||||
</AccordionContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Box, Button, Stack } from '@mui/material';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import AccordionContainer from '../components/AccordionContainer';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import NovelItemCard from '../components/NovelItemCard';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
import usePlugin from '@hooks/usePlugin';
|
||||
|
||||
export default function PopularNovels() {
|
||||
const plugin = usePlugin();
|
||||
const [novels, setNovels] = useState<Plugin.NovelItem[]>([]);
|
||||
const [filterValues, setFilterValues] = useState<Filters | undefined>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const fetchNovels = () => {
|
||||
if (plugin) {
|
||||
setLoading(true);
|
||||
plugin
|
||||
.popularNovels(1, { filters: filterValues })
|
||||
.then(res => setNovels(res))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (plugin?.filters) {
|
||||
let filters = {};
|
||||
for (const fKey in plugin.filters) {
|
||||
filters[fKey] = {
|
||||
type: plugin.filters[fKey].type,
|
||||
value: plugin.filters[fKey].value,
|
||||
};
|
||||
}
|
||||
setFilterValues(filters);
|
||||
}
|
||||
}, [plugin]);
|
||||
return (
|
||||
<AccordionContainer title="Popular Novels" loading={loading}>
|
||||
<Stack direction={'row'} spacing={2}>
|
||||
<Button
|
||||
disabled={plugin === undefined}
|
||||
onClick={fetchNovels}
|
||||
variant="contained"
|
||||
>
|
||||
Fetch
|
||||
</Button>
|
||||
<Button disabled={plugin === undefined} variant="outlined">
|
||||
Next Page
|
||||
</Button>
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
overflowX: 'auto',
|
||||
}}
|
||||
>
|
||||
<Stack sx={{ width: 'max-content' }} direction={'row'} spacing={2}>
|
||||
{novels.map((novel, index) => (
|
||||
<NovelItemCard key={'novelItem_' + index} item={novel} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</AccordionContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Box, Button, Stack, TextField } from '@mui/material';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import AccordionContainer from '../components/AccordionContainer';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import NovelItemCard from '../components/NovelItemCard';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from '@redux/store';
|
||||
import { getPlugin } from '@provider/plugins';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
|
||||
export default function SearchNovels() {
|
||||
const selectedPluginItem = useSelector(
|
||||
(state: AppState) => state.plugin.selected,
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [plugin, setPlugin] = useState<Plugin.PluginBase | undefined>();
|
||||
const [novels, setNovels] = useState<Plugin.NovelItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
useEffect(() => {
|
||||
if (selectedPluginItem) {
|
||||
setPlugin(getPlugin(selectedPluginItem.id));
|
||||
}
|
||||
}, [selectedPluginItem]);
|
||||
const fetchNovels = () => {
|
||||
if (plugin) {
|
||||
setLoading(true);
|
||||
plugin
|
||||
.searchNovels(searchTerm, page)
|
||||
.then(res => setNovels(res))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<AccordionContainer title="Search Novels" loading={loading}>
|
||||
<Stack direction={'row'} spacing={2}>
|
||||
<TextField
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
size="small"
|
||||
label="Search term"
|
||||
/>
|
||||
<Button
|
||||
disabled={plugin === undefined}
|
||||
onClick={fetchNovels}
|
||||
variant="contained"
|
||||
>
|
||||
Fetch
|
||||
</Button>
|
||||
<Button disabled={plugin === undefined} variant="outlined">
|
||||
Next Page
|
||||
</Button>
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
overflowX: 'auto',
|
||||
}}
|
||||
>
|
||||
<Stack sx={{ width: 'max-content' }} direction={'row'} spacing={2}>
|
||||
{novels.map((novel, index) => (
|
||||
<NovelItemCard key={'searchedNovelItem_' + index} item={novel} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</AccordionContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Box,
|
||||
LinearProgress,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
export default function AccordionContainer({
|
||||
children,
|
||||
title,
|
||||
loading,
|
||||
}: {
|
||||
title: string;
|
||||
loading?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Accordion sx={{ my: 2 }}>
|
||||
<AccordionSummary expandIcon={<ArrowDropDownIcon />}>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: 20 }}>{title}</Typography>
|
||||
</AccordionSummary>
|
||||
{loading ? (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<LinearProgress />
|
||||
</Box>
|
||||
) : null}
|
||||
<AccordionDetails sx={{ alignItems: 'flex-start' }}>
|
||||
{children}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import React from 'react';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import CopiableItem from '@components/CopiableItem';
|
||||
import PathItem from '@components/PathItem';
|
||||
export default function NovelItemCard({ item }: { item: Plugin.NovelItem }) {
|
||||
return (
|
||||
<Card sx={{ display: 'flex', p: 1 }}>
|
||||
<CopiableItem content={item.cover}>
|
||||
<Tooltip title={item.cover}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
sx={{ width: 200, height: '100%' }}
|
||||
image={item.cover}
|
||||
/>
|
||||
</Tooltip>
|
||||
</CopiableItem>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', width: 200 }}>
|
||||
<CardContent sx={{ height: 150 }}>
|
||||
<Tooltip title={item.name}>
|
||||
<Box>
|
||||
<CopiableItem content={item.name}>
|
||||
<Button variant="outlined">
|
||||
<Typography
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: '5',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</Typography>
|
||||
</Button>
|
||||
</CopiableItem>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<PathItem path={item.path} />
|
||||
</CardContent>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user