Move "core" folder out of "features" into "base"

This commit is contained in:
schroda
2025-08-16 02:48:46 +02:00
parent f4c06d474d
commit 9e5af57a5f
311 changed files with 712 additions and 727 deletions

View 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>
);
};

View 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>
);

View 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>
</>
);
}

View 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>
)}
</>
);
},
);

View 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>
);
};

View 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>
),
);

View 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>
),
);

View 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>
);
};

View 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;

View 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>
);
};

View 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>
);
};

View 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>
);
}

View 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,
}}
/>
);
}

View 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>
);
};

View 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>
);
}

View 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>
);

View 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>
);
},
),
);

View 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>
);

View 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',
});

View 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} />
);

View 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>
</>
);
}

View 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}
/>
);
};

View 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} />
);

View 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>
),
},
}}
/>
);

View 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>
);

View 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>
);

View 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}
/>
));

View 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}
/>
);
};

View 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} />
);

View 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} />;
}

View 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>
);

View 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>
);

View 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 { 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>
);
};

View 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>
),
);

View 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>
);
};

View 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;

View 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>
);

View 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 };

View 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>
);
};

View 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>
);

View 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>
);
};

View 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>
);
}

View 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>
</>
);
};

View 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}
/>
)}
</>
);
};

View 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>
</>
);
};

View 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>
</>
);
};

View 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>
</>
);
};

View 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} />
</>
);
};

View 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>
);
};

View 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>
);
}

View 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>
);
},
);

View 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>
);

View 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',
}));

View 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>
);

View 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>
);
};

View 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',
}));

View 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),
},
}));

View 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),
})),
);

View 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'})`,
}}
/>
);
};