Move "core" folder out of "features" into "base"
This commit is contained in:
128
src/base/components/AppbarSearch.tsx
Normal file
128
src/base/components/AppbarSearch.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { useQueryParam, StringParam } from 'use-query-params';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
|
||||
import { SearchTextField } from '@/base/components/inputs/SearchTextField.tsx';
|
||||
import { SearchParam } from '@/base/Base.types.ts';
|
||||
|
||||
interface IProps {
|
||||
isClosable?: boolean;
|
||||
}
|
||||
|
||||
export const AppbarSearch: React.FunctionComponent<IProps> = (props) => {
|
||||
const { isClosable = true } = props;
|
||||
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [prevLocationKey, setPrevLocationKey] = useState<string>();
|
||||
const location = useLocation();
|
||||
|
||||
const [query, setQuery] = useQueryParam(SearchParam.QUERY, StringParam);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(!isClosable || !!query);
|
||||
const inputRef = React.useRef<HTMLInputElement>(undefined);
|
||||
|
||||
const [searchString, setSearchString] = useState(query ?? '');
|
||||
|
||||
if (prevLocationKey !== location.key) {
|
||||
setPrevLocationKey(location.key);
|
||||
setSearchString(query ?? '');
|
||||
setIsSearchOpen(!isClosable || !!query);
|
||||
}
|
||||
|
||||
const isOpen = isSearchOpen || !!query;
|
||||
|
||||
const updateSearchOpenState = (open: boolean) => {
|
||||
if (!isClosable) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearchOpen(open);
|
||||
|
||||
// try to focus input component since in case of navigating to the previous/next page in the browser history
|
||||
// the "openSearch" state might not change and thus, won't trigger a focus
|
||||
if (open) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
function handleChange(newQuery: string) {
|
||||
if (newQuery === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
setQuery(newQuery);
|
||||
updateSearchOpenState(false);
|
||||
}
|
||||
|
||||
const cancelSearch = () => {
|
||||
setSearchString('');
|
||||
setQuery(undefined);
|
||||
updateSearchOpenState(false);
|
||||
};
|
||||
const handleBlur = () => {
|
||||
if (!searchString) updateSearchOpenState(false);
|
||||
};
|
||||
|
||||
useHotkeys(
|
||||
'ctrl+f, F3',
|
||||
() => {
|
||||
updateSearchOpenState(true);
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
if (isOpen) {
|
||||
return (
|
||||
<SearchTextField
|
||||
autoFocus
|
||||
variant="standard"
|
||||
value={searchString}
|
||||
onCancel={cancelSearch}
|
||||
onChange={(e) => setSearchString(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleChange(searchString);
|
||||
}
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
inputRef={inputRef}
|
||||
sx={{
|
||||
...theme.applyStyles('light', {
|
||||
'& .MuiInput-underline:before': {
|
||||
borderBottomColor: 'primary.contrastText', // Default color
|
||||
},
|
||||
'& .MuiInput-underline:hover:before': {
|
||||
borderBottomColor: 'primary.contrastText', // Hover color
|
||||
},
|
||||
'& .MuiInput-underline:after': {
|
||||
borderBottomColor: 'primary.dark', // Focused color
|
||||
},
|
||||
}),
|
||||
}}
|
||||
cancelButtonProps={{ sx: { ...theme.applyStyles('light', { color: 'primary.contrastText' }) } }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomTooltip title={t('search.title.search')}>
|
||||
<IconButton onClick={() => updateSearchOpenState(true)} color="inherit">
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</CustomTooltip>
|
||||
);
|
||||
};
|
||||
20
src/base/components/CustomTooltip.tsx
Normal file
20
src/base/components/CustomTooltip.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Tooltip, { TooltipProps } from '@mui/material/Tooltip';
|
||||
|
||||
export const CustomTooltip = ({
|
||||
children,
|
||||
disabled = false,
|
||||
title,
|
||||
...props
|
||||
}: TooltipProps & { disabled?: boolean }) => (
|
||||
<Tooltip {...props} title={disabled ? '' : title}>
|
||||
{children}
|
||||
</Tooltip>
|
||||
);
|
||||
105
src/base/components/GridLayouts.tsx
Normal file
105
src/base/components/GridLayouts.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Radio from '@mui/material/Radio';
|
||||
import React from 'react';
|
||||
import ViewModuleIcon from '@mui/icons-material/ViewModule';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
|
||||
|
||||
import { GridLayout } from '@/base/Base.types.ts';
|
||||
|
||||
// TODO: clean up this to use a FormControl, and remove dependency on name o radio button
|
||||
export function GridLayouts({
|
||||
gridLayout,
|
||||
onChange,
|
||||
}: {
|
||||
gridLayout: GridLayout;
|
||||
onChange: (gridLayout: GridLayout) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [anchorEl, setAnchorEl] = React.useState(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = (event: any) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
onChange(parseInt(e.target.name, 10));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomTooltip title={t('global.label.display')}>
|
||||
<IconButton
|
||||
onClick={handleClick}
|
||||
size="small"
|
||||
aria-controls={open ? 'account-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
color="inherit"
|
||||
>
|
||||
<ViewModuleIcon />
|
||||
</IconButton>
|
||||
</CustomTooltip>
|
||||
<Menu
|
||||
id="basic-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{ 'aria-labelledby': 'basic-button' }}
|
||||
>
|
||||
<MenuItem onClick={handleClose}>
|
||||
<FormControlLabel
|
||||
label={t('global.grid_layout.label.compact_grid')}
|
||||
value={GridLayout.Compact}
|
||||
control={
|
||||
<Radio
|
||||
name={GridLayout.Compact.toString()}
|
||||
checked={gridLayout === GridLayout.Compact}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleClose}>
|
||||
<FormControlLabel
|
||||
label={t('global.grid_layout.label.comfortable_grid')}
|
||||
control={
|
||||
<Radio
|
||||
name={GridLayout.Comfortable.toString()}
|
||||
checked={gridLayout === GridLayout.Comfortable}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleClose}>
|
||||
<FormControlLabel
|
||||
label={t('global.grid_layout.label.list')}
|
||||
control={
|
||||
<Radio
|
||||
name={GridLayout.List.toString()}
|
||||
checked={gridLayout === GridLayout.List}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
225
src/base/components/SpinnerImage.tsx
Normal file
225
src/base/components/SpinnerImage.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, forwardRef, ForwardedRef, useCallback, useRef } from 'react';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Button from '@mui/material/Button';
|
||||
import BrokenImageIcon from '@mui/icons-material/BrokenImage';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import { SxProps, Theme } from '@mui/material/styles';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { Priority } from '@/lib/Queue.ts';
|
||||
import { applyStyles } from '@/base/utils/ApplyStyles.ts';
|
||||
import { useIntersectionObserver } from '@/base/hooks/useIntersectionObserver.tsx';
|
||||
|
||||
export interface SpinnerImageProps {
|
||||
shouldLoad?: boolean;
|
||||
|
||||
src: string;
|
||||
alt: string;
|
||||
|
||||
spinnerStyle?: SxProps<Theme> & { small?: boolean };
|
||||
imgStyle?: SxProps<Theme>;
|
||||
hideImgStyle?: Omit<SxProps<Theme>, 'accentColor'>;
|
||||
|
||||
onLoad?: () => void;
|
||||
onError?: () => void;
|
||||
|
||||
shouldDecode?: boolean;
|
||||
useFetchApi?: boolean;
|
||||
disableCors?: boolean;
|
||||
|
||||
priority?: Priority;
|
||||
|
||||
retryKeyPrefix?: string;
|
||||
}
|
||||
|
||||
export const SpinnerImage = forwardRef(
|
||||
(props: SpinnerImageProps, imgRef: ForwardedRef<HTMLImageElement | HTMLDivElement | null>) => {
|
||||
const {
|
||||
shouldLoad = true,
|
||||
shouldDecode,
|
||||
useFetchApi,
|
||||
disableCors,
|
||||
src,
|
||||
alt,
|
||||
onLoad,
|
||||
onError,
|
||||
spinnerStyle: { small, ...spinnerStyle } = {},
|
||||
imgStyle,
|
||||
hideImgStyle,
|
||||
priority,
|
||||
retryKeyPrefix,
|
||||
} = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const loadingIndicatorRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const showMissingImageIcon = !src.length;
|
||||
|
||||
const [imageSourceUrl, setImageSourceUrl] = useState<string>();
|
||||
const [imgLoadRetryKey, setImgLoadRetryKey] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState<boolean>();
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const updateImageState = (loading: boolean, error: boolean = false, aborted: boolean = false) => {
|
||||
setIsLoading(loading);
|
||||
setHasError(error);
|
||||
|
||||
if (error && !loading && !aborted) {
|
||||
onError?.();
|
||||
}
|
||||
|
||||
if (!loading && !error && !aborted) {
|
||||
onLoad?.();
|
||||
}
|
||||
};
|
||||
|
||||
useIntersectionObserver(
|
||||
loadingIndicatorRef,
|
||||
useCallback((entries) => setIsVisible(entries[0].isIntersecting), []),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (showMissingImageIcon || !shouldLoad) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const imageRequest = requestManager.requestImage(src, {
|
||||
priority,
|
||||
shouldDecode,
|
||||
useFetchApi,
|
||||
disableCors,
|
||||
});
|
||||
let cacheTimeout: NodeJS.Timeout;
|
||||
|
||||
const fetchImage = async () => {
|
||||
try {
|
||||
const updateImage = async () => {
|
||||
const image = await imageRequest.response;
|
||||
|
||||
updateImageState(false);
|
||||
setImageSourceUrl(image);
|
||||
};
|
||||
|
||||
const checkCache = await Promise.race([
|
||||
imageRequest.response,
|
||||
new Promise((resolve) => {
|
||||
cacheTimeout = setTimeout(resolve, 50);
|
||||
}),
|
||||
]);
|
||||
const isImageCached = !!checkCache;
|
||||
|
||||
if (isImageCached) {
|
||||
await updateImage();
|
||||
return;
|
||||
}
|
||||
|
||||
updateImageState(true);
|
||||
await updateImage();
|
||||
} catch (e) {
|
||||
const wasAborted =
|
||||
e instanceof Error && (e.name === 'AbortError' || e.message === 'Component was unmounted');
|
||||
updateImageState(false, !wasAborted, wasAborted);
|
||||
}
|
||||
};
|
||||
|
||||
fetchImage().catch(() => {});
|
||||
|
||||
return () => {
|
||||
imageRequest.cleanup();
|
||||
clearTimeout(cacheTimeout);
|
||||
imageRequest.abortRequest(new Error('Component was unmounted'));
|
||||
};
|
||||
}, [src, imgLoadRetryKey, retryKeyPrefix, showMissingImageIcon, shouldLoad]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showMissingImageIcon ? (
|
||||
<Stack
|
||||
ref={imgRef}
|
||||
sx={{
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: (theme) => theme.palette.background.default,
|
||||
...spinnerStyle,
|
||||
}}
|
||||
>
|
||||
<ImageIcon fontSize="large" />
|
||||
</Stack>
|
||||
) : (
|
||||
<Box
|
||||
component="img"
|
||||
key={`${src}_${imgLoadRetryKey}_${retryKeyPrefix}`}
|
||||
sx={[
|
||||
...(Array.isArray(imgStyle) ? (imgStyle ?? []) : [imgStyle]),
|
||||
applyStyles(!imageSourceUrl || isLoading || hasError, {
|
||||
...hideImgStyle,
|
||||
...applyStyles(!hideImgStyle, {
|
||||
display: 'none',
|
||||
}),
|
||||
}),
|
||||
]}
|
||||
ref={imgRef}
|
||||
crossOrigin={disableCors ? undefined : 'anonymous'}
|
||||
src={imageSourceUrl}
|
||||
alt={alt}
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(isLoading || (src && !imageSourceUrl) || hasError) && (
|
||||
<Stack
|
||||
ref={loadingIndicatorRef}
|
||||
sx={{
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
...spinnerStyle,
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
sx={{
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{isVisible && (isLoading || (src && !imageSourceUrl && !hasError)) && (
|
||||
<CircularProgress thickness={5} />
|
||||
)}
|
||||
{hasError && isLoading === false && (
|
||||
<>
|
||||
<BrokenImageIcon />
|
||||
<Button
|
||||
startIcon={!small && <RefreshIcon />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setImgLoadRetryKey((prevState) => (prevState + 1) % 100);
|
||||
}}
|
||||
size={small ? 'small' : 'large'}
|
||||
>
|
||||
{small ? <RefreshIcon /> : t('global.button.retry')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
55
src/base/components/buttons/ButtonSelect.tsx
Normal file
55
src/base/components/buttons/ButtonSelect.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Button from '@mui/material/Button';
|
||||
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
|
||||
import { MultiValueButtonProps } from '@/base/Base.types.ts';
|
||||
import { Superscript } from '@/base/components/texts/Superscript.tsx';
|
||||
|
||||
export const ButtonSelect = <Value extends string | number>({
|
||||
value,
|
||||
values,
|
||||
defaultValue,
|
||||
setValue,
|
||||
valueToDisplayData,
|
||||
isDefaultable,
|
||||
onDefault,
|
||||
}: MultiValueButtonProps<Value>) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack sx={{ flexDirection: 'row', flexWrap: 'wrap', gap: 1 }}>
|
||||
{isDefaultable && (
|
||||
<Button key="default" onClick={onDefault} variant={value === undefined ? 'contained' : 'outlined'}>
|
||||
{t('global.label.default')}
|
||||
</Button>
|
||||
)}
|
||||
{values.map((displayValue) => {
|
||||
const isDefault = value === undefined && displayValue === defaultValue;
|
||||
|
||||
const text = valueToDisplayData[displayValue].isTitleString
|
||||
? valueToDisplayData[displayValue].title
|
||||
: t(valueToDisplayData[displayValue].title);
|
||||
|
||||
return (
|
||||
<CustomTooltip key={displayValue} title={isDefault ? t('reader.settings.active_setting') : ''}>
|
||||
<Button
|
||||
onClick={() => setValue(displayValue)}
|
||||
variant={displayValue === value ? 'contained' : 'outlined'}
|
||||
startIcon={valueToDisplayData[displayValue].icon}
|
||||
>
|
||||
{isDefault ? <Superscript i18nKey="global.label.footnote" value={text} /> : text}
|
||||
</Button>
|
||||
</CustomTooltip>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
32
src/base/components/buttons/CustomButton.tsx
Normal file
32
src/base/components/buttons/CustomButton.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Button, { ButtonProps } from '@mui/material/Button';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { ForwardedRef, forwardRef } from 'react';
|
||||
|
||||
export const CustomButton = forwardRef(
|
||||
<C extends React.ElementType>(
|
||||
{ children, ...props }: ButtonProps<C, { component?: C }>,
|
||||
ref: ForwardedRef<HTMLButtonElement | null>,
|
||||
) => (
|
||||
<Button ref={ref} {...props}>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Stack>
|
||||
</Button>
|
||||
),
|
||||
);
|
||||
29
src/base/components/buttons/CustomButtonIcon.tsx
Normal file
29
src/base/components/buttons/CustomButtonIcon.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Button, { ButtonProps } from '@mui/material/Button';
|
||||
import { ForwardedRef, forwardRef } from 'react';
|
||||
|
||||
export const CustomButtonIcon = forwardRef(
|
||||
<C extends React.ElementType>(
|
||||
{ children, ...props }: ButtonProps<C, { component?: C }>,
|
||||
ref: ForwardedRef<HTMLButtonElement | null>,
|
||||
) => (
|
||||
<Button
|
||||
ref={ref}
|
||||
{...props}
|
||||
sx={{
|
||||
minWidth: 'unset',
|
||||
px: '10px',
|
||||
...props.sx,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
),
|
||||
);
|
||||
37
src/base/components/buttons/ResetButton.tsx
Normal file
37
src/base/components/buttons/ResetButton.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Button, { ButtonProps } from '@mui/material/Button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
||||
import IconButton, { IconButtonProps } from '@mui/material/IconButton';
|
||||
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
|
||||
|
||||
type PropsIconButton = { asIconButton: true } & IconButtonProps;
|
||||
type PropsButton = { asIconButton?: false } & ButtonProps;
|
||||
type Props = PropsIconButton | PropsButton;
|
||||
|
||||
export const ResetButton = ({ asIconButton, ...props }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (asIconButton) {
|
||||
return (
|
||||
<CustomTooltip title={t('global.button.reset')}>
|
||||
<IconButton color="inherit" {...props}>
|
||||
<RestartAltIcon />
|
||||
</IconButton>
|
||||
</CustomTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button startIcon={<RestartAltIcon />} {...(props as ButtonProps)}>
|
||||
{t('global.button.reset')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
23
src/base/components/buttons/StyledFab.tsx
Normal file
23
src/base/components/buttons/StyledFab.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Fab from '@mui/material/Fab';
|
||||
import { styled } from '@mui/material/styles';
|
||||
|
||||
export const DEFAULT_FAB_STYLE = {
|
||||
position: 'fixed',
|
||||
height: '48px',
|
||||
right: '48px',
|
||||
bottom: '28px',
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_FULL_FAB_HEIGHT = `calc(${DEFAULT_FAB_STYLE.bottom} + ${DEFAULT_FAB_STYLE.height})`;
|
||||
|
||||
export const StyledFab = styled(Fab)({
|
||||
...DEFAULT_FAB_STYLE,
|
||||
}) as typeof Fab;
|
||||
86
src/base/components/buttons/ValueRotationButton.tsx
Normal file
86
src/base/components/buttons/ValueRotationButton.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
|
||||
import { MultiValueButtonProps } from '@/base/Base.types.ts';
|
||||
import { getNextRotationValue } from '@/base/utils/ValueRotationButton.utils.ts';
|
||||
import { Superscript } from '@/base/components/texts/Superscript.tsx';
|
||||
|
||||
export const ValueRotationButton = <Value extends string | number>({
|
||||
tooltip,
|
||||
value,
|
||||
defaultValue,
|
||||
values,
|
||||
setValue,
|
||||
valueToDisplayData,
|
||||
isDefaultable,
|
||||
onDefault,
|
||||
defaultIcon,
|
||||
}: MultiValueButtonProps<Value> & { defaultIcon?: ReactNode }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isDefault = value === undefined;
|
||||
const indexOfValue = useMemo(() => {
|
||||
if (isDefault) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return values.indexOf(value);
|
||||
}, [value, values]);
|
||||
|
||||
return (
|
||||
<CustomTooltip title={tooltip}>
|
||||
{isDefault ? (
|
||||
<Button
|
||||
onClick={() => setValue(values[0])}
|
||||
sx={{ justifyContent: 'start', textTransform: 'unset', flexGrow: 1 }}
|
||||
variant="contained"
|
||||
startIcon={defaultIcon}
|
||||
size="large"
|
||||
>
|
||||
{defaultValue === undefined ? (
|
||||
t('global.label.default')
|
||||
) : (
|
||||
<Superscript
|
||||
i18nKey="settings.default_value"
|
||||
value={
|
||||
valueToDisplayData[defaultValue].isTitleString
|
||||
? valueToDisplayData[defaultValue].title
|
||||
: t(valueToDisplayData[defaultValue].title)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => {
|
||||
const nextValue = getNextRotationValue(indexOfValue, values, isDefaultable);
|
||||
|
||||
if (nextValue === undefined) {
|
||||
onDefault?.();
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(nextValue);
|
||||
}}
|
||||
sx={{ justifyContent: 'start', textTransform: 'unset', flexGrow: 1 }}
|
||||
variant="contained"
|
||||
startIcon={valueToDisplayData[value].icon}
|
||||
size="large"
|
||||
>
|
||||
{valueToDisplayData[value].isTitleString
|
||||
? valueToDisplayData[value].title
|
||||
: t(valueToDisplayData[value].title)}
|
||||
</Button>
|
||||
)}
|
||||
</CustomTooltip>
|
||||
);
|
||||
};
|
||||
74
src/base/components/downloads/DownloadStateIndicator.tsx
Normal file
74
src/base/components/downloads/DownloadStateIndicator.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DownloadState } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { Chapters } from '@/features/chapter/services/Chapters.ts';
|
||||
import { ChapterIdInfo } from '@/features/chapter/Chapter.types.ts';
|
||||
import { TranslationKey } from '@/base/Base.types.ts';
|
||||
|
||||
const DOWNLOAD_STATE_TO_TRANSLATION_KEY_MAP: { [state in DownloadState]: TranslationKey } = {
|
||||
DOWNLOADING: 'download.state.label.downloading',
|
||||
ERROR: 'download.state.label.error',
|
||||
FINISHED: 'download.state.label.finished',
|
||||
QUEUED: 'download.state.label.queued',
|
||||
} as const;
|
||||
|
||||
export const DownloadStateIndicator = ({ chapterId, color }: { chapterId: ChapterIdInfo['id']; color?: string }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const download = Chapters.useDownloadStatusFromCache(chapterId);
|
||||
|
||||
if (!download) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isDownloading = download.state === DownloadState.Downloading;
|
||||
const isPartiallyDownloaded = download.progress !== 0;
|
||||
|
||||
const progress = `${Math.round(download.progress * 100)}%`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'inline-flex',
|
||||
width: '50px',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{isDownloading && <CircularProgress variant="determinate" value={download.progress * 100} sx={{ color }} />}
|
||||
<Box
|
||||
sx={{
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
position: 'absolute',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" component="div" sx={{ color }}>
|
||||
<>
|
||||
{isDownloading && progress}
|
||||
{!isDownloading &&
|
||||
t('global.value', {
|
||||
value: t(DOWNLOAD_STATE_TO_TRANSLATION_KEY_MAP[download.state]),
|
||||
unit: isPartiallyDownloaded ? ` (${progress})` : '',
|
||||
})}
|
||||
</>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
123
src/base/components/feedback/EmptyView.tsx
Normal file
123
src/base/components/feedback/EmptyView.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
// adopted from: https://github.com/tachiyomiorg/tachiyomi/blob/master/app/src/main/java/eu/kanade/tachiyomi/widget/EmptyView.kt
|
||||
|
||||
import { type JSX, useMemo, useState } from 'react';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { SxProps, Theme } from '@mui/material/styles';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Button from '@mui/material/Button';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import { extractGraphqlExceptionInfo } from '@/lib/HelperFunctions.ts';
|
||||
|
||||
const ERROR_FACES = ['(・o・;)', 'Σ(ಠ_ಠ)', 'ಥ_ಥ', '(˘・_・˘)', '(; ̄Д ̄)', '(・Д・。'];
|
||||
|
||||
function getRandomErrorFace() {
|
||||
const randIndex = Math.floor(Math.random() * ERROR_FACES.length);
|
||||
return ERROR_FACES[randIndex];
|
||||
}
|
||||
|
||||
export interface EmptyViewProps {
|
||||
message: string;
|
||||
messageExtra?: JSX.Element | string;
|
||||
retry?: () => void;
|
||||
noFaces?: boolean;
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
const ExtraMessage = ({ messageExtra }: Pick<EmptyViewProps, 'messageExtra'>) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [showFullError, setShowFullError] = useState(false);
|
||||
|
||||
const { isGraphqlException, graphqlError, graphqlStackTrace } = extractGraphqlExceptionInfo(messageExtra);
|
||||
|
||||
if (!isGraphqlException) {
|
||||
return (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ wordBreak: 'break-word', whiteSpace: 'pre-line', pointerEvents: 'all' }}
|
||||
color="textSecondary"
|
||||
>
|
||||
{messageExtra}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack
|
||||
sx={{
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ wordBreak: 'break-word', whiteSpace: 'pre-line', pointerEvents: 'all' }}
|
||||
>
|
||||
{graphqlError}…
|
||||
</Typography>
|
||||
<Button variant="text" onClick={() => setShowFullError(!showFullError)} sx={{ pointerEvents: 'all' }}>
|
||||
{t(showFullError ? 'global.button.show_less' : 'global.button.show_more')}
|
||||
</Button>
|
||||
</Stack>
|
||||
<Collapse in={showFullError}>
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="textSecondary"
|
||||
sx={{ wordBreak: 'break-word', whiteSpace: 'pre-line', pointerEvents: 'all' }}
|
||||
>
|
||||
{graphqlStackTrace}
|
||||
</Typography>
|
||||
</Collapse>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export function EmptyView({ message, messageExtra, retry, noFaces, sx }: EmptyViewProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const errorFace = useMemo(() => getRandomErrorFace(), []);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
sx={{
|
||||
p: 2,
|
||||
textAlign: 'center',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '-webkit-fill-available',
|
||||
maxWidth: '100%',
|
||||
minHeight: '100%',
|
||||
pointerEvents: 'none',
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{!noFaces && (
|
||||
<Typography variant="h3" gutterBottom sx={{ pointerEvents: 'all' }}>
|
||||
{errorFace}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="h5" sx={{ wordBreak: 'break-word', whiteSpace: 'pre-line', pointerEvents: 'all' }}>
|
||||
{message}
|
||||
</Typography>
|
||||
<ExtraMessage messageExtra={messageExtra} />
|
||||
{retry && (
|
||||
<Button onClick={retry} sx={{ pointerEvents: 'all' }}>
|
||||
{t('global.button.retry')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
22
src/base/components/feedback/EmptyViewAbsoluteCentered.tsx
Normal file
22
src/base/components/feedback/EmptyViewAbsoluteCentered.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { EmptyView, EmptyViewProps } from '@/base/components/feedback/EmptyView.tsx';
|
||||
|
||||
export function EmptyViewAbsoluteCentered({ sx, ...emptyViewProps }: EmptyViewProps) {
|
||||
return (
|
||||
<EmptyView
|
||||
{...emptyViewProps}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
minHeight: '-webkit-fill-available',
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
91
src/base/components/feedback/ErrorBoundary.tsx
Normal file
91
src/base/components/feedback/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, ErrorInfo, ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { t } from 'i18next';
|
||||
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
|
||||
import { EmptyView } from '@/base/components/feedback/EmptyView.tsx';
|
||||
|
||||
interface Props {
|
||||
children?: ReactNode;
|
||||
setTrackPathChange: (change: boolean) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: any;
|
||||
}
|
||||
|
||||
class RealErrorBoundary extends Component<Props, State> {
|
||||
// eslint-disable-next-line react/state-in-constructor
|
||||
public state: State = { error: null };
|
||||
|
||||
private prevPath: string = '';
|
||||
|
||||
public static getDerivedStateFromError(error: any): State {
|
||||
// Update state so the next render will show the fallback UI.
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.prevPath = window.location.pathname;
|
||||
}
|
||||
|
||||
public componentDidUpdate() {
|
||||
if (window.location.pathname !== this.prevPath) {
|
||||
this.setState({ error: null });
|
||||
}
|
||||
|
||||
this.prevPath = window.location.pathname;
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
// eslint-disable-next-line
|
||||
console.error('Uncaught error:', error, errorInfo);
|
||||
// eslint-disable-next-line react/destructuring-assignment
|
||||
this.props.setTrackPathChange(true);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { error } = this.state;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<EmptyView
|
||||
message={t('global.error.label.unrecoverable_error')}
|
||||
messageExtra={getErrorMessage(error)}
|
||||
retry={() => window.location.reload()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { children } = this.props;
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
export const ErrorBoundary = ({ children }: { children: React.ReactNode }) => {
|
||||
const [key, setKey] = useState(0);
|
||||
const { pathname } = useLocation();
|
||||
const previousPathnameRef = useRef(pathname);
|
||||
const [trackPathChange, setTrackPathChange] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (trackPathChange && previousPathnameRef.current !== pathname) {
|
||||
previousPathnameRef.current = pathname;
|
||||
setKey((currentKey) => (currentKey + 1) % 999999);
|
||||
setTrackPathChange(false);
|
||||
}
|
||||
}, [pathname, previousPathnameRef.current, trackPathChange]);
|
||||
|
||||
return (
|
||||
<RealErrorBoundary key={key} setTrackPathChange={setTrackPathChange}>
|
||||
{children}
|
||||
</RealErrorBoundary>
|
||||
);
|
||||
};
|
||||
53
src/base/components/feedback/LoadingPlaceholder.tsx
Normal file
53
src/base/components/feedback/LoadingPlaceholder.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from 'react';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
interface IProps {
|
||||
shouldRender?: boolean | (() => boolean);
|
||||
children?: React.ReactNode;
|
||||
component?: string | React.FunctionComponent<any> | React.ComponentClass<any, any>;
|
||||
componentProps?: any;
|
||||
usePadding?: boolean;
|
||||
}
|
||||
|
||||
export function LoadingPlaceholder(props: IProps) {
|
||||
const { children, shouldRender, component, componentProps, usePadding } = props;
|
||||
|
||||
let condition = true;
|
||||
if (shouldRender !== undefined) {
|
||||
condition = shouldRender instanceof Function ? shouldRender() : shouldRender;
|
||||
}
|
||||
|
||||
if (condition) {
|
||||
if (component) {
|
||||
return React.createElement(component, componentProps);
|
||||
}
|
||||
|
||||
if (children) {
|
||||
return children as JSX.Element;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
margin: '0px auto',
|
||||
marginTop: usePadding ? 'unset' : '10px',
|
||||
marginBottom: usePadding ? 'unset' : '10px',
|
||||
padding: usePadding ? '10px 0' : 'unset',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<CircularProgress thickness={5} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
34
src/base/components/feedback/Progress.tsx
Normal file
34
src/base/components/feedback/Progress.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress, { CircularProgressProps } from '@mui/material/CircularProgress';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
export const Progress = ({
|
||||
progress,
|
||||
showText = true,
|
||||
progressProps = {},
|
||||
}: {
|
||||
progress: number;
|
||||
showText?: boolean;
|
||||
progressProps?: CircularProgressProps;
|
||||
}) => (
|
||||
<Box sx={{ display: 'grid', placeItems: 'center', position: 'relative' }}>
|
||||
<CircularProgress {...progressProps} variant="determinate" value={progress} />
|
||||
{showText && (
|
||||
<Box sx={{ position: 'absolute' }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
>{`${Math.round(progress)}%`}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
117
src/base/components/feedback/SnackbarWithDescription.tsx
Normal file
117
src/base/components/feedback/SnackbarWithDescription.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { closeSnackbar, CustomContentProps, SnackbarContent, VariantType } from 'notistack';
|
||||
import { ForwardedRef, forwardRef, Fragment, memo } from 'react';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import AlertTitle from '@mui/material/AlertTitle';
|
||||
import Button from '@mui/material/Button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { awaitConfirmation } from '@/base/utils/AwaitableDialog.tsx';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
|
||||
import { extractGraphqlExceptionInfo } from '@/lib/HelperFunctions.ts';
|
||||
import { TranslationKey } from '@/base/Base.types.ts';
|
||||
|
||||
const MAX_DESCRIPTION_LENGTH = 200;
|
||||
|
||||
const SNACKBAR_VARIANT_TO_TRANSLATION_KEY: Record<VariantType, TranslationKey> = {
|
||||
default: 'global.label.info',
|
||||
info: 'global.label.info',
|
||||
success: 'global.label.success',
|
||||
warning: 'global.label.warning',
|
||||
error: 'global.label.error',
|
||||
};
|
||||
|
||||
export const SnackbarWithDescription = memo(
|
||||
forwardRef(
|
||||
(
|
||||
{
|
||||
id,
|
||||
message,
|
||||
description,
|
||||
variant,
|
||||
action,
|
||||
}: CustomContentProps & {
|
||||
// eslint-disable-next-line react/no-unused-prop-types
|
||||
description?: string;
|
||||
},
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
const severity = variant === 'default' ? 'info' : variant;
|
||||
const finalAction = typeof action === 'function' ? action(id) : action;
|
||||
|
||||
const { isGraphqlException, graphqlError, graphqlStackTrace } = extractGraphqlExceptionInfo(description);
|
||||
|
||||
const finalDescription = isGraphqlException ? graphqlError : description;
|
||||
const isDescriptionTooLong = (finalDescription?.length ?? 0) > MAX_DESCRIPTION_LENGTH;
|
||||
const actualDescription = isDescriptionTooLong
|
||||
? finalDescription?.slice(0, MAX_DESCRIPTION_LENGTH)
|
||||
: finalDescription;
|
||||
|
||||
const TitleComponent = actualDescription?.length ? AlertTitle : Fragment;
|
||||
|
||||
return (
|
||||
<SnackbarContent ref={ref}>
|
||||
<Alert
|
||||
elevation={1}
|
||||
severity={severity}
|
||||
action={finalAction}
|
||||
sx={{
|
||||
wordBreak: 'break-word',
|
||||
minWidth: '300px',
|
||||
[theme.breakpoints.down(MediaQuery.MOBILE_WIDTH)]: {
|
||||
maxWidth: '100vw',
|
||||
},
|
||||
[theme.breakpoints.between(MediaQuery.MOBILE_WIDTH, MediaQuery.TABLET_WIDTH)]: {
|
||||
maxWidth: '75vw',
|
||||
},
|
||||
[theme.breakpoints.up(MediaQuery.TABLET_WIDTH)]: {
|
||||
maxWidth: '50vw',
|
||||
},
|
||||
}}
|
||||
onClose={() => closeSnackbar(id)}
|
||||
>
|
||||
<TitleComponent>{message}</TitleComponent>
|
||||
{actualDescription}
|
||||
{isDescriptionTooLong || (isGraphqlException && graphqlStackTrace) ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
awaitConfirmation({
|
||||
title:
|
||||
typeof message === 'string'
|
||||
? message
|
||||
: t(SNACKBAR_VARIANT_TO_TRANSLATION_KEY[variant]),
|
||||
message: description ?? '',
|
||||
actions: {
|
||||
cancel: { show: false },
|
||||
confirm: { title: t('global.label.close') },
|
||||
},
|
||||
}).catch(
|
||||
defaultPromiseErrorHandler(
|
||||
`SnackbarWithDescription: ${id} - ${message} - ${description}`,
|
||||
),
|
||||
);
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
{t('global.button.show_more')}
|
||||
</Button>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</Alert>
|
||||
</SnackbarContent>
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
29
src/base/components/inputs/ButtonSelectInput.tsx
Normal file
29
src/base/components/inputs/ButtonSelectInput.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
import { ButtonSelect } from '@/base/components/buttons/ButtonSelect.tsx';
|
||||
|
||||
export const ButtonSelectInput = <Value extends string | number>({
|
||||
label,
|
||||
description,
|
||||
...buttonSelectProps
|
||||
}: ComponentProps<typeof ButtonSelect<Value>> & { label: string; description?: string }) => (
|
||||
<Stack>
|
||||
<Typography>{label}</Typography>
|
||||
{description && (
|
||||
<Typography variant="body2" color="textDisabled">
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
<ButtonSelect {...buttonSelectProps} />
|
||||
</Stack>
|
||||
);
|
||||
16
src/base/components/inputs/CheckboxContainer.ts
Normal file
16
src/base/components/inputs/CheckboxContainer.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { styled } from '@mui/material/styles';
|
||||
|
||||
export const CheckboxContainer = styled('div')({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxHeight: '170px',
|
||||
overflow: 'auto',
|
||||
});
|
||||
19
src/base/components/inputs/CheckboxInput.tsx
Normal file
19
src/base/components/inputs/CheckboxInput.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Checkbox, { CheckboxProps } from '@mui/material/Checkbox';
|
||||
import FormControlLabel, { FormControlLabelProps } from '@mui/material/FormControlLabel';
|
||||
import React from 'react';
|
||||
|
||||
interface IProps extends CheckboxProps {
|
||||
label?: FormControlLabelProps['label'];
|
||||
}
|
||||
|
||||
export const CheckboxInput: React.FC<IProps> = ({ label, sx, ...rest }) => (
|
||||
<FormControlLabel control={<Checkbox {...rest} />} label={label} sx={sx} />
|
||||
);
|
||||
108
src/base/components/inputs/LanguageSelect.tsx
Normal file
108
src/base/components/inputs/LanguageSelect.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import FilterListIcon from '@mui/icons-material/FilterList';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
|
||||
import { translateExtensionLanguage } from '@/features/extension/Extensions.utils.ts';
|
||||
import { languageSortComparator, toUniqueLanguageCodes } from '@/base/utils/Languages.ts';
|
||||
|
||||
interface IProps {
|
||||
selectedLanguages: string[];
|
||||
setSelectedLanguages: (languages: string[]) => void;
|
||||
languages: string[];
|
||||
}
|
||||
|
||||
export function LanguageSelect(props: IProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { selectedLanguages, setSelectedLanguages, languages } = props;
|
||||
const [tmpSelectedLanguages, setTmpSelectedLanguages] = useState(toUniqueLanguageCodes(selectedLanguages));
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
|
||||
const languagesSortedBySelectState = useMemo(
|
||||
() =>
|
||||
toUniqueLanguageCodes([
|
||||
...tmpSelectedLanguages.toSorted(languageSortComparator),
|
||||
...languages.toSorted(languageSortComparator),
|
||||
]),
|
||||
[languages, tmpSelectedLanguages],
|
||||
);
|
||||
|
||||
const handleCancel = () => {
|
||||
setOpen(false);
|
||||
setTmpSelectedLanguages(toUniqueLanguageCodes(selectedLanguages));
|
||||
};
|
||||
|
||||
const handleOk = () => {
|
||||
setOpen(false);
|
||||
setSelectedLanguages(toUniqueLanguageCodes(tmpSelectedLanguages));
|
||||
};
|
||||
|
||||
const handleChange = (language: string, selected: boolean) => {
|
||||
if (selected) {
|
||||
setTmpSelectedLanguages([...tmpSelectedLanguages, language]);
|
||||
} else {
|
||||
setTmpSelectedLanguages(tmpSelectedLanguages.toSpliced(tmpSelectedLanguages.indexOf(language), 1));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomTooltip title={t('settings.title')}>
|
||||
<IconButton onClick={() => setOpen(true)} aria-label="display more actions" edge="end" color="inherit">
|
||||
<FilterListIcon />
|
||||
</IconButton>
|
||||
</CustomTooltip>
|
||||
<Dialog fullWidth maxWidth="xs" open={open} onClose={handleCancel}>
|
||||
<DialogTitle>{t('global.language.title.enabled_languages')}</DialogTitle>
|
||||
<DialogContent dividers sx={{ padding: 0 }}>
|
||||
<Virtuoso
|
||||
style={{
|
||||
height: languagesSortedBySelectState.length * 54,
|
||||
minHeight: '25vh',
|
||||
maxHeight: '50vh',
|
||||
}}
|
||||
data={languagesSortedBySelectState}
|
||||
increaseViewportBy={400}
|
||||
computeItemKey={(index) => languagesSortedBySelectState[index]}
|
||||
itemContent={(_index, language) => (
|
||||
<ListItem>
|
||||
<ListItemText primary={translateExtensionLanguage(language)} />
|
||||
|
||||
<Switch
|
||||
checked={tmpSelectedLanguages.includes(language)}
|
||||
onChange={(e) => handleChange(language, e.target.checked)}
|
||||
/>
|
||||
</ListItem>
|
||||
)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button autoFocus onClick={handleCancel} color="primary">
|
||||
{t('global.button.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleOk} color="primary">
|
||||
{t('global.button.ok')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
src/base/components/inputs/PasswordTextField.tsx
Normal file
47
src/base/components/inputs/PasswordTextField.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import TextField, { TextFieldProps } from '@mui/material/TextField';
|
||||
import { useState } from 'react';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Visibility from '@mui/icons-material/Visibility';
|
||||
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const PasswordTextField = (props: TextFieldProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const handleClickShowPassword = () => setShowPassword((show) => !show);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
id="password"
|
||||
name="password"
|
||||
label={t('global.label.password')}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={handleClickShowPassword}
|
||||
edge="end"
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
19
src/base/components/inputs/RadioInput.tsx
Normal file
19
src/base/components/inputs/RadioInput.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Radio, { RadioProps } from '@mui/material/Radio';
|
||||
import React from 'react';
|
||||
|
||||
export interface RadioInputProps extends RadioProps {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const RadioInput: React.FC<RadioInputProps> = ({ label, sx, ...rest }) => (
|
||||
<FormControlLabel control={<Radio {...rest} />} label={label} sx={sx} />
|
||||
);
|
||||
37
src/base/components/inputs/SearchTextField.tsx
Normal file
37
src/base/components/inputs/SearchTextField.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import TextField, { TextFieldProps } from '@mui/material/TextField';
|
||||
import IconButton, { IconButtonProps } from '@mui/material/IconButton';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
|
||||
export const SearchTextField = ({
|
||||
onCancel,
|
||||
cancelButtonProps,
|
||||
...textFieldProps
|
||||
}: TextFieldProps & { onCancel: () => void; cancelButtonProps?: IconButtonProps }) => (
|
||||
<TextField
|
||||
{...textFieldProps}
|
||||
slotProps={{
|
||||
input: {
|
||||
...textFieldProps.InputProps,
|
||||
sx: {
|
||||
color: 'inherit',
|
||||
},
|
||||
endAdornment: textFieldProps.InputProps?.endAdornment ?? (
|
||||
<InputAdornment position="end">
|
||||
<IconButton {...cancelButtonProps} onClick={() => onCancel()}>
|
||||
<CancelIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
19
src/base/components/inputs/Select.tsx
Normal file
19
src/base/components/inputs/Select.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import MuiSelect from '@mui/material/Select';
|
||||
|
||||
export const Select = <Value,>({
|
||||
children,
|
||||
maxSelectionHeightPx = 250,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MuiSelect<Value>> & { maxSelectionHeightPx?: number }) => (
|
||||
<MuiSelect<Value> MenuProps={{ PaperProps: { style: { maxHeight: maxSelectionHeightPx } } }} {...props}>
|
||||
{children}
|
||||
</MuiSelect>
|
||||
);
|
||||
41
src/base/components/inputs/SliderInput.tsx
Normal file
41
src/base/components/inputs/SliderInput.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Slider, { SliderProps } from '@mui/material/Slider';
|
||||
import Typography, { TypographyProps } from '@mui/material/Typography';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { ResetButton } from '@/base/components/buttons/ResetButton.tsx';
|
||||
|
||||
export const SliderInput = ({
|
||||
label,
|
||||
value,
|
||||
onDefault,
|
||||
slotProps,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | string;
|
||||
onDefault?: () => void;
|
||||
slotProps?: {
|
||||
label?: TypographyProps;
|
||||
value?: TypographyProps;
|
||||
slider?: SliderProps;
|
||||
};
|
||||
}) => (
|
||||
<Stack sx={{ flexDirection: 'row', gap: 2, alignItems: 'center' }}>
|
||||
<Stack sx={{ flexBasis: '25%' }}>
|
||||
<Typography {...slotProps?.label} sx={{ ...slotProps?.label?.sx }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography {...slotProps?.value} sx={{ ...slotProps?.value?.sx }}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Slider {...slotProps?.slider} sx={{ flexBasis: '75%', ...slotProps?.slider?.sx }} />
|
||||
{onDefault && <ResetButton asIconButton onClick={onDefault} />}
|
||||
</Stack>
|
||||
);
|
||||
23
src/base/components/inputs/SortRadioInput.tsx
Normal file
23
src/base/components/inputs/SortRadioInput.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import ArrowDownward from '@mui/icons-material/ArrowDownward';
|
||||
import ArrowUpward from '@mui/icons-material/ArrowUpward';
|
||||
import { memo } from 'react';
|
||||
import { RadioInput, RadioInputProps } from '@/base/components/inputs/RadioInput.tsx';
|
||||
|
||||
interface IProps extends RadioInputProps {
|
||||
sortDescending?: boolean | null | undefined;
|
||||
}
|
||||
|
||||
export const SortRadioInput = memo(({ sortDescending, ...rest }: IProps) => (
|
||||
<RadioInput
|
||||
checkedIcon={sortDescending ? <ArrowDownward color="primary" /> : <ArrowUpward color="primary" />}
|
||||
{...rest}
|
||||
/>
|
||||
));
|
||||
48
src/base/components/inputs/ThreeStateCheckbox.tsx
Normal file
48
src/base/components/inputs/ThreeStateCheckbox.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import DisabledByDefaultRounded from '@mui/icons-material/DisabledByDefaultRounded';
|
||||
import Checkbox, { CheckboxProps } from '@mui/material/Checkbox';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
type CheckState = boolean | undefined | null;
|
||||
|
||||
function nextState(state: CheckState): CheckState {
|
||||
if (state === true) return false;
|
||||
if (state === false) return undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface ThreeStateCheckboxProps extends Omit<CheckboxProps, 'checked' | 'onChange'> {
|
||||
checked?: boolean | undefined | null;
|
||||
onChange?: (checked: boolean | undefined | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* When checked is true, checkbox contains checkmark
|
||||
* When checked is false, checkbox contains cross
|
||||
* When checked is null or undefined, checkbox is empty
|
||||
*/
|
||||
export const ThreeStateCheckbox: React.FC<ThreeStateCheckboxProps> = ({ checked, onChange, ...rest }) => {
|
||||
const handleChange = useCallback(() => {
|
||||
if (onChange) {
|
||||
const newState = nextState(checked);
|
||||
onChange(newState);
|
||||
}
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
indeterminateIcon={<DisabledByDefaultRounded />}
|
||||
checked={checked === true}
|
||||
indeterminate={checked === false}
|
||||
onChange={handleChange}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
19
src/base/components/inputs/ThreeStateCheckboxInput.tsx
Normal file
19
src/base/components/inputs/ThreeStateCheckboxInput.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import React from 'react';
|
||||
import { ThreeStateCheckbox, ThreeStateCheckboxProps } from '@/base/components/inputs/ThreeStateCheckbox.tsx';
|
||||
|
||||
interface IProps extends ThreeStateCheckboxProps {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const ThreeStateCheckboxInput: React.FC<IProps> = ({ label, sx, ...rest }) => (
|
||||
<FormControlLabel control={<ThreeStateCheckbox {...rest} />} label={label} sx={sx} />
|
||||
);
|
||||
14
src/base/components/lists/ListItemLink.tsx
Normal file
14
src/base/components/lists/ListItemLink.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import ListItemButton, { ListItemButtonProps } from '@mui/material/ListItemButton';
|
||||
|
||||
export function ListItemLink(props: ListItemButtonProps<typeof Link>) {
|
||||
return <ListItemButton component={Link} {...props} />;
|
||||
}
|
||||
41
src/base/components/lists/cards/ListCardAvatar.tsx
Normal file
41
src/base/components/lists/cards/ListCardAvatar.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Avatar, { AvatarProps } from '@mui/material/Avatar';
|
||||
import { SpinnerImage, SpinnerImageProps } from '@/base/components/SpinnerImage.tsx';
|
||||
|
||||
export const ListCardAvatar = ({
|
||||
iconUrl,
|
||||
alt,
|
||||
slots,
|
||||
}: {
|
||||
iconUrl: string;
|
||||
alt: string;
|
||||
slots?: { avatarProps?: Partial<AvatarProps>; spinnerImageProps?: Partial<SpinnerImageProps> };
|
||||
}) => (
|
||||
<Avatar
|
||||
variant="rounded"
|
||||
alt={alt}
|
||||
{...slots?.avatarProps}
|
||||
sx={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
flex: '0 0 auto',
|
||||
background: 'transparent',
|
||||
...slots?.avatarProps?.sx,
|
||||
}}
|
||||
>
|
||||
<SpinnerImage
|
||||
alt={alt}
|
||||
src={iconUrl}
|
||||
{...slots?.spinnerImageProps}
|
||||
spinnerStyle={{ small: true, ...slots?.spinnerImageProps?.spinnerStyle }}
|
||||
imgStyle={{ objectFit: 'cover', width: '100%', height: '100%', ...slots?.spinnerImageProps?.imgStyle }}
|
||||
/>
|
||||
</Avatar>
|
||||
);
|
||||
27
src/base/components/lists/cards/ListCardContent.tsx
Normal file
27
src/base/components/lists/cards/ListCardContent.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import CardContent, { CardContentProps } from '@mui/material/CardContent';
|
||||
|
||||
export const ListCardContent = ({ children, ...props }: CardContentProps) => (
|
||||
<CardContent
|
||||
{...props}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
p: 1,
|
||||
'&:last-child': {
|
||||
paddingBottom: 1,
|
||||
},
|
||||
...props.sx,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CardContent>
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import CardActionArea from '@mui/material/CardActionArea';
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
export const OptionalCardActionAreaLink = ({
|
||||
disabled,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof CardActionArea> & ComponentProps<typeof Link> & { disabled?: boolean }) => {
|
||||
if (disabled) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<CardActionArea component={Link} {...props}>
|
||||
{children}
|
||||
</CardActionArea>
|
||||
);
|
||||
};
|
||||
51
src/base/components/menu/IconMenuItem.tsx
Normal file
51
src/base/components/menu/IconMenuItem.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/*
|
||||
* src: https://github.com/webzep/mui-nested-menu/blob/main/packages/mui-nested-menu/src/components/IconMenuItem.tsx (2024-04-20 01:42)
|
||||
*/
|
||||
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import MenuItem, { MenuItemProps as MuiMenuItemProps } from '@mui/material/MenuItem';
|
||||
import { SxProps, Theme } from '@mui/material/styles';
|
||||
import React, { forwardRef, RefObject } from 'react';
|
||||
|
||||
import { OverridableComponent } from '@mui/material/OverridableComponent';
|
||||
import { SvgIconTypeMap } from '@mui/material/SvgIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
|
||||
type IconMenuItemProps = {
|
||||
MenuItemProps?: MuiMenuItemProps;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
renderLabel?: () => React.ReactNode;
|
||||
LeftIcon?: OverridableComponent<SvgIconTypeMap> & { muiName: string };
|
||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
ref?: RefObject<HTMLLIElement | null>;
|
||||
RightIcon?: OverridableComponent<SvgIconTypeMap> & { muiName: string };
|
||||
sx?: SxProps<Theme>;
|
||||
};
|
||||
|
||||
export const IconMenuItem = forwardRef<HTMLLIElement, IconMenuItemProps>(
|
||||
({ MenuItemProps, className, label, LeftIcon, renderLabel, RightIcon, ...props }, ref) => (
|
||||
<MenuItem {...MenuItemProps} ref={ref} className={className} {...props}>
|
||||
{LeftIcon && (
|
||||
<ListItemIcon>
|
||||
<LeftIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
)}
|
||||
<ListItemText>{label}</ListItemText>
|
||||
{RightIcon && (
|
||||
<ListItemIcon style={{ minWidth: 0 }}>
|
||||
<RightIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
)}
|
||||
</MenuItem>
|
||||
),
|
||||
);
|
||||
35
src/base/components/menu/Menu.tsx
Normal file
35
src/base/components/menu/Menu.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import MuiMenu, { MenuProps } from '@mui/material/Menu';
|
||||
import { useState, type JSX } from 'react';
|
||||
|
||||
export const Menu = ({
|
||||
children,
|
||||
onClose,
|
||||
...props
|
||||
}: Omit<MenuProps, 'children' | 'onClose'> &
|
||||
Required<Pick<MenuProps, 'onClose'>> & {
|
||||
children: (onClose: () => void, setHideMenu: (hide: boolean) => void) => JSX.Element | JSX.Element[];
|
||||
}) => {
|
||||
const [shouldHideMenu, setShouldHideMenu] = useState(false);
|
||||
|
||||
return (
|
||||
<MuiMenu
|
||||
{...props}
|
||||
open={props.open}
|
||||
onClose={onClose}
|
||||
sx={{ visibility: !props.open || shouldHideMenu ? 'hidden' : 'visible' }}
|
||||
>
|
||||
{children(() => {
|
||||
onClose({}, 'backdropClick');
|
||||
setShouldHideMenu(false);
|
||||
}, setShouldHideMenu)}
|
||||
</MuiMenu>
|
||||
);
|
||||
};
|
||||
43
src/base/components/menu/Menu.utils.ts
Normal file
43
src/base/components/menu/Menu.utils.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { t as translate } from 'i18next';
|
||||
|
||||
import { TranslationKey } from '@/base/Base.types.ts';
|
||||
|
||||
export const createGetMenuItemTitle =
|
||||
<Action extends string>(
|
||||
isSingleMode: boolean,
|
||||
actionToTranslationKey: Record<
|
||||
Action,
|
||||
{
|
||||
action: {
|
||||
single: TranslationKey;
|
||||
selected: TranslationKey;
|
||||
};
|
||||
success: TranslationKey;
|
||||
error: TranslationKey;
|
||||
}
|
||||
>,
|
||||
) =>
|
||||
(action: Action, count: number): string => {
|
||||
const countSuffix = count > 0 ? ` (${count})` : '';
|
||||
return `${translate(
|
||||
actionToTranslationKey[action].action[isSingleMode ? 'single' : 'selected'],
|
||||
)}${countSuffix}`;
|
||||
};
|
||||
|
||||
export const createShouldShowMenuItem =
|
||||
(isSingleMode: boolean) =>
|
||||
(shouldBeVisible: boolean = false): boolean =>
|
||||
isSingleMode ? shouldBeVisible : true;
|
||||
|
||||
export const createIsMenuItemDisabled =
|
||||
(isSingleMode: boolean) =>
|
||||
(shouldBeDisabled: boolean): boolean =>
|
||||
isSingleMode ? false : shouldBeDisabled;
|
||||
27
src/base/components/menu/MenuItem.tsx
Normal file
27
src/base/components/menu/MenuItem.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import MuiMenuItem, { MenuItemProps } from '@mui/material/MenuItem';
|
||||
import { OverridableComponent } from '@mui/material/OverridableComponent';
|
||||
import { SvgIconTypeMap } from '@mui/material/SvgIcon';
|
||||
|
||||
interface IProps extends MenuItemProps {
|
||||
title: string;
|
||||
Icon: OverridableComponent<SvgIconTypeMap> & { muiName: string };
|
||||
}
|
||||
|
||||
export const MenuItem = ({ title, Icon, ...menuItemProps }: IProps) => (
|
||||
<MuiMenuItem {...menuItemProps}>
|
||||
<ListItemIcon>
|
||||
<Icon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{title}</ListItemText>
|
||||
</MuiMenuItem>
|
||||
);
|
||||
235
src/base/components/menu/NestedMenuItem.tsx
Normal file
235
src/base/components/menu/NestedMenuItem.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/*
|
||||
* src: https://github.com/webzep/mui-nested-menu/blob/main/packages/mui-nested-menu/src/components/NestedMenuItem.tsx (2024-04-20 01:42)
|
||||
*
|
||||
* with a few changes to fix a bug on mobile devices where opening the sub menu immediately triggered the on click of the underlying menu item
|
||||
*/
|
||||
|
||||
import Menu, { MenuProps as MuiMenuProps } from '@mui/material/Menu';
|
||||
import { MenuItemProps as MuiMenuItemProps } from '@mui/material/MenuItem';
|
||||
import {
|
||||
ElementType,
|
||||
forwardRef,
|
||||
HTMLAttributes,
|
||||
KeyboardEvent,
|
||||
FocusEvent,
|
||||
MouseEvent,
|
||||
ReactNode,
|
||||
RefAttributes,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
import { OverridableComponent } from '@mui/material/OverridableComponent';
|
||||
import { SvgIconTypeMap } from '@mui/material/SvgIcon';
|
||||
import { useMergedRef } from '@mantine/hooks';
|
||||
import { IconMenuItem } from '@/base/components/menu/IconMenuItem.tsx';
|
||||
import { getOptionForDirection } from '@/features/theme/services/ThemeCreator.ts';
|
||||
import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
|
||||
|
||||
export type NestedMenuItemProps = Omit<MuiMenuItemProps, 'button'> & {
|
||||
parentMenuOpen: boolean;
|
||||
component?: ElementType;
|
||||
label?: string;
|
||||
renderLabel?: () => ReactNode;
|
||||
RightIcon?: OverridableComponent<SvgIconTypeMap> & { muiName: string };
|
||||
LeftIcon?: OverridableComponent<SvgIconTypeMap> & { muiName: string };
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
tabIndex?: number;
|
||||
disabled?: boolean;
|
||||
ContainerProps?: HTMLAttributes<HTMLElement> & RefAttributes<HTMLElement>;
|
||||
MenuProps?: Partial<Omit<MuiMenuProps, 'children'>>;
|
||||
button?: true | undefined;
|
||||
};
|
||||
|
||||
const NestedMenuItem = forwardRef<HTMLLIElement | null, NestedMenuItemProps>((props, ref) => {
|
||||
const {
|
||||
parentMenuOpen,
|
||||
label,
|
||||
renderLabel,
|
||||
RightIcon = getOptionForDirection(ChevronRightIcon, ChevronLeftIcon),
|
||||
LeftIcon,
|
||||
children,
|
||||
className,
|
||||
tabIndex: tabIndexProp,
|
||||
ContainerProps: ContainerPropsProp = {},
|
||||
MenuProps,
|
||||
...MenuItemProps
|
||||
} = props;
|
||||
|
||||
const isTouchDevice = MediaQuery.useIsTouchDevice();
|
||||
|
||||
const { ref: containerRefProp, ...ContainerProps } = ContainerPropsProp;
|
||||
|
||||
const menuItemRef = useRef<HTMLLIElement | null>(null);
|
||||
const mergedMenuItemRef = useMergedRef(ref, menuItemRef);
|
||||
|
||||
const containerRef = useRef<HTMLElement>(null);
|
||||
const mergedContainerRef = useMergedRef(containerRefProp, containerRef);
|
||||
|
||||
const menuContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [isSubMenuOpen, setIsSubMenuOpen] = useState(false);
|
||||
|
||||
const changeMenuOpenState = (open: boolean) => {
|
||||
if (isSubMenuOpen === open) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.disabled) {
|
||||
setIsSubMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubMenuOpen(open);
|
||||
};
|
||||
|
||||
const handleMouseEnter = (e: MouseEvent<HTMLElement>) => {
|
||||
if (isTouchDevice) {
|
||||
return;
|
||||
}
|
||||
|
||||
changeMenuOpenState(true);
|
||||
|
||||
if (ContainerProps.onMouseEnter) {
|
||||
ContainerProps.onMouseEnter(e);
|
||||
}
|
||||
};
|
||||
const handleMouseLeave = (e: MouseEvent<HTMLElement>) => {
|
||||
changeMenuOpenState(false);
|
||||
|
||||
if (ContainerProps.onMouseLeave) {
|
||||
ContainerProps.onMouseLeave(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if any immediate children are active
|
||||
const isSubmenuFocused = () => {
|
||||
const active = containerRef.current?.ownerDocument.activeElement ?? null;
|
||||
if (menuContainerRef.current == null) {
|
||||
return false;
|
||||
}
|
||||
for (const child of menuContainerRef.current.children) {
|
||||
if (child === active) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleFocus = (e: FocusEvent<HTMLElement>) => {
|
||||
if (isTouchDevice) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target === containerRef.current) {
|
||||
changeMenuOpenState(true);
|
||||
}
|
||||
|
||||
if (ContainerProps.onFocus) {
|
||||
ContainerProps.onFocus(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: MouseEvent<HTMLElement>) => {
|
||||
changeMenuOpenState(!isSubMenuOpen);
|
||||
|
||||
if (ContainerProps.onClick) {
|
||||
ContainerProps.onClick(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSubmenuFocused()) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
const active = containerRef.current?.ownerDocument.activeElement;
|
||||
|
||||
if (e.key === 'ArrowLeft' && isSubmenuFocused()) {
|
||||
containerRef.current?.focus();
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowRight' && e.target === containerRef.current && e.target === active) {
|
||||
const firstChild = menuContainerRef.current?.children[0] as HTMLDivElement;
|
||||
firstChild?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const open = isSubMenuOpen && parentMenuOpen;
|
||||
|
||||
// Root element must have a `tabIndex` attribute for keyboard navigation
|
||||
let tabIndex;
|
||||
if (!props.disabled) {
|
||||
tabIndex = tabIndexProp !== undefined ? tabIndexProp : -1;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...ContainerProps}
|
||||
ref={mergedContainerRef}
|
||||
onFocus={handleFocus}
|
||||
onClick={handleClick}
|
||||
tabIndex={tabIndex}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<IconMenuItem
|
||||
MenuItemProps={MenuItemProps}
|
||||
className={className}
|
||||
ref={mergedMenuItemRef}
|
||||
LeftIcon={LeftIcon}
|
||||
RightIcon={RightIcon}
|
||||
label={label}
|
||||
renderLabel={renderLabel}
|
||||
/>
|
||||
|
||||
<Menu
|
||||
// Set pointer events to 'none' to prevent the invisible Popover div
|
||||
// from capturing events for clicks and hovers
|
||||
style={{ pointerEvents: 'none' }}
|
||||
anchorEl={menuItemRef.current}
|
||||
anchorOrigin={{
|
||||
horizontal: getOptionForDirection('right', 'left'),
|
||||
vertical: 'top',
|
||||
}}
|
||||
transformOrigin={{
|
||||
horizontal: getOptionForDirection('left', 'right'),
|
||||
vertical: 'top',
|
||||
}}
|
||||
open={open}
|
||||
autoFocus={false}
|
||||
disableAutoFocus
|
||||
disableEnforceFocus
|
||||
onClose={() => {
|
||||
changeMenuOpenState(false);
|
||||
}}
|
||||
{...MenuProps}
|
||||
>
|
||||
<Box ref={menuContainerRef} style={{ pointerEvents: 'auto' }}>
|
||||
{children}
|
||||
</Box>
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
NestedMenuItem.displayName = 'NestedMenuItem';
|
||||
export { NestedMenuItem };
|
||||
113
src/base/components/modals/ConfirmDialog.tsx
Normal file
113
src/base/components/modals/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Button from '@mui/material/Button';
|
||||
import Stack from '@mui/material/Stack';
|
||||
|
||||
type Action = {
|
||||
show?: boolean;
|
||||
title?: string;
|
||||
contain?: boolean;
|
||||
};
|
||||
|
||||
type Actions = {
|
||||
extra?: Action;
|
||||
cancel?: Action;
|
||||
confirm?: Action;
|
||||
};
|
||||
|
||||
export const ConfirmDialog = ({
|
||||
title,
|
||||
message,
|
||||
actions: passedActions,
|
||||
onExtra,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
title: string;
|
||||
message: string;
|
||||
actions?: Actions;
|
||||
onExtra?: () => void;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const actions = {
|
||||
extra: {
|
||||
show: passedActions?.extra?.show ?? false,
|
||||
title: passedActions?.extra?.title ?? '',
|
||||
contain: passedActions?.extra?.contain ?? false,
|
||||
},
|
||||
cancel: {
|
||||
show: passedActions?.cancel?.show ?? true,
|
||||
title: passedActions?.cancel?.title ?? t('global.button.cancel'),
|
||||
contain: passedActions?.cancel?.contain ?? false,
|
||||
},
|
||||
confirm: {
|
||||
show: passedActions?.confirm?.show ?? true,
|
||||
title: passedActions?.confirm?.title ?? t('global.button.ok'),
|
||||
contain:
|
||||
!passedActions?.extra?.contain &&
|
||||
!passedActions?.cancel?.contain &&
|
||||
!passedActions?.confirm?.contain &&
|
||||
true,
|
||||
},
|
||||
} satisfies Actions;
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onCancel}>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent
|
||||
sx={{
|
||||
whiteSpace: 'pre-line',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Stack
|
||||
sx={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: actions.extra.show ? 'space-between' : 'end',
|
||||
width: '100%',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{actions.extra.show && (
|
||||
<Button onClick={onExtra} variant={actions.extra.contain ? 'contained' : undefined}>
|
||||
{actions.extra.title}
|
||||
</Button>
|
||||
)}
|
||||
<Stack
|
||||
sx={{
|
||||
flexDirection: 'row',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{actions.cancel.show && (
|
||||
<Button onClick={onCancel} variant={actions.cancel.contain ? 'contained' : undefined}>
|
||||
{actions.cancel.title}
|
||||
</Button>
|
||||
)}
|
||||
{actions.confirm.show && (
|
||||
<Button onClick={onConfirm} variant={actions.confirm.contain ? 'contained' : undefined}>
|
||||
{actions.confirm.title}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
36
src/base/components/modals/OptionsPanel.tsx
Normal file
36
src/base/components/modals/OptionsPanel.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
import Box from '@mui/material/Box';
|
||||
import React from 'react';
|
||||
|
||||
interface IProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
minHeight?: number;
|
||||
}
|
||||
|
||||
export const OptionsPanel: React.FC<IProps> = ({ open, onClose, children, minHeight }) => (
|
||||
<Drawer
|
||||
anchor="bottom"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
PaperProps={{
|
||||
style: {
|
||||
maxWidth: 600,
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
minHeight,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box>{children}</Box>
|
||||
</Drawer>
|
||||
);
|
||||
55
src/base/components/modals/OptionsTabs.tsx
Normal file
55
src/base/components/modals/OptionsTabs.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Tab from '@mui/material/Tab';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
import React, { useState } from 'react';
|
||||
import { TabPanel } from '@/base/components/tabs/TabPanel.tsx';
|
||||
import { OptionsPanel } from '@/base/components/modals/OptionsPanel.tsx';
|
||||
|
||||
interface IProps<T = string> {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
tabs: T[];
|
||||
tabTitle: (key: T) => React.ReactNode;
|
||||
tabContent: (key: T) => React.ReactNode;
|
||||
minHeight?: number;
|
||||
}
|
||||
|
||||
export const OptionsTabs = <T extends string = string>({
|
||||
open,
|
||||
onClose,
|
||||
tabs,
|
||||
tabTitle,
|
||||
tabContent,
|
||||
minHeight,
|
||||
}: IProps<T>) => {
|
||||
const [tabNum, setTabNum] = useState(0);
|
||||
|
||||
return (
|
||||
<OptionsPanel open={open} onClose={onClose} minHeight={minHeight}>
|
||||
<Tabs
|
||||
value={tabNum}
|
||||
variant="fullWidth"
|
||||
onChange={(e, newTab) => setTabNum(newTab)}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
>
|
||||
{tabs.map((tab, tabIndex) => (
|
||||
<Tab key={tab} value={tabIndex} label={tabTitle(tab)} />
|
||||
))}
|
||||
</Tabs>
|
||||
{tabs.map((tab, tabIndex) => (
|
||||
<TabPanel key={tab} index={tabIndex} currentIndex={tabNum}>
|
||||
<Stack sx={{ px: 3, py: 1, minHeight }}>{tabContent(tab)}</Stack>
|
||||
</TabPanel>
|
||||
))}
|
||||
</OptionsPanel>
|
||||
);
|
||||
};
|
||||
125
src/base/components/settings/CheckboxListSetting.tsx
Normal file
125
src/base/components/settings/CheckboxListSetting.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Button from '@mui/material/Button';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import FormGroup from '@mui/material/FormGroup';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { CheckboxProps } from '@mui/material/Checkbox';
|
||||
import { CheckboxInput } from '@/base/components/inputs/CheckboxInput.tsx';
|
||||
import { useSelectableCollection } from '@/features/collection/hooks/useSelectableCollection.ts';
|
||||
|
||||
export function CheckboxListSetting<Item>({
|
||||
title,
|
||||
emptyMessage,
|
||||
items,
|
||||
getId,
|
||||
getLabel,
|
||||
isChecked,
|
||||
open,
|
||||
onClose,
|
||||
slotProps,
|
||||
}: {
|
||||
title: string;
|
||||
emptyMessage?: string;
|
||||
items: Item[];
|
||||
getId: (item: Item) => string;
|
||||
getLabel: (item: Item) => string;
|
||||
isChecked: (item: Item) => boolean;
|
||||
open: boolean;
|
||||
onClose: (selectedItems?: Item[]) => void;
|
||||
slotProps?: {
|
||||
checkbox?: Omit<CheckboxProps, 'checked' | 'onChange' | 'label'>;
|
||||
};
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const itemIds = useMemo(() => items.map(getId), [items]);
|
||||
const currentSelectedItemIds = useMemo(() => items.filter(isChecked).map(getId), [items]);
|
||||
|
||||
const { selectedItemIds, handleSelection, handleSelectAll, reset } = useSelectableCollection(items.length, {
|
||||
currentKey: 'default',
|
||||
itemIds,
|
||||
initialState: { default: currentSelectedItemIds },
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
onClose();
|
||||
reset();
|
||||
};
|
||||
|
||||
const handleOk = useCallback(() => {
|
||||
const didSelectionChange =
|
||||
selectedItemIds.length !== currentSelectedItemIds.length ||
|
||||
selectedItemIds.some((id) => !currentSelectedItemIds.includes(id));
|
||||
const selectedItems = items.filter((item) => selectedItemIds.includes(getId(item)));
|
||||
|
||||
onClose(didSelectionChange ? selectedItems : undefined);
|
||||
}, [selectedItemIds]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
sx={{
|
||||
'.MuiDialog-paper': {
|
||||
maxHeight: 435,
|
||||
width: '80%',
|
||||
},
|
||||
}}
|
||||
maxWidth="xs"
|
||||
open={open}
|
||||
onClose={handleCancel}
|
||||
>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<FormGroup>
|
||||
{items.length === 0 && <span>{emptyMessage}</span>}
|
||||
{items.map((item) => (
|
||||
<CheckboxInput
|
||||
{...slotProps?.checkbox}
|
||||
checked={selectedItemIds.includes(getId(item))}
|
||||
onChange={(e, checked) => handleSelection(getId(item), checked)}
|
||||
label={getLabel(item)}
|
||||
key={getId(item)}
|
||||
/>
|
||||
))}
|
||||
</FormGroup>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Stack sx={{ width: '100%' }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'end',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Button onClick={() => handleSelectAll(!selectedItemIds.length, items.map(getId))}>
|
||||
{t(selectedItemIds.length ? 'global.button.reset' : 'global.button.select_all')}
|
||||
</Button>
|
||||
<Stack direction="row">
|
||||
<Button autoFocus onClick={handleCancel} color="primary">
|
||||
{t('global.button.cancel')}
|
||||
</Button>
|
||||
{!!items.length && (
|
||||
<Button onClick={handleOk} color="primary">
|
||||
{t('global.button.ok')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
153
src/base/components/settings/DateSetting.tsx
Normal file
153
src/base/components/settings/DateSetting.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
||||
import dayjs from 'dayjs';
|
||||
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
|
||||
|
||||
export const DateSetting = ({
|
||||
settingName,
|
||||
value,
|
||||
defaultValue,
|
||||
handleChange,
|
||||
remove,
|
||||
}: {
|
||||
settingName: string;
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
handleChange: (path?: string | null) => void;
|
||||
remove?: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [dialogValue, setDialogValue] = useState(value ?? defaultValue);
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDialogValue(value);
|
||||
}, [value]);
|
||||
|
||||
const closeDialog = useCallback(
|
||||
(resetValue: boolean) => {
|
||||
setIsDialogOpen(false);
|
||||
|
||||
if (resetValue) {
|
||||
setDialogValue(value ?? defaultValue);
|
||||
}
|
||||
},
|
||||
[value],
|
||||
);
|
||||
|
||||
const closeDialogWithReset = useCallback(() => closeDialog(true), [closeDialog]);
|
||||
|
||||
const updateSetting = useCallback(
|
||||
(newValue?: string, shouldCloseDialog: boolean = true) => {
|
||||
if (shouldCloseDialog) {
|
||||
closeDialog(false);
|
||||
}
|
||||
|
||||
const didValueChange = value !== newValue;
|
||||
if (!didValueChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleChange(newValue);
|
||||
},
|
||||
[value, handleChange, closeDialog],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItemButton onClick={() => setIsDialogOpen(true)}>
|
||||
<ListItemText
|
||||
primary={settingName}
|
||||
secondary={value ? dayjs(Number(value)).format('L') : '-'}
|
||||
secondaryTypographyProps={{ style: { display: 'flex', flexDirection: 'column' } }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
|
||||
<Dialog open={isDialogOpen} onClose={closeDialog}>
|
||||
<DialogTitle>{settingName}</DialogTitle>
|
||||
<DialogContent>
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale={dayjs.locale()}>
|
||||
<DatePicker
|
||||
value={dialogValue ? dayjs(Number(dialogValue)) : null}
|
||||
onChange={(date) => {
|
||||
if (!date) return;
|
||||
setDialogValue(date.valueOf().toString());
|
||||
}}
|
||||
/>
|
||||
</LocalizationProvider>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'end',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Stack>
|
||||
{defaultValue !== undefined && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDialogValue(defaultValue);
|
||||
updateSetting(defaultValue, false);
|
||||
}}
|
||||
color="primary"
|
||||
>
|
||||
{t('global.button.reset_to_default')}
|
||||
</Button>
|
||||
)}
|
||||
{remove && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDialogValue(undefined);
|
||||
updateSetting(undefined, false);
|
||||
}}
|
||||
color="primary"
|
||||
>
|
||||
{t('global.button.remove')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack direction="row">
|
||||
<Button onClick={closeDialogWithReset} color="primary">
|
||||
{t('global.button.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
updateSetting(dialogValue);
|
||||
}}
|
||||
color="primary"
|
||||
>
|
||||
{t('global.button.ok')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
245
src/base/components/settings/MutableListSetting.tsx
Normal file
245
src/base/components/settings/MutableListSetting.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useEffect, useState, type JSX } from 'react';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import List from '@mui/material/List';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
|
||||
import { TextSetting, TextSettingProps } from '@/base/components/settings/text/TextSetting.tsx';
|
||||
import { TextSettingDialog } from '@/base/components/settings/text/TextSettingDialog.tsx';
|
||||
import { makeToast } from '@/base/utils/Toast.ts';
|
||||
|
||||
const MutableListItem = ({
|
||||
handleDelete,
|
||||
mutable = true,
|
||||
deletable = true,
|
||||
...textSettingProps
|
||||
}: Omit<TextSettingProps, 'isPassword' | 'disabled'> & {
|
||||
handleDelete: () => void;
|
||||
mutable?: boolean;
|
||||
deletable?: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack sx={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
{mutable ? (
|
||||
<TextSetting {...textSettingProps} dialogTitle="" />
|
||||
) : (
|
||||
<ListItem>
|
||||
<ListItemText secondary={textSettingProps.value} />
|
||||
</ListItem>
|
||||
)}
|
||||
<CustomTooltip title={t('chapter.action.download.delete.label.action')} disabled={!deletable}>
|
||||
<IconButton disabled={!deletable} onClick={handleDelete}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</CustomTooltip>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
type MutableListSettingProps = Pick<TextSettingProps, 'settingName' | 'placeholder'> & {
|
||||
valueInfos?: (
|
||||
| [value: string]
|
||||
| [value: string, Pick<React.ComponentProps<typeof MutableListItem>, 'mutable' | 'deletable'>]
|
||||
)[];
|
||||
description?: string;
|
||||
dialogDisclaimer?: JSX.Element | string;
|
||||
addItemButtonTitle?: string;
|
||||
handleChange: (values: string[], removedValues: string[]) => void;
|
||||
allowDuplicates?: boolean;
|
||||
validateItem?: (value: string, tmpValues?: string[]) => boolean;
|
||||
invalidItemError?: string;
|
||||
};
|
||||
|
||||
const getValues = (valueInfos: MutableListSettingProps['valueInfos']): string[] =>
|
||||
valueInfos?.map((valueInfo) => valueInfo[0]) ?? [];
|
||||
|
||||
export const MutableListSetting = ({
|
||||
settingName,
|
||||
description,
|
||||
dialogDisclaimer,
|
||||
valueInfos,
|
||||
handleChange,
|
||||
addItemButtonTitle,
|
||||
placeholder,
|
||||
allowDuplicates = false,
|
||||
validateItem = () => true,
|
||||
invalidItemError,
|
||||
}: MutableListSettingProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const values = getValues(valueInfos);
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [dialogValues, setDialogValues] = useState(values);
|
||||
|
||||
const [isAddItemDialogOpen, setIsAddItemDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!valueInfos) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDialogValues(values);
|
||||
}, [valueInfos]);
|
||||
|
||||
const closeDialog = (resetValue: boolean = true) => {
|
||||
if (resetValue) {
|
||||
setDialogValues(values);
|
||||
}
|
||||
|
||||
setIsDialogOpen(false);
|
||||
};
|
||||
|
||||
const updateSetting = (index: number, newValue: string | undefined) => {
|
||||
const deleteValue = newValue === undefined;
|
||||
if (deleteValue) {
|
||||
setDialogValues(dialogValues.toSpliced(index, 1));
|
||||
return;
|
||||
}
|
||||
|
||||
const isDuplicate = !allowDuplicates && dialogValues.includes(newValue);
|
||||
if (isDuplicate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newValue === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateItem?.(newValue, dialogValues)) {
|
||||
makeToast(invalidItemError ?? t('global.error.label.invalid_input'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setDialogValues(dialogValues.toSpliced(index, 1, newValue.trim()));
|
||||
};
|
||||
|
||||
const saveChanges = () => {
|
||||
closeDialog(true);
|
||||
|
||||
const updatedValues = dialogValues.filter((dialogValue) => dialogValue !== '');
|
||||
const removedValues = values.filter((value) => !updatedValues.includes(value));
|
||||
|
||||
handleChange(updatedValues, removedValues);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItemButton onClick={() => setIsDialogOpen(true)}>
|
||||
<ListItemText
|
||||
primary={settingName}
|
||||
secondary={values?.length ? values?.join(', ') : description}
|
||||
secondaryTypographyProps={{
|
||||
style: { display: 'flex', flexDirection: 'column', wordBreak: 'break-word' },
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
|
||||
<Dialog open={isDialogOpen} onClose={() => closeDialog()} fullWidth>
|
||||
<DialogTitle>{settingName}</DialogTitle>
|
||||
{(!!description || !!dialogDisclaimer) && (
|
||||
<DialogContent>
|
||||
<DialogContentText sx={{ paddingBottom: '10px' }} component="div">
|
||||
{description && (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
whiteSpace: 'pre-line',
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
{dialogDisclaimer && (
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<InfoIcon color="warning" />
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
marginLeft: '10px',
|
||||
marginTop: '5px',
|
||||
whiteSpace: 'pre-line',
|
||||
}}
|
||||
>
|
||||
{dialogDisclaimer}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
)}
|
||||
<DialogContent dividers sx={{ maxHeight: '300px' }}>
|
||||
<List>
|
||||
{dialogValues.map((dialogValue, index) => (
|
||||
<MutableListItem
|
||||
key={dialogValue}
|
||||
settingName=""
|
||||
placeholder={placeholder}
|
||||
handleChange={(newValue: string) => updateSetting(index, newValue)}
|
||||
handleDelete={() => updateSetting(index, undefined)}
|
||||
value={dialogValue}
|
||||
mutable={valueInfos?.find(([value]) => value === dialogValue)?.[1]?.mutable}
|
||||
deletable={valueInfos?.find(([value]) => value === dialogValue)?.[1]?.deletable}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Button onClick={() => setIsAddItemDialogOpen(true)}>
|
||||
{addItemButtonTitle ?? t('global.button.add')}
|
||||
</Button>
|
||||
<Stack direction="row">
|
||||
<Button onClick={() => closeDialog()}>{t('global.button.cancel')}</Button>
|
||||
<Button onClick={() => saveChanges()}>{t('global.button.ok')}</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{isAddItemDialogOpen && (
|
||||
<TextSettingDialog
|
||||
settingName=""
|
||||
placeholder={placeholder}
|
||||
handleChange={(newValue: string) => updateSetting(dialogValues.length, newValue)}
|
||||
isDialogOpen={isAddItemDialogOpen}
|
||||
setIsDialogOpen={setIsAddItemDialogOpen}
|
||||
validate={(value) => validateItem?.(value, dialogValues) ?? true}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
217
src/base/components/settings/NumberSetting.tsx
Normal file
217
src/base/components/settings/NumberSetting.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Button from '@mui/material/Button';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import * as React from 'react';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import Slider from '@mui/material/Slider';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
import { SxProps, Theme } from '@mui/material/styles';
|
||||
|
||||
type BaseProps = {
|
||||
settingTitle: string;
|
||||
settingValue: string;
|
||||
settingIcon?: React.ReactNode;
|
||||
value: number;
|
||||
defaultValue?: number;
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
stepSize?: number;
|
||||
dialogTitle?: string;
|
||||
dialogDescription?: string;
|
||||
dialogDisclaimer?: string;
|
||||
valueUnit: string;
|
||||
handleUpdate: (value: number) => void;
|
||||
showSlider?: never;
|
||||
disabled?: boolean;
|
||||
listItemTextSx?: SxProps<Theme>;
|
||||
handleLiveUpdate?: (value: number) => void;
|
||||
};
|
||||
|
||||
type PropsWithSlider = Omit<BaseProps, 'defaultValue' | 'minValue' | 'maxValue' | 'showSlider'> &
|
||||
Required<Pick<BaseProps, 'defaultValue' | 'minValue' | 'maxValue'>> & { showSlider: true };
|
||||
|
||||
type Props = BaseProps | PropsWithSlider;
|
||||
|
||||
export const NumberSetting = ({
|
||||
settingTitle,
|
||||
settingValue,
|
||||
settingIcon,
|
||||
dialogDescription,
|
||||
dialogDisclaimer,
|
||||
value,
|
||||
defaultValue,
|
||||
minValue,
|
||||
maxValue,
|
||||
stepSize,
|
||||
dialogTitle = settingTitle,
|
||||
valueUnit,
|
||||
handleUpdate,
|
||||
showSlider,
|
||||
disabled = false,
|
||||
handleLiveUpdate,
|
||||
listItemTextSx: sx,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [dialogValue, setDialogValue] = useState(value);
|
||||
const [originalValue, setOriginalValue] = useState(value);
|
||||
|
||||
const isInvalid =
|
||||
(minValue !== undefined && minValue > dialogValue) || (maxValue !== undefined && maxValue < dialogValue);
|
||||
|
||||
const updateValue = useCallback(
|
||||
(newValue: number, persist: boolean) => {
|
||||
setDialogValue(newValue);
|
||||
const didValueChange = newValue !== originalValue;
|
||||
// Call handleUpdate if the value changed and 'persist' is true,
|
||||
// otherwise call handleLiveUpdate if it's defined.
|
||||
if (persist && didValueChange) {
|
||||
handleUpdate(newValue);
|
||||
} else if (handleLiveUpdate) {
|
||||
handleLiveUpdate(newValue);
|
||||
}
|
||||
},
|
||||
[originalValue, setDialogValue, handleLiveUpdate, handleUpdate],
|
||||
);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
updateValue(originalValue, true);
|
||||
setOriginalValue(originalValue);
|
||||
setIsDialogOpen(false);
|
||||
}, [originalValue, handleUpdate]);
|
||||
|
||||
const resetToDefault = useCallback(() => {
|
||||
if (defaultValue !== undefined) {
|
||||
updateValue(defaultValue, true);
|
||||
setOriginalValue(defaultValue);
|
||||
setIsDialogOpen(false);
|
||||
}
|
||||
}, [defaultValue, handleUpdate]);
|
||||
|
||||
const submit = () => {
|
||||
updateValue(dialogValue, true);
|
||||
setOriginalValue(dialogValue);
|
||||
setIsDialogOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItemButton disabled={disabled} onClick={() => setIsDialogOpen(true)}>
|
||||
{settingIcon ? <ListItemIcon>{settingIcon}</ListItemIcon> : null}
|
||||
<ListItemText
|
||||
primary={settingTitle}
|
||||
secondary={settingValue}
|
||||
sx={sx}
|
||||
secondaryTypographyProps={{ style: { display: 'flex', flexDirection: 'column' } }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
|
||||
<Dialog open={isDialogOpen} onClose={cancel}>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogContent>
|
||||
{(!!dialogDescription || !!dialogDisclaimer) && (
|
||||
<DialogContentText sx={{ paddingBottom: '10px' }} component="div">
|
||||
{dialogDescription && (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
whiteSpace: 'pre-line',
|
||||
}}
|
||||
>
|
||||
{dialogDescription}
|
||||
</Typography>
|
||||
)}
|
||||
{dialogDisclaimer && (
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<InfoIcon color="warning" />
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
marginLeft: '10px',
|
||||
marginTop: '5px',
|
||||
whiteSpace: 'pre-line',
|
||||
}}
|
||||
>
|
||||
{dialogDisclaimer}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</DialogContentText>
|
||||
)}
|
||||
<TextField
|
||||
sx={{
|
||||
width: '100%',
|
||||
margin: 'auto',
|
||||
}}
|
||||
autoFocus
|
||||
value={dialogValue}
|
||||
type="number"
|
||||
error={isInvalid}
|
||||
helperText={isInvalid ? t('global.error.label.invalid_input') : ''}
|
||||
onChange={(e) => {
|
||||
const newValue = Number(e.target.value);
|
||||
updateValue(newValue, false);
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
inputProps: { min: minValue, max: maxValue, step: stepSize },
|
||||
endAdornment: <InputAdornment position="end">{valueUnit}</InputAdornment>,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{showSlider ? (
|
||||
<Slider
|
||||
aria-label="number-setting-slider"
|
||||
defaultValue={defaultValue}
|
||||
value={dialogValue}
|
||||
step={stepSize}
|
||||
min={minValue}
|
||||
max={maxValue}
|
||||
onChange={(_, newValue) => {
|
||||
updateValue(newValue as number, false);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{defaultValue !== undefined ? (
|
||||
<Button onClick={resetToDefault} color="primary">
|
||||
{t('global.button.reset_to_default')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button onClick={cancel} color="primary">
|
||||
{t('global.button.cancel')}
|
||||
</Button>
|
||||
<Button disabled={isInvalid} onClick={submit} color="primary">
|
||||
{t('global.button.ok')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
155
src/base/components/settings/SelectSetting.tsx
Normal file
155
src/base/components/settings/SelectSetting.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
import { Select } from '@/base/components/inputs/Select.tsx';
|
||||
|
||||
import { TranslationKey } from '@/base/Base.types.ts';
|
||||
|
||||
export type SelectSettingValueDisplayInfo = {
|
||||
text: TranslationKey | string;
|
||||
description?: TranslationKey | string;
|
||||
disclaimer?: TranslationKey | string;
|
||||
};
|
||||
|
||||
export type SelectSettingValue<Value> = [Value: Value, DisplayInfo: SelectSettingValueDisplayInfo];
|
||||
|
||||
export const SelectSetting = <SettingValue extends string | number>({
|
||||
settingName,
|
||||
dialogDescription,
|
||||
value,
|
||||
values,
|
||||
handleChange,
|
||||
disabled = false,
|
||||
}: {
|
||||
settingName: string;
|
||||
dialogDescription?: string;
|
||||
value: SettingValue;
|
||||
values: SelectSettingValue<SettingValue>[];
|
||||
handleChange: (value: SettingValue) => void;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [dialogValue, setDialogValue] = useState(value);
|
||||
|
||||
const valueDisplayText = useMemo(() => values.find(([key]) => key === value)?.[1]?.text, [value]);
|
||||
const dialogValueDisplayInfo = useMemo(() => values.find(([key]) => key === dialogValue)![1], [dialogValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDialogValue(value);
|
||||
}, [value]);
|
||||
|
||||
const closeDialog = (resetValue: boolean = true) => {
|
||||
if (resetValue) {
|
||||
setDialogValue(value);
|
||||
}
|
||||
|
||||
setIsDialogOpen(false);
|
||||
};
|
||||
|
||||
const updateSetting = () => {
|
||||
closeDialog(false);
|
||||
handleChange(dialogValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItemButton disabled={disabled} onClick={() => setIsDialogOpen(true)}>
|
||||
<ListItemText
|
||||
primary={settingName}
|
||||
secondary={valueDisplayText ? t(valueDisplayText as TranslationKey) : t('global.label.loading')}
|
||||
secondaryTypographyProps={{ style: { display: 'flex', flexDirection: 'column' } }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
|
||||
<Dialog open={isDialogOpen} onClose={() => closeDialog()} fullWidth>
|
||||
<DialogTitle>{settingName}</DialogTitle>
|
||||
<DialogContent>
|
||||
{!!dialogDescription && (
|
||||
<DialogContentText sx={{ paddingBottom: '10px' }}>{dialogDescription}</DialogContentText>
|
||||
)}
|
||||
{(!!dialogValueDisplayInfo.description || !!dialogValueDisplayInfo.disclaimer) && (
|
||||
<DialogContentText sx={{ paddingBottom: '10px' }} component="div">
|
||||
{dialogValueDisplayInfo.description && (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
whiteSpace: 'pre-line',
|
||||
}}
|
||||
>
|
||||
{t(dialogValueDisplayInfo.description as TranslationKey)}
|
||||
</Typography>
|
||||
)}
|
||||
{dialogValueDisplayInfo.disclaimer && (
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<InfoIcon color="warning" />
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
marginLeft: '10px',
|
||||
marginTop: '5px',
|
||||
whiteSpace: 'pre-line',
|
||||
}}
|
||||
>
|
||||
{t(dialogValueDisplayInfo.disclaimer as TranslationKey)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</DialogContentText>
|
||||
)}
|
||||
<FormControl fullWidth>
|
||||
<Select
|
||||
id="dialog-select"
|
||||
value={dialogValue}
|
||||
onChange={(e) => setDialogValue(e.target.value as SettingValue)}
|
||||
>
|
||||
{values.map(([selectValue, { text: selectText }]) => (
|
||||
<MenuItem key={selectValue} value={selectValue}>
|
||||
{t(selectText as TranslationKey)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => closeDialog()} color="primary">
|
||||
{t('global.button.cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => updateSetting()} color="primary">
|
||||
{t('global.button.ok')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
126
src/base/components/settings/TimeSetting.tsx
Normal file
126
src/base/components/settings/TimeSetting.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
||||
import { TimePicker } from '@mui/x-date-pickers/TimePicker';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export const TimeSetting = ({
|
||||
settingName,
|
||||
value,
|
||||
defaultValue,
|
||||
handleChange,
|
||||
}: {
|
||||
settingName: string;
|
||||
value: string;
|
||||
defaultValue: string;
|
||||
handleChange: (path: string) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [dialogValue, setDialogValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDialogValue(value);
|
||||
}, [value]);
|
||||
|
||||
const closeDialog = useCallback(
|
||||
(resetValue: boolean) => {
|
||||
setIsDialogOpen(false);
|
||||
|
||||
if (resetValue) {
|
||||
setDialogValue(value);
|
||||
}
|
||||
},
|
||||
[value],
|
||||
);
|
||||
|
||||
const closeDialogWithReset = useCallback(() => closeDialog(true), [closeDialog]);
|
||||
|
||||
const updateSetting = useCallback(
|
||||
(newValue: string, shouldCloseDialog: boolean = true) => {
|
||||
if (shouldCloseDialog) {
|
||||
closeDialog(false);
|
||||
}
|
||||
|
||||
const didValueChange = value !== newValue;
|
||||
if (!didValueChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleChange(newValue);
|
||||
},
|
||||
[value, handleChange, closeDialog],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItemButton onClick={() => setIsDialogOpen(true)}>
|
||||
<ListItemText
|
||||
primary={settingName}
|
||||
secondary={dayjs(value, 'HH:mm').format('LT')}
|
||||
secondaryTypographyProps={{ style: { display: 'flex', flexDirection: 'column' } }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
|
||||
<Dialog open={isDialogOpen} onClose={closeDialog}>
|
||||
<DialogTitle>{settingName}</DialogTitle>
|
||||
<DialogContent>
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale={dayjs.locale()}>
|
||||
<TimePicker
|
||||
autoFocus
|
||||
value={dayjs(dialogValue, 'HH:mm')}
|
||||
defaultValue={dayjs(defaultValue, 'HH:mm')}
|
||||
format="LT"
|
||||
onChange={(time) => setDialogValue(time?.format('HH:mm') ?? '00:00')}
|
||||
/>
|
||||
</LocalizationProvider>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{defaultValue !== undefined ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDialogValue(defaultValue);
|
||||
updateSetting(defaultValue, false);
|
||||
}}
|
||||
color="primary"
|
||||
>
|
||||
{t('global.button.reset_to_default')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button onClick={closeDialogWithReset} color="primary">
|
||||
{t('global.button.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
updateSetting(dialogValue);
|
||||
}}
|
||||
color="primary"
|
||||
>
|
||||
{t('global.button.ok')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
40
src/base/components/settings/text/TextSetting.tsx
Normal file
40
src/base/components/settings/text/TextSetting.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import { useState } from 'react';
|
||||
import { TextSettingDialog, TextSettingDialogProps } from '@/base/components/settings/text/TextSettingDialog.tsx';
|
||||
|
||||
export type TextSettingProps = Omit<TextSettingDialogProps, 'isDialogOpen' | 'setIsDialogOpen' | 'value'> &
|
||||
Required<Pick<TextSettingDialogProps, 'value'>> & {
|
||||
disabled?: boolean;
|
||||
settingDescription?: string;
|
||||
};
|
||||
|
||||
export const TextSetting = (props: TextSettingProps) => {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
const { settingName, settingDescription, value, isPassword = false, disabled = false } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItemButton disabled={disabled} onClick={() => setIsDialogOpen(true)}>
|
||||
<ListItemText
|
||||
primary={settingName}
|
||||
secondary={settingDescription ?? (isPassword ? value.replace(/./g, '*') : value)}
|
||||
secondaryTypographyProps={{
|
||||
sx: { display: 'flex', flexDirection: 'column', wordWrap: 'break-word' },
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
|
||||
<TextSettingDialog {...props} isDialogOpen={isDialogOpen} setIsDialogOpen={setIsDialogOpen} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
111
src/base/components/settings/text/TextSettingDialog.tsx
Normal file
111
src/base/components/settings/text/TextSettingDialog.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PasswordTextField } from '@/base/components/inputs/PasswordTextField.tsx';
|
||||
|
||||
export type TextSettingDialogProps = {
|
||||
settingName: string;
|
||||
dialogTitle?: string;
|
||||
dialogDescription?: string;
|
||||
value?: string;
|
||||
handleChange: (value: string) => void;
|
||||
isPassword?: boolean;
|
||||
placeholder?: string;
|
||||
isDialogOpen: boolean;
|
||||
setIsDialogOpen: (open: boolean) => void;
|
||||
validate?: (value: string) => boolean;
|
||||
};
|
||||
|
||||
export const TextSettingDialog = ({
|
||||
settingName,
|
||||
dialogTitle = settingName,
|
||||
dialogDescription,
|
||||
value,
|
||||
handleChange,
|
||||
isPassword = false,
|
||||
placeholder = '',
|
||||
isDialogOpen,
|
||||
setIsDialogOpen,
|
||||
validate = () => true,
|
||||
}: TextSettingDialogProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [dialogValue, setDialogValue] = useState(value ?? '');
|
||||
const [isValidValue, setIsValidValue] = useState(true);
|
||||
|
||||
const error = !isValidValue && !!dialogValue.length;
|
||||
|
||||
const TextFieldComponent = useMemo(() => (isPassword ? PasswordTextField : TextField), [isPassword]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDialogValue(value);
|
||||
}, [value]);
|
||||
|
||||
const closeDialog = (resetValue: boolean = true) => {
|
||||
if (resetValue) {
|
||||
setDialogValue(value ?? '');
|
||||
setIsValidValue(true);
|
||||
}
|
||||
|
||||
setIsDialogOpen(false);
|
||||
};
|
||||
|
||||
const updateSetting = () => {
|
||||
closeDialog(false);
|
||||
handleChange(dialogValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isDialogOpen} onClose={() => closeDialog()} fullWidth>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogContent>
|
||||
{!!dialogDescription && (
|
||||
<DialogContentText sx={{ paddingBottom: '10px' }}>{dialogDescription}</DialogContentText>
|
||||
)}
|
||||
<TextFieldComponent
|
||||
sx={{
|
||||
width: '100%',
|
||||
margin: 'auto',
|
||||
}}
|
||||
autoFocus
|
||||
placeholder={placeholder}
|
||||
value={dialogValue}
|
||||
error={error}
|
||||
helperText={error ? t('global.error.label.invalid_input') : ''}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
|
||||
setIsValidValue(validate(newValue));
|
||||
setDialogValue(newValue);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => closeDialog()} color="primary">
|
||||
{t('global.button.cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => updateSetting()} disabled={!isValidValue} color="primary">
|
||||
{t('global.button.ok')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
26
src/base/components/tabs/TabPanel.tsx
Normal file
26
src/base/components/tabs/TabPanel.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Box, { BoxProps } from '@mui/material/Box';
|
||||
import React from 'react';
|
||||
|
||||
interface IProps extends BoxProps {
|
||||
children: React.ReactNode;
|
||||
index: any;
|
||||
currentIndex: any;
|
||||
}
|
||||
|
||||
export function TabPanel(props: IProps) {
|
||||
const { children, index, currentIndex, ...boxProps } = props;
|
||||
|
||||
return (
|
||||
<Box {...boxProps} role="tabpanel" hidden={index !== currentIndex} id={`simple-tabpanel-${index}`}>
|
||||
{currentIndex === index && children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
46
src/base/components/tabs/TabsMenu.tsx
Normal file
46
src/base/components/tabs/TabsMenu.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Tabs, { TabsProps } from '@mui/material/Tabs';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { ForwardedRef, forwardRef } from 'react';
|
||||
import { useNavBarContext } from '@/features/navigation-bar/NavbarContext.tsx';
|
||||
|
||||
const StyledTabsMenu = styled(Tabs)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
position: 'sticky',
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1,
|
||||
backgroundColor: theme.palette.background.default,
|
||||
border: 0,
|
||||
borderBottomWidth: 2,
|
||||
borderStyle: 'solid',
|
||||
borderColor: theme.palette.divider,
|
||||
}));
|
||||
|
||||
export const TabsMenu = forwardRef(
|
||||
({ children, sx, ...props }: TabsProps, ref: ForwardedRef<HTMLDivElement | null>) => {
|
||||
const { appBarHeight } = useNavBarContext();
|
||||
|
||||
return (
|
||||
<StyledTabsMenu
|
||||
sx={{ ...sx, top: appBarHeight }}
|
||||
ref={ref}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
allowScrollButtonsMobile
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StyledTabsMenu>
|
||||
);
|
||||
},
|
||||
);
|
||||
15
src/base/components/tabs/TabsWrapper.tsx
Normal file
15
src/base/components/tabs/TabsWrapper.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Box, { BoxProps } from '@mui/material/Box';
|
||||
|
||||
export const TabsWrapper = ({ children, ...props }: BoxProps) => (
|
||||
<Box {...props} sx={{ ...props.sx, position: 'relative', height: `100%` }}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
23
src/base/components/texts/Kbd.tsx
Normal file
23
src/base/components/texts/Kbd.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
export const Kbd = styled(Typography)(({ theme }) => ({
|
||||
display: 'inline-block',
|
||||
padding: '0.2em 0.4em',
|
||||
fontSize: '0.85em',
|
||||
lineHeight: '1.4',
|
||||
color: theme.palette.text.primary,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: '3px',
|
||||
boxShadow: `inset 0 -1px 0 ${theme.palette.divider}`,
|
||||
fontFamily: 'monospace, monospace',
|
||||
}));
|
||||
41
src/base/components/texts/Metadata.tsx
Normal file
41
src/base/components/texts/Metadata.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Stack, { StackProps } from '@mui/material/Stack';
|
||||
import Typography, { TypographyProps } from '@mui/material/Typography';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export const Metadata = ({
|
||||
title,
|
||||
value,
|
||||
stackProps,
|
||||
titleProps,
|
||||
valueProps,
|
||||
}: {
|
||||
title: string;
|
||||
value: ReactNode;
|
||||
stackProps?: StackProps;
|
||||
titleProps?: TypographyProps;
|
||||
valueProps?: TypographyProps;
|
||||
}) => (
|
||||
<Stack
|
||||
{...stackProps}
|
||||
sx={{ flexDirection: 'row', columnGap: 1, flexWrap: 'wrap', alignItems: 'baseline', ...stackProps?.sx }}
|
||||
>
|
||||
<Typography
|
||||
{...titleProps}
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
...titleProps?.sx,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography {...valueProps}>{value}</Typography>
|
||||
</Stack>
|
||||
);
|
||||
35
src/base/components/texts/Superscript.tsx
Normal file
35
src/base/components/texts/Superscript.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { TranslationKey } from '@/base/Base.types.ts';
|
||||
|
||||
/**
|
||||
* Expects a translation key of format "{{value}}<0>superscript</0>"
|
||||
* @param i18nKey
|
||||
* @param value
|
||||
* @constructor
|
||||
*/
|
||||
export const Superscript = ({ i18nKey, value }: { i18nKey: TranslationKey; value: string }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack sx={{ flexDirection: 'row', gap: 0.25 }}>
|
||||
<Trans
|
||||
t={t}
|
||||
// the type of "key" causes tsc error: "TS2590: Expression produces a union type that is too complex to represent"
|
||||
i18nKey={i18nKey as any}
|
||||
values={{ value }}
|
||||
components={[<Typography variant="caption" sx={{ fontSize: 'x-small', opacity: 0.75 }} />]}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
41
src/base/components/texts/TypographyMaxLines.tsx
Normal file
41
src/base/components/texts/TypographyMaxLines.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { styled, Theme, TypographyVariant } from '@mui/material/styles';
|
||||
import Typography, { TypographyProps } from '@mui/material/Typography';
|
||||
import { shouldForwardProp } from '@/base/utils/ShouldForwardProp.ts';
|
||||
|
||||
const DEFAULT_LINE_HEIGHT = '1.5';
|
||||
|
||||
const getLineHeight = (theme: Theme, variant: TypographyProps['variant']): string => {
|
||||
if (variant === undefined) {
|
||||
return DEFAULT_LINE_HEIGHT;
|
||||
}
|
||||
|
||||
if (!(variant in theme.typography)) {
|
||||
return DEFAULT_LINE_HEIGHT;
|
||||
}
|
||||
|
||||
return theme.typography[variant as TypographyVariant].lineHeight?.toString() ?? DEFAULT_LINE_HEIGHT;
|
||||
};
|
||||
|
||||
type TypographyMaxLinesProps = {
|
||||
lines?: number;
|
||||
};
|
||||
|
||||
export const TypographyMaxLines = styled(Typography, {
|
||||
shouldForwardProp: shouldForwardProp<TypographyMaxLinesProps>(['lines']),
|
||||
})<TypographyProps & TypographyMaxLinesProps>(({ variant, theme, lines = 2 }) => ({
|
||||
lineHeight: getLineHeight(theme, variant),
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: `${lines}`,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
overflowWrap: 'break-word',
|
||||
}));
|
||||
28
src/base/components/virtuoso/StyledGroupHeader.tsx
Normal file
28
src/base/components/virtuoso/StyledGroupHeader.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { TypographyProps } from '@mui/material/Typography';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { shouldForwardProp } from '@/base/utils/ShouldForwardProp.ts';
|
||||
|
||||
type StyledGroupHeaderProps = {
|
||||
isFirstItem: boolean;
|
||||
};
|
||||
export const StyledGroupHeader = styled(Stack, {
|
||||
shouldForwardProp: shouldForwardProp<StyledGroupHeaderProps>(['isFirstItem']),
|
||||
})<StyledGroupHeaderProps & TypographyProps>(({ theme, isFirstItem }) => ({
|
||||
paddingLeft: theme.spacing(3),
|
||||
paddingTop: theme.spacing(0.75),
|
||||
paddingBottom: theme.spacing(2),
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: theme.palette.background.default,
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
paddingTop: isFirstItem ? theme.spacing(1) : theme.spacing(0.75),
|
||||
},
|
||||
}));
|
||||
17
src/base/components/virtuoso/StyledGroupItemWrapper.tsx
Normal file
17
src/base/components/virtuoso/StyledGroupItemWrapper.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Box from '@mui/material/Box';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { memo } from 'react';
|
||||
|
||||
export const StyledGroupItemWrapper = memo(
|
||||
styled(Box)(({ theme }) => ({
|
||||
padding: theme.spacing(0, 1, 1, 1),
|
||||
})),
|
||||
);
|
||||
49
src/base/components/virtuoso/StyledGroupedVirtuoso.tsx
Normal file
49
src/base/components/virtuoso/StyledGroupedVirtuoso.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { ContextProp, TopItemListProps } from 'react-virtuoso';
|
||||
import { ComponentProps, useMemo } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { useNavBarContext } from '@/features/navigation-bar/NavbarContext.tsx';
|
||||
import { GroupedVirtuosoPersisted } from '@/lib/virtuoso/Component/GroupedVirtuosoPersisted.tsx';
|
||||
|
||||
const StickyVirtuosoHeaderWithOffset =
|
||||
(topOffset: number) =>
|
||||
({ children, ...args }: TopItemListProps & ContextProp<unknown>) => (
|
||||
<Box {...args} style={{ ...args.style, top: topOffset }}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
export const StyledGroupedVirtuoso = ({
|
||||
heightToSubtract = 0,
|
||||
style,
|
||||
...props
|
||||
}: ComponentProps<typeof GroupedVirtuosoPersisted> & { heightToSubtract?: number }) => {
|
||||
const { appBarHeight, bottomBarHeight } = useNavBarContext();
|
||||
|
||||
const TopItemList = useMemo(
|
||||
() => StickyVirtuosoHeaderWithOffset(appBarHeight + heightToSubtract),
|
||||
[appBarHeight, heightToSubtract],
|
||||
);
|
||||
|
||||
return (
|
||||
<GroupedVirtuosoPersisted
|
||||
useWindowScroll
|
||||
{...props}
|
||||
components={{
|
||||
TopItemList,
|
||||
...props.components,
|
||||
}}
|
||||
style={{
|
||||
...style,
|
||||
height: `calc(100vh - ${heightToSubtract}px - ${appBarHeight}px - ${bottomBarHeight}px - ${!bottomBarHeight ? 'env(safe-area-inset-bottom)' : '0px'})`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user