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:
committed by
GitHub
parent
252beff0bf
commit
20c2b75b01
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+90
-71
@@ -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>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user