refactor: Overhaul Plugin Architecture & Playground UI (#1846)

* refactor: Move Plugins Out and Consolidate with Multisrc

* feat: Overhaul Playground UI

* feat: Add Filters to New UI

* feat: Persist Selected Plugin

* feat: Add Direct Parse Novel/Chapter Options and Feedback Toasts

[skip ci]

* feat: UX Improvements

* chore: Update Plugin Path in Publish Workflow

* fix: Add Backward Compatibility

* chore: Update Plugin Publishing Script
This commit is contained in:
Rajarshee Chatterjee
2025-10-20 22:54:08 +05:30
committed by GitHub
parent 252beff0bf
commit 20c2b75b01
363 changed files with 6848 additions and 3007 deletions
-161
View File
@@ -1,161 +0,0 @@
import React, { 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,
FormControl,
InputAdornment,
Link,
ListItem,
ListItemAvatar,
ListItemButton,
ListItemText,
TextField,
Typography,
} from '@mui/material';
import { Search } from '@mui/icons-material';
import { searchPlugins } from '@provider/plugins';
import { Plugin } from '@typings/plugin';
import { FixedSizeList, ListChildComponentProps } from 'react-window';
import { useAppStore } from '@store';
const resovleIcon = (iconPath: string) => {
return '/static/' + iconPath;
};
function renderPlugin(setListVisible: (v: boolean) => void) {
return function (props: ListChildComponentProps<Plugin.PluginBase[]>) {
const { index, style, data } = props;
const plugin = data[index];
const { selectPlugin, pluginItem } = useAppStore(state => state);
return (
<ListItem style={style} key={index} component="div" disablePadding>
<ListItemButton
selected={plugin.id === pluginItem?.id}
onClick={() => {
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>
);
};
}
export default function Appbar() {
const [searchedPlugins, setSearchPlugins] = useState<Plugin.PluginBase[]>([]);
const [keyword, setKeyword] = useState('');
const [listVisible, setListVisible] = useState(false);
const plugin = useAppStore(state => state.plugin);
useEffect(() => {
const plugins = searchPlugins(keyword);
setSearchPlugins(plugins);
}, [keyword]);
return (
<AppBar
sx={{ width: '100vw' }}
position="sticky"
color="inherit"
elevation={0}
>
<Toolbar>
<FormControl
sx={{
display: 'block',
position: 'relative',
zIndex: 10,
width: {
xs: '80%',
md: '40%',
},
}}
>
<TextField
size="small"
variant="outlined"
placeholder="Seach plugin"
value={keyword}
onChange={e => setKeyword(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search />
</InputAdornment>
),
}}
fullWidth
autoComplete="off"
onFocus={() => {
setListVisible(true);
if (searchedPlugins.length === 0)
setSearchPlugins(searchPlugins(keyword));
}}
/>
{listVisible ? (
<FixedSizeList
height={400}
width={'100%'}
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(setListVisible)}
</FixedSizeList>
) : null}
</FormControl>
<Backdrop
open={listVisible}
onClick={() => setListVisible(false)}
sx={{ zIndex: 9 }}
invisible={true}
/>
{plugin ? (
<Link
sx={{
pl: 2,
display: 'flex',
alignItems: 'center',
textDecoration: 'none',
}}
href={plugin.site}
target="_blank"
>
<Avatar
sx={{ mr: 1 }}
src={resovleIcon(plugin.icon)}
variant="square"
/>
<Typography
sx={{ display: { xs: 'none', sm: 'none', md: 'block' } }}
>
{plugin.name} - v{plugin.version}
</Typography>
</Link>
) : null}
</Toolbar>
</AppBar>
);
}
-42
View File
@@ -1,42 +0,0 @@
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
@@ -1,83 +0,0 @@
import { Box, Button, Tooltip } from '@mui/material';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import React from 'react';
import CopiableItem from './CopiableItem';
type 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>
);
}
-14
View File
@@ -1,14 +0,0 @@
import { styled, Switch } from '@mui/material';
export const StyledSwitch = styled(Switch)(() => ({
padding: 8,
'& .MuiSwitch-track': {
borderRadius: 22 / 2,
},
'& .MuiSwitch-thumb': {
boxShadow: 'none',
width: 16,
height: 16,
margin: 2,
},
}));
-56
View File
@@ -1,56 +0,0 @@
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;
-122
View File
@@ -1,122 +0,0 @@
import React, { useEffect, useState } from 'react';
import AccordionContainer from '../components/AccordionContainer';
import {
Alert,
Box,
InputAdornment,
MenuItem,
Select,
TextField,
Tooltip,
} from '@mui/material';
import Checkbox from '@mui/material/Checkbox';
import useDebounce from '@hooks/useDebounce';
import { FetchMode } from '@typings/types';
export default function HeadersSection() {
const [loading, setLoading] = useState(false);
const [cookies, setCookies] = useState('');
const debounceCookies = useDebounce(cookies, 500);
const [fetchMode, setFetchMode] = useState(FetchMode.PROXY);
const [alertVisible, setAlertVisble] = useState(false);
const [useUserAgent, setUseUserAgent] = useState(true);
useEffect(() => {
if (alertVisible) {
const id = setTimeout(() => setAlertVisble(false), 1000);
return () => clearTimeout(id);
}
}, [alertVisible]);
useEffect(() => {
setLoading(true);
fetch('settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
cookies: debounceCookies,
fetchMode: fetchMode,
useUserAgent,
}),
})
.then(() => setAlertVisble(true))
.finally(() => setLoading(false));
}, [debounceCookies, fetchMode, useUserAgent]);
return (
<AccordionContainer title="Settings" loading={loading}>
{alertVisible ? (
<Alert
sx={{
position: 'absolute',
zIndex: 2,
top: 0,
right: 0,
}}
>
Settings updated
</Alert>
) : null}
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Tooltip title={navigator.userAgent}>
<TextField
size="small"
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">User Agent</InputAdornment>
),
},
}}
disabled
value={navigator.userAgent}
sx={{ width: '100%' }}
/>
</Tooltip>
<Checkbox
size="large"
checked={useUserAgent}
onChange={e => setUseUserAgent(e.target.checked)}
/>
</Box>
<Box sx={{ height: 10 }} />
<TextField
size="small"
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">Cookies</InputAdornment>
),
},
}}
value={cookies}
onChange={e => {
setCookies(e.target.value.trim());
}}
sx={{ width: '100%' }}
/>
<Box sx={{ height: 10 }} />
<div
style={{
alignContent: 'flex-start',
display: 'flex',
alignItems: 'center',
gap: 10,
}}
>
Fetch Mode:
<Select
variant="outlined"
label="Fetcher"
value={fetchMode}
onChange={e => setFetchMode(e.target.value as FetchMode)}
>
<MenuItem value={FetchMode.PROXY}>Proxy</MenuItem>
<MenuItem value={FetchMode.NODE_FETCH}>Node fetch</MenuItem>
<MenuItem value={FetchMode.CURL}>Curl</MenuItem>
</Select>
</div>
</AccordionContainer>
);
}
@@ -1,50 +0,0 @@
import { Box, Button, Stack, TextField } from '@mui/material';
import React, { useState } from 'react';
import AccordionContainer from '../components/AccordionContainer';
import Textarea from '@components/Textarea';
import { useAppStore } from '@store';
export default function ParseChapter() {
const plugin = useAppStore(state => state.plugin);
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
value={chapterText}
/>
) : null}
</AccordionContainer>
);
}
@@ -1,74 +0,0 @@
import PathItem from '@components/PathItem';
import {
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} 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, minWidth: 600, textAlign: 'left' }}>
<TableVirtuoso
data={chapters}
components={VirtuosoTableComponents}
fixedHeaderContent={fixedHeaderContent}
itemContent={rowContent}
/>
</Paper>
);
}
@@ -1,117 +0,0 @@
import React, { ReactNode } from 'react';
import { Plugin } from '@typings/plugin';
import {
Box,
Button,
Card,
CardContent,
CardMedia,
Chip,
ListItem,
Stack,
Tooltip,
Typography,
useTheme,
} from '@mui/material';
import CopiableItem from '@components/CopiableItem';
import PathItem from '@components/PathItem';
const FlexRow = ({ children }: { children: ReactNode }) => (
<Stack direction="row" gap={1} sx={{ alignItems: 'center' }}>
{children}
</Stack>
);
export default function SourceNovelCard({
sourceNovel,
}: {
sourceNovel: Plugin.SourceNovel;
}) {
return (
<Box sx={{ display: 'flex', p: 1, width: '100%', textAlign: 'left' }}>
<CopiableItem content={sourceNovel.cover}>
<Tooltip title={sourceNovel.cover}>
<CardMedia
component="img"
sx={{ width: 300, height: 400, objectFit: 'cover' }}
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>
<Typography
sx={{
overflowY: 'auto',
height: 300,
p: 2,
borderRadius: 2,
backgroundColor: 'rgba(0,0,0,0.1)',
}}
>
{sourceNovel.summary}
</Typography>
</CardContent>
</Box>
</Box>
);
}
@@ -1,70 +0,0 @@
import { Box, Button, Pagination, Stack, TextField } from '@mui/material';
import React, { useState } from 'react';
import AccordionContainer from '../components/AccordionContainer';
import { Plugin } from '@typings/plugin';
import SourceNovelCard from './SourceNovelCard';
import ChapterList from './ChaptertList';
import { useAppStore } from '@store';
export default function ParseNovel() {
const plugin = useAppStore(state => state.plugin);
const [sourceNovel, setSouceNovel] = useState<
(Plugin.SourceNovel & { totalPages?: number }) | 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}>
<Box sx={{ overflowX: 'auto', width: '100%' }}>
<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 }} />
{sourceNovel?.totalPages ? (
<Pagination
count={sourceNovel.totalPages}
onChange={(e, page) => fetchPage(page)}
variant="outlined"
/>
) : null}
</Box>
</AccordionContainer>
);
}
@@ -1,11 +0,0 @@
#filtersContent {
display: flex;
flex-direction: column;
align-content: center;
}
.switch_filter {
display: flex;
flex-direction: column;
align-items: center;
}
@@ -1,158 +0,0 @@
import {
Box,
Button,
Stack,
ToggleButton,
ToggleButtonGroup,
Switch,
FormControlLabel,
FormGroup,
} from '@mui/material';
import React, { useEffect, useState, ChangeEvent, useRef } from 'react';
import AccordionContainer from '../components/AccordionContainer';
import { Plugin } from '@typings/plugin';
import NovelItemCard from '../components/NovelItemCard';
import { Filters, FilterToValues } from '@libs/filterInputs';
import { useAppStore } from '@store';
import './index.css';
import { DialogRef, FiltersDialog } from '../components/filters/FiltersDialog';
export default function PopularNovels() {
const plugin = useAppStore(state => state.plugin);
const [novels, setNovels] = useState<Plugin.NovelItem[]>([]);
const [filterValues, setFilterValues] = useState<
FilterToValues<Filters> | undefined
>();
const [loading, setLoading] = useState(false);
const [currentIndex, setCurrentIndex] = useState(0);
const [maxIndex, setMaxIndex] = useState(0);
const [isLatest, setIsLatest] = useState(true);
const dialogRef = useRef<DialogRef>(null);
const fetchNovelsByIndex = async (index: number) => {
if (plugin && index) {
setLoading(true);
const novels = await plugin.popularNovels(index, {
filters: filterValues || {},
showLatestNovels: isLatest,
});
if (novels.length !== 0) {
setCurrentIndex(index);
if (index > maxIndex) {
setMaxIndex(index);
}
setNovels(novels);
}
setLoading(false);
}
};
const handleSwitchLatestChange = (event: ChangeEvent<HTMLInputElement>) => {
setIsLatest(event.target.checked);
};
useEffect(() => {
if (plugin) {
setCurrentIndex(1);
setMaxIndex(1);
fetchNovelsByIndex(1);
}
}, [isLatest]);
useEffect(() => {
// Reset when changing plugins.
// TODO: Fix crashing on plugin change!
setCurrentIndex(0);
setMaxIndex(0);
setNovels([]);
if (plugin?.filters) {
const filters: FilterToValues<typeof plugin.filters> = {};
for (const fKey in plugin.filters) {
filters[fKey as keyof typeof filters] = {
type: plugin.filters[fKey].type,
value: plugin.filters[fKey].value,
};
}
setFilterValues(filters);
}
}, [plugin]);
return (
<AccordionContainer title="Popular Novels" loading={loading}>
<Stack
direction={'row'}
useFlexGap
sx={{ flexWrap: 'wrap' }}
spacing={2}
alignItems={'center'}
>
<FormGroup>
<FormControlLabel
control={
<Switch
checked={isLatest}
onChange={handleSwitchLatestChange}
color="secondary"
/>
}
label={<Box minWidth={70}>{isLatest ? 'Latest' : 'Popular'}</Box>}
/>
</FormGroup>
<Button
disabled={plugin === undefined}
onClick={() => fetchNovelsByIndex(1)}
variant="contained"
>
Fetch
</Button>
<Button
disabled={currentIndex === 0}
variant="outlined"
onClick={() => fetchNovelsByIndex(currentIndex + 1)}
>
Next Page
</Button>
<Button
disabled={!plugin || isLatest || !plugin.filters}
onClick={() => {
dialogRef.current?.show();
}}
>
Filters
</Button>
<FiltersDialog
ref={dialogRef}
values={filterValues}
filters={plugin?.filters}
setValues={setFilterValues}
refetch={() => fetchNovelsByIndex(1)}
/>
<ToggleButtonGroup
value={currentIndex}
exclusive
onChange={(_event, value) => fetchNovelsByIndex(value)}
>
{Array.from({ length: maxIndex }, (_, index) => index + 1).map(
number => (
<ToggleButton key={`novel_page_index_${number}`} value={number}>
{number}
</ToggleButton>
),
)}
</ToggleButtonGroup>
</Stack>
<Box
sx={{
width: '100%',
overflowX: 'auto',
display: 'flex',
}}
>
{novels.map((novel, index) => (
<NovelItemCard key={'novelItem_' + index} item={novel} />
))}
</Box>
</AccordionContainer>
);
}
@@ -1,77 +0,0 @@
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 { useAppStore } from '@store';
export default function SearchNovels() {
const plugin = useAppStore(state => state.plugin);
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [novels, setNovels] = useState<Plugin.NovelItem[] | undefined>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState('');
const fetchNovels = (page: number) => {
if (plugin) {
setLoading(true);
plugin
.searchNovels(searchTerm, page)
.then(res => setNovels(res))
.finally(() => setLoading(false))
.catch(e => {
setFetchError(e.message);
});
}
};
useEffect(() => {
setCurrentPage(1);
}, [plugin]);
return (
<AccordionContainer title="Search Novels" loading={loading}>
{fetchError && <span style={{ color: 'red' }}>{fetchError}</span>}
<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(currentPage)}
variant="contained"
>
Fetch
</Button>
<Button
disabled={plugin === undefined}
variant="outlined"
onClick={() => {
fetchNovels(currentPage + 1);
setCurrentPage(pre => pre + 1);
}}
>
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>
);
}
@@ -1,36 +0,0 @@
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>
);
}
@@ -1,55 +0,0 @@
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 (
<Box 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>
</Box>
);
}
@@ -1,64 +0,0 @@
import React from 'react';
import { Filter, FilterTypes, ValueOfFilter } from '@libs/filterInputs';
import {
Checkbox,
FormControl,
FormControlLabel,
FormLabel,
Grid,
} from '@mui/material';
type FilterType = FilterTypes.CheckboxGroup;
export const CheckboxFilter = ({
filter: { key, filter },
value,
set,
}: {
filter: { key: string; filter: Filter<FilterType> };
value: ValueOfFilter<FilterType>;
set: (v: typeof value) => void;
}) => {
const label = filter.label.replace(/s+/gi, '-');
return (
<FormControl key={key} variant="outlined">
<FormLabel>{filter.label}</FormLabel>
<Grid
container
columns={{ xs: 3 }}
id={`checkbox_filter_${key}_${label}`}
>
{filter.options.map(option => {
return (
<Grid
key={`${key}_${option.label}`}
id={`checkbox_filter_option_${key}_${option.value.replace(/\s+/gi, '-')}`}
item
xs={1}
justifyContent={'center'}
display={'flex'}
>
<FormControl>
<FormControlLabel
control={
<Checkbox
onChange={() => {
set(
value.includes(option.value)
? value.filter(q => q !== option.value)
: [...value, option.value],
);
}}
checked={value.includes(option.value)}
/>
}
label={option.label}
/>
</FormControl>
</Grid>
);
})}
</Grid>
</FormControl>
);
};
@@ -1,61 +0,0 @@
import React from 'react';
import { Filter, FilterTypes, ValueOfFilter } from '@libs/filterInputs';
import { FormControl, InputLabel, MenuItem, Select } from '@mui/material';
type FilterType = FilterTypes.Picker;
export const PickerFilter = ({
filter: { key, filter },
value,
set,
}: {
filter: { key: string; filter: Filter<FilterType> };
value: ValueOfFilter<FilterType>;
set: (v: ValueOfFilter<FilterType>) => void;
}) => {
const label = filter.label.replace(/s+/gi, '-');
return (
<FormControl
key={key}
variant="filled"
fullWidth
margin="dense"
style={{ alignSelf: 'center' }}
>
<InputLabel id={`picker_filter_label_${label}_${key}`}>
{filter.label}
</InputLabel>
<Select
size="medium"
labelId={`picker_filter_label_${label}_${key}`}
id={`picker_filter_${label}_${key}`}
value={value || key}
onChange={({ target: { value: newValue } }) => {
if (newValue === key) newValue = '';
set(newValue);
}}
MenuProps={{ slotProps: { paper: { sx: { maxHeight: 36 * 4 } } } }}
label={filter.label}
>
{filter.options.map(option => {
return (
<MenuItem
style={{ minHeight: 36 }}
key={`${key}_${option.value}`}
value={
/** Because Mui treats empty string as `empty value`
* and doesn't show the MenuItem, if the value is empty, just use the key...
* We could do some other weird stuff, but I doubt there will be a filter
* that has the same name as the value being used inside of it.
* We can warn people in the documentation about this behavior */
option.value || key
}
>
{option.label}
</MenuItem>
);
})}
</Select>
</FormControl>
);
};
@@ -1,33 +0,0 @@
import React from 'react';
import { Filter, FilterTypes, ValueOfFilter } from '@libs/filterInputs';
import { FormControl, FormControlLabel } from '@mui/material';
import { StyledSwitch } from '../../../StyledSwitch';
type FilterType = FilterTypes.Switch;
export const SwitchFilter = ({
filter: { key, filter },
value,
set,
}: {
filter: { key: string; filter: Filter<FilterType> };
value: ValueOfFilter<FilterType>;
set: (v: typeof value) => void;
}) => {
const label = filter.label.replace(/s+/gi, '-');
return (
<FormControl
key={key}
className="switch_filter"
fullWidth
variant="outlined"
>
<FormControlLabel
style={{ userSelect: 'none' }}
label={filter.label}
id={`switch_filter_${label}_${key}`}
control={<StyledSwitch onChange={() => set(!value)} />}
/>
</FormControl>
);
};
@@ -1,38 +0,0 @@
import React, { useRef } from 'react';
import { Filter, FilterTypes, ValueOfFilter } from '@libs/filterInputs';
import { FormControl, TextField } from '@mui/material';
type FilterType = FilterTypes.TextInput;
export const TextFilter = ({
filter: { key, filter },
value,
set,
}: {
filter: { key: string; filter: Filter<FilterType> };
value: ValueOfFilter<FilterType>;
set: (v: typeof value) => void;
}) => {
const label = filter.label.replace(/s+/gi, '-');
const textRef = useRef<HTMLInputElement>(null);
return (
<FormControl key={key} className="switch_filter">
<TextField
color="primary"
id={`text_filter_${label}_${key}`}
inputRef={textRef}
label={filter.label}
value={value}
type="text"
variant="filled"
fullWidth
margin="dense"
onInput={() => {
if (textRef.current) {
set(textRef.current.value ?? '');
}
}}
/>
</FormControl>
);
};
@@ -1,140 +0,0 @@
import { styled } from '@mui/system';
import React, { useRef, useState } from 'react';
class TriStateCheckboxClasses {
root = 'TriStateCheckboxRoot';
base = 'TriStateCheckboxBase';
input = 'TriStateCheckboxInput';
svg = 'TriStateCheckboxSVG';
checked: string;
unchecked: string;
neutral: string;
offsets = {
neutral: 16,
checked: 16 * 2,
unchecked: 0,
};
constructor() {
this.checked = `${this.base}-checked`;
this.neutral = `${this.base}-neutral`;
this.unchecked = `${this.base}-unchecked`;
}
}
const classes = new TriStateCheckboxClasses();
const TriStateCheckboxRoot = styled(`span`, {
name: classes.root,
slot: 'root',
})(({ theme }) => ({
[`&.${classes.checked}`]: {
color: theme.palette.primary.main,
},
[`&.${classes.unchecked}`]: {
color: 'red',
},
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
boxSizing: 'border-box',
outline: 0,
border: 0,
margin: 0,
cursor: 'pointer',
userSelect: 'none',
textDecoration: 'none',
borderRadius: '50%',
padding: '9px',
}));
const TriStateCheckboxInput = styled(`input`, {
name: classes.input,
slot: 'input',
})(() => ({
cursor: 'inherit',
position: 'absolute',
opacity: 0,
height: '100%',
width: '100%',
top: 0,
left: 0,
margin: 0,
padding: 0,
zIndex: 1,
}));
const TriStateCheckboxSVG = styled(`svg`, { name: classes.svg, slot: 'image' })(
() => ({
userSelect: 'none',
width: '1em',
height: '1em',
display: 'inline-block',
fill: 'currentcolor',
flexShrink: 0,
fontSize: '1.5rem',
transition: 'fill 200ms cubic-bezier(0.4,0,0.2,1) 0ms',
}),
);
const emptyPath = (
<path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"></path>
);
const checkmarkPath = (
<path d="M19 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.11 0 2-.9 2-2V5c0-1.1-.89-2-2-2zm-9 14l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"></path>
);
const uncheckedPath = (
<path
d={`M19 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.11 0 2-.9 2-2V5c0-1.1-.89-2-2-2zM8 6l4 4l4-4l2 2l-4 4l4,4l-2 2l-4-4l-4 4l-2-2l4-4l-4-4z`}
></path>
);
export type TriStateCheckboxState = 'checked' | 'unchecked' | 'neutral';
export const TriStateCheckbox = function ({
value,
onChange,
}: {
value?: TriStateCheckboxState;
onChange?: (newValue: TriStateCheckboxState) => void;
}) {
const [state, sSt] = useState<TriStateCheckboxState>(value ?? 'neutral');
const setState = (v: TriStateCheckboxState) => {
onChange?.(v);
sSt(value ?? v);
};
const root = useRef<HTMLSpanElement>(null);
const curVal = value ?? state;
return (
<>
<TriStateCheckboxRoot
className={classes[curVal]}
data-state={curVal}
ref={root}
onClick={() => {
switch (curVal) {
case 'checked':
return setState('unchecked');
case 'neutral':
return setState('checked');
case 'unchecked':
return setState('neutral');
}
}}
>
<TriStateCheckboxInput />
<TriStateCheckboxSVG>
{curVal === 'checked'
? checkmarkPath
: curVal === 'unchecked'
? uncheckedPath
: emptyPath}
</TriStateCheckboxSVG>
</TriStateCheckboxRoot>
</>
);
};
@@ -1,191 +0,0 @@
import { styled } from '@mui/system';
import React, { useRef, useState } from 'react';
class TriStateSwitchClasses {
root = 'TriStateSwitchRoot';
base = 'TriStateSwitchBase';
track = 'TriStateSwitchTrack';
thumb = 'TriStateSwitchThumb';
input = 'TriStateSwitchInput';
checked: string;
unchecked: string;
neutral: string;
offsets = {
neutral: 18,
checked: 18 * 2,
unchecked: 0,
};
width = 16 * 4 + 10;
constructor() {
this.base;
this.checked = `${this.base}-checked`;
this.neutral = `${this.base}-neutral`;
this.unchecked = `${this.base}-unchecked`;
}
}
const classes = new TriStateSwitchClasses();
const TriStateSwitchRoot = styled(`span`, {
name: classes.root,
slot: 'root',
})(() => ({
display: 'inline-flex',
width: `${classes.width}px`,
height: '38px',
overflow: 'hidden',
boxSizing: 'border-box',
position: 'relative',
flexShrink: 0,
zIndex: 0,
verticalAlign: 'middle',
padding: '8px',
}));
const TriStateSwitchTrack = styled(`span`, {
name: classes.track,
slot: 'track',
})(() => ({
// Android12 visuals:
borderRadius: 22 / 2,
// base Track
width: '100%',
height: '100%',
zIndex: -1,
content: `''`,
transition:
'opacity 150ms cubic-bezier(0.4,0,0.2,1) 0ms, background-color 150ms cubic-bezier(0.4,0,0.2,1) 0ms',
backgroundColor: '#000',
opacity: 0.38,
}));
const TriStateSwitchBase = styled(`span`, {
name: classes.base,
slot: 'base',
})(({ theme }) => ({
[`&.${classes.unchecked}`]: {
transform: `translateX(${classes.offsets.unchecked}px)`,
color: '#f33',
},
[`&.${classes.checked}`]: {
transform: `translateX(${classes.offsets.checked}px)`,
color: theme.palette.primary.main,
},
[`&.${classes.neutral}`]: {
transform: `translateX(${classes.offsets.neutral}px)`,
color: 'gray',
},
[`&.${classes.checked} + span`]: {
opacity: 0.5,
backgroundColor: theme.palette.primary.main,
},
[`&.${classes.unchecked} + span`]: {
opacity: 0.5,
backgroundColor: '#f33',
},
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
outline: 0,
border: 0,
margin: 0,
cursor: 'pointer',
userSelect: 'none',
textDecoration: 'none',
padding: '9px',
borderRadius: '50%',
position: 'absolute',
top: 0,
left: 0,
zIndex: 1,
color: '#fff',
transition:
'left 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,transform 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;',
}));
const TriStateSwitchInput = styled(`input`, {
name: classes.input,
slot: 'input',
})(() => ({
left: '-100%',
width: '300%',
cursor: 'inherit',
position: 'absolute',
opacity: 0,
height: '100%',
top: 0,
margin: 0,
padding: 0,
zIndex: 1,
}));
const TriStateSwitchThumb = styled(`span`, {
name: classes.thumb,
slot: 'thumb',
})(() => ({
boxShadow: 'none',
width: '16px',
height: '16px',
margin: '2px',
backgroundColor: 'currentColor',
borderRadius: '50%',
}));
type TriStateSwitchState = 'checked' | 'unchecked' | 'neutral';
export const TriStateSwitch = function ({
value,
onChange,
}: {
value?: TriStateSwitchState;
onChange?: (newValue: TriStateSwitchState) => void;
}) {
const [state, sSt] = useState<TriStateSwitchState>(value ?? 'neutral');
const setState = (v: TriStateSwitchState) => {
onChange?.(v);
sSt(value ?? v);
};
const root = useRef<HTMLSpanElement>(null);
return (
<>
<TriStateSwitchRoot
data-state={state}
ref={root}
onClick={e => {
const offsetX =
e.clientX - (root.current?.getBoundingClientRect().x ?? 0);
const inputWidth = parseFloat(
getComputedStyle(e.currentTarget).width,
);
const clickPercentage = offsetX / inputWidth;
const sidePressed =
clickPercentage < 0.33
? 'left'
: clickPercentage > 0.66
? 'right'
: 'center';
console.log(inputWidth, offsetX);
switch (sidePressed) {
case 'center':
return setState('neutral');
case 'left':
return setState('unchecked');
case 'right':
return setState('checked');
}
}}
>
<TriStateSwitchBase className={classes[value ?? state]}>
<TriStateSwitchInput />
<TriStateSwitchThumb />
</TriStateSwitchBase>
<TriStateSwitchTrack />
</TriStateSwitchRoot>
</>
);
};
@@ -1,88 +0,0 @@
import React from 'react';
import { Filter, FilterTypes, ValueOfFilter } from '@libs/filterInputs';
import { FormControl, FormControlLabel, FormLabel, Grid } from '@mui/material';
import { useAppStore } from '@store';
import { TriStateCheckbox, TriStateCheckboxState } from './TriStateCheckbox';
import { TriStateSwitch } from './TriStateSwitch';
type FilterType = FilterTypes.ExcludableCheckboxGroup;
export const XCheckboxFilter = ({
filter: { key, filter },
value,
set,
}: {
filter: { key: string; filter: Filter<FilterType> };
value: ValueOfFilter<FilterType>;
set: (v: typeof value) => void;
}) => {
const useSwitches = useAppStore(state => state.useSwitches);
const getCheckboxState = (v: string): TriStateCheckboxState => {
if (value.include?.includes(v)) return 'checked';
if (value.exclude?.includes(v)) return 'unchecked';
return 'neutral';
};
const handleChange = (key: string, v: TriStateCheckboxState) => {
console.log(`setting ${key} to`, v);
if (v === 'unchecked')
return set({
exclude: value.exclude
? Array.from(new Set([...value.exclude, key]))
: [key],
include: value.include?.filter(f => f !== key),
});
if (v === 'checked')
return set({
include: value.include
? Array.from(new Set([...value.include, key]))
: [key],
exclude: value.exclude?.filter(f => f !== key),
});
return set({
include: value.include?.filter(f => f !== key),
exclude: value.exclude?.filter(f => f !== key),
});
};
return (
<FormControl key={key} variant="outlined">
<FormLabel>{filter.label}</FormLabel>
<Grid container columns={{ xs: 3 }}>
{filter.options.map(option => {
return (
<Grid
key={`${key}_${option.label}`}
item
xs={1}
justifyContent={'center'}
display={'flex'}
>
<FormControl>
<FormControlLabel
labelPlacement={useSwitches ? 'top' : 'end'}
style={{ userSelect: 'none' }}
control={
useSwitches ? (
<TriStateSwitch
value={getCheckboxState(option.value)}
onChange={c => handleChange(option.value, c)}
/>
) : (
<TriStateCheckbox
value={getCheckboxState(option.value)}
onChange={c => handleChange(option.value, c)}
/>
)
}
label={option.label}
/>
</FormControl>
</Grid>
);
})}
</Grid>
</FormControl>
);
};
@@ -0,0 +1,47 @@
import React from 'react';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { FilterTypes } from '@libs/filterInputs';
import type { Filter } from '@libs/filterInputs';
type CheckboxFilterProps = {
filter: {
key: string;
filter: Filter<FilterTypes.CheckboxGroup>;
};
value: string[];
set: (value: string[]) => void;
};
export function CheckboxFilter({ filter, value, set }: CheckboxFilterProps) {
const toggleOption = (optionValue: string) => {
if (value.includes(optionValue)) {
set(value.filter(v => v !== optionValue));
} else {
set([...value, optionValue]);
}
};
return (
<div className="space-y-3">
<Label className="text-sm font-medium">{filter.filter.label}</Label>
<div className="space-y-2">
{filter.filter.options.map(option => (
<div key={option.value} className="flex items-center space-x-2">
<Checkbox
id={`${filter.key}-${option.value}`}
checked={value.includes(option.value)}
onCheckedChange={() => toggleOption(option.value)}
/>
<Label
htmlFor={`${filter.key}-${option.value}`}
className="text-sm font-normal cursor-pointer"
>
{option.label}
</Label>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,89 @@
import React from 'react';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { FilterTypes } from '@libs/filterInputs';
import type { Filter } from '@libs/filterInputs';
type ExcludableCheckboxFilterProps = {
filter: {
key: string;
filter: Filter<FilterTypes.ExcludableCheckboxGroup>;
};
value: {
include?: string[];
exclude?: string[];
};
set: (value: { include?: string[]; exclude?: string[] }) => void;
};
type CheckState = 'unchecked' | 'included' | 'excluded';
export function ExcludableCheckboxFilter({
filter,
value,
set,
}: ExcludableCheckboxFilterProps) {
const getState = (optionValue: string): CheckState => {
if (value.include?.includes(optionValue)) return 'included';
if (value.exclude?.includes(optionValue)) return 'excluded';
return 'unchecked';
};
const toggleOption = (optionValue: string) => {
const currentState = getState(optionValue);
let newInclude = value.include || [];
let newExclude = value.exclude || [];
switch (currentState) {
case 'unchecked':
// Unchecked -> Included
newInclude = [...newInclude, optionValue];
break;
case 'included':
// Included -> Excluded
newInclude = newInclude.filter(v => v !== optionValue);
newExclude = [...newExclude, optionValue];
break;
case 'excluded':
// Excluded -> Unchecked
newExclude = newExclude.filter(v => v !== optionValue);
break;
}
set({ include: newInclude, exclude: newExclude });
};
return (
<div className="space-y-3">
<Label className="text-sm font-medium">{filter.filter.label}</Label>
<div className="space-y-2">
{filter.filter.options.map(option => {
const state = getState(option.value);
return (
<button
key={option.value}
type="button"
onClick={() => toggleOption(option.value)}
className="flex items-center justify-between w-full px-3 py-2 text-sm rounded-md border border-border hover:bg-muted/50 transition-colors"
>
<span className="text-foreground">{option.label}</span>
{state === 'included' && (
<Badge variant="default" className="text-xs">
Include
</Badge>
)}
{state === 'excluded' && (
<Badge variant="destructive" className="text-xs">
Exclude
</Badge>
)}
</button>
);
})}
</div>
<p className="text-xs text-muted-foreground">
Click once to include, twice to exclude, three times to reset
</p>
</div>
);
}
@@ -1,28 +1,25 @@
import React, {
forwardRef,
ReactNode,
useEffect,
useImperativeHandle,
} from 'react';
import React, { useEffect, useState, ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
} from '@mui/material';
import { useState } from 'react';
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import {
AnyFilterValue,
Filters,
FilterToValues,
FilterTypes,
} from '@libs/filterInputs';
import { PickerFilter } from './PickerFilter';
import { SwitchFilter } from './SwitchFilter';
import { TextFilter } from './TextFilter';
import { CheckboxFilter } from './CheckboxFilter';
import { XCheckboxFilter } from './XCheckboxFilter';
import { PickerFilter } from './picker-filter';
import { SwitchFilter } from './switch-filter';
import { TextFilter } from './text-filter';
import { CheckboxFilter } from './checkbox-filter';
import { ExcludableCheckboxFilter } from './excludable-checkbox-filter';
import { RotateCcw } from 'lucide-react';
const renderFilters = (
filters: Filters | undefined,
@@ -41,7 +38,8 @@ const renderFilters = (
return typeof o === typeof f || checkIfIsCorrectObjectType(o, f);
};
if (!filters || !values) return false;
if (!filters || !values) return null;
return (
<>
{Object.entries(filters).map(([key, filter]) => {
@@ -51,17 +49,13 @@ const renderFilters = (
}
switch (filter.type) {
case FilterTypes.Picker: {
// Check if filterValues have correct type
// this needs to be inside of every case in this switch to get correct value type
const value = values[key].value; // here value has every possible value type
// We could just do `as typeof filter.value` but just to be sure I made a typeguard
const value = values[key].value;
if (!isValueCorrectType<typeof filter.value>(value, filter.value)) {
console.error(
`FilterValue for filter [${key}] has a wrong type!`,
);
return null;
}
// value; // here value has only Picker's value
return (
<PickerFilter
key={`picker_filter_${key}`}
@@ -72,17 +66,13 @@ const renderFilters = (
);
}
case FilterTypes.Switch: {
// Check if filterValues have correct type
// this needs to be inside of every case in this switch to get correct value type
const value = values[key].value; // here value has every possible value type
// We could just do `as typeof filter.value` but just to be sure I made a typeguard
const value = values[key].value;
if (!isValueCorrectType<typeof filter.value>(value, filter.value)) {
console.error(
`FilterValue for filter [${key}] has a wrong type!`,
);
return null;
}
// value; // here value has only Picker's value
return (
<SwitchFilter
filter={{ key, filter }}
@@ -135,11 +125,11 @@ const renderFilters = (
return null;
}
return (
<XCheckboxFilter
<ExcludableCheckboxFilter
filter={{ key, filter }}
set={newValue => set(key, newValue)}
value={value}
key={`checkbox_filter_${key}`}
key={`xcheсkbox_filter_${key}`}
/>
);
}
@@ -149,21 +139,27 @@ const renderFilters = (
);
};
export type DialogRef = { show(): void; hide(): void };
type FiltersSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
values: FilterToValues<Filters> | undefined;
filters: Filters | undefined;
setValues: React.Dispatch<
React.SetStateAction<FilterToValues<Filters> | undefined>
>;
refetch: () => void;
};
export function FiltersSheet({
open,
onOpenChange,
values,
setValues,
filters,
refetch,
}: FiltersSheetProps) {
const [filterElements, setFilterElements] = useState<ReactNode>(null);
export const FiltersDialog = forwardRef<
DialogRef,
{
values: FilterToValues<Filters> | undefined;
filters: Filters | undefined;
setValues: React.Dispatch<
React.SetStateAction<FilterToValues<Filters> | undefined>
>;
refetch(): void;
}
>(function FiltersDialog({ values, setValues, filters, refetch }, ref) {
const [dialogOpen, setDialogOpen] = useState(false);
const [filterElements, setFilterElements] = useState<ReactNode>(false);
const setFilterWithKey = (key: string, newValue: AnyFilterValue) =>
setValues(fValues =>
!fValues
@@ -177,35 +173,58 @@ export const FiltersDialog = forwardRef<
},
);
useImperativeHandle(ref, () => ({
show() {
setDialogOpen(true);
},
hide() {
setDialogOpen(false);
},
}));
const resetFilters = () => {
if (filters) {
const resetValues: FilterToValues<typeof filters> = {};
for (const fKey in filters) {
resetValues[fKey as keyof typeof resetValues] = {
type: filters[fKey].type,
value: filters[fKey].value,
};
}
setValues(resetValues);
}
};
useEffect(() => {
setFilterElements(renderFilters(filters, values, setFilterWithKey));
}, [values]);
}, [values, filters]);
const handleApply = () => {
refetch();
onOpenChange(false);
};
return (
<Dialog
scroll="paper"
open={dialogOpen}
onClose={() => setDialogOpen(false)}
>
<DialogTitle>Filters</DialogTitle>
<DialogContent dividers id="filtersContent">
{filterElements}
</DialogContent>
<DialogActions>
<Button onClick={() => refetch()}>Refetch</Button>
<Button style={{ color: 'red' }} onClick={() => setDialogOpen(false)}>
Close
</Button>
</DialogActions>
</Dialog>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-md overflow-y-auto">
<SheetHeader>
<SheetTitle>Filters</SheetTitle>
<SheetDescription>
Customize your search with these filter options
</SheetDescription>
</SheetHeader>
<div className="py-6 space-y-4">
{filterElements || (
<p className="text-sm text-muted-foreground text-center py-8">
No filters available
</p>
)}
</div>
<SheetFooter className="flex-col sm:flex-col gap-2">
<Button onClick={handleApply} className="w-full">
Apply Filters
</Button>
<Button
variant="outline"
onClick={resetFilters}
className="w-full gap-2"
>
<RotateCcw className="w-4 h-4" />
Reset to Default
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
});
}
+55
View File
@@ -0,0 +1,55 @@
import React from 'react';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { FilterTypes } from '@libs/filterInputs';
import type { Filter } from '@libs/filterInputs';
type PickerFilterProps = {
filter: {
key: string;
filter: Filter<FilterTypes.Picker>;
};
value: string;
set: (value: string) => void;
};
const EMPTY_VALUE_PLACEHOLDER = '__lnreader_empty__';
export function PickerFilter({ filter, value, set }: PickerFilterProps) {
// Map empty string to placeholder for Radix UI Select
const displayValue = value === '' ? EMPTY_VALUE_PLACEHOLDER : value;
// Handle change and map placeholder back to empty string
const handleChange = (newValue: string) => {
set(newValue === EMPTY_VALUE_PLACEHOLDER ? '' : newValue);
};
return (
<div className="space-y-2">
<Label className="text-sm font-medium">{filter.filter.label}</Label>
<Select value={displayValue} onValueChange={handleChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{filter.filter.options.map(option => {
// Map empty string values to placeholder
const optionValue =
option.value === '' ? EMPTY_VALUE_PLACEHOLDER : option.value;
return (
<SelectItem key={option.value || 'empty'} value={optionValue}>
{option.label || option.value || 'All'}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import React from 'react';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { FilterTypes } from '@libs/filterInputs';
import type { Filter } from '@libs/filterInputs';
type SwitchFilterProps = {
filter: {
key: string;
filter: Filter<FilterTypes.Switch>;
};
value: boolean;
set: (value: boolean) => void;
};
export function SwitchFilter({ filter, value, set }: SwitchFilterProps) {
return (
<div className="flex items-center justify-between space-x-2 py-2">
<Label
htmlFor={filter.key}
className="text-sm font-medium cursor-pointer"
>
{filter.filter.label}
</Label>
<Switch id={filter.key} checked={value} onCheckedChange={set} />
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import React from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { FilterTypes } from '@libs/filterInputs';
import type { Filter } from '@libs/filterInputs';
type TextFilterProps = {
filter: {
key: string;
filter: Filter<FilterTypes.TextInput>;
};
value: string;
set: (value: string) => void;
};
export function TextFilter({ filter, value, set }: TextFilterProps) {
return (
<div className="space-y-2">
<Label htmlFor={filter.key} className="text-sm font-medium">
{filter.filter.label}
</Label>
<Input
id={filter.key}
value={value}
onChange={e => set(e.target.value)}
placeholder={`Enter ${filter.filter.label.toLowerCase()}...`}
/>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
import React from 'react';
import { Copy, Zap } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Plugin } from '@/types/plugin';
type NovelCardProps = {
novel: Plugin.NovelItem;
onParse: (path: string) => void;
};
export function NovelCard({ novel, onParse }: NovelCardProps) {
const handleCopyPath = () => {
navigator.clipboard.writeText(novel.path);
toast.success('Novel path copied!');
};
return (
<div className="group cursor-pointer flex flex-col h-full">
<div className="relative mb-2 overflow-hidden rounded-lg bg-muted aspect-[3/4]">
<img
src={novel.cover || '/static/coverNotAvailable.webp'}
alt={novel.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
</div>
<div className="flex flex-col flex-1 min-h-0">
<h3 className="text-sm font-medium text-foreground line-clamp-2 mb-2 leading-tight h-10">
{novel.name}
</h3>
<div className="flex gap-1.5 mt-auto">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex-1 h-8 px-2 bg-transparent"
onClick={handleCopyPath}
>
<Copy className="w-3.5 h-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy path</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
className="flex-1 h-8 px-2"
onClick={() => onParse(novel.path)}
>
<Zap className="w-3.5 h-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Parse novel</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
);
}
+247
View File
@@ -0,0 +1,247 @@
import React, { useState, useEffect } from 'react';
import { Copy, FileText } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useAppStore } from '@/store';
export default function ParseChapterSection() {
const plugin = useAppStore(state => state.plugin);
const parseChapterPath = useAppStore(state => state.parseChapterPath);
const shouldAutoSubmitChapter = useAppStore(
state => state.shouldAutoSubmitChapter,
);
const clearParseChapterPath = useAppStore(
state => state.clearParseChapterPath,
);
const [chapterPath, setChapterPath] = useState('');
const [chapterText, setChapterText] = useState('');
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState('');
const fetchChapterByPath = async (path: string) => {
if (plugin && path.trim()) {
setLoading(true);
setFetchError('');
try {
const result = await plugin.parseChapter(path);
setChapterText(result);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to fetch chapter';
setFetchError(errorMessage);
console.error('Error parsing chapter:', error);
} finally {
setLoading(false);
}
}
};
const fetchChapter = async () => {
await fetchChapterByPath(chapterPath);
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && chapterPath.trim()) {
fetchChapter();
}
};
const copyToClipboard = (text?: string, label?: string) => {
if (text) {
navigator.clipboard.writeText(text);
toast.success(`${label || 'Text'} copied to clipboard!`);
}
};
// Handle pre-filled path from navigation
useEffect(() => {
if (parseChapterPath) {
setChapterPath(parseChapterPath);
if (shouldAutoSubmitChapter && plugin) {
fetchChapterByPath(parseChapterPath);
}
clearParseChapterPath();
}
}, [
parseChapterPath,
shouldAutoSubmitChapter,
plugin,
clearParseChapterPath,
]);
return (
<div className="space-y-6">
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-foreground">
Parse Chapter
</h2>
<p className="text-sm text-muted-foreground mt-1">
{plugin
? 'Enter a chapter path to fetch content'
: 'Select a plugin to parse chapters'}
</p>
</div>
</div>
<div className="flex gap-3 mb-6">
<Input
placeholder="Enter chapter path..."
value={chapterPath}
onChange={e => setChapterPath(e.target.value)}
onKeyPress={handleKeyPress}
className="flex-1"
disabled={!plugin}
/>
<Button
onClick={fetchChapter}
disabled={!plugin || !chapterPath.trim() || loading}
>
{loading ? 'Fetching...' : 'Fetch'}
</Button>
</div>
{fetchError && (
<div className="p-4 mb-6 border border-destructive/50 bg-destructive/10 rounded-lg">
<p className="text-sm text-destructive">{fetchError}</p>
</div>
)}
{loading && !chapterText ? (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-1/3" />
<Skeleton className="h-4 w-2/3" />
</div>
<div className="flex gap-2">
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-28" />
</div>
</div>
<div className="border border-border rounded-lg">
<Skeleton className="h-10 w-full rounded-t-lg" />
<div className="p-6 space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-11/12" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-10/12" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-11/12" />
</div>
</div>
</div>
) : !chapterText ? (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<div className="rounded-full bg-muted p-4 mb-4">
<FileText className="w-8 h-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
{plugin ? 'Ready to parse' : 'No plugin selected'}
</h3>
<p className="text-sm text-muted-foreground max-w-md">
{plugin
? 'Enter a chapter path in the field above and click "Fetch" to load the chapter content.'
: 'Please select a plugin from the sidebar to get started.'}
</p>
</div>
) : chapterText ? (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex-1">
<h3 className="text-lg font-semibold text-foreground">
Chapter Content
</h3>
<p className="text-sm text-muted-foreground mt-1">
{chapterPath}
</p>
</div>
<div className="flex gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2 bg-transparent"
onClick={() =>
copyToClipboard(chapterPath, 'Chapter path')
}
>
<Copy className="w-4 h-4" />
Copy Path
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy chapter path to clipboard</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2 bg-transparent"
onClick={() =>
copyToClipboard(chapterText, 'Chapter text')
}
>
<Copy className="w-4 h-4" />
Copy Text
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy chapter text to clipboard</p>
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="border border-border rounded-lg">
<div className="bg-muted/50 rounded-t-lg px-4 py-2 border-b border-border">
<p className="text-xs text-muted-foreground font-medium">
CHAPTER CONTENT ({chapterText.length} characters)
</p>
</div>
<div className="bg-background rounded-b-lg p-6 max-h-[600px] overflow-y-auto">
<div
className="prose prose-sm dark:prose-invert max-w-none text-foreground"
dangerouslySetInnerHTML={{
__html: chapterText,
}}
/>
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t border-border">
<p className="text-sm text-muted-foreground">
Content loaded successfully
</p>
<Button
variant="outline"
size="sm"
onClick={() => {
setChapterText('');
setChapterPath('');
}}
>
Clear
</Button>
</div>
</div>
) : null}
</Card>
</div>
);
}
+499
View File
@@ -0,0 +1,499 @@
import React, { useState, useEffect } from 'react';
import { BookOpen, ChevronLeft, ChevronRight, Copy, Zap } from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useAppStore } from '@/store';
import { Plugin } from '@/types/plugin';
type ParseNovelSectionProps = {
onNavigateToParseChapter?: () => void;
};
export default function ParseNovelSection({
onNavigateToParseChapter,
}: ParseNovelSectionProps) {
const plugin = useAppStore(state => state.plugin);
const parseNovelPath = useAppStore(state => state.parseNovelPath);
const shouldAutoSubmitNovel = useAppStore(
state => state.shouldAutoSubmitNovel,
);
const clearParseNovelPath = useAppStore(state => state.clearParseNovelPath);
const setParseChapterPath = useAppStore(state => state.setParseChapterPath);
const [novelPath, setNovelPath] = useState('');
const [sourceNovel, setSourceNovel] = useState<
(Plugin.SourceNovel & { totalPages?: number }) | undefined
>();
const [chapters, setChapters] = useState<Plugin.ChapterItem[]>([]);
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [fetchError, setFetchError] = useState('');
const fetchNovelByPath = async (path: string) => {
if (plugin && path.trim()) {
setLoading(true);
setFetchError('');
try {
const result = await plugin.parseNovel(path);
setSourceNovel(result);
setChapters(result.chapters || []);
setCurrentPage(1);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to fetch novel';
setFetchError(errorMessage);
console.error('Error parsing novel:', error);
} finally {
setLoading(false);
}
}
};
const fetchNovel = async () => {
await fetchNovelByPath(novelPath);
};
const fetchPage = async (page: number) => {
if (plugin && novelPath && sourceNovel?.totalPages) {
setLoading(true);
setFetchError('');
try {
const result = await (plugin as Plugin.PagePlugin).parsePage(
novelPath,
page.toString(),
);
setChapters(result.chapters);
setCurrentPage(page);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to fetch page';
setFetchError(errorMessage);
console.error('Error fetching page:', error);
} finally {
setLoading(false);
}
}
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && novelPath.trim()) {
fetchNovel();
}
};
const copyToClipboard = (text?: string, label?: string) => {
if (text) {
navigator.clipboard.writeText(text);
toast.success(`${label || 'Text'} copied to clipboard!`);
}
};
const handleParseChapter = (path: string) => {
setParseChapterPath(path, true);
onNavigateToParseChapter?.();
};
useEffect(() => {
if (parseNovelPath) {
setNovelPath(parseNovelPath);
if (shouldAutoSubmitNovel && plugin) {
fetchNovelByPath(parseNovelPath);
}
clearParseNovelPath();
}
}, [parseNovelPath, shouldAutoSubmitNovel, plugin, clearParseNovelPath]);
const formatDate = (dateString?: string | null) => {
if (!dateString) return 'N/A';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
} catch {
return dateString;
}
};
return (
<div className="space-y-6">
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-foreground">
Parse Novel
</h2>
<p className="text-sm text-muted-foreground mt-1">
{plugin
? 'Enter a novel path to fetch details'
: 'Select a plugin to parse novels'}
</p>
</div>
</div>
<div className="flex gap-3 mb-6">
<Input
placeholder="Enter novel path..."
value={novelPath}
onChange={e => setNovelPath(e.target.value)}
onKeyPress={handleKeyPress}
className="flex-1"
disabled={!plugin}
/>
<Button
onClick={fetchNovel}
disabled={!plugin || !novelPath.trim() || loading}
>
{loading ? 'Fetching...' : 'Fetch'}
</Button>
</div>
{fetchError && (
<div className="p-4 mb-6 border border-destructive/50 bg-destructive/10 rounded-lg">
<p className="text-sm text-destructive">{fetchError}</p>
</div>
)}
{loading && !sourceNovel ? (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="md:col-span-2 space-y-4">
<div className="flex gap-4">
<Skeleton className="w-32 h-48 rounded-lg" />
<div className="flex-1 space-y-3">
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-4 w-1/4" />
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-4 w-1/2" />
</div>
</div>
<Skeleton className="h-32 w-full" />
</div>
<Skeleton className="h-64" />
</div>
</div>
) : !sourceNovel ? (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<div className="rounded-full bg-muted p-4 mb-4">
<BookOpen className="w-8 h-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
{plugin ? 'Ready to parse' : 'No plugin selected'}
</h3>
<p className="text-sm text-muted-foreground max-w-md">
{plugin
? 'Enter a novel path in the field above and click "Fetch" to load detailed information, including chapters, metadata, and more.'
: 'Please select a plugin from the sidebar to get started.'}
</p>
</div>
) : sourceNovel ? (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Novel Info */}
<div className="md:col-span-2 space-y-4">
<div className="flex gap-4">
<div
className="cursor-pointer"
onClick={() =>
copyToClipboard(sourceNovel.cover, 'Cover URL')
}
>
<img
src={
sourceNovel.cover || '/static/coverNotAvailable.webp'
}
alt={sourceNovel.name}
className="w-32 h-48 rounded-lg object-cover hover:opacity-80 transition-opacity"
title="Click to copy cover URL"
/>
</div>
<div className="flex-1">
<h3 className="text-2xl font-bold text-foreground mb-3 line-clamp-3">
{sourceNovel.name}
</h3>
<div className="grid grid-cols-2 gap-3 mb-4">
{sourceNovel.status && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">
Status
</p>
<Badge className="text-xs">
{sourceNovel.status}
</Badge>
</div>
)}
{sourceNovel.author && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">
Author
</p>
<p className="text-sm text-foreground line-clamp-1">
{sourceNovel.author}
</p>
</div>
)}
{sourceNovel.artist && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">
Artist
</p>
<p className="text-sm text-foreground line-clamp-1">
{sourceNovel.artist}
</p>
</div>
)}
{sourceNovel.rating && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">
Rating
</p>
<p className="text-sm text-foreground">
{sourceNovel.rating.toFixed(1)} / 5.0
</p>
</div>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2 bg-transparent"
onClick={() =>
copyToClipboard(sourceNovel.path, 'Novel path')
}
>
<Copy className="w-4 h-4" />
Copy Path
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy novel path to clipboard</p>
</TooltipContent>
</Tooltip>
</div>
</div>
{sourceNovel.genres && (
<div>
<h4 className="font-semibold text-foreground mb-2">
Genres
</h4>
<div className="flex flex-wrap gap-2">
{sourceNovel.genres.split(/,\s*/).map((genre, index) => (
<Badge key={`genre-${index}`} variant="secondary">
{genre}
</Badge>
))}
</div>
</div>
)}
{sourceNovel.summary && (
<div>
<h4 className="font-semibold text-foreground mb-2">
Summary
</h4>
<p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
{sourceNovel.summary}
</p>
</div>
)}
</div>
{/* Sidebar Info */}
<div className="bg-muted/50 rounded-lg p-4 h-fit">
<h4 className="font-semibold text-foreground mb-4">Metadata</h4>
<div className="space-y-3 text-sm">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">
Total Chapters
</p>
<p className="font-semibold text-foreground">
{chapters.length}
</p>
</div>
{sourceNovel.totalPages && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">
Total Pages
</p>
<p className="font-semibold text-foreground">
{sourceNovel.totalPages}
</p>
</div>
)}
{chapters.length > 0 && chapters[0].releaseTime && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">
First Chapter
</p>
<p className="font-semibold text-foreground">
{formatDate(chapters[0].releaseTime)}
</p>
</div>
)}
{chapters.length > 0 &&
chapters[chapters.length - 1].releaseTime && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">
Last Updated
</p>
<p className="font-semibold text-foreground">
{formatDate(
chapters[chapters.length - 1].releaseTime,
)}
</p>
</div>
)}
</div>
</div>
</div>
{/* Chapters Table */}
{chapters.length > 0 && (
<div>
<div className="flex items-center justify-between mb-4">
<h4 className="font-semibold text-foreground">
Chapters ({chapters.length})
</h4>
{sourceNovel.totalPages && sourceNovel.totalPages > 1 && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => fetchPage(currentPage - 1)}
disabled={currentPage === 1 || loading}
>
<ChevronLeft className="w-4 h-4" />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {currentPage} of {sourceNovel.totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => fetchPage(currentPage + 1)}
disabled={
currentPage === sourceNovel.totalPages || loading
}
>
Next
<ChevronRight className="w-4 h-4" />
</Button>
</div>
)}
</div>
<div className="overflow-x-auto border border-border rounded-lg">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr className="border-b border-border">
<th className="text-left py-3 px-4 font-semibold text-foreground w-16">
#
</th>
<th className="text-left py-3 px-4 font-semibold text-foreground">
Name
</th>
<th className="text-left py-3 px-4 font-semibold text-foreground w-28">
Actions
</th>
<th className="text-left py-3 px-4 font-semibold text-foreground w-32">
Release Time
</th>
{chapters.some(ch => ch.chapterNumber) && (
<th className="text-left py-3 px-4 font-semibold text-foreground w-24">
Chapter #
</th>
)}
</tr>
</thead>
<tbody>
{chapters.map((chapter, index) => (
<tr
key={`${chapter.path}-${index}`}
className={`border-b border-border hover:bg-muted/70 transition-colors ${
index % 2 === 0 ? 'bg-background' : 'bg-muted/20'
}`}
>
<td className="py-2.5 px-4 text-muted-foreground text-xs">
{index}
</td>
<td className="py-2.5 px-4 text-foreground">
{chapter.name}
</td>
<td className="py-2.5 px-4">
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() =>
copyToClipboard(
chapter.path,
'Chapter path',
)
}
>
<Copy className="w-3.5 h-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy chapter path</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() =>
handleParseChapter(chapter.path)
}
>
<Zap className="w-3.5 h-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Parse chapter</p>
</TooltipContent>
</Tooltip>
</div>
</td>
<td className="py-2.5 px-4 text-muted-foreground text-xs">
{formatDate(chapter.releaseTime)}
</td>
{chapters.some(ch => ch.chapterNumber) && (
<td className="py-2.5 px-4 text-muted-foreground text-xs">
{chapter.chapterNumber || '-'}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
) : null}
</Card>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import React from 'react';
import { Plugin } from '@/types/plugin';
type PluginHeaderProps = {
selectedPlugin?: Plugin.PluginBase;
};
export default function PluginHeader({ selectedPlugin }: PluginHeaderProps) {
return (
<header className="border-b">
<div className="px-8 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-sm bg-[var(--color-accent-soft)] text-xl font-semibold text-[var(--color-accent-strong)] transition-colors">
読
</span>
<div>
<h1 className="text-sm font-semibold text-foreground">
Plugin Playground
</h1>
<p className="text-xs text-muted-foreground">
{selectedPlugin?.name}
</p>
</div>
</div>
</div>
</header>
);
}
+219
View File
@@ -0,0 +1,219 @@
import React, { useState, useEffect } from 'react';
import { Filter, Zap } from 'lucide-react';
import { FiltersSheet } from '@/components/filters/filters-sheet';
import { NovelCard } from '@/components/novel-card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { useAppStore } from '@/store';
import { FilterToValues, Filters } from '@libs/filterInputs';
import { Plugin } from '@/types/plugin';
type PopularNovelsSectionProps = {
onNavigateToParseNovel?: () => void;
};
export default function PopularNovelsSection({
onNavigateToParseNovel,
}: PopularNovelsSectionProps) {
const plugin = useAppStore(state => state.plugin);
const setParseNovelPath = useAppStore(state => state.setParseNovelPath);
const [novels, setNovels] = useState<Plugin.NovelItem[]>([]);
const [loading, setLoading] = useState(false);
const [currentIndex, setCurrentIndex] = useState(0);
const [maxIndex, setMaxIndex] = useState(0);
const [isLatest, setIsLatest] = useState(true);
const [filtersOpen, setFiltersOpen] = useState(false);
const [filterValues, setFilterValues] = useState<
FilterToValues<Filters> | undefined
>();
const fetchNovelsByIndex = async (index: number) => {
if (plugin && index) {
setLoading(true);
try {
const fetchedNovels = await plugin.popularNovels(index, {
filters: filterValues || {},
showLatestNovels: isLatest,
});
if (fetchedNovels.length !== 0) {
setCurrentIndex(index);
if (index > maxIndex) {
setMaxIndex(index);
}
setNovels(fetchedNovels);
}
} catch (error) {
console.error('Error fetching novels:', error);
} finally {
setLoading(false);
}
}
};
useEffect(() => {
if (plugin) {
setCurrentIndex(1);
setMaxIndex(1);
fetchNovelsByIndex(1);
}
}, [isLatest]);
useEffect(() => {
// Reset when changing plugins
setCurrentIndex(0);
setMaxIndex(0);
setNovels([]);
if (plugin?.filters) {
const filters: FilterToValues<typeof plugin.filters> = {};
for (const fKey in plugin.filters) {
filters[fKey as keyof typeof filters] = {
type: plugin.filters[fKey].type,
value: plugin.filters[fKey].value,
};
}
setFilterValues(filters);
}
}, [plugin]);
const handleParseNovel = (path: string) => {
setParseNovelPath(path, true);
onNavigateToParseNovel?.();
};
return (
<div className="space-y-6">
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-foreground">
Popular Novels
</h2>
<p className="text-sm text-muted-foreground mt-1">
{plugin
? `Browse ${isLatest ? 'latest' : 'popular'} novels`
: 'Select a plugin to browse novels'}
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="gap-2 bg-transparent"
disabled={!plugin || isLatest || !plugin.filters}
onClick={() => setFiltersOpen(true)}
>
<Filter className="w-4 h-4" />
Filters
</Button>
<Button
size="sm"
disabled={!plugin || loading}
onClick={() => fetchNovelsByIndex(1)}
>
{loading ? 'Loading...' : 'Fetch'}
</Button>
<Button
variant="outline"
size="sm"
disabled={currentIndex === 0 || loading}
onClick={() => fetchNovelsByIndex(currentIndex + 1)}
>
Next Page
</Button>
</div>
</div>
<div className="flex items-center gap-2 mb-6">
{['Latest', 'Popular'].map(option => (
<Badge
key={option}
variant={
isLatest === (option === 'Latest') ? 'default' : 'outline'
}
className="cursor-pointer"
onClick={() => setIsLatest(option === 'Latest')}
>
{option}
</Badge>
))}
{currentIndex > 0 && (
<div className="ml-auto flex items-center gap-2">
<span className="text-xs text-muted-foreground">Page</span>
<Input
type="number"
min="1"
max={maxIndex}
value={currentIndex}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const page = parseInt(e.target.value);
if (page > 0 && page <= maxIndex) {
fetchNovelsByIndex(page);
}
}}
className="w-16 h-7 text-center text-xs"
disabled={loading}
/>
<span className="text-xs text-muted-foreground">
of {maxIndex}+
</span>
</div>
)}
</div>
{loading ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4 lg:gap-6">
{Array.from({ length: 12 }).map((_, index) => (
<div key={index} className="space-y-3">
<Skeleton className="w-full aspect-[3/4] rounded-lg" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<div className="flex gap-2">
<Skeleton className="h-8 flex-1" />
<Skeleton className="h-8 flex-1" />
</div>
</div>
))}
</div>
) : novels.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<div className="rounded-full bg-muted p-4 mb-4">
<Zap className="w-8 h-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
{plugin ? 'No novels to display' : 'No plugin selected'}
</h3>
<p className="text-sm text-muted-foreground max-w-md">
{plugin
? 'Click the "Fetch" button above to load the latest or popular novels from this source.'
: 'Please select a plugin from the sidebar to get started.'}
</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-3 lg:gap-4">
{novels.map((novel, index) => (
<NovelCard
key={`${novel.path}-${index}`}
novel={novel}
onParse={handleParseNovel}
/>
))}
</div>
)}
</Card>
<FiltersSheet
open={filtersOpen}
onOpenChange={setFiltersOpen}
values={filterValues}
filters={plugin?.filters}
setValues={setFilterValues}
refetch={() => fetchNovelsByIndex(1)}
/>
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
import React, { useState, useEffect } from 'react';
import { Search as SearchIcon } from 'lucide-react';
import { NovelCard } from '@/components/novel-card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { useAppStore } from '@/store';
import { Plugin } from '@/types/plugin';
type SearchNovelsSectionProps = {
onNavigateToParseNovel?: () => void;
};
export default function SearchNovelsSection({
onNavigateToParseNovel,
}: SearchNovelsSectionProps) {
const plugin = useAppStore(state => state.plugin);
const setParseNovelPath = useAppStore(state => state.setParseNovelPath);
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [novels, setNovels] = useState<Plugin.NovelItem[]>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState('');
const fetchNovels = async (page: number) => {
if (plugin && searchTerm.trim()) {
setLoading(true);
setFetchError('');
try {
const results = await plugin.searchNovels(searchTerm, page);
setNovels(results);
setCurrentPage(page);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to fetch novels';
setFetchError(errorMessage);
console.error('Error searching novels:', error);
} finally {
setLoading(false);
}
}
};
useEffect(() => {
setCurrentPage(1);
setNovels([]);
setSearchTerm('');
setFetchError('');
}, [plugin]);
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && searchTerm.trim()) {
fetchNovels(1);
}
};
const handleParseNovel = (path: string) => {
setParseNovelPath(path, true);
onNavigateToParseNovel?.();
};
return (
<div className="space-y-6">
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-foreground">
Search Novels
</h2>
<p className="text-sm text-muted-foreground mt-1">
{plugin
? 'Search for novels by title or keywords'
: 'Select a plugin to search novels'}
</p>
</div>
{currentPage > 1 && (
<Badge variant="secondary">Page {currentPage}</Badge>
)}
</div>
<div className="flex gap-3 mb-6">
<Input
placeholder="Enter search term..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
onKeyPress={handleKeyPress}
className="flex-1"
disabled={!plugin}
/>
<Button
onClick={() => fetchNovels(1)}
disabled={!plugin || !searchTerm.trim() || loading}
>
{loading ? 'Searching...' : 'Search'}
</Button>
<Button
variant="outline"
onClick={() => fetchNovels(currentPage + 1)}
disabled={!plugin || novels.length === 0 || loading}
>
Next Page
</Button>
</div>
{fetchError && (
<div className="p-4 mb-6 border border-destructive/50 bg-destructive/10 rounded-lg">
<p className="text-sm text-destructive">{fetchError}</p>
</div>
)}
{loading ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-3 lg:gap-4">
{Array.from({ length: 12 }).map((_, index) => (
<div key={index} className="space-y-3">
<Skeleton className="w-full aspect-[3/4] rounded-lg" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<div className="flex gap-1.5">
<Skeleton className="h-8 flex-1" />
<Skeleton className="h-8 flex-1" />
</div>
</div>
))}
</div>
) : novels.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<div className="rounded-full bg-muted p-4 mb-4">
<SearchIcon className="w-8 h-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
{searchTerm
? 'No results found'
: plugin
? 'Ready to search'
: 'No plugin selected'}
</h3>
<p className="text-sm text-muted-foreground max-w-md">
{searchTerm
? 'Try adjusting your search term or check the spelling. Different sources may have different availability.'
: plugin
? 'Enter a search term in the field above and click "Search" to find novels.'
: 'Please select a plugin from the sidebar to get started.'}
</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-3 lg:gap-4">
{novels.map((novel, index) => (
<NovelCard
key={`${novel.path}-${index}`}
novel={novel}
onParse={handleParseNovel}
/>
))}
</div>
)}
</Card>
</div>
);
}
+199
View File
@@ -0,0 +1,199 @@
import React, { useState, useEffect } from 'react';
import { CheckedState } from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import useDebounce from '@/hooks/useDebounce';
import { FetchMode } from '@/types/types';
export default function SettingsSection() {
const [cookies, setCookies] = useState('');
const debouncedCookies = useDebounce(cookies, 500);
const [fetchMode, setFetchMode] = useState<FetchMode>(FetchMode.PROXY);
const [useUserAgent, setUseUserAgent] = useState<CheckedState>(true);
const [loading, setLoading] = useState(false);
const [alertVisible, setAlertVisible] = useState(false);
useEffect(() => {
if (alertVisible) {
const id = setTimeout(() => setAlertVisible(false), 2000);
return () => clearTimeout(id);
}
}, [alertVisible]);
useEffect(() => {
setLoading(true);
fetch('settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
cookies: debouncedCookies,
fetchMode: fetchMode,
useUserAgent: useUserAgent === true,
}),
})
.then(() => setAlertVisible(true))
.catch(error => console.error('Failed to save settings:', error))
.finally(() => setLoading(false));
}, [debouncedCookies, fetchMode, useUserAgent]);
const getFetchModeLabel = (mode: FetchMode) => {
switch (mode) {
case FetchMode.PROXY:
return 'Proxy';
case FetchMode.NODE_FETCH:
return 'Node Fetch';
case FetchMode.CURL:
return 'Curl';
default:
return 'Proxy';
}
};
return (
<div className="space-y-6">
<Card className="p-6 relative">
{alertVisible && (
<div className="absolute top-4 right-4 z-10 bg-green-500/90 text-white px-4 py-2 rounded-md flex items-center gap-2 shadow-lg animate-in fade-in slide-in-from-top-2">
<Check className="w-4 h-4" />
Settings updated
</div>
)}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-foreground">Settings</h2>
<p className="text-sm text-muted-foreground mt-1">
Settings are automatically saved
</p>
</div>
{loading && (
<div className="text-sm text-muted-foreground">Saving...</div>
)}
</div>
<div className="space-y-6">
{/* Request Configuration Section */}
<div>
<div className="flex items-center gap-2 mb-4">
<div className="h-px flex-1 bg-border"></div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Request Configuration
</h3>
<div className="h-px flex-1 bg-border"></div>
</div>
<div className="space-y-6">
{/* User Agent */}
<div className="space-y-2">
<Label className="font-semibold text-foreground">
Browser User Agent
</Label>
<div className="flex items-center gap-3">
<Input
value={navigator.userAgent}
disabled
className="font-mono text-xs flex-1 opacity-60"
title={navigator.userAgent}
/>
<div className="flex items-center gap-2 whitespace-nowrap">
<Checkbox
id="use-ua"
checked={useUserAgent}
onCheckedChange={setUseUserAgent}
/>
<Label
htmlFor="use-ua"
className="text-sm text-foreground cursor-pointer"
>
Use
</Label>
</div>
</div>
<p className="text-xs text-muted-foreground">
Enable to send your browser's user agent with requests
</p>
</div>
{/* Cookies */}
<div className="space-y-2">
<Label
htmlFor="cookies"
className="font-semibold text-foreground"
>
Cookies
</Label>
<Input
id="cookies"
value={cookies}
onChange={e => setCookies(e.target.value.trim())}
placeholder="Enter cookies (optional)..."
className="font-mono text-xs"
/>
<p className="text-xs text-muted-foreground">
Additional cookies to send with requests (optional)
</p>
</div>
</div>
</div>
{/* Fetch Settings Section */}
<div>
<div className="flex items-center gap-2 mb-4">
<div className="h-px flex-1 bg-border"></div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Fetch Settings
</h3>
<div className="h-px flex-1 bg-border"></div>
</div>
<div className="space-y-2">
<Label
htmlFor="fetch-mode"
className="font-semibold text-foreground"
>
Fetch Mode
</Label>
<Select
value={fetchMode.toString()}
onValueChange={value =>
setFetchMode(parseInt(value) as FetchMode)
}
>
<SelectTrigger id="fetch-mode">
<SelectValue>{getFetchModeLabel(fetchMode)}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={FetchMode.PROXY.toString()}>
Proxy
</SelectItem>
<SelectItem value={FetchMode.NODE_FETCH.toString()}>
Node Fetch
</SelectItem>
<SelectItem value={FetchMode.CURL.toString()}>
Curl
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Select the method used to fetch data from sources
</p>
</div>
</div>
</div>
</Card>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span';
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };
+60
View File
@@ -0,0 +1,60 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
+92
View File
@@ -0,0 +1,92 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('leading-none font-semibold', className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-content"
className={cn('px-6', className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};
+32
View File
@@ -0,0 +1,32 @@
'use client';
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { CheckIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
+21
View File
@@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className,
)}
{...props}
/>
);
}
export { Input };
+22
View File
@@ -0,0 +1,22 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@/lib/utils';
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className,
)}
{...props}
/>
);
}
export { Label };
+185
View File
@@ -0,0 +1,185 @@
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default';
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = 'popper',
align = 'center',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+131
View File
@@ -0,0 +1,131 @@
import * as React from 'react';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
type SheetContentProps = React.ComponentPropsWithoutRef<
typeof SheetPrimitive.Content
> & {
side?: 'top' | 'right' | 'bottom' | 'left';
};
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = 'right', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out',
side === 'top' &&
'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
side === 'bottom' &&
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
side === 'left' &&
'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
side === 'right' &&
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className,
)}
{...props}
/>
);
SheetHeader.displayName = 'SheetHeader';
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className,
)}
{...props}
/>
);
SheetFooter.displayName = 'SheetFooter';
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-foreground', className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};
+16
View File
@@ -0,0 +1,16 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('animate-pulse rounded-md bg-muted/50', className)}
{...props}
/>
);
}
export { Skeleton };
+36
View File
@@ -0,0 +1,36 @@
import React from 'react';
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from 'lucide-react';
import { Toaster as Sonner, type ToasterProps } from 'sonner';
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme="system"
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };
+27
View File
@@ -0,0 +1,27 @@
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from '@/lib/utils';
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
+64
View File
@@ -0,0 +1,64 @@
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn('flex flex-col gap-2', className)}
{...props}
/>
);
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
className,
)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow,border-color] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm data-[state=active]:border-b-2 data-[state=active]:border-accent [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn('flex-1 outline-none', className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };
+39
View File
@@ -0,0 +1,39 @@
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root {...props} />;
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger {...props} />;
}
function TooltipContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };