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:
nyagami
2024-06-16 18:13:41 +07:00
committed by GitHub
parent a4cb781f32
commit d36e8ec85f
333 changed files with 5857 additions and 28579 deletions
+147
View File
@@ -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>
);
}
+42
View File
@@ -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>
);
}
+83
View File
@@ -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>
);
}
+57
View File
@@ -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>
);
}