Rename folder "modules" to "features"

This commit is contained in:
schroda
2025-08-15 22:02:58 +02:00
parent 7e6ced1d09
commit 1b4bf22542
415 changed files with 1859 additions and 1852 deletions

View File

@@ -0,0 +1,12 @@
/*
* 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/.
*/
export type MetadataUpdateSettings = {
webUIInformAvailableUpdate: boolean;
serverInformAvailableUpdate: boolean;
};

View File

@@ -0,0 +1,146 @@
/*
* 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 { useState } from 'react';
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 DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { useUpdateChecker } from '@/features/app-updates/hooks/useUpdateChecker.tsx';
import { VersionUpdateInfoDialog } from '@/features/app-updates/components/VersionUpdateInfoDialog.tsx';
import { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
import { useLocalStorage } from '@/features/core/hooks/useStorage.tsx';
import { AppRoutes } from '@/features/core/AppRoute.constants.ts';
const disabledUpdateCheck = () => Promise.resolve();
export const ServerUpdateChecker = () => {
const { t } = useTranslation();
const [serverVersion, setServerVersion] = useLocalStorage<string>('serverVersion');
const [open, setOpen] = useState(false);
const {
settings: { serverInformAvailableUpdate },
} = useMetadataServerSettings();
const {
data: serverUpdateCheckData,
loading: isCheckingForServerUpdate,
error: serverUpdateCheckError,
refetch: checkForUpdate,
} = requestManager.useCheckForServerUpdate({
notifyOnNetworkStatusChange: true,
fetchPolicy: 'cache-only',
});
const { data } = requestManager.useGetAbout();
const { aboutServer } = data ?? {};
const selectedServerChannelInfo = serverUpdateCheckData?.checkForServerUpdates?.find(
(channel) => channel.channel === aboutServer?.buildType,
);
const version = aboutServer ? aboutServer.version : undefined;
const isServerUpdateAvailable = !!selectedServerChannelInfo?.tag && selectedServerChannelInfo.tag !== version;
const updateChecker = useUpdateChecker(
'server',
serverInformAvailableUpdate ? checkForUpdate : disabledUpdateCheck,
selectedServerChannelInfo?.tag,
);
const changelogUrl =
aboutServer?.buildType.toLowerCase() === 'stable'
? `https://github.com/Suwayomi/Suwayomi-Server/releases/tag/${aboutServer.version}`
: undefined;
const isSameAsCurrent = !version || !serverVersion || serverVersion === version;
const saveInitialVersion = !serverVersion && !!version;
if (saveInitialVersion) {
setServerVersion(version);
}
if (!isSameAsCurrent && !open) {
setOpen(true);
}
if (isCheckingForServerUpdate) {
return null;
}
if (serverUpdateCheckError) {
return null;
}
if (isServerUpdateAvailable) {
if (!serverInformAvailableUpdate) {
return null;
}
const isAboutPage = window.location.pathname === AppRoutes.about.path;
if (isAboutPage) {
return null;
}
if (!updateChecker.handleUpdate) {
return null;
}
return (
<VersionUpdateInfoDialog
info={t('global.update.label.info', {
channel: selectedServerChannelInfo.channel,
version: selectedServerChannelInfo.tag,
})}
actionTitle={t('chapter.action.download.add.label.action')}
actionUrl={selectedServerChannelInfo.url}
updateCheckerProps={['server', checkForUpdate, selectedServerChannelInfo?.tag]}
/>
);
}
if (!open) {
return null;
}
return (
<Dialog open={open}>
<DialogTitle>{t('settings.about.webui.label.updated')}</DialogTitle>
<DialogContent>
<DialogContentText>
{t('global.update.label.update_success', {
name: t('settings.server.title.server'),
version,
channel: aboutServer?.buildType,
})}
</DialogContentText>
</DialogContent>
<DialogActions>
{changelogUrl && (
<Button href={changelogUrl} target="_blank" rel="noreferrer">
{t('global.button.changelog')}
</Button>
)}
<Button
onClick={() => {
setServerVersion(version);
setOpen(false);
}}
variant="contained"
>
{t('global.button.ok')}
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -0,0 +1,166 @@
/*
* 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 CircularProgress from '@mui/material/CircularProgress';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import RefreshIcon from '@mui/icons-material/Refresh';
import DownloadIcon from '@mui/icons-material/Download';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import { t as translate } from 'i18next';
import DownloadingIcon from '@mui/icons-material/Downloading';
import { UpdateState } from '@/lib/graphql/generated/graphql.ts';
export type BaseVersionInfoProps = {
version: string;
isCheckingForUpdate: boolean;
isUpdateAvailable: boolean;
updateCheckError: any;
checkForUpdate: () => void;
};
export type LinkVersionInfoProps = {
downloadAsLink: true;
url: string;
};
export type TriggerVersionInfoProps = {
triggerUpdate: () => void;
updateState: UpdateState;
progress: number;
};
export type VersionInfoProps =
| (BaseVersionInfoProps & PropertiesNever<TriggerVersionInfoProps> & LinkVersionInfoProps)
| (BaseVersionInfoProps & TriggerVersionInfoProps & PropertiesNever<LinkVersionInfoProps>);
const getUpdateCheckButtonIcon = (
isLoading: boolean,
isUpdateAvailable: boolean,
updateState?: UpdateState,
asLink: boolean = false,
) => {
const isUpdateInProgress = updateState === UpdateState.Downloading;
if (isUpdateInProgress) {
return <DownloadingIcon />;
}
if (isLoading) {
return <CircularProgress size={15} />;
}
const isRefreshRequired = !isUpdateAvailable || updateState === UpdateState.Error;
if (isRefreshRequired) {
return <RefreshIcon />;
}
return asLink ? <OpenInNewIcon /> : <DownloadIcon />;
};
const getUpdateCheckButtonText = (
isLoading: boolean,
isUpdateAvailable: boolean,
error: any,
updateState?: UpdateState,
progress: number = 0,
) => {
const isUpdating = updateState === UpdateState.Downloading;
if (isUpdating) {
return translate('global.update.label.updating', { progress });
}
const didUpdateFail = updateState === UpdateState.Error;
if (didUpdateFail) {
return translate('global.update.label.update_failure');
}
if (isLoading) {
return translate('global.update.label.checking');
}
if (error) {
return translate('global.update.label.check_failure');
}
if (isUpdateAvailable) {
return translate('global.update.label.available');
}
return translate('global.update.label.up_to_date');
};
export const VersionInfo = ({
version,
isCheckingForUpdate,
isUpdateAvailable,
updateCheckError,
checkForUpdate,
triggerUpdate,
updateState,
progress,
downloadAsLink,
url,
}: VersionInfoProps) => {
const isUpdateInProgress = updateState === UpdateState.Downloading;
const onClick = () => {
if (isUpdateInProgress) {
return;
}
const shouldCheckForUpdate = !isUpdateAvailable || updateCheckError || updateState === UpdateState.Error;
if (shouldCheckForUpdate) {
checkForUpdate();
return;
}
if (isUpdateAvailable) {
triggerUpdate?.();
}
};
return (
<Stack
sx={{
alignItems: 'start',
}}
>
<Typography component="span" variant="body2">
{version}
</Typography>
<Button
sx={{
marginTop: '5px',
backgroundColor: 'transparent',
pointerEvents: isUpdateInProgress ? 'none' : 'unset',
}}
size="small"
variant="outlined"
startIcon={getUpdateCheckButtonIcon(
isCheckingForUpdate,
isUpdateAvailable,
updateState,
downloadAsLink,
)}
onClick={onClick}
{...(!!url && isUpdateAvailable
? {
href: url,
target: '_blank',
}
: undefined)}
>
{getUpdateCheckButtonText(
isCheckingForUpdate,
isUpdateAvailable,
updateCheckError,
updateState,
progress,
)}
</Button>
</Stack>
);
};

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 Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import Button from '@mui/material/Button';
import DialogContentText from '@mui/material/DialogContentText';
import PopupState, { bindMenu, bindTrigger } from 'material-ui-popup-state';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { useTranslation } from 'react-i18next';
import Stack from '@mui/material/Stack';
import { useUpdateChecker } from '@/features/app-updates/hooks/useUpdateChecker.tsx';
interface BaseProps {
info: string;
actionTitle: string;
updateCheckerProps: Parameters<typeof useUpdateChecker>;
changelogUrl?: string;
disabled?: boolean;
}
interface UrlActionProps extends BaseProps {
actionUrl: string;
}
interface ActionProps extends BaseProps {
onAction: () => void;
}
type VersionUpdateInfoDialogProps =
| (UrlActionProps & PropertiesNever<Pick<ActionProps, 'onAction'>>)
| (PropertiesNever<Pick<UrlActionProps, 'actionUrl'>> & ActionProps);
export const VersionUpdateInfoDialog = ({
info,
actionUrl,
onAction,
actionTitle,
updateCheckerProps,
changelogUrl,
disabled,
}: VersionUpdateInfoDialogProps) => {
const { t } = useTranslation();
const updateChecker = useUpdateChecker(...updateCheckerProps);
if (!updateChecker.handleUpdate) {
return null;
}
return (
<Dialog open>
<DialogTitle>{t('global.update.label.available')}</DialogTitle>
<DialogContent>
<DialogContentText>{info}</DialogContentText>
</DialogContent>
<DialogActions>
<Stack
direction="row"
sx={{
justifyContent: changelogUrl ? 'space-between' : 'end',
width: '100%',
}}
>
{changelogUrl && (
<Button href={changelogUrl} target="_blank" rel="noreferrer">
{t('global.button.changelog')}
</Button>
)}
<Stack direction="row">
<PopupState variant="popover" popupId="update-checker-close-menu">
{(popupState) => (
<>
<Button disabled={disabled} {...bindTrigger(popupState)}>
{t('global.label.close')}
</Button>
<Menu {...bindMenu(popupState)}>
<MenuItem
onClick={() => {
updateChecker.remindLater();
popupState.close();
}}
>
{t('global.button.remind_later')}
</MenuItem>
<MenuItem
onClick={() => {
updateChecker.ignoreUpdate();
popupState.close();
}}
>
{t('global.button.ignore')}
</MenuItem>
</Menu>
</>
)}
</PopupState>
{actionUrl ? (
<Button
disabled={disabled}
variant="contained"
href={actionUrl}
target="_blank"
rel="noreferrer"
>
{actionTitle}
</Button>
) : (
<Button disabled={disabled} onClick={onAction} variant="contained">
{actionTitle}
</Button>
)}
</Stack>
</Stack>
</DialogActions>
</Dialog>
);
};

View File

@@ -0,0 +1,191 @@
/*
* 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 { useEffect, useState } from 'react';
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 DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { UpdateState, WebUiChannel, WebUiUpdateStatus } from '@/lib/graphql/generated/graphql.ts';
import { useLocalStorage } from '@/features/core/hooks/useStorage.tsx';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { makeToast } from '@/features/core/utils/Toast.ts';
import { ABOUT_WEBUI, WEBUI_UPDATE_CHECK } from '@/lib/graphql/fragments/InfoFragments.ts';
import { VersionUpdateInfoDialog } from '@/features/app-updates/components/VersionUpdateInfoDialog.tsx';
import { useUpdateChecker } from '@/features/app-updates/hooks/useUpdateChecker.tsx';
import { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
const disabledUpdateCheck = () => Promise.resolve();
export const WebUIUpdateChecker = () => {
const { t } = useTranslation();
const [webUIVersion, setWebUIVersion] = useLocalStorage<string>('webUIVersion');
const [open, setOpen] = useState(false);
const {
settings: { webUIInformAvailableUpdate },
} = useMetadataServerSettings();
const serverSettings = requestManager.useGetServerSettings();
const isAutoUpdateEnabled = !!serverSettings.data?.settings.webUIUpdateCheckInterval;
const shouldCheckForUpdate = !isAutoUpdateEnabled && webUIInformAvailableUpdate;
const { data: aboutData } = requestManager.useGetAbout();
const { aboutWebUI } = aboutData ?? {};
const { data: webUIUpdateData, refetch: checkForUpdate } = requestManager.useCheckForWebUIUpdate({
notifyOnNetworkStatusChange: true,
fetchPolicy: 'cache-only',
});
const { data: webUIUpdateStatusData } = requestManager.useGetWebUIUpdateStatus();
const { state: webUIUpdateState, ...updateStatus } = (webUIUpdateStatusData?.getWebUIUpdateStatus ?? {
state: UpdateState.Idle,
progress: 0,
info: undefined,
}) satisfies OptionalProperty<WebUiUpdateStatus, 'info'>;
const updateChecker = useUpdateChecker(
'webUI',
shouldCheckForUpdate ? checkForUpdate : disabledUpdateCheck,
webUIUpdateData?.checkForWebUIUpdate.tag,
);
const changelogUrl =
updateStatus.info?.channel === WebUiChannel.Stable
? `https://github.com/Suwayomi/Suwayomi-WebUI/releases/latest`
: `https://github.com/Suwayomi/Suwayomi-WebUI/issues/749`;
const newVersion = aboutWebUI?.tag;
const isSameAsCurrent = !newVersion || !webUIVersion || webUIVersion === newVersion;
const saveInitialVersion = !webUIVersion && !!newVersion;
if (saveInitialVersion) {
setWebUIVersion(newVersion);
}
if (!isSameAsCurrent && !open) {
setOpen(true);
}
useEffect(() => {
const isError = webUIUpdateState === UpdateState.Error;
if (isError) {
makeToast(t('settings.about.webui.label.update_failure'), 'error');
}
const updateFinished = webUIUpdateState === UpdateState.Finished;
const resetUpdateStatus = isError || updateFinished;
if (resetUpdateStatus) {
requestManager
.resetWebUIUpdateStatus()
.response.catch(defaultPromiseErrorHandler('WebUIUpdateChecker::resetWebUIUpdateStatus'));
}
if (!updateFinished) {
return;
}
if (!updateStatus.info) {
return;
}
requestManager.graphQLClient.client.cache.writeFragment({
fragment: ABOUT_WEBUI,
data: {
__typename: 'AboutWebUI',
channel: webUIUpdateStatusData!.getWebUIUpdateStatus.info.channel,
tag: webUIUpdateStatusData!.getWebUIUpdateStatus.info.tag,
},
});
requestManager.graphQLClient.client.cache.writeFragment({
fragment: WEBUI_UPDATE_CHECK,
data: {
__typename: 'WebUIUpdateCheck',
channel: webUIUpdateStatusData!.getWebUIUpdateStatus.info.channel,
tag: webUIUpdateStatusData!.getWebUIUpdateStatus.info.tag,
updateAvailable: false,
},
});
}, [webUIUpdateState]);
const isUpdateAvailable =
shouldCheckForUpdate && updateChecker.handleUpdate && webUIUpdateData?.checkForWebUIUpdate.updateAvailable;
if (isUpdateAvailable) {
const isUpdateInProgress = webUIUpdateState === UpdateState.Downloading;
return (
<VersionUpdateInfoDialog
info={t('settings.about.webui.label.info', {
version: webUIUpdateData?.checkForWebUIUpdate.tag,
channel: webUIUpdateData?.checkForWebUIUpdate.channel,
})}
changelogUrl={changelogUrl}
disabled={isUpdateInProgress}
onAction={() =>
requestManager
.updateWebUI()
.response.catch((e) =>
makeToast(t('settings.about.webui.label.update_failure'), 'error', getErrorMessage(e)),
)
}
actionTitle={
isUpdateInProgress
? t('global.update.label.updating', { progress: updateStatus.progress })
: t('extension.action.label.update')
}
updateCheckerProps={[
'webUI',
isAutoUpdateEnabled ? disabledUpdateCheck : checkForUpdate,
webUIUpdateData?.checkForWebUIUpdate.tag,
]}
/>
);
}
if (!open) {
return null;
}
return (
<Dialog open={open}>
<DialogTitle>{t('settings.about.webui.label.updated')}</DialogTitle>
<DialogContent>
<DialogContentText>
{t('global.update.label.update_success', {
name: t('settings.webui.title.webui'),
version: newVersion,
channel: aboutWebUI?.channel,
})}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button href={changelogUrl} target="_blank" rel="noreferrer">
{t('global.button.changelog')}
</Button>
<Button
onClick={() => {
setWebUIVersion(newVersion);
setOpen(false);
window.location.reload();
}}
variant="contained"
>
{t('global.button.refresh')}
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -0,0 +1,61 @@
/*
* 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 { useCallback, useEffect, useMemo } from 'react';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { useLocalStorage } from '@/features/core/hooks/useStorage.tsx';
const UPDATE_CHECK_INTERVAL = 1000 * 60 * 60; // 1 hour
const UPDATE_REMINDER_THRESHOLD = 1000 * 60 * 60; // 1 hour
export const useUpdateChecker = (
storageKey: string,
checkForUpdate: () => Promise<unknown>,
version?: string,
interval: number = UPDATE_CHECK_INTERVAL,
): { handleUpdate: boolean; ignoreUpdate: () => void; remindLater: () => void } => {
const [lastUpdateCheck, setLastUpdateCheck] = useLocalStorage(`UpdateChecker::${storageKey}::lastUpdateCheck`, 0);
const [ignoreVersionUpdate, setIgnoreVersionUpdate] = useLocalStorage<string>(
`UpdateChecker::${storageKey}::ignoreUpdate`,
);
const [updateClosedTimestamp, setUpdateClosedTimestamp] = useLocalStorage(
`UpdateChecker::${storageKey}::closeTimestamp`,
0,
);
useEffect(() => {
const remainingTimeTillNextUpdateCheck = (interval - (Date.now() - lastUpdateCheck)) % interval;
let timeout: NodeJS.Timeout | undefined;
const scheduleUpdateCheck = (timeoutMS: number) => {
timeout = setTimeout(() => {
checkForUpdate().catch(defaultPromiseErrorHandler(`UpdateChecker(${storageKey})::checkForUpdate`));
setLastUpdateCheck(Date.now());
scheduleUpdateCheck(interval);
}, timeoutMS);
};
scheduleUpdateCheck(remainingTimeTillNextUpdateCheck);
return () => clearTimeout(timeout);
}, [storageKey, checkForUpdate, interval]);
const ignoreUpdate = useCallback(() => {
setIgnoreVersionUpdate(version);
}, [storageKey, version]);
const remindLater = useCallback(() => {
setUpdateClosedTimestamp(Date.now());
}, [storageKey]);
const wasRecentlyClosed = Date.now() - updateClosedTimestamp < UPDATE_REMINDER_THRESHOLD;
const wasUpdateIgnored = !!ignoreVersionUpdate && ignoreVersionUpdate === version;
const handleUpdate = !wasRecentlyClosed && !wasUpdateIgnored;
return useMemo(() => ({ handleUpdate, ignoreUpdate, remindLater }), [handleUpdate, ignoreUpdate, remindLater]);
};

View File

@@ -0,0 +1,385 @@
/*
* 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 { useEffect, useRef, useState } from 'react';
import List from '@mui/material/List';
import ListItemText from '@mui/material/ListItemText';
import { fromEvent } from 'file-selector';
import { useTranslation } from 'react-i18next';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListSubheader from '@mui/material/ListSubheader';
import { t as translate } from 'i18next';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import ListItem from '@mui/material/ListItem';
import { Link } from 'react-router-dom';
import Stack from '@mui/material/Stack';
import { useEventListener, useMergedRef, useWindowEvent } from '@mantine/hooks';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { makeToast } from '@/features/core/utils/Toast.ts';
import { BackupRestoreState, ValidateBackupQuery } from '@/lib/graphql/generated/graphql.ts';
import { Progress } from '@/features/core/components/feedback/Progress.tsx';
import { TextSetting } from '@/features/core/components/settings/text/TextSetting.tsx';
import { NumberSetting } from '@/features/core/components/settings/NumberSetting.tsx';
import { TimeSetting } from '@/features/core/components/settings/TimeSetting.tsx';
import { LoadingPlaceholder } from '@/features/core/components/feedback/LoadingPlaceholder.tsx';
import { EmptyViewAbsoluteCentered } from '@/features/core/components/feedback/EmptyViewAbsoluteCentered.tsx';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { ServerSettings } from '@/features/settings/Settings.types.ts';
import { AppRoutes } from '@/features/core/AppRoute.constants.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { useAppTitle } from '@/features/navigation-bar/hooks/useAppTitle.ts';
import { BrowseTab } from '@/features/browse/Browse.types.ts';
type BackupSettingsType = Pick<ServerSettings, 'backupPath' | 'backupTime' | 'backupInterval' | 'backupTTL'>;
const extractBackupSettings = (settings: ServerSettings): BackupSettingsType => ({
backupPath: settings.backupPath,
backupTime: settings.backupTime,
backupInterval: settings.backupInterval,
backupTTL: settings.backupTTL,
});
const getBackupCleanupDisplayValue = (ttl: number): string => {
if (ttl === 0) {
return translate('global.label.never');
}
return translate('settings.backup.automated.cleanup.label.value', { days: ttl, count: ttl });
};
let backupRestoreId: string | undefined;
export function Backup() {
const { t } = useTranslation();
useAppTitle(t('settings.backup.title'));
const {
data: settingsData,
loading,
error,
refetch,
} = requestManager.useGetServerSettings({ notifyOnNetworkStatusChange: true });
const [mutateSettings] = requestManager.useUpdateServerSettings();
const { data } = requestManager.useGetBackupRestoreStatus(backupRestoreId ?? '', {
skip: !backupRestoreId,
pollInterval: 1000,
});
const [currentBackupFile, setCurrentBackupFile] = useState<File | null>(null);
const [isInvalidBackupDialogOpen, setIsInvalidBackupDialogOpen] = useState(false);
const [validationResult, setValidationResult] = useState<ValidateBackupQuery['validateBackup']>();
const [, setTriggerReRender] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const restoreProgress = (() => {
if (!data?.restoreStatus) {
return 0;
}
const progress = 100 * (data.restoreStatus.mangaProgress / data.restoreStatus.totalManga);
return Number.isNaN(progress) ? 0 : progress;
})();
const updateSetting = <Setting extends keyof BackupSettingsType>(
setting: Setting,
value: BackupSettingsType[Setting],
) => {
mutateSettings({ variables: { input: { settings: { [setting]: value } } } }).catch((e) =>
makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)),
);
};
useEffect(() => {
if (!data?.restoreStatus) {
return;
}
const isSuccess = data.restoreStatus.state === BackupRestoreState.Success;
const isFailure = data.restoreStatus.state === BackupRestoreState.Failure;
const isRestoreFinished = isSuccess || isFailure;
if (isRestoreFinished) {
if (isSuccess) {
makeToast(t('settings.backup.action.restore.label.success'), 'success');
}
if (isFailure) {
makeToast(t('settings.backup.action.restore.error.label.failure'), 'error');
}
requestManager.reset();
backupRestoreId = undefined;
setTriggerReRender(Date.now());
}
}, [data?.restoreStatus?.state]);
const resetBackupState = () => {
setCurrentBackupFile(null);
const input = document.getElementById('backup-file') as HTMLInputElement;
if (input) {
input.value = '';
}
};
const validateBackup = async (file: File) => {
try {
const {
data: { validateBackup: validateBackupData },
} = await requestManager.validateBackupFile(file, { fetchPolicy: 'network-only' }).response;
if (validateBackupData.missingSources.length || validateBackupData.missingTrackers.length) {
setValidationResult(validateBackupData);
setIsInvalidBackupDialogOpen(true);
return false;
}
return true;
} catch (e) {
makeToast(t('settings.backup.action.validate.error.label.failure'), 'error', getErrorMessage(e));
resetBackupState();
}
return false;
};
const restoreBackup = async (file: File) => {
try {
makeToast(t('settings.backup.action.restore.label.in_progress'), 'info');
const response = await requestManager.restoreBackupFile(file).response;
backupRestoreId = response.data?.restoreBackup.id;
setTriggerReRender(Date.now());
} catch (e) {
makeToast(t('settings.backup.action.restore.error.label.failure'), 'error', getErrorMessage(e));
} finally {
resetBackupState();
}
};
const submitBackup = async (file: File) => {
if (file.name.toLowerCase().endsWith('json')) {
makeToast(t('settings.backup.action.restore.error.label.legacy_backup_unsupported'), 'error');
return;
}
const isValidFilename = file.name.toLowerCase().match(/proto\.gz$|tachibk$/g);
if (!isValidFilename) {
makeToast(t('global.error.label.invalid_file_type'), 'error');
return;
}
setCurrentBackupFile(file);
const isBackupValid = await validateBackup(file);
if (isBackupValid) {
await restoreBackup(file);
}
};
const closeInvalidBackupDialog = () => {
setIsInvalidBackupDialogOpen(false);
resetBackupState();
};
useWindowEvent('drop', async (e) => {
e.preventDefault();
const files = await fromEvent(e);
submitBackup(files[0] as File);
});
useWindowEvent('dragover', (e) => {
e.preventDefault();
});
const inputEventListenerRef = useEventListener('change', async (event) => {
const files = await fromEvent(event);
submitBackup(files[0] as File);
});
const mergedInputRef = useMergedRef(inputRef, inputEventListenerRef);
if (loading) {
return <LoadingPlaceholder />;
}
if (error) {
return (
<EmptyViewAbsoluteCentered
message={t('global.error.label.failed_to_load_data')}
messageExtra={getErrorMessage(error)}
retry={() => refetch().catch(defaultPromiseErrorHandler('Backup::refetch'))}
/>
);
}
const backupSettings = extractBackupSettings(settingsData!.settings);
return (
<>
<List sx={{ padding: 0 }}>
<ListItemButton component="a" href={requestManager.getExportBackupUrl()} download>
<ListItemText
primary={t('settings.backup.action.create.label.title')}
secondary={t('settings.backup.action.create.label.description')}
/>
</ListItemButton>
<ListItemButton onClick={() => inputRef.current?.click()} disabled={!!backupRestoreId}>
<ListItemText
primary={t('settings.backup.action.restore.label.title')}
secondary={t('settings.backup.action.restore.label.description')}
/>
{backupRestoreId ? (
<ListItemIcon>
<Progress progress={restoreProgress} />
</ListItemIcon>
) : null}
</ListItemButton>
<List
subheader={
<ListSubheader component="div" id="backup-settings">
Automated backup
</ListSubheader>
}
>
<TextSetting
settingName={t('settings.backup.automated.location.label.title')}
dialogDescription={t('settings.backup.automated.location.label.description')}
value={backupSettings.backupPath}
settingDescription={
backupSettings.backupPath.length ? backupSettings.backupPath : t('global.label.default')
}
handleChange={(path) => updateSetting('backupPath', path)}
/>
<TimeSetting
settingName={t('settings.backup.automated.label.time')}
value={backupSettings.backupTime}
defaultValue="00:00"
handleChange={(time: string) => updateSetting('backupTime', time)}
/>
<NumberSetting
settingTitle={t('settings.backup.automated.label.interval')}
settingValue={t('global.date.value.label.day', {
days: backupSettings.backupInterval,
count: backupSettings.backupInterval,
})}
value={backupSettings.backupInterval}
defaultValue={1}
minValue={1}
maxValue={31}
stepSize={1}
valueUnit={t('global.date.label.day_one')}
showSlider
handleUpdate={(interval: number) => updateSetting('backupInterval', interval)}
/>
<NumberSetting
settingTitle={t('settings.backup.automated.cleanup.label.title')}
settingValue={getBackupCleanupDisplayValue(backupSettings.backupTTL)}
value={backupSettings.backupTTL}
defaultValue={14}
minValue={0}
maxValue={1000}
stepSize={1}
valueUnit={t('global.date.label.day_one')}
showSlider
handleUpdate={(ttl: number) => updateSetting('backupTTL', ttl)}
/>
</List>
</List>
<input ref={mergedInputRef} type="file" style={{ display: 'none' }} />
<Dialog open={isInvalidBackupDialogOpen}>
<DialogTitle>{t('settings.backup.action.validate.dialog.title')}</DialogTitle>
<DialogContent dividers>
{!!validationResult?.missingSources.length && (
<List
sx={{ listStyleType: 'initial', listStylePosition: 'inside' }}
subheader={t('settings.backup.action.validate.dialog.content.label.missing_sources')}
>
{validationResult?.missingSources.map(({ id, name }) => (
<ListItem sx={{ display: 'list-item' }} key={id}>
{`${name} (${id})`}
</ListItem>
))}
</List>
)}
{!!validationResult?.missingTrackers.length && (
<List
sx={{ listStyleType: 'initial', listStylePosition: 'inside' }}
subheader={t('settings.backup.action.validate.dialog.content.label.missing_trackers')}
>
{validationResult?.missingTrackers.map(({ name }) => (
<ListItem sx={{ display: 'list-item' }} key={name}>
{`${name}`}
</ListItem>
))}
</List>
)}
</DialogContent>
<DialogActions>
<Stack
direction="row"
sx={{
justifyContent: 'space-between',
width: '100%',
}}
>
{!!validationResult?.missingSources.length && (
<Button
onClick={closeInvalidBackupDialog}
component={Link}
to={AppRoutes.browse.path(BrowseTab.EXTENSIONS)}
autoFocus={!!validationResult?.missingSources.length}
variant={validationResult?.missingSources.length ? 'contained' : 'text'}
>
{t('extension.action.label.install')}
</Button>
)}
{!!validationResult?.missingTrackers.length && (
<Button
onClick={closeInvalidBackupDialog}
component={Link}
to={AppRoutes.tracker.path}
autoFocus={!!validationResult?.missingTrackers.length}
variant={validationResult?.missingTrackers.length ? 'contained' : 'text'}
>
{t('global.button.log_in')}
</Button>
)}
<Stack direction="row">
<Button onClick={closeInvalidBackupDialog}>{t('global.button.cancel')}</Button>
<Button
onClick={() => {
closeInvalidBackupDialog();
restoreBackup(currentBackupFile!);
}}
autoFocus={
!validationResult?.missingSources.length &&
!validationResult?.missingTrackers.length
}
variant={
!validationResult?.missingSources.length &&
!validationResult?.missingTrackers.length
? 'contained'
: 'text'
}
>
{t('global.button.restore')}
</Button>
</Stack>
</Stack>
</DialogActions>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,25 @@
/*
* 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 { SourceIdInfo } from '@/features/source/Source.types.ts';
export type MetadataBrowseSettings = {
hideLibraryEntries: boolean;
extensionLanguages: string[];
sourceLanguages: string[];
showNsfw: boolean;
lastUsedSourceId: SourceIdInfo['id'] | null;
shouldShowOnlySourcesWithResults: boolean;
};
export enum BrowseTab {
SOURCE_DEPRECATED = 'source',
SOURCES = 'sources',
EXTENSIONS = 'extensions',
MIGRATE = 'migrate',
}

View File

@@ -0,0 +1,70 @@
/*
* 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 { useCallback, useRef, useState } from 'react';
import Tab from '@mui/material/Tab';
import { useTranslation } from 'react-i18next';
import { StringParam, useQueryParam } from 'use-query-params';
import { Sources } from '@/features/source/screens/Sources.tsx';
import { Extensions } from '@/features/extension/screens/Extensions.tsx';
import { TabPanel } from '@/features/core/components/tabs/TabPanel.tsx';
import { TabsWrapper } from '@/features/core/components/tabs/TabsWrapper.tsx';
import { TabsMenu } from '@/features/core/components/tabs/TabsMenu.tsx';
import { Migration } from '@/features/migration/screens/Migration.tsx';
import { useResizeObserver } from '@/features/core/hooks/useResizeObserver.tsx';
import { useAppTitle } from '@/features/navigation-bar/hooks/useAppTitle.ts';
import { BrowseTab } from '@/features/browse/Browse.types.ts';
import { GROUPED_VIRTUOSO_Z_INDEX } from '@/lib/virtuoso/Virtuoso.constants.ts';
import { SearchParam } from '@/features/core/Core.types.ts';
export function Browse() {
const { t } = useTranslation();
useAppTitle(t('global.label.browse'));
const tabsMenuRef = useRef<HTMLDivElement | null>(null);
const [tabsMenuHeight, setTabsMenuHeight] = useState(0);
useResizeObserver(
tabsMenuRef,
useCallback(() => setTabsMenuHeight(tabsMenuRef.current!.offsetHeight), [tabsMenuRef.current]),
);
const [tabSearchParam, setTabSearchParam] = useQueryParam(SearchParam.TAB, StringParam, {});
const tabName = (tabSearchParam as BrowseTab) ?? BrowseTab.SOURCES;
if (!tabSearchParam) {
setTabSearchParam(tabName, 'replaceIn');
}
return (
<TabsWrapper>
<TabsMenu
ref={tabsMenuRef}
sx={{ zIndex: GROUPED_VIRTUOSO_Z_INDEX }}
variant="fullWidth"
value={tabName}
onChange={(_, newTab) => setTabSearchParam(newTab, 'replaceIn')}
>
<Tab value={BrowseTab.SOURCES} sx={{ textTransform: 'none' }} label={t('source.title_other')} />
<Tab value={BrowseTab.EXTENSIONS} sx={{ textTransform: 'none' }} label={t('extension.title_other')} />
<Tab value={BrowseTab.MIGRATE} sx={{ textTransform: 'none' }} label={t('migrate.title')} />
</TabsMenu>
<TabPanel index={BrowseTab.SOURCE_DEPRECATED} currentIndex={tabName}>
<Sources tabsMenuHeight={tabsMenuHeight} />
</TabPanel>
<TabPanel index={BrowseTab.SOURCES} currentIndex={tabName}>
<Sources tabsMenuHeight={tabsMenuHeight} />
</TabPanel>
<TabPanel index={BrowseTab.EXTENSIONS} currentIndex={tabName}>
<Extensions tabsMenuHeight={tabsMenuHeight} />
</TabPanel>
<TabPanel index={BrowseTab.MIGRATE} currentIndex={tabName}>
<Migration tabsMenuHeight={tabsMenuHeight} />
</TabPanel>
</TabsWrapper>
);
}

View File

@@ -0,0 +1,154 @@
/*
* 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 { Trans, useTranslation } from 'react-i18next';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText';
import Switch from '@mui/material/Switch';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { NumberSetting } from '@/features/core/components/settings/NumberSetting.tsx';
import { MutableListSetting } from '@/features/core/components/settings/MutableListSetting.tsx';
import { TextSetting } from '@/features/core/components/settings/text/TextSetting.tsx';
import {
createUpdateMetadataServerSettings,
useMetadataServerSettings,
} from '@/features/settings/services/ServerSettingsMetadata.ts';
import { LoadingPlaceholder } from '@/features/core/components/feedback/LoadingPlaceholder.tsx';
import { EmptyViewAbsoluteCentered } from '@/features/core/components/feedback/EmptyViewAbsoluteCentered.tsx';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { makeToast } from '@/features/core/utils/Toast.ts';
import { MetadataBrowseSettings } from '@/features/browse/Browse.types.ts';
import { ServerSettings as GqlServerSettings } from '@/features/settings/Settings.types.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { useAppTitle } from '@/features/navigation-bar/hooks/useAppTitle.ts';
type ExtensionsSettings = Pick<GqlServerSettings, 'maxSourcesInParallel' | 'localSourcePath' | 'extensionRepos'>;
const extractBrowseSettings = (settings: GqlServerSettings): ExtensionsSettings => ({
maxSourcesInParallel: settings.maxSourcesInParallel,
localSourcePath: settings.localSourcePath,
extensionRepos: settings.extensionRepos,
});
export const BrowseSettings = () => {
const { t } = useTranslation();
useAppTitle(t('global.label.browse'));
const { data, loading, error, refetch } = requestManager.useGetServerSettings({
notifyOnNetworkStatusChange: true,
});
const [mutateSettings] = requestManager.useUpdateServerSettings();
const updateSetting = <Setting extends keyof ExtensionsSettings>(
setting: Setting,
value: ExtensionsSettings[Setting],
) => {
mutateSettings({ variables: { input: { settings: { [setting]: value } } } }).catch((e) =>
makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)),
);
};
const {
settings: { hideLibraryEntries, showNsfw },
} = useMetadataServerSettings();
const updateMetadataServerSettings = createUpdateMetadataServerSettings<keyof MetadataBrowseSettings>((e) =>
makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)),
);
if (loading) {
return <LoadingPlaceholder />;
}
if (error) {
return (
<EmptyViewAbsoluteCentered
message={t('global.error.label.failed_to_load_data')}
messageExtra={getErrorMessage(error)}
retry={() => refetch().catch(defaultPromiseErrorHandler('BrowseSettings::refetch'))}
/>
);
}
const serverSettings = extractBrowseSettings(data!.settings);
return (
<List sx={{ pt: 0 }}>
<ListItem>
<ListItemText primary={t('settings.label.hide_library_entries')} />
<Switch
edge="end"
checked={hideLibraryEntries}
onChange={() => updateMetadataServerSettings('hideLibraryEntries', !hideLibraryEntries)}
/>
</ListItem>
<ListItem>
<ListItemText
primary={t('settings.label.show_nsfw')}
secondary={t('settings.label.show_nsfw_description')}
/>
<Switch
edge="end"
checked={showNsfw}
onChange={() => updateMetadataServerSettings('showNsfw', !showNsfw)}
/>
</ListItem>
<NumberSetting
settingTitle={t('settings.server.requests.sources.parallel.label.title')}
settingValue={t('settings.server.requests.sources.parallel.label.value', {
value: serverSettings.maxSourcesInParallel,
count: serverSettings.maxSourcesInParallel,
})}
valueUnit={t('source.title_one')}
value={serverSettings.maxSourcesInParallel}
defaultValue={6}
minValue={1}
maxValue={20}
showSlider
stepSize={1}
handleUpdate={(parallelSources) => updateSetting('maxSourcesInParallel', parallelSources)}
/>
<MutableListSetting
settingName={t('extension.settings.repositories.custom.label.title')}
description={t('extension.settings.repositories.custom.label.description')}
dialogDisclaimer={
<Trans i18nKey="extension.settings.repositories.custom.label.disclaimer">
<strong>Suwayomi does not provide any support for 3rd party repositories or extensions!</strong>
<br />
Use with caution as there could be malicious actors making those repositories.
<br />
You as the user need to verify the security and that you trust any repository or extension.
</Trans>
}
handleChange={(repos) => {
updateSetting('extensionRepos', repos);
requestManager.clearExtensionCache();
}}
valueInfos={serverSettings.extensionRepos.map((extensionRepo) => [extensionRepo])}
addItemButtonTitle={t('extension.settings.repositories.custom.dialog.action.button.add')}
placeholder="https://github.com/MY_ACCOUNT/MY_REPO/tree/repo"
validateItem={(repo) =>
!!repo.match(
/https:\/\/(www\.|raw\.)?(github|githubusercontent)\.com\/([^/]+)\/([^/]+)((\/tree|\/blob)?\/([^/\n]*))?(\/([^/\n]*\.json)?)?/g,
)
}
invalidItemError={t('extension.settings.repositories.custom.error.label.invalid_url')}
/>
<TextSetting
settingName={t('settings.server.local_source.path.label.title')}
dialogDescription={t('settings.server.local_source.path.label.description')}
value={serverSettings.localSourcePath}
settingDescription={
serverSettings.localSourcePath.length ? serverSettings.localSourcePath : t('global.label.default')
}
handleChange={(path) => updateSetting('localSourcePath', path)}
/>
</List>
);
};

View File

@@ -0,0 +1,9 @@
/*
* 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/.
*/
export const CREATE_NEW_CATEGORY_ID = -1;

View File

@@ -0,0 +1,21 @@
/*
* 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 { LibraryOptions } from '@/features/library/Library.types.ts';
import { CategoryMetaType, CategoryType } from '@/lib/graphql/generated/graphql.ts';
export interface ICategoryMetadata extends LibraryOptions {}
export type CategoryMetadataKeys = keyof ICategoryMetadata;
export type CategoryIdInfo = Pick<CategoryType, 'id'>;
export type CategoryNameInfo = Pick<CategoryType, 'name'>;
export type CategoryDefaultInfo = Pick<CategoryType, 'default'>;
export type CategoryUpdateInclusionInfo = Pick<CategoryType, 'includeInUpdate'>;
export type CategoryDownloadInclusionInfo = Pick<CategoryType, 'includeInDownload'>;
export type CategoryMetadataInfo = CategoryIdInfo & { meta: Pick<CategoryMetaType, 'key' | 'value'>[] };

View File

@@ -0,0 +1,219 @@
/*
* 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 { useEffect, useState } from 'react';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContentText from '@mui/material/DialogContentText';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import { t as translate } from 'i18next';
import { ThreeStateCheckboxInput } from '@/features/core/components/inputs/ThreeStateCheckboxInput.tsx';
import { makeToast } from '@/features/core/utils/Toast.ts';
import { IncludeOrExclude } from '@/lib/graphql/generated/graphql.ts';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { CheckboxContainer } from '@/features/core/components/inputs/CheckboxContainer.ts';
import {
CategoryDownloadInclusionInfo,
CategoryIdInfo,
CategoryNameInfo,
CategoryUpdateInclusionInfo,
} from '@/features/category/Category.types.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
type CategoryType = CategoryIdInfo & CategoryNameInfo & CategoryUpdateInclusionInfo & CategoryDownloadInclusionInfo;
const booleanToIncludeOrExcludeStatus = (status: boolean | null | undefined): IncludeOrExclude => {
switch (status) {
case false:
return IncludeOrExclude.Exclude;
case true:
return IncludeOrExclude.Include;
case null:
case undefined:
return IncludeOrExclude.Unset;
default:
throw new Error(`booleanToIncludeInStatus: unexpected IncludeOrExclude status "${status}"`);
}
};
const includeInUpdateStatusToBoolean = (status: IncludeOrExclude): boolean | null => {
switch (status) {
case IncludeOrExclude.Exclude:
return false;
case IncludeOrExclude.Include:
return true;
case IncludeOrExclude.Unset:
return null;
default:
throw new Error(`includeInUpdateStatusToBoolean: unexpected IncludeOrExclude status "${status}"`);
}
};
const getCategoryUpdateInfo = (
categories: CategoryType[],
areIncluded: boolean,
unsetCategories: number,
allCategories: number,
) => {
const noSpecificallyIncludedCategories = areIncluded && !categories.length && unsetCategories;
const includesAllCategories = categories.length === allCategories;
if (noSpecificallyIncludedCategories || includesAllCategories) {
return translate('extension.language.all');
}
if (!categories.length) {
return translate('global.label.none');
}
return categories.map((category) => category.name).join(', ');
};
type CategoryIncludeField = keyof Pick<CategoryType, 'includeInUpdate' | 'includeInDownload'>;
export type CategoriesInclusionSettingProps = {
categories: CategoryType[];
includeField: CategoryIncludeField;
dialogText?: string;
};
export const CategoriesInclusionSetting = ({
categories,
includeField,
dialogText,
}: CategoriesInclusionSettingProps) => {
const { t } = useTranslation();
const [dialogCategories, setDialogCategories] = useState(categories);
const [isDialogOpen, setIsDialogOpen] = useState(false);
useEffect(() => {
if (!categories) {
return;
}
setDialogCategories(categories);
}, [categories]);
const unsetCategories = categories.filter((category) => category[includeField] === IncludeOrExclude.Unset);
const excludedCategories = categories.filter((category) => category[includeField] === IncludeOrExclude.Exclude);
const includedCategories = categories.filter((category) => category[includeField] === IncludeOrExclude.Include);
const excludedCategoriesText = getCategoryUpdateInfo(
excludedCategories,
false,
unsetCategories.length,
categories.length,
);
const includedCategoriesText = getCategoryUpdateInfo(
includedCategories,
true,
unsetCategories.length,
categories.length,
);
const updateCategory = (category: CategoryType) =>
requestManager.updateCategory(category.id, { [includeField]: category[includeField] }).response;
const updateCategories = async () => {
const categoriesToUpdate = dialogCategories.filter((category) => {
const currentCategory = categories?.find((currCategory) => currCategory.id === category.id);
if (!currentCategory) {
return false;
}
return currentCategory[includeField] !== category[includeField];
});
setIsDialogOpen(false);
try {
await Promise.all(categoriesToUpdate.map((category) => updateCategory(category)));
// TODO - update cache immediately
// mutate(categoriesEndpoint, [...dialogCategories], { revalidate: false });
} catch (e) {
makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e));
// mutate(categoriesEndpoint, [...categories]);
}
};
const closeDialog = () => {
setDialogCategories(categories);
setIsDialogOpen(false);
};
return (
<>
<ListItemButton onClick={() => setIsDialogOpen(true)}>
<ListItemText
primary={t('category.title.category_other')}
secondary={
<>
<span>
{t('category.settings.inclusion.label.include', {
includedCategoriesText,
})}
</span>
<span>
{t('category.settings.inclusion.label.exclude', {
excludedCategoriesText,
})}
</span>
</>
}
secondaryTypographyProps={{ style: { display: 'flex', flexDirection: 'column' } }}
/>
</ListItemButton>
<Dialog open={isDialogOpen} onClose={closeDialog}>
<DialogTitle>{t('category.title.category_other')}</DialogTitle>
<DialogContent>
{dialogText && <DialogContentText sx={{ paddingBottom: '10px' }}>{dialogText}</DialogContentText>}
<CheckboxContainer>
{dialogCategories.map((category) => (
<ThreeStateCheckboxInput
key={category.id}
label={category.name}
checked={includeInUpdateStatusToBoolean(category[includeField])}
onChange={(checked) => {
const newIncludeState = booleanToIncludeOrExcludeStatus(checked);
const categoryIndex = dialogCategories.findIndex(
(category_) => category_ === category,
);
const updatedDialogCategories = [
...dialogCategories.slice(0, categoryIndex),
{
...category,
[includeField]: newIncludeState,
},
...dialogCategories.slice(categoryIndex + 1, dialogCategories.length),
];
setDialogCategories(updatedDialogCategories);
}}
/>
))}
</CheckboxContainer>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog} color="primary">
{t('global.button.cancel')}
</Button>
<Button onClick={updateCategories} color="primary">
{t('global.button.ok')}
</Button>
</DialogActions>
</Dialog>
</>
);
};

View File

@@ -0,0 +1,255 @@
/*
* 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, useMemo } 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 FormGroup from '@mui/material/FormGroup';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import Stack from '@mui/material/Stack';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { Mangas } from '@/features/manga/services/Mangas.ts';
import { useSelectableCollection } from '@/features/collection/hooks/useSelectableCollection.ts';
import { ThreeStateCheckboxInput } from '@/features/core/components/inputs/ThreeStateCheckboxInput.tsx';
import { Categories } from '@/features/category/services/Categories.ts';
import { CheckboxInput } from '@/features/core/components/inputs/CheckboxInput.tsx';
import { makeToast } from '@/features/core/utils/Toast.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { updateMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
import {
GetCategoriesBaseQuery,
GetCategoriesBaseQueryVariables,
GetMangaCategoriesQuery,
GetMangaCategoriesQueryVariables,
} from '@/lib/graphql/generated/graphql.ts';
import { GET_CATEGORIES_BASE } from '@/lib/graphql/queries/CategoryQuery.ts';
import { GET_MANGA_CATEGORIES } from '@/lib/graphql/queries/MangaQuery.ts';
import { AppRoutes } from '@/features/core/AppRoute.constants.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
type BaseProps = {
open: boolean;
onClose: (didUpdateCategories: boolean, addToCategories?: number[], removeFromCategories?: number[]) => void;
};
type SingleMangaModeProps = {
mangaId: number;
addToLibrary?: boolean;
};
type MultiMangaModeProps = {
mangaIds: number[];
};
export type CategorySelectProps =
| (BaseProps & SingleMangaModeProps & PropertiesNever<MultiMangaModeProps>)
| (BaseProps & PropertiesNever<SingleMangaModeProps> & MultiMangaModeProps);
const useGetMangaCategoryIds = (mangaId: number | undefined): number[] => {
const { data: mangaResult } = requestManager.useGetManga<GetMangaCategoriesQuery, GetMangaCategoriesQueryVariables>(
GET_MANGA_CATEGORIES,
mangaId ?? -1,
{ skip: mangaId === undefined },
);
return useMemo(() => {
if (mangaId === undefined || !mangaResult) {
return [];
}
return Categories.getIds(mangaResult.manga.categories.nodes);
}, [mangaResult?.manga.categories.nodes, mangaId]);
};
const getCategoryCheckedState = (
categoryId: number,
categoriesToAdd: number[],
categoriesToRemove: number[],
isSingleSelectionMode: boolean,
): boolean | undefined => {
if (categoriesToAdd.includes(categoryId)) {
return true;
}
if (isSingleSelectionMode) {
return undefined;
}
if (categoriesToRemove.includes(categoryId)) {
return false;
}
return undefined;
};
export function CategorySelect(props: CategorySelectProps) {
const { t } = useTranslation();
const { open, onClose, mangaId, mangaIds: passedMangaIds, addToLibrary = false } = props;
const isSingleSelectionMode = mangaId !== undefined;
const mangaIds = passedMangaIds ?? [mangaId];
const [doNotShowAddToLibraryDialogAgain, setDoNotShowAddToLibraryDialogAgain] = useState(false);
const mangaCategoryIds = useGetMangaCategoryIds(mangaId);
const { data } = requestManager.useGetCategories<GetCategoriesBaseQuery, GetCategoriesBaseQueryVariables>(
GET_CATEGORIES_BASE,
);
const categoriesData = data?.categories.nodes;
const allCategories = useMemo(() => Categories.getUserCreated(categoriesData ?? []), [categoriesData]);
const defaultCategoryIds = useMemo(
() => (addToLibrary ? Categories.getIds(Categories.getDefaults(allCategories)) : []),
[allCategories],
);
const { handleSelection, setSelectionForKey, getSelectionForKey } = useSelectableCollection<
number,
'categoriesToAdd' | 'categoriesToRemove'
>(allCategories.length, {
currentKey: 'categoriesToAdd',
initialState: {
categoriesToAdd: [...mangaCategoryIds, ...defaultCategoryIds],
categoriesToRemove: [],
},
});
useEffect(() => {
setSelectionForKey('categoriesToAdd', [...mangaCategoryIds, ...defaultCategoryIds]);
setSelectionForKey('categoriesToRemove', []);
}, [mangaCategoryIds]);
const categoriesToAdd = getSelectionForKey('categoriesToAdd');
const categoriesToRemove = getSelectionForKey('categoriesToRemove');
const handleCancel = () => {
setSelectionForKey('categoriesToAdd', mangaCategoryIds);
setSelectionForKey('categoriesToRemove', []);
onClose(false);
};
const handleOk = () => {
const addToCategories = isSingleSelectionMode
? categoriesToAdd.filter((categoryId) => !mangaCategoryIds.includes(categoryId))
: categoriesToAdd;
const removeFromCategories = isSingleSelectionMode
? mangaCategoryIds.filter((categoryId) => !categoriesToAdd.includes(categoryId))
: categoriesToRemove;
onClose(true, addToCategories, removeFromCategories);
if (doNotShowAddToLibraryDialogAgain) {
updateMetadataServerSettings('showAddToLibraryCategorySelectDialog', false).catch((e) =>
makeToast(t('search.error.label.failed_to_save_settings'), 'error', getErrorMessage(e)),
);
}
const isUpdateRequired = !!addToCategories.length || !!removeFromCategories.length;
if (!isUpdateRequired) {
return;
}
if (addToLibrary) {
// categories get updated in MangaDetails
return;
}
Mangas.performAction('change_categories', mangaIds, {
changeCategoriesPatch: {
addToCategories,
removeFromCategories,
},
}).catch(defaultPromiseErrorHandler('CategorySelect::handleOk'));
};
return (
<Dialog
sx={{
'.MuiDialog-paper': {
maxHeight: 435,
width: '80%',
},
}}
maxWidth="xs"
open={open}
onClose={handleCancel}
>
<DialogTitle>{t('category.title.set_categories')}</DialogTitle>
<DialogContent dividers>
<FormGroup>
{allCategories.length === 0 && <span>{t('category.error.no_categories_found.label.info')}</span>}
{allCategories.map((category) => (
<ThreeStateCheckboxInput
checked={getCategoryCheckedState(
category.id,
categoriesToAdd,
categoriesToRemove,
isSingleSelectionMode,
)}
onChange={(checked) => {
handleSelection(category.id, false, { key: 'categoriesToAdd' });
handleSelection(category.id, false, { key: 'categoriesToRemove' });
if (checked) {
handleSelection(category.id, true, { key: 'categoriesToAdd' });
}
if (checked === false) {
handleSelection(category.id, true, { key: 'categoriesToRemove' });
}
}}
label={category.name}
key={category.id}
/>
))}
</FormGroup>
</DialogContent>
<DialogActions>
<Stack sx={{ width: '100%' }}>
{addToLibrary && (
<CheckboxInput
sx={{ margin: 0 }}
size="small"
label={t('global.button.dont_show_dialog_again')}
onChange={(e) => setDoNotShowAddToLibraryDialogAgain(e.target.checked)}
/>
)}
<Stack
direction="row"
sx={{
justifyContent: 'space-between',
alignItems: 'end',
width: '100%',
}}
>
<Button component={Link} to={AppRoutes.settings.childRoutes.categories.path}>
{t(allCategories.length ? 'global.button.edit' : 'global.button.create')}
</Button>
<Stack direction="row">
<Button autoFocus onClick={handleCancel} color="primary">
{t('global.button.cancel')}
</Button>
{!!allCategories.length && (
<Button onClick={handleOk} color="primary">
{t('global.button.ok')}
</Button>
)}
</Stack>
</Stack>
</Stack>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,60 @@
/*
* 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 DragHandleIcon from '@mui/icons-material/DragHandle';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import { useTranslation } from 'react-i18next';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { CategoryType } from '@/lib/graphql/generated/graphql.ts';
import { ListCardContent } from '@/features/core/components/lists/cards/ListCardContent.tsx';
export const CategorySettingsCard = ({
category,
onEdit,
}: {
category: Pick<CategoryType, 'id' | 'name'>;
onEdit: () => void;
}) => {
const { t } = useTranslation();
const deleteCategory = () => {
requestManager.deleteCategory(category.id);
};
return (
<Box sx={{ p: 1, pb: 0 }}>
<Card>
<ListCardContent sx={{ gap: 2 }}>
<DragHandleIcon />
<Typography sx={{ flexGrow: 1 }} variant="h6" component="h2">
{category.name}
</Typography>
<Stack sx={{ flexDirection: 'row' }}>
<CustomTooltip title={t('global.button.edit')}>
<IconButton component={Box} onClick={onEdit}>
<EditIcon />
</IconButton>
</CustomTooltip>
<CustomTooltip title={t('chapter.action.download.delete.label.action')}>
<IconButton component={Box} onClick={deleteCategory}>
<DeleteIcon />
</IconButton>
</CustomTooltip>
</Stack>
</ListCardContent>
</Card>
</Box>
);
};

View File

@@ -0,0 +1,95 @@
/*
* 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 } from 'react';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
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 Checkbox from '@mui/material/Checkbox';
import FormControlLabel from '@mui/material/FormControlLabel';
import { useTranslation } from 'react-i18next';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { CategoryDefaultInfo, CategoryIdInfo, CategoryNameInfo } from '@/features/category/Category.types.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { makeToast } from '@/features/core/utils/Toast.ts';
import { assertIsDefined } from '@/Asserts.ts';
export const CreateOrEditCategoryDialog = ({
category,
onClose,
}: {
category: (CategoryIdInfo & CategoryNameInfo & CategoryDefaultInfo) | undefined;
onClose: () => void;
}) => {
const isEditMode = !!category;
const { t } = useTranslation();
const [dialogName, setDialogName] = useState(category?.name);
const [dialogDefault, setDialogDefault] = useState(!!category?.default);
const isInvalidName = dialogName !== undefined && !dialogName.trim().length;
const canSubmit = dialogName !== undefined && !isInvalidName;
const handleDialogSubmit = () => {
assertIsDefined(dialogName);
onClose();
if (isEditMode) {
requestManager
.updateCategory(category.id, { name: dialogName, default: dialogDefault })
.response.catch((e) =>
makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)),
);
return;
}
requestManager
.createCategory({ name: dialogName, default: dialogDefault })
.response.catch((e) => makeToast(t('category.error.label.create_failure'), 'error', getErrorMessage(e)));
};
return (
<Dialog open onClose={onClose}>
<DialogTitle id="form-dialog-title">
{isEditMode ? t('category.dialog.title.edit_category_one') : t('category.dialog.title.new_category')}
</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
id="name"
label={t('category.label.category_name')}
type="text"
fullWidth
value={dialogName}
onChange={(e) => setDialogName(e.target.value.trim())}
error={isInvalidName}
helperText={isInvalidName ? t`global.error.label.invalid_input` : undefined}
/>
<FormControlLabel
control={<Checkbox checked={dialogDefault} onChange={(e) => setDialogDefault(e.target.checked)} />}
label={t('category.label.use_as_default_category')}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="primary">
{t('global.button.cancel')}
</Button>
<Button onClick={handleDialogSubmit} color="primary" disabled={!canSubmit}>
{t('global.button.submit')}
</Button>
</DialogActions>
</Dialog>
);
};

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 { useMemo, useState } from 'react';
import { CategorySelect, CategorySelectProps } from '@/features/category/components/CategorySelect.tsx';
export const useCategorySelect = ({
mangaId,
mangaIds,
onClose,
addToLibrary,
}: Omit<CategorySelectProps, 'open' | 'onClose'> & Pick<Partial<CategorySelectProps>, 'onClose'>) => {
const [isCategorySelectOpen, setIsCategorySelectOpen] = useState(false);
const CategorySelectComponent = useMemo(() => {
if (!isCategorySelectOpen) {
return null;
}
return (
<CategorySelect
open={isCategorySelectOpen}
onClose={(...args) => {
setIsCategorySelectOpen(false);
onClose?.(...args);
}}
mangaId={mangaId!} // either mangaId or mangaIds is undefined, however, ts is not able to infer it correctly and raises an error
mangaIds={mangaIds as undefined}
addToLibrary={addToLibrary}
/>
);
}, [mangaId, mangaIds, addToLibrary, onClose, isCategorySelectOpen]);
return {
openCategorySelect: setIsCategorySelectOpen,
CategorySelectComponent,
};
};

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 { ComponentProps, useMemo, useState } from 'react';
import Fab from '@mui/material/Fab';
import AddIcon from '@mui/icons-material/Add';
import { useTranslation } from 'react-i18next';
import Box from '@mui/material/Box';
import { closestCenter, DndContext, DragEndEvent } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { DEFAULT_FULL_FAB_HEIGHT } from '@/features/core/components/buttons/StyledFab.tsx';
import { LoadingPlaceholder } from '@/features/core/components/feedback/LoadingPlaceholder.tsx';
import { EmptyViewAbsoluteCentered } from '@/features/core/components/feedback/EmptyViewAbsoluteCentered.tsx';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { GetCategoriesSettingsQuery, GetCategoriesSettingsQueryVariables } from '@/lib/graphql/generated/graphql.ts';
import { GET_CATEGORIES_SETTINGS } from '@/lib/graphql/queries/CategoryQuery.ts';
import { CategorySettingsCard } from '@/features/category/components/CategorySettingsCard.tsx';
import { CategoryIdInfo } from '@/features/category/Category.types.ts';
import { getErrorMessage, noOp } from '@/lib/HelperFunctions.ts';
import { DndSortableItem } from '@/lib/dnd-kit/DndSortableItem.tsx';
import { DndKitUtil } from '@/lib/dnd-kit/DndKitUtil.ts';
import { DndOverlayItem } from '@/lib/dnd-kit/DndOverlayItem.tsx';
import { useAppTitle } from '@/features/navigation-bar/hooks/useAppTitle.ts';
import { CREATE_NEW_CATEGORY_ID } from '@/features/category/Category.constants.ts';
import { CreateOrEditCategoryDialog } from '@/features/category/components/CreateOrEditCategoryDialog.tsx';
export function CategorySettings() {
const { t } = useTranslation();
const dndSensors = DndKitUtil.useSensorsForDevice();
useAppTitle(t('category.dialog.title.edit_category_other'));
const { data, loading, error, refetch } = requestManager.useGetCategories<
GetCategoriesSettingsQuery,
GetCategoriesSettingsQueryVariables
>(GET_CATEGORIES_SETTINGS, { notifyOnNetworkStatusChange: true });
const [reorderCategory, { reset: revertReorder }] = requestManager.useReorderCategory();
const [categoryToEdit, setCategoryToEdit] = useState<number>(CREATE_NEW_CATEGORY_ID);
const [dialogOpen, setDialogOpen] = useState(false);
const [dndActiveCategory, setDndActiveCategory] = useState<
ComponentProps<typeof CategorySettingsCard>['category'] | null
>(null);
const categories = useMemo(() => {
const res = [...(data?.categories.nodes ?? [])];
if (res.length > 0 && res[0].name === 'Default') {
res.shift();
}
return res;
}, [data]);
const categoryReorder = (list: CategoryIdInfo[], from: number, to: number) => {
const reorderedCategory = list[from];
reorderCategory({ variables: { input: { id: reorderedCategory.id, position: to + 1 } } }).catch(() =>
revertReorder(),
);
};
const onDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setDndActiveCategory(null);
if (!over || active.id === over.id) {
return;
}
const oldIndex = categories.findIndex((category) => category.id === active.id);
const newIndex = categories.findIndex((category) => category.id === over.id);
categoryReorder(categories, oldIndex, newIndex);
};
const handleDialogOpen = (categoryId?: CategoryIdInfo['id']) => {
setCategoryToEdit(categoryId ?? CREATE_NEW_CATEGORY_ID);
setDialogOpen(true);
};
const handleDialogCancel = () => {
setCategoryToEdit(CREATE_NEW_CATEGORY_ID);
setDialogOpen(false);
};
if (loading) {
return <LoadingPlaceholder />;
}
if (error) {
return (
<EmptyViewAbsoluteCentered
message={t('category.error.label.request_failure')}
messageExtra={getErrorMessage(error)}
retry={() => refetch().catch(defaultPromiseErrorHandler('CategorySettings::refetch'))}
/>
);
}
return (
<>
<DndContext
sensors={dndSensors}
collisionDetection={closestCenter}
onDragStart={(event) =>
setDndActiveCategory(categories.find((category) => category.id === event.active.id) ?? null)
}
onDragEnd={onDragEnd}
onDragCancel={() => setDndActiveCategory(null)}
onDragAbort={() => setDndActiveCategory(null)}
>
<Box sx={{ paddingBottom: DEFAULT_FULL_FAB_HEIGHT }}>
<SortableContext items={categories} strategy={verticalListSortingStrategy}>
{categories.map((category, index) => (
<DndSortableItem
key={category.id}
id={category.id}
isDragging={category.id === dndActiveCategory?.id}
>
<CategorySettingsCard category={category} onEdit={() => handleDialogOpen(index)} />
</DndSortableItem>
))}
</SortableContext>
<DndOverlayItem isActive={!!dndActiveCategory}>
<CategorySettingsCard category={dndActiveCategory!} onEdit={noOp} />
</DndOverlayItem>
</Box>
</DndContext>
<Fab
color="primary"
aria-label="add"
sx={{
position: 'fixed',
bottom: (theme) => theme.spacing(2),
right: (theme) => theme.spacing(2),
}}
onClick={() => handleDialogOpen()}
>
<AddIcon />
</Fab>
{dialogOpen && (
<CreateOrEditCategoryDialog category={categories[categoryToEdit]} onClose={handleDialogCancel} />
)}
</>
);
}

View File

@@ -0,0 +1,25 @@
/*
* 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 { CategoryDefaultInfo, CategoryIdInfo } from '@/features/category/Category.types.ts';
export const DEFAULT_CATEGORY_ID = 0;
export class Categories {
static getIds(categories: CategoryIdInfo[]): number[] {
return categories.map((category) => category.id);
}
static getUserCreated<Category extends CategoryIdInfo>(categories: Category[]): Category[] {
return categories.filter((category) => category.id !== DEFAULT_CATEGORY_ID);
}
static getDefaults<Category extends CategoryDefaultInfo>(categories: Category[]): Category[] {
return categories.filter((category) => category.default);
}
}

View File

@@ -0,0 +1,94 @@
/*
* 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 { useEffect, useMemo } from 'react';
import { requestUpdateCategoryMetadata } from '@/features/metadata/services/MetadataUpdater.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { LibraryOptions } from '@/features/library/Library.types.ts';
import { CategoryIdInfo, CategoryMetadataKeys, ICategoryMetadata } from '@/features/category/Category.types.ts';
import { convertFromGqlMeta } from '@/features/metadata/services/MetadataConverter.ts';
import { getMetadataFrom } from '@/features/metadata/services/MetadataReader.ts';
import {
AllowedMetadataValueTypes,
GqlMetaHolder,
Metadata,
MetadataHolder,
} from '@/features/metadata/Metadata.types.ts';
export const DEFAULT_CATEGORY_METADATA: ICategoryMetadata = {
// sort options
sortDesc: undefined,
sortBy: undefined,
// filter options
hasDownloadedChapters: undefined,
hasBookmarkedChapters: undefined,
hasUnreadChapters: undefined,
hasReadChapters: undefined,
hasDuplicateChapters: undefined,
hasTrackerBinding: {},
hasStatus: {} as LibraryOptions['hasStatus'],
};
const convertAppMetadataToGqlMetadata = (
metadata: Partial<ICategoryMetadata>,
): Metadata<string, AllowedMetadataValueTypes> => ({
...metadata,
hasTrackerBinding: metadata.hasTrackerBinding ? JSON.stringify(metadata.hasTrackerBinding) : undefined,
hasStatus: metadata.hasStatus ? JSON.stringify(metadata.hasStatus) : undefined,
});
const getCategoryMetadataWithDefaultValueFallback = (
meta: CategoryIdInfo & MetadataHolder,
defaultMetadata: ICategoryMetadata = DEFAULT_CATEGORY_METADATA,
useEffectFn?: typeof useEffect,
): ICategoryMetadata => getMetadataFrom('category', meta, defaultMetadata, undefined, useEffectFn);
const getMetadata = (
metaHolder: CategoryIdInfo & GqlMetaHolder,
defaultMetadata?: ICategoryMetadata,
useEffectFn?: typeof useEffect,
) =>
getCategoryMetadataWithDefaultValueFallback(
{ ...metaHolder, meta: convertFromGqlMeta(metaHolder.meta) },
defaultMetadata,
useEffectFn,
);
export const getCategoryMetadata = (
metaHolder: CategoryIdInfo & GqlMetaHolder,
defaultMetadata?: ICategoryMetadata,
): ICategoryMetadata => getMetadata(metaHolder, defaultMetadata);
export const useGetCategoryMetadata = (
metaHolder: CategoryIdInfo & GqlMetaHolder,
defaultMetadata?: ICategoryMetadata,
): ICategoryMetadata => {
const metadata = getMetadata(metaHolder, defaultMetadata, useEffect);
return useMemo(() => metadata, [metaHolder, defaultMetadata]);
};
export const updateCategoryMetadata = async <
MetadataKeys extends CategoryMetadataKeys = CategoryMetadataKeys,
MetadataKey extends MetadataKeys = MetadataKeys,
>(
category: CategoryIdInfo & GqlMetaHolder,
metadataKey: MetadataKey,
value: ICategoryMetadata[MetadataKey],
): Promise<void[]> =>
requestUpdateCategoryMetadata(category, [
[metadataKey, convertAppMetadataToGqlMetadata({ [metadataKey]: value })[metadataKey]],
]);
export const createUpdateCategoryMetadata =
<Settings extends CategoryMetadataKeys>(
category: CategoryIdInfo & GqlMetaHolder,
handleError: (error: any) => void = defaultPromiseErrorHandler('createUpdateCategoryMetadata'),
): ((...args: OmitFirst<Parameters<typeof updateCategoryMetadata<Settings>>>) => Promise<void | void[]>) =>
(metadataKey, value) =>
updateCategoryMetadata(category, metadataKey, value).catch(handleError);

View File

@@ -0,0 +1,107 @@
/*
* 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 { TranslationKey } from '@/Base.types.ts';
import { ChapterAction, ChapterListOptions, ChapterSortMode } from '@/features/chapter/Chapter.types.ts';
export const FALLBACK_CHAPTER = { id: -1, name: '', realUrl: '', isBookmarked: false };
export const DEFAULT_CHAPTER_OPTIONS: ChapterListOptions = {
unread: undefined,
downloaded: undefined,
bookmarked: undefined,
reverse: true,
sortBy: 'source',
showChapterNumber: false,
excludedScanlators: [],
};
export const CHAPTER_SORT_OPTIONS_TO_TRANSLATION_KEY: Record<ChapterSortMode, TranslationKey> = {
source: 'global.sort.label.by_source',
chapterNumber: 'global.sort.label.by_chapter_number',
uploadedAt: 'global.sort.label.by_upload_date',
fetchedAt: 'global.sort.label.by_fetch_date',
};
export const CHAPTER_ACTION_TO_CONFIRMATION_REQUIRED: Record<
ChapterAction,
{ always: boolean; bulkAction: boolean; bulkActionCountForce?: number }
> = {
download: { always: false, bulkAction: false, bulkActionCountForce: 300 },
delete: { always: true, bulkAction: true },
bookmark: { always: false, bulkAction: false },
unbookmark: { always: false, bulkAction: true },
mark_as_read: { always: false, bulkAction: true },
mark_as_unread: { always: false, bulkAction: true },
};
export const CHAPTER_ACTION_TO_TRANSLATION: {
[key in ChapterAction]: {
action: {
single: TranslationKey;
selected: TranslationKey;
};
confirmation?: TranslationKey;
success: TranslationKey;
error: TranslationKey;
};
} = {
download: {
action: {
single: 'chapter.action.download.add.label.action',
selected: 'chapter.action.download.add.button.selected',
},
confirmation: 'chapter.action.download.add.label.confirmation',
success: 'chapter.action.download.add.label.success',
error: 'chapter.action.download.add.label.error',
},
delete: {
action: {
single: 'chapter.action.download.delete.label.action',
selected: 'chapter.action.download.delete.button.selected',
},
confirmation: 'chapter.action.download.delete.label.confirmation',
success: 'chapter.action.download.delete.label.success',
error: 'chapter.action.download.delete.label.error',
},
bookmark: {
action: {
single: 'chapter.action.bookmark.add.label.action',
selected: 'chapter.action.bookmark.add.button.selected',
},
success: 'chapter.action.bookmark.add.label.success',
error: 'chapter.action.bookmark.add.label.error',
},
unbookmark: {
action: {
single: 'chapter.action.bookmark.remove.label.action',
selected: 'chapter.action.bookmark.remove.button.selected',
},
confirmation: 'chapter.action.bookmark.remove.label.confirmation',
success: 'chapter.action.bookmark.remove.label.success',
error: 'chapter.action.bookmark.remove.label.error',
},
mark_as_read: {
action: {
single: 'chapter.action.mark_as_read.add.label.action.current',
selected: 'chapter.action.mark_as_read.add.button.selected',
},
confirmation: 'chapter.action.mark_as_read.add.label.confirmation',
success: 'chapter.action.mark_as_read.add.label.success',
error: 'chapter.action.mark_as_read.add.label.error',
},
mark_as_unread: {
action: {
single: 'chapter.action.mark_as_read.remove.label.action',
selected: 'chapter.action.mark_as_read.remove.button.selected',
},
confirmation: 'chapter.action.mark_as_read.remove.label.confirmation',
success: 'chapter.action.mark_as_read.remove.label.success',
error: 'chapter.action.mark_as_read.remove.label.error',
},
};

View File

@@ -0,0 +1,50 @@
/*
* 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 { NullAndUndefined } from '@/Base.types.ts';
import {
ChapterReaderFieldsFragment,
ChapterType,
DownloadStatusFieldsFragment,
} from '@/lib/graphql/generated/graphql.ts';
export type ChapterSortMode = 'fetchedAt' | 'source' | 'chapterNumber' | 'uploadedAt';
export interface ChapterListOptions {
unread: NullAndUndefined<boolean>;
downloaded: NullAndUndefined<boolean>;
bookmarked: NullAndUndefined<boolean>;
reverse: boolean;
sortBy: ChapterSortMode;
showChapterNumber: boolean;
excludedScanlators: string[];
}
export type TChapterReader = ChapterReaderFieldsFragment;
export type ChapterAction = 'download' | 'delete' | 'bookmark' | 'unbookmark' | 'mark_as_read' | 'mark_as_unread';
export type ChapterDownloadStatus = DownloadStatusFieldsFragment['queue'][number];
export type ChapterIdInfo = Pick<ChapterType, 'id'>;
export type ChapterMangaInfo = Pick<ChapterType, 'mangaId'>;
export type ChapterDownloadInfo = Pick<ChapterType, 'isDownloaded'>;
export type ChapterBookmarkInfo = Pick<ChapterType, 'isBookmarked'>;
export type ChapterReadInfo = Pick<ChapterType, 'isRead'>;
export type ChapterNumberInfo = Pick<ChapterType, 'chapterNumber'>;
export type ChapterSourceOrderInfo = Pick<ChapterType, 'sourceOrder'>;
export type ChapterScanlatorInfo = Pick<ChapterType, 'scanlator'>;
export type ChapterRealUrlInfo = Pick<ChapterType, 'realUrl'>;

View File

@@ -0,0 +1,63 @@
/*
* 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 { bindTrigger, usePopupState } from 'material-ui-popup-state/hooks';
import { useTranslation } from 'react-i18next';
import PeopleAltOutlinedIcon from '@mui/icons-material/PeopleAltOutlined';
import DisabledByDefaultRounded from '@mui/icons-material/DisabledByDefaultRounded';
import { CheckboxListSetting } from '@/features/core/components/settings/CheckboxListSetting.tsx';
import { updateChapterListOptions } from '@/features/chapter/utils/ChapterList.util.tsx';
import { CheckboxInput } from '@/features/core/components/inputs/CheckboxInput.tsx';
export const ChapterExcludeSanlatorsFilter = ({
updateOption,
scanlators,
excludedScanlators,
}: {
updateOption: ReturnType<typeof updateChapterListOptions>;
scanlators: string[];
excludedScanlators: string[];
}) => {
const { t } = useTranslation();
const popupState = usePopupState({ variant: 'dialog', popupId: 'chapter-list-options-scanlator-filter-dialog' });
if (!scanlators.length) {
return null;
}
return (
<>
<CheckboxInput
{...bindTrigger(popupState)}
label={t('global.label.scanlator')}
icon={<PeopleAltOutlinedIcon />}
checkedIcon={<PeopleAltOutlinedIcon color="warning" />}
checked={!!excludedScanlators.length}
/>
<CheckboxListSetting
title={t('chapter.option.exclude_scanlators')}
open={popupState.isOpen}
onClose={(selectedScanlators) => {
if (selectedScanlators) {
updateOption('excludedScanlators', selectedScanlators);
}
popupState.close();
}}
items={scanlators}
getId={(scanlator) => scanlator}
getLabel={(scanlator) => scanlator}
isChecked={(scanlator) => excludedScanlators.includes(scanlator)}
slotProps={{
checkbox: {
checkedIcon: <DisabledByDefaultRounded />,
},
}}
/>
</>
);
};

View File

@@ -0,0 +1,266 @@
/*
* 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 Stack from '@mui/material/Stack';
import { styled } from '@mui/material/styles';
import Typography from '@mui/material/Typography';
import { ComponentProps, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { ResumeFab } from '@/features/manga/components/ResumeFAB.tsx';
import {
filterAndSortChapters,
updateChapterListOptions,
useChapterListOptions,
} from '@/features/chapter/utils/ChapterList.util.tsx';
import { EmptyViewAbsoluteCentered } from '@/features/core/components/feedback/EmptyViewAbsoluteCentered.tsx';
import { ChaptersToolbarMenu } from '@/features/chapter/components/ChaptersToolbarMenu.tsx';
import { SelectionFAB } from '@/features/collection/components/SelectionFAB.tsx';
import { DEFAULT_FULL_FAB_HEIGHT } from '@/features/core/components/buttons/StyledFab.tsx';
import {
ChapterListFieldsFragment,
GetChaptersMangaQuery,
GetChaptersMangaQueryVariables,
MangaScreenFieldsFragment,
} from '@/lib/graphql/generated/graphql.ts';
import { useSelectableCollection } from '@/features/collection/hooks/useSelectableCollection.ts';
import { SelectableCollectionSelectAll } from '@/features/collection/components/SelectableCollectionSelectAll.tsx';
import { Chapters } from '@/features/chapter/services/Chapters.ts';
import { ChapterActionMenuItems } from '@/features/chapter/components/actions/ChapterActionMenuItems.tsx';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { LoadingPlaceholder } from '@/features/core/components/feedback/LoadingPlaceholder.tsx';
import { GET_CHAPTERS_MANGA } from '@/lib/graphql/queries/ChapterQuery.ts';
import { useNavBarContext } from '@/features/navigation-bar/contexts/NavbarContext.tsx';
import { useResizeObserver } from '@/features/core/hooks/useResizeObserver.tsx';
import { MediaQuery } from '@/features/core/utils/MediaQuery.tsx';
import { shouldForwardProp } from '@/features/core/utils/ShouldForwardProp.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { makeToast } from '@/features/core/utils/Toast.ts';
import { ChapterListCard } from '@/features/chapter/components/cards/ChapterListCard.tsx';
import { VirtuosoPersisted } from '@/lib/virtuoso/Component/VirtuosoPersisted.tsx';
type ChapterListHeaderProps = {
scrollbarWidth: number;
};
const ChapterListHeader = styled(Stack, {
shouldForwardProp: shouldForwardProp<ChapterListHeaderProps>(['scrollbarWidth']),
})<ChapterListHeaderProps>(({ theme, scrollbarWidth }) => ({
padding: theme.spacing(1),
paddingRight: `calc(${scrollbarWidth}px + ${theme.spacing(1)})`,
paddingBottom: 0,
[theme.breakpoints.down('md')]: {
paddingRight: theme.spacing(1),
},
}));
type StyledVirtuosoProps = { topOffset: number };
const StyledVirtuoso = styled(VirtuosoPersisted, {
shouldForwardProp: shouldForwardProp<StyledVirtuosoProps>(['topOffset']),
})<StyledVirtuosoProps>(({ theme, topOffset }) => ({
listStyle: 'none',
padding: 0,
[theme.breakpoints.up('md')]: {
height: `calc(100vh - ${topOffset}px)`,
margin: 0,
},
}));
const ChapterListFAB = ({
selectedChapters,
firstUnreadChapter,
onFABMenuClose,
}: {
selectedChapters: ChapterListFieldsFragment[];
firstUnreadChapter: ComponentProps<typeof ResumeFab>['chapter'] | null | undefined;
onFABMenuClose?: () => void;
}) => {
if (selectedChapters.length) {
return (
<SelectionFAB selectedItemsCount={selectedChapters.length} title="chapter.title_one">
{(handleClose) => (
<ChapterActionMenuItems
selectedChapters={selectedChapters}
onClose={() => {
onFABMenuClose?.();
handleClose();
}}
/>
)}
</SelectionFAB>
);
}
if (firstUnreadChapter) {
return <ResumeFab chapter={firstUnreadChapter} />;
}
return null;
};
export const ChapterList = ({
manga,
isRefreshing,
}: {
manga: Pick<MangaScreenFieldsFragment, 'id' | 'firstUnreadChapter' | 'chapters' | 'unreadCount' | 'downloadCount'>;
isRefreshing: boolean;
}) => {
const { t } = useTranslation();
const { appBarHeight } = useNavBarContext();
const isMobileWidth = MediaQuery.useIsBelowWidth('md');
const [chapterListHeaderHeight, setChapterListHeaderHeight] = useState(50);
const [chapterListHeaderRef, setChapterListHeaderRef] = useState<HTMLDivElement | null>(null);
useResizeObserver(
chapterListHeaderRef,
useCallback(() => setChapterListHeaderHeight(chapterListHeaderRef?.offsetHeight ?? 0), [chapterListHeaderRef]),
);
const scrollbarWidth = MediaQuery.useGetScrollbarSize('width');
const options = useChapterListOptions(manga);
const updateOption = updateChapterListOptions(manga, (e) =>
makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)),
);
const {
data: chaptersData,
loading: isLoading,
error,
refetch,
} = requestManager.useGetMangaChapters<GetChaptersMangaQuery, GetChaptersMangaQueryVariables>(
GET_CHAPTERS_MANGA,
manga.id,
{ notifyOnNetworkStatusChange: true },
);
const chapters = useMemo(() => chaptersData?.chapters.nodes ?? [], [chaptersData?.chapters.nodes]);
const visibleChapters = useMemo(() => filterAndSortChapters(chapters, options), [chapters, options]);
const visibleChapterIds = useMemo(() => Chapters.getIds(visibleChapters), [visibleChapters]);
const missingChapterCount = useMemo(() => Chapters.getMissingCount(visibleChapters), [visibleChapters]);
const noChaptersFound = chapters.length === 0;
const noChaptersMatchingFilter = !noChaptersFound && visibleChapters.length === 0;
const {
areNoItemsSelected,
areAllItemsSelected,
selectedItemIds,
handleSelectAll,
handleSelection,
clearSelection,
} = useSelectableCollection(visibleChapterIds.length, { itemIds: visibleChapterIds, currentKey: 'default' });
const onSelect = useCallback(
(id: number, selected: boolean, selectRange?: boolean) => handleSelection(id, selected, { selectRange }),
[handleSelection],
);
if (isLoading || (noChaptersFound && isRefreshing)) {
return (
<Stack sx={{ justifyContent: 'center', alignItems: 'center', position: 'relative', flexGrow: 1 }}>
<LoadingPlaceholder />
</Stack>
);
}
if (error) {
return (
<Stack sx={{ justifyContent: 'center', position: 'relative', flexGrow: 1 }}>
<EmptyViewAbsoluteCentered
message={t('global.error.label.failed_to_load_data')}
messageExtra={getErrorMessage(error)}
retry={() => refetch().catch(defaultPromiseErrorHandler('ChapterList::refetch'))}
/>
</Stack>
);
}
return (
<>
<Stack direction="column" sx={{ position: 'relative', flexBasis: '60%' }}>
<ChapterListHeader
ref={setChapterListHeaderRef}
direction="row"
alignItems="center"
justifyContent="space-between"
scrollbarWidth={scrollbarWidth}
>
<Stack>
<Typography variant="h5" component="h3">
{t('chapter.value', { count: visibleChapters.length })}
</Typography>
{!!missingChapterCount && (
<Typography variant="body2" color="warning">
{`${t('chapter.missing', {
count: missingChapterCount,
})}`}
</Typography>
)}
</Stack>
<Stack direction="row">
{areNoItemsSelected && (
<ChaptersToolbarMenu
mangaId={manga.id}
options={options}
updateOption={updateOption}
chapters={visibleChapters}
scanlators={Chapters.getScanlators(chapters)}
excludeScanlators={options.excludedScanlators}
/>
)}
{!!visibleChapterIds.length && (
<SelectableCollectionSelectAll
areAllItemsSelected={areAllItemsSelected}
areNoItemsSelected={areNoItemsSelected}
onChange={(checked) => handleSelectAll(checked, checked ? visibleChapterIds : [])}
/>
)}
</Stack>
</ChapterListHeader>
{noChaptersFound && <EmptyViewAbsoluteCentered message={t('chapter.error.label.no_chapter_found')} />}
{noChaptersMatchingFilter && (
<EmptyViewAbsoluteCentered message={t('chapter.error.label.no_matches')} />
)}
<StyledVirtuoso
persistKey={`manga-${manga.id}-chapter-list`}
topOffset={appBarHeight + chapterListHeaderHeight}
style={{
// override Virtuoso default values and set them with class
height: 'undefined',
}}
components={{ Footer: () => <Box sx={{ paddingBottom: DEFAULT_FULL_FAB_HEIGHT }} /> }}
totalCount={visibleChapters.length}
computeItemKey={(index) => visibleChapters[index].id}
itemContent={(index: number) => (
<ChapterListCard
index={index}
isSortDesc={options.reverse}
chapters={visibleChapters}
selected={!areNoItemsSelected ? selectedItemIds.includes(visibleChapters[index].id) : null}
showChapterNumber={options.showChapterNumber}
onSelect={onSelect}
/>
)}
useWindowScroll={isMobileWidth}
overscan={window.innerHeight * 0.5}
/>
</Stack>
<ChapterListFAB
selectedChapters={selectedItemIds
.map((id) => chapters.find((chapter) => chapter.id === id))
.filter((chapter) => chapter != null)}
firstUnreadChapter={manga.firstUnreadChapter}
onFABMenuClose={clearSelection}
/>
</>
);
};

View File

@@ -0,0 +1,114 @@
/*
* 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 RadioGroup from '@mui/material/RadioGroup';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { RadioInput } from '@/features/core/components/inputs/RadioInput.tsx';
import { SortRadioInput } from '@/features/core/components/inputs/SortRadioInput.tsx';
import { ThreeStateCheckboxInput } from '@/features/core/components/inputs/ThreeStateCheckboxInput.tsx';
import { OptionsTabs } from '@/features/core/components/modals/OptionsTabs.tsx';
import { CHAPTER_SORT_OPTIONS_TO_TRANSLATION_KEY } from '@/features/chapter/Chapter.constants.ts';
import { TranslationKey } from '@/Base.types.ts';
import { ChapterListOptions } from '@/features/chapter/Chapter.types.ts';
import { updateChapterListOptions } from '@/features/chapter/utils/ChapterList.util.tsx';
import { ChapterExcludeSanlatorsFilter } from '@/features/chapter/components/ChapterExcludeSanlatorsFilter.tsx';
interface IProps {
open: boolean;
onClose: () => void;
options: ChapterListOptions;
updateOption: ReturnType<typeof updateChapterListOptions>;
scanlators: string[];
excludedScanlators: string[];
}
const TITLES: { [key in 'filter' | 'sort' | 'display']: TranslationKey } = {
filter: 'global.label.filter',
sort: 'global.label.sort',
display: 'global.label.display',
};
export const ChapterOptions: React.FC<IProps> = ({
open,
onClose,
options,
updateOption,
scanlators,
excludedScanlators,
}) => {
const { t } = useTranslation();
return (
<OptionsTabs<'filter' | 'sort' | 'display'>
open={open}
onClose={onClose}
minHeight={150}
tabs={['filter', 'sort', 'display']}
tabTitle={(key) => t(TITLES[key])}
tabContent={(key) => {
if (key === 'filter') {
return (
<>
<ThreeStateCheckboxInput
label={t('global.filter.label.unread')}
checked={options.unread}
onChange={(c) => updateOption('unread', c)}
/>
<ThreeStateCheckboxInput
label={t('global.filter.label.downloaded')}
checked={options.downloaded}
onChange={(c) => updateOption('downloaded', c)}
/>
<ThreeStateCheckboxInput
label={t('global.filter.label.bookmarked')}
checked={options.bookmarked}
onChange={(c) => updateOption('bookmarked', c)}
/>
<ChapterExcludeSanlatorsFilter
scanlators={scanlators}
excludedScanlators={excludedScanlators}
updateOption={updateOption}
/>
</>
);
}
if (key === 'sort') {
return Object.entries(CHAPTER_SORT_OPTIONS_TO_TRANSLATION_KEY).map(([mode, label]) => (
<SortRadioInput
key={mode}
label={t(label)}
checked={options.sortBy === mode}
sortDescending={options.reverse}
onClick={() =>
mode !== options.sortBy
? updateOption(
'sortBy',
mode as keyof typeof CHAPTER_SORT_OPTIONS_TO_TRANSLATION_KEY,
)
: updateOption('reverse', !options.reverse)
}
/>
));
}
if (key === 'display') {
return (
<RadioGroup
onChange={() => updateOption('showChapterNumber', !options.showChapterNumber)}
value={options.showChapterNumber}
>
<RadioInput label={t('chapter.option.display.label.source_title')} value={false} />
<RadioInput label={t('chapter.option.display.label.chapter_number')} value />
</RadioGroup>
);
}
return null;
}}
/>
);
};

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 FilterList from '@mui/icons-material/FilterList';
import IconButton from '@mui/material/IconButton';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import PopupState, { bindMenu, bindTrigger } from 'material-ui-popup-state';
import Menu from '@mui/material/Menu';
import DownloadIcon from '@mui/icons-material/Download';
import DoneAllIcon from '@mui/icons-material/DoneAll';
import { useMemo } from 'react';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
import { ChapterOptions } from '@/features/chapter/components/ChapterOptions.tsx';
import { isFilterActive, updateChapterListOptions } from '@/features/chapter/utils/ChapterList.util.tsx';
import {
ChapterBookmarkInfo,
ChapterDownloadInfo,
ChapterIdInfo,
ChapterListOptions,
ChapterReadInfo,
} from '@/features/chapter/Chapter.types.ts';
import { ChaptersDownloadActionMenuItems } from '@/features/chapter/components/actions/ChaptersDownloadActionMenuItems.tsx';
import { Chapters } from '@/features/chapter/services/Chapters.ts';
interface IProps {
mangaId: number;
options: ChapterListOptions;
updateOption: ReturnType<typeof updateChapterListOptions>;
chapters: (ChapterIdInfo & ChapterReadInfo & ChapterDownloadInfo & ChapterBookmarkInfo)[];
scanlators: string[];
excludeScanlators: string[];
}
export const ChaptersToolbarMenu = ({
mangaId,
options,
updateOption,
chapters,
scanlators,
excludeScanlators,
}: IProps) => {
const { t } = useTranslation();
const [open, setOpen] = React.useState(false);
const isFiltered = isFilterActive(options);
const areAllChaptersRead = useMemo(() => chapters.every(Chapters.isRead), [chapters]);
const areAllChaptersDownloaded = useMemo(() => chapters.every(Chapters.isDownloaded), [chapters]);
return (
<>
<CustomTooltip
title={t('chapter.action.mark_as_read.add.label.action.current')}
disabled={areAllChaptersRead}
>
<IconButton
disabled={areAllChaptersRead}
onClick={() => Chapters.markAsRead(Chapters.getNonRead(chapters), true, mangaId)}
color="inherit"
>
<DoneAllIcon />
</IconButton>
</CustomTooltip>
<PopupState variant="popover" popupId="chapterlist-download-button">
{(popupState) => (
<>
<CustomTooltip
title={t('chapter.action.download.add.label.action')}
disabled={areAllChaptersRead}
>
<IconButton
disabled={areAllChaptersDownloaded}
{...bindTrigger(popupState)}
color="inherit"
>
<DownloadIcon />
</IconButton>
</CustomTooltip>
{popupState.isOpen && (
<Menu {...bindMenu(popupState)}>
<ChaptersDownloadActionMenuItems mangaIds={[mangaId]} closeMenu={popupState.close} />
</Menu>
)}
</>
)}
</PopupState>
<CustomTooltip title={t('settings.title')}>
<IconButton onClick={() => setOpen(true)} color="inherit">
<FilterList color={isFiltered ? 'warning' : undefined} />
</IconButton>
</CustomTooltip>
<ChapterOptions
open={open}
onClose={() => setOpen(false)}
options={options}
updateOption={updateOption}
scanlators={scanlators}
excludedScanlators={excludeScanlators}
/>
</>
);
};

View File

@@ -0,0 +1,56 @@
/*
* 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/.
*/
/*
* 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 Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next';
export const MissingChaptersInfoSeparator = ({ missingChaptersGap }: { missingChaptersGap: number }) => {
const { t } = useTranslation();
return (
<Stack
sx={{
width: '100%',
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
p: 2,
pt: 3.5,
pb: 2.5,
}}
>
<Box
sx={{
flexGrow: 1,
border: '1px solid',
borderColor: (theme) => theme.palette.text.secondary,
}}
/>
<Typography sx={{ px: 2 }} variant="body2" color="textSecondary">
{t('chapter.missing', { count: missingChaptersGap })}
</Typography>
<Box
sx={{
flexGrow: 1,
border: '1px solid',
borderColor: (theme) => theme.palette.text.secondary,
}}
/>
</Stack>
);
};

View File

@@ -0,0 +1,263 @@
/*
* 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 CheckBoxOutlineBlank from '@mui/icons-material/CheckBoxOutlineBlank';
import Delete from '@mui/icons-material/Delete';
import Download from '@mui/icons-material/Download';
import RemoveDone from '@mui/icons-material/RemoveDone';
import Done from '@mui/icons-material/Done';
import { useTranslation } from 'react-i18next';
import BookmarkRemove from '@mui/icons-material/BookmarkRemove';
import BookmarkAdd from '@mui/icons-material/BookmarkAdd';
import DoneAll from '@mui/icons-material/DoneAll';
import { ComponentProps, useMemo } from 'react';
import { SelectableCollectionReturnType } from '@/features/collection/hooks/useSelectableCollection.ts';
import { Chapters } from '@/features/chapter/services/Chapters.ts';
import { MenuItem } from '@/features/core/components/menu/MenuItem.tsx';
import {
createGetMenuItemTitle,
createIsMenuItemDisabled,
createShouldShowMenuItem,
} from '@/features/core/components/menu/Menu.utils.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
import { ChapterCard } from '@/features/chapter/components/cards/ChapterCard.tsx';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { GetChaptersMangaQuery } from '@/lib/graphql/generated/graphql.ts';
import { GET_CHAPTERS_MANGA } from '@/lib/graphql/queries/ChapterQuery.ts';
import { CHAPTER_ACTION_TO_TRANSLATION } from '@/features/chapter/Chapter.constants.ts';
import {
ChapterAction,
ChapterBookmarkInfo,
ChapterDownloadInfo,
ChapterIdInfo,
ChapterMangaInfo,
ChapterReadInfo,
ChapterRealUrlInfo,
} from '@/features/chapter/Chapter.types.ts';
import { IconWebView } from '@/assets/icons/IconWebView.tsx';
import { IconBrowser } from '@/assets/icons/IconBrowser.tsx';
type BaseProps = { onClose: () => void; selectable?: boolean };
type TChapter = ChapterIdInfo &
ChapterMangaInfo &
ChapterDownloadInfo &
ChapterBookmarkInfo &
ChapterReadInfo &
ChapterRealUrlInfo;
type SingleModeProps = {
chapter: TChapter;
handleSelection?: SelectableCollectionReturnType<TChapter['id']>['handleSelection'];
canBeDownloaded: boolean;
};
type SelectModeProps = {
selectedChapters: ComponentProps<typeof ChapterCard>['chapter'][];
};
type Props =
| (BaseProps & SingleModeProps & PropertiesNever<SelectModeProps>)
| (BaseProps & PropertiesNever<SingleModeProps> & SelectModeProps);
export const ChapterActionMenuItems = ({
chapter,
handleSelection,
canBeDownloaded = false,
selectedChapters = [],
onClose,
selectable = true,
}: Props) => {
const { t } = useTranslation();
const isSingleMode = !!chapter;
const { isDownloaded, isRead, isBookmarked } = chapter ?? {};
const mangaChaptersResponse = requestManager.useGetMangaChapters<GetChaptersMangaQuery>(
GET_CHAPTERS_MANGA,
chapter?.mangaId ?? -1,
{
skip: !chapter,
fetchPolicy: 'cache-only',
},
);
const allChapters = mangaChaptersResponse.data?.chapters.nodes ?? [];
const {
settings: { deleteChaptersWithBookmark },
} = useMetadataServerSettings();
const getMenuItemTitle = createGetMenuItemTitle(isSingleMode, CHAPTER_ACTION_TO_TRANSLATION);
const shouldShowMenuItem = createShouldShowMenuItem(isSingleMode);
const isMenuItemDisabled = createIsMenuItemDisabled(isSingleMode);
const {
downloadableChapters,
downloadedChapters,
unbookmarkedChapters,
bookmarkedChapters,
unreadChapters,
readChapters,
} = useMemo(
() => ({
downloadableChapters: Chapters.getDownloadable(selectedChapters),
downloadedChapters: Chapters.getDownloaded(selectedChapters),
unbookmarkedChapters: Chapters.getNonBookmarked(selectedChapters),
bookmarkedChapters: Chapters.getBookmarked(selectedChapters),
unreadChapters: Chapters.getNonRead(selectedChapters),
readChapters: Chapters.getRead(selectedChapters),
}),
[selectedChapters],
);
const handleSelect = () => {
handleSelection?.(chapter.id, true);
onClose();
};
const performAction = (action: ChapterAction | 'mark_prev_as_read', chapters: TChapter[]) => {
const isMarkPrevAsRead = action === 'mark_prev_as_read';
const actualAction: ChapterAction = isMarkPrevAsRead ? 'mark_as_read' : action;
if (actualAction === 'delete' && chapter) {
const isDeletable = Chapters.isDeletable(chapter, deleteChaptersWithBookmark);
if (!isDeletable) {
onClose();
return;
}
}
const getChapters = (): SingleModeProps['chapter'][] => {
// select mode
if (!chapter) {
return chapters;
}
if (!isMarkPrevAsRead) {
return [chapter];
}
const index = allChapters.findIndex(({ id: chapterId }) => chapterId === chapter.id);
const isFirstChapter = index + 1 > allChapters.length - 1;
if (isFirstChapter) {
return [];
}
const previousChapters = allChapters.slice(index + 1);
return Chapters.getNonRead(previousChapters);
};
const chaptersToUpdate = getChapters();
if (!chaptersToUpdate.length) {
onClose();
return;
}
Chapters.performAction(actualAction, Chapters.getIds(chaptersToUpdate), {
chapters: chaptersToUpdate,
wasManuallyMarkedAsRead: true,
trackProgressMangaId: chaptersToUpdate[0]?.mangaId,
}).catch(defaultPromiseErrorHandler('ChapterActionMenuItems::performAction'));
onClose();
};
return (
<>
{isSingleMode && selectable && (
<MenuItem onClick={handleSelect} Icon={CheckBoxOutlineBlank} title={t('chapter.action.label.select')} />
)}
{isSingleMode && (
<>
<MenuItem
Icon={IconBrowser}
disabled={!chapter!.realUrl}
onClick={() => {
window.open(chapter!.realUrl!, '_blank', 'noopener,noreferrer');
onClose();
}}
title={t('global.button.open_browser')}
/>
<MenuItem
Icon={IconWebView}
disabled={!chapter!.realUrl}
onClick={() => {
window.open(
requestManager.getWebviewUrl(chapter!.realUrl!),
'_blank',
'noopener,noreferrer',
);
onClose();
}}
title={t('global.button.open_webview')}
/>
</>
)}
{shouldShowMenuItem(canBeDownloaded) && (
<MenuItem
Icon={Download}
disabled={isMenuItemDisabled(!downloadableChapters.length)}
onClick={() => performAction('download', downloadableChapters)}
title={getMenuItemTitle('download', downloadableChapters.length)}
/>
)}
{shouldShowMenuItem(isDownloaded) && (
<MenuItem
Icon={Delete}
disabled={isMenuItemDisabled(!downloadedChapters.length)}
onClick={() =>
performAction('delete', Chapters.getDeletable(downloadedChapters, deleteChaptersWithBookmark))
}
title={getMenuItemTitle('delete', downloadedChapters.length)}
/>
)}
{shouldShowMenuItem(!isBookmarked) && (
<MenuItem
Icon={BookmarkAdd}
disabled={isMenuItemDisabled(!unbookmarkedChapters.length)}
onClick={() => performAction('bookmark', unbookmarkedChapters)}
title={getMenuItemTitle('bookmark', unbookmarkedChapters.length)}
/>
)}
{shouldShowMenuItem(isBookmarked) && (
<MenuItem
Icon={BookmarkRemove}
disabled={isMenuItemDisabled(!bookmarkedChapters.length)}
onClick={() => performAction('unbookmark', bookmarkedChapters)}
title={getMenuItemTitle('unbookmark', bookmarkedChapters.length)}
/>
)}
{shouldShowMenuItem(!isRead) && (
<MenuItem
Icon={Done}
disabled={isMenuItemDisabled(!unreadChapters.length)}
onClick={() => performAction('mark_as_read', unreadChapters)}
title={getMenuItemTitle('mark_as_read', unreadChapters.length)}
/>
)}
{shouldShowMenuItem(isRead) && (
<MenuItem
Icon={RemoveDone}
disabled={isMenuItemDisabled(!readChapters.length)}
onClick={() => performAction('mark_as_unread', readChapters)}
title={getMenuItemTitle('mark_as_unread', readChapters.length)}
/>
)}
{isSingleMode && (
<MenuItem
onClick={() => performAction('mark_prev_as_read', [])}
Icon={DoneAll}
title={t('chapter.action.mark_as_read.add.label.action.previous')}
/>
)}
</>
);
};

View File

@@ -0,0 +1,166 @@
/*
* 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 MenuItem from '@mui/material/MenuItem';
import { useTranslation } from 'react-i18next';
import gql from 'graphql-tag';
import { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
import { Mangas } from '@/features/manga/services/Mangas.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import {
ChapterOrderBy,
GetChaptersMangaQuery,
GetChaptersMangaQueryVariables,
MangaType,
SortOrder,
} from '@/lib/graphql/generated/graphql.ts';
import { TranslationKey } from '@/Base.types.ts';
import { MANGA_META_FIELDS } from '@/lib/graphql/fragments/MangaFragments.ts';
import { getMangaMetadata } from '@/features/manga/services/MangaMetadata.ts';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { GET_CHAPTERS_MANGA } from '@/lib/graphql/queries/ChapterQuery.ts';
import { filterChapters } from '@/features/chapter/utils/ChapterList.util.tsx';
import { Chapters } from '@/features/chapter/services/Chapters.ts';
import { makeToast } from '@/features/core/utils/Toast.ts';
import { CHAPTER_ACTION_TO_TRANSLATION } from '@/features/chapter/Chapter.constants.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
const DOWNLOAD_OPTIONS: {
title: TranslationKey;
getCount: (downloadAheadLimit: number) => number | undefined;
onlyUnread?: boolean;
isDownloadAhead?: boolean;
}[] = [
{ title: 'chapter.action.download.add.label.next', getCount: () => 1 },
{ title: 'chapter.action.download.add.label.next', getCount: () => 5 },
{ title: 'chapter.action.download.add.label.next', getCount: () => 10 },
{ title: 'chapter.action.download.add.label.next', getCount: () => 25 },
{
title: 'chapter.action.download.add.label.ahead',
getCount: (downloadAheadLimit) => downloadAheadLimit,
onlyUnread: true,
isDownloadAhead: true,
},
{ title: 'chapter.action.download.add.label.unread', getCount: () => undefined, onlyUnread: true },
{ title: 'chapter.action.download.add.label.all', getCount: () => undefined, onlyUnread: false },
];
const handleDownload = async (
mangaIds: MangaType['id'][],
onlyUnread: boolean,
size: number | undefined,
downloadAhead: boolean,
): Promise<void> => {
const isMultiMangaManga = mangaIds.length > 1;
if (isMultiMangaManga) {
Mangas.performAction('download', mangaIds, {
downloadAhead,
onlyUnread,
size,
}).catch(defaultPromiseErrorHandler('ChaptersDownloadActionMenuItems::handleSelect:multiMangaMode'));
return;
}
const mangaId = mangaIds[0];
const manga = Mangas.getFromCache(
mangaId,
gql`
${MANGA_META_FIELDS}
fragment MangaInLibraryState on MangaType {
id
meta {
...MANGA_META_FIELDS
}
}
`,
'MangaInLibraryState',
)!;
const meta = getMangaMetadata(manga);
const chapters = await requestManager.getChapters<GetChaptersMangaQuery, GetChaptersMangaQueryVariables>(
GET_CHAPTERS_MANGA,
{
// Align conditions/filters with the query from ChapterList to potentially be able to reuse the cache
condition: { mangaId: Number(mangaId) },
order: [{ by: ChapterOrderBy.SourceOrder, byType: SortOrder.Desc }],
},
).response;
const filteredChapters = filterChapters(chapters.data.chapters.nodes, meta);
const doNecessaryDownloadAheadDownloadsExist =
downloadAhead &&
Chapters.removeDuplicates(filteredChapters.slice(-1)[0], filteredChapters)
.slice(-(size ?? 0))
.every((chapter) => !Chapters.isRead(chapter) && Chapters.isDownloaded(chapter));
if (doNecessaryDownloadAheadDownloadsExist) {
return;
}
const unreadUndownloadedChapters = filteredChapters.filter((chapter) => {
if (onlyUnread && chapter.isRead) {
return false;
}
return !chapter.isDownloaded;
});
const uniqueChapters = Chapters.removeDuplicates(
unreadUndownloadedChapters.slice(-1)[0],
unreadUndownloadedChapters,
);
const chaptersToDownload = uniqueChapters.slice(-(size ?? 0));
const chaptersToDownloadWithDuplicates = Chapters.addDuplicates(chaptersToDownload, unreadUndownloadedChapters);
if (!chaptersToDownloadWithDuplicates.length) {
return;
}
Chapters.performAction('download', Chapters.getIds(chaptersToDownloadWithDuplicates), {}).catch(
defaultPromiseErrorHandler('ChaptersDownloadActionMenuItems::handleSelect::singleMangaMode'),
);
};
export const ChaptersDownloadActionMenuItems = ({
mangaIds,
closeMenu,
}: {
mangaIds: MangaType['id'][];
closeMenu: () => void;
}) => {
const { t } = useTranslation();
const {
settings: { downloadAheadLimit },
} = useMetadataServerSettings();
const handleSelect = (size?: number, onlyUnread: boolean = true, downloadAhead: boolean = false) => {
handleDownload(mangaIds, onlyUnread, size, downloadAhead).catch((e) =>
makeToast(
t(CHAPTER_ACTION_TO_TRANSLATION.download.error, {
count: size,
}),
'error',
getErrorMessage(e),
),
);
closeMenu?.();
};
return (
<>
{DOWNLOAD_OPTIONS.map(({ title, getCount, onlyUnread, isDownloadAhead }) => (
<MenuItem
key={t(title, { count: getCount(downloadAheadLimit) })}
onClick={() => handleSelect(getCount(downloadAheadLimit), onlyUnread, isDownloadAhead)}
>
{t(title, { count: getCount(downloadAheadLimit) })}
</MenuItem>
))}
</>
);
};

View File

@@ -0,0 +1,56 @@
/*
* 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 { useTranslation } from 'react-i18next';
import DownloadIcon from '@mui/icons-material/Download';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
import { Chapters } from '@/features/chapter/services/Chapters.ts';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { makeToast } from '@/features/core/utils/Toast.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { MUIUtil } from '@/lib/mui/MUI.util.ts';
import { ChapterIdInfo } from '@/features/chapter/Chapter.types.ts';
export const ChapterDownloadButton = ({
chapterId,
isDownloaded,
}: {
chapterId: ChapterIdInfo['id'];
isDownloaded: boolean;
}) => {
const { t } = useTranslation();
const download = Chapters.useDownloadStatusFromCache(chapterId);
const downloadChapter = () => {
requestManager
.addChapterToDownloadQueue(chapterId)
.response.catch((e) =>
makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)),
);
};
if (download == null && isDownloaded) {
return null;
}
return (
<CustomTooltip title={t('chapter.action.download.add.label.action')}>
<IconButton
{...MUIUtil.preventRippleProp()}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
downloadChapter();
}}
>
<DownloadIcon />
</IconButton>
</CustomTooltip>
);
};

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/.
*/
import Refresh from '@mui/icons-material/Refresh';
import IconButton from '@mui/material/IconButton';
import { useTranslation } from 'react-i18next';
import { DownloadState } from '@/lib/graphql/generated/graphql.ts';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
import { Chapters } from '@/features/chapter/services/Chapters.ts';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { makeToast } from '@/features/core/utils/Toast.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { MUIUtil } from '@/lib/mui/MUI.util.ts';
import { ChapterIdInfo } from '@/features/chapter/Chapter.types.ts';
export const ChapterDownloadRetryButton = ({ chapterId }: { chapterId: ChapterIdInfo['id'] }) => {
const { t } = useTranslation();
const download = Chapters.useDownloadStatusFromCache(chapterId);
const handleRetry = async () => {
try {
await requestManager.addChapterToDownloadQueue(chapterId).response;
} catch (e) {
makeToast(t('download.queue.error.label.failed_to_retry'), 'error', getErrorMessage(e));
}
};
if (download?.state !== DownloadState.Error) {
return null;
}
return (
<CustomTooltip title={t('global.button.retry')}>
<IconButton
{...MUIUtil.preventRippleProp()}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleRetry();
}}
>
<Refresh />
</IconButton>
</CustomTooltip>
);
};

View File

@@ -0,0 +1,228 @@
/*
* 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 BookmarkIcon from '@mui/icons-material/Bookmark';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import CardActionArea from '@mui/material/CardActionArea';
import Checkbox from '@mui/material/Checkbox';
import Stack from '@mui/material/Stack';
import Card from '@mui/material/Card';
import IconButton from '@mui/material/IconButton';
import { useTheme } from '@mui/material/styles';
import React, { memo, MouseEvent, TouchEvent, useRef } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import PopupState, { bindMenu, bindTrigger } from 'material-ui-popup-state';
import { useLongPress } from 'use-long-press';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
import { getDateString } from '@/util/DateHelper.ts';
import { DownloadStateIndicator } from '@/features/core/components/downloads/DownloadStateIndicator.tsx';
import { ChapterType } from '@/lib/graphql/generated/graphql.ts';
import { ChapterActionMenuItems } from '@/features/chapter/components/actions/ChapterActionMenuItems.tsx';
import { Menu } from '@/features/core/components/menu/Menu.tsx';
import { Chapters } from '@/features/chapter/services/Chapters.ts';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { ChapterCardMetadata } from '@/features/chapter/components/cards/ChapterCardMetadata.tsx';
import { MUIUtil } from '@/lib/mui/MUI.util.ts';
import { ListCardContent } from '@/features/core/components/lists/cards/ListCardContent.tsx';
import {
ChapterBookmarkInfo,
ChapterDownloadInfo,
ChapterIdInfo,
ChapterMangaInfo,
ChapterNumberInfo,
ChapterReadInfo,
ChapterScanlatorInfo,
} from '@/features/chapter/Chapter.types.ts';
import { MediaQuery } from '@/features/core/utils/MediaQuery.tsx';
type TChapter = ChapterIdInfo &
ChapterMangaInfo &
ChapterDownloadInfo &
ChapterReadInfo &
ChapterBookmarkInfo &
ChapterNumberInfo &
ChapterScanlatorInfo &
Pick<ChapterType, 'name' | 'sourceOrder' | 'uploadDate'>;
interface IProps {
mode?: 'manga.page' | 'reader';
chapter: TChapter;
showChapterNumber: boolean;
onSelect: (id: number, selected: boolean, isShiftKey?: boolean) => void;
selected: boolean | null;
selectable?: boolean;
isActiveChapter?: boolean; // reader
}
export const ChapterCard = memo((props: IProps) => {
const { t } = useTranslation();
const theme = useTheme();
const preventMobileContextMenu = MediaQuery.usePreventMobileContextMenu();
const menuButtonRef = useRef<HTMLButtonElement>(null);
const {
mode = 'manga.page',
chapter,
showChapterNumber,
onSelect,
selected,
selectable = true,
isActiveChapter = false,
} = props;
const isSelecting = selected !== null;
const { isDownloaded } = chapter;
const handleClick = (event: MouseEvent | TouchEvent) => {
if (!isSelecting) return;
event.preventDefault();
event.stopPropagation();
onSelect(chapter.id, !selected, event.shiftKey);
};
const handleClickOpenMenu = (
event: React.MouseEvent | React.TouchEvent,
openMenu?: (e: React.SyntheticEvent) => void,
) => {
event.stopPropagation();
event.preventDefault();
openMenu?.(event);
};
const longPressBind = useLongPress((event, { context: openMenu }) => {
if (!isSelecting && !!menuButtonRef.current) {
handleClickOpenMenu(event, () => (openMenu as (event: Element) => void)?.(menuButtonRef.current!));
return;
}
// eslint-disable-next-line no-param-reassign
event.shiftKey = true;
handleClick(event);
});
return (
<PopupState variant="popover" popupId="chapter-card-action-menu">
{(popupState) => (
<Stack sx={{ pt: 1, px: 1 }}>
<Card
sx={{
...applyStyles(mode === 'reader' && isActiveChapter, {
backgroundColor: 'primary.main',
}),
}}
>
<CardActionArea
component={Link}
to={Chapters.getReaderUrl(chapter)}
onContextMenu={preventMobileContextMenu}
sx={MediaQuery.preventMobileContextMenuSx()}
style={{
color: theme.palette.text[chapter.isRead ? 'disabled' : 'primary'],
}}
state={Chapters.getReaderOpenChapterLocationState(chapter, true)}
replace={mode === 'reader'}
onClick={(e) => handleClick(e)}
{...longPressBind(popupState.open)}
>
<ListCardContent>
<ChapterCardMetadata
title={
showChapterNumber
? `${t('chapter.title_one')} ${chapter.chapterNumber}`
: chapter.name
}
secondaryText={chapter.scanlator}
ternaryText={`${getDateString(Number(chapter.uploadDate ?? 0), true)}${isDownloaded ? `${t('chapter.status.label.downloaded')}` : ''}`}
infoIcons={
chapter.isBookmarked && (
<BookmarkIcon
color={mode === 'reader' && isActiveChapter ? 'secondary' : 'primary'}
/>
)
}
slotProps={{
title: {
variant: 'h6',
component: 'h3',
sx: applyStyles(mode === 'reader' && isActiveChapter, {
color: theme.palette.primary.contrastText,
}),
},
secondaryText: {
sx: applyStyles(mode === 'reader' && isActiveChapter, {
color: theme.palette.primary.contrastText,
}),
},
ternaryText: {
sx: applyStyles(mode === 'reader' && isActiveChapter, {
color: theme.palette.primary.contrastText,
}),
},
}}
/>
<DownloadStateIndicator
chapterId={chapter.id}
color={
mode === 'reader' && isActiveChapter
? theme.palette.primary.contrastText
: undefined
}
/>
<Stack sx={{ minHeight: '48px' }}>
{selected === null ? (
<CustomTooltip title={t('global.button.options')}>
<IconButton
ref={menuButtonRef}
{...MUIUtil.preventRippleProp(bindTrigger(popupState), {
onClick: (e: MouseEvent) => handleClickOpenMenu(e),
})}
aria-label="more"
sx={{
color: 'inherit',
...applyStyles(mode === 'reader' && isActiveChapter, {
color: 'primary.contrastText',
}),
}}
>
<MoreVertIcon />
</IconButton>
</CustomTooltip>
) : (
<CustomTooltip
title={t(selected ? 'global.button.deselect' : 'global.button.select')}
>
<Checkbox checked={selected} />
</CustomTooltip>
)}
</Stack>
</ListCardContent>
</CardActionArea>
</Card>
{!isSelecting && popupState.isOpen && (
<Menu {...bindMenu(popupState)}>
{(onClose) => (
<ChapterActionMenuItems
onClose={onClose}
chapter={chapter}
handleSelection={() => onSelect(chapter.id, true)}
canBeDownloaded={Chapters.isDownloadable(chapter)}
selectable={selectable}
/>
)}
</Menu>
)}
</Stack>
)}
</PopupState>
);
});

View File

@@ -0,0 +1,83 @@
/*
* 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 { ComponentProps, ReactNode } from 'react';
import Stack from '@mui/material/Stack';
import { TypographyMaxLines } from '@/features/core/components/texts/TypographyMaxLines.tsx';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
export const ChapterCardMetadata = ({
title,
secondaryText,
ternaryText,
infoIcons,
slotProps,
}: {
title: string;
secondaryText?: string | null;
ternaryText?: string | null;
infoIcons?: ReactNode;
slotProps?: {
title?: ComponentProps<typeof TypographyMaxLines>;
secondaryText?: ComponentProps<typeof TypographyMaxLines>;
ternaryText?: ComponentProps<typeof TypographyMaxLines>;
};
}) => (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
flexGrow: 1,
flexShrink: 1,
wordBreak: 'break-word',
}}
>
<Stack
sx={{
flexDirection: 'row',
gap: 0.5,
alignItems: 'center',
}}
>
{infoIcons}
<CustomTooltip title={title}>
<TypographyMaxLines variant="h6" component="h3" {...slotProps?.title}>
{title}
</TypographyMaxLines>
</CustomTooltip>
</Stack>
{secondaryText && (
<CustomTooltip title={secondaryText}>
<TypographyMaxLines
variant="caption"
display="block"
lines={1}
{...slotProps?.secondaryText}
sx={{ maxWidth: 'fit-content', ...slotProps?.secondaryText?.sx }}
>
{secondaryText}
</TypographyMaxLines>
</CustomTooltip>
)}
{ternaryText && (
<CustomTooltip title={ternaryText}>
<TypographyMaxLines
variant="caption"
display="block"
lines={1}
{...slotProps?.ternaryText}
sx={{ maxWidth: 'fit-content', ...slotProps?.ternaryText?.sx }}
>
{ternaryText}
</TypographyMaxLines>
</CustomTooltip>
)}
</Box>
);

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 { Link } from 'react-router-dom';
import { AppRoutes } from '@/features/core/AppRoute.constants.ts';
import { Mangas } from '@/features/manga/services/Mangas.ts';
import { MangaIdInfo, MangaThumbnailInfo } from '@/features/manga/Manga.types.ts';
import { MangaType } from '@/lib/graphql/generated/graphql.ts';
import { ListCardAvatar } from '@/features/core/components/lists/cards/ListCardAvatar.tsx';
export const ChapterCardThumbnail = ({
mangaId,
mangaTitle,
thumbnailUrl,
thumbnailUrlLastFetched,
}: MangaThumbnailInfo & {
mangaId: MangaIdInfo['id'];
mangaTitle: MangaType['title'];
}) => (
<Link to={AppRoutes.manga.path(mangaId)} style={{ textDecoration: 'none' }}>
<ListCardAvatar
iconUrl={Mangas.getThumbnailUrl({ thumbnailUrl, thumbnailUrlLastFetched })}
alt={mangaTitle}
slots={{ spinnerImageProps: { imgStyle: { imageRendering: 'pixelated' } } }}
/>
</Link>
);

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 Stack from '@mui/material/Stack';
import { ComponentProps } from 'react';
import { ChapterCard } from '@/features/chapter/components/cards/ChapterCard.tsx';
import { Chapters } from '@/features/chapter/services/Chapters.ts';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { MissingChaptersInfoSeparator } from '@/features/chapter/components/MissingChaptersInfoSeparator.tsx';
type ChapterCardProps = ComponentProps<typeof ChapterCard>;
export const ChapterListCard = ({
index,
isSortDesc,
chapters,
...chapterCardProps
}: Omit<ChapterCardProps, 'chapter'> & {
index: number;
isSortDesc: boolean;
chapters: ChapterCardProps['chapter'][];
}) => {
const previousChapterIndex = isSortDesc ? index + 1 : index - 1;
const chapter = chapters[index];
const previousChapter = chapters[previousChapterIndex] ?? { chapterNumber: 0 };
const missingChaptersGap = Chapters.getGap(chapter, previousChapter);
const areChaptersMissing = missingChaptersGap > 0;
return (
<Stack sx={applyStyles(!isSortDesc, { flexDirection: 'column-reverse' })}>
<ChapterCard {...chapterCardProps} chapter={chapters[index]} />
{areChaptersMissing && <MissingChaptersInfoSeparator missingChaptersGap={missingChaptersGap} />}
</Stack>
);
};

View File

@@ -0,0 +1,485 @@
/*
* 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 { DocumentNode, MaybeMasked, Unmasked, useFragment } from '@apollo/client';
import { makeToast } from '@/features/core/utils/Toast.ts';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { getMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
import {
ChapterListFieldsFragment,
ChapterType,
DownloadState,
DownloadTypeFieldsFragment,
} from '@/lib/graphql/generated/graphql.ts';
import { CHAPTER_LIST_FIELDS } from '@/lib/graphql/fragments/ChapterFragments.ts';
import { DirectionOffset } from '@/Base.types.ts';
import { MangaIdInfo } from '@/features/manga/Manga.types.ts';
import { ReaderOpenChapterLocationState, ReaderResumeMode } from '@/features/reader/types/Reader.types.ts';
import { AppRoutes } from '@/features/core/AppRoute.constants.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { DOWNLOAD_TYPE_FIELDS } from '@/lib/graphql/fragments/DownloadFragments.ts';
import { epochToDate, getDateString } from '@/util/DateHelper.ts';
import {
CHAPTER_ACTION_TO_CONFIRMATION_REQUIRED,
CHAPTER_ACTION_TO_TRANSLATION,
} from '@/features/chapter/Chapter.constants.ts';
import {
ChapterAction,
ChapterBookmarkInfo,
ChapterDownloadInfo,
ChapterIdInfo,
ChapterMangaInfo,
ChapterNumberInfo,
ChapterReadInfo,
ChapterScanlatorInfo,
ChapterSourceOrderInfo,
} from '@/features/chapter/Chapter.types.ts';
import { assertIsDefined } from '@/Asserts.ts';
import { awaitConfirmation } from '@/features/core/utils/AwaitableDialog.tsx';
export class Chapters {
static getIds(chapters: { id: number }[]): number[] {
return chapters.map((chapter) => chapter.id);
}
static getFromCache<T = ChapterListFieldsFragment>(
id: number,
fragment: DocumentNode = CHAPTER_LIST_FIELDS,
fragmentName: string = 'CHAPTER_LIST_FIELDS',
): Unmasked<T> | null {
return requestManager.graphQLClient.client.cache.readFragment<T>({
id: requestManager.graphQLClient.client.cache.identify({
__typename: 'ChapterType',
id,
}),
fragment,
fragmentName,
});
}
static getDownloadStatusFromCache<T = DownloadTypeFieldsFragment>(
id: number,
fragment: DocumentNode = DOWNLOAD_TYPE_FIELDS,
fragmentName: string = 'DOWNLOAD_TYPE_FIELDS',
): Unmasked<T> | null {
return requestManager.graphQLClient.client.cache.readFragment<T>({
id: requestManager.graphQLClient.client.cache.identify({
__typename: 'DownloadType',
chapter: {
__ref: requestManager.graphQLClient.client.cache.identify({ __typename: 'ChapterType', id }),
},
}),
fragment,
fragmentName,
});
}
static useDownloadStatusFromCache<T = DownloadTypeFieldsFragment>(
id: number,
fragment: DocumentNode = DOWNLOAD_TYPE_FIELDS,
fragmentName: string = 'DOWNLOAD_TYPE_FIELDS',
): MaybeMasked<T> | null {
const downloadStatus = useFragment<T>({
from: {
__typename: 'DownloadType',
chapter: {
__ref: requestManager.graphQLClient.client.cache.identify({ __typename: 'ChapterType', id }),
},
},
fragment,
fragmentName,
client: requestManager.graphQLClient.client,
});
if (!downloadStatus.complete || !Object.keys(downloadStatus.data ?? {}).length) {
return null;
}
return downloadStatus.data;
}
static getReaderUrl<Chapter extends ChapterMangaInfo & ChapterSourceOrderInfo>(chapter: Chapter): string {
return AppRoutes.reader.path(chapter.mangaId, chapter.sourceOrder);
}
static isDownloading(id: number): boolean {
const activeDownloadStates = [DownloadState.Downloading, DownloadState.Queued];
const downloadStatus = Chapters.getDownloadStatusFromCache(id);
return activeDownloadStates.includes(downloadStatus?.state as DownloadState);
}
static isDownloaded({ isDownloaded }: ChapterDownloadInfo): boolean {
return isDownloaded;
}
static getDownloaded<Chapter extends ChapterDownloadInfo>(chapters: Chapter[]): Chapter[] {
return chapters.filter(Chapters.isDownloaded);
}
static isDownloadable<Chapter extends ChapterIdInfo & ChapterDownloadInfo>(chapter: Chapter): boolean {
const downloadStatus = Chapters.getDownloadStatusFromCache(chapter.id);
return !Chapters.isDownloaded(chapter) && (!downloadStatus || downloadStatus.state === DownloadState.Error);
}
static getDownloadable<Chapter extends ChapterIdInfo & ChapterDownloadInfo>(chapters: Chapter[]): Chapter[] {
return chapters.filter(this.isDownloadable);
}
static isDeletable(
{ isBookmarked, ...chapter }: ChapterDownloadInfo & ChapterBookmarkInfo,
canDeleteBookmarked: boolean = false,
): boolean {
return Chapters.isDownloaded(chapter) && (!isBookmarked || canDeleteBookmarked);
}
static getDeletable<Chapters extends ChapterDownloadInfo & ChapterBookmarkInfo>(
chapters: Chapters[],
canDeleteBookmarked?: boolean,
): Chapters[] {
return chapters.filter((chapter) => Chapters.isDeletable(chapter, canDeleteBookmarked));
}
static isBookmarked({ isBookmarked }: ChapterBookmarkInfo): boolean {
return isBookmarked;
}
static getBookmarked<Chapter extends ChapterBookmarkInfo>(chapters: Chapter[]): Chapter[] {
return chapters.filter(Chapters.isBookmarked);
}
static getNonBookmarked<Chapter extends ChapterBookmarkInfo>(chapters: Chapter[]): Chapter[] {
return chapters.filter((chapter) => !Chapters.isBookmarked(chapter));
}
static isRead({ isRead }: ChapterReadInfo): boolean {
return isRead;
}
static getRead<Chapter extends ChapterReadInfo>(chapters: Chapter[]): Chapter[] {
return chapters.filter(Chapters.isRead);
}
static getNonRead<Chapter extends ChapterReadInfo>(chapters: Chapter[]): Chapter[] {
return chapters.filter((chapter) => !Chapters.isRead(chapter));
}
static getMatchingChapterNumberChapters<Chapter extends ChapterNumberInfo>(
chaptersA: Chapter[],
chaptersB: Chapter[],
): [ChapterA: Chapter, ChapterB: Chapter][] {
return chaptersA
.map((chapterA) => {
const matchingChapter = chaptersB.find((chapterB) => chapterA.chapterNumber === chapterB.chapterNumber);
if (!matchingChapter) {
return null;
}
return [chapterA, matchingChapter];
})
.filter((matchingChapters): matchingChapters is [Chapter, Chapter] => matchingChapters !== null);
}
static async download(chapterIds: number[], disableConfirmation?: boolean): Promise<void> {
return Chapters.executeAction(
'download',
chapterIds.length,
() => requestManager.addChaptersToDownloadQueue(chapterIds).response,
disableConfirmation,
);
}
static async delete(chapterIds: number[], disableConfirmation?: boolean): Promise<void> {
return Chapters.executeAction(
'delete',
chapterIds.length,
() => requestManager.deleteDownloadedChapters(chapterIds).response,
disableConfirmation,
);
}
static async markAsRead(
chapters: (ChapterIdInfo & ChapterDownloadInfo & ChapterBookmarkInfo)[],
wasManuallyMarkedAsRead: boolean = false,
trackProgressMangaId?: MangaIdInfo['id'],
disableConfirmation?: boolean,
): Promise<void> {
const { deleteChaptersManuallyMarkedRead, deleteChaptersWithBookmark, updateProgressManualMarkRead } =
await getMetadataServerSettings();
const chapterIdsToDelete =
deleteChaptersManuallyMarkedRead && wasManuallyMarkedAsRead
? Chapters.getIds(Chapters.getDeletable(chapters, deleteChaptersWithBookmark))
: [];
return Chapters.executeAction(
'mark_as_read',
chapters.length,
() =>
requestManager.updateChapters(Chapters.getIds(chapters), {
isRead: true,
lastPageRead: 0,
chapterIdsToDelete,
trackProgressMangaId:
updateProgressManualMarkRead && wasManuallyMarkedAsRead ? trackProgressMangaId : undefined,
}).response,
disableConfirmation,
);
}
static async markAsUnread(chapterIds: number[], disableConfirmation?: boolean): Promise<void> {
return Chapters.executeAction(
'mark_as_unread',
chapterIds.length,
() => requestManager.updateChapters(chapterIds, { isRead: false }).response,
disableConfirmation,
);
}
static async bookmark(chapterIds: number[]): Promise<void> {
return Chapters.executeAction(
'bookmark',
chapterIds.length,
() => requestManager.updateChapters(chapterIds, { isBookmarked: true }).response,
);
}
static async unBookmark(chapterIds: number[]): Promise<void> {
return Chapters.executeAction(
'unbookmark',
chapterIds.length,
() => requestManager.updateChapters(chapterIds, { isBookmarked: false }).response,
);
}
private static async executeAction(
action: ChapterAction,
itemCount: number,
fnToExecute: () => Promise<unknown>,
disableConfirmation?: boolean,
): Promise<void> {
const { always, bulkAction, bulkActionCountForce } = CHAPTER_ACTION_TO_CONFIRMATION_REQUIRED[action];
const requiresConfirmation =
(!disableConfirmation && (always || (bulkAction && itemCount > 1))) ||
(bulkActionCountForce && itemCount >= bulkActionCountForce);
const confirmationMessage = CHAPTER_ACTION_TO_TRANSLATION[action].confirmation;
try {
if (requiresConfirmation) {
assertIsDefined(confirmationMessage);
try {
await awaitConfirmation({
title: translate('global.label.are_you_sure'),
message: translate(confirmationMessage, { count: itemCount }),
actions: {
confirm: {
title: translate('global.button.ok'),
},
},
});
} catch (_) {
return;
}
}
await fnToExecute();
makeToast(translate(CHAPTER_ACTION_TO_TRANSLATION[action].success, { count: itemCount }), 'success');
} catch (e) {
makeToast(
translate(CHAPTER_ACTION_TO_TRANSLATION[action].error, { count: itemCount }),
'error',
getErrorMessage(e),
);
throw e;
}
}
static async performAction<Action extends ChapterAction>(
action: Action,
chapterIds: number[],
{
wasManuallyMarkedAsRead,
trackProgressMangaId,
chapters,
}: Action extends 'mark_as_read'
? {
wasManuallyMarkedAsRead: boolean;
trackProgressMangaId?: MangaIdInfo['id'];
chapters: (ChapterIdInfo & ChapterDownloadInfo & ChapterBookmarkInfo & ChapterReadInfo)[];
}
: {
wasManuallyMarkedAsRead?: never;
trackProgressMangaId?: never;
chapters?: never;
},
): Promise<void> {
switch (action) {
case 'download':
return Chapters.download(chapterIds);
case 'delete':
return Chapters.delete(chapterIds);
case 'mark_as_read':
return Chapters.markAsRead(chapters!, wasManuallyMarkedAsRead!, trackProgressMangaId);
case 'mark_as_unread':
return Chapters.markAsUnread(chapterIds);
case 'bookmark':
return Chapters.bookmark(chapterIds);
case 'unbookmark':
return Chapters.unBookmark(chapterIds);
default:
throw new Error(`Chapters::performAction: unknown action "${action}"`);
}
}
/**
* Returns the provided "uniqueChapters" plus their duplicates found in "allChapters"
*/
static addDuplicates<T extends ChapterScanlatorInfo & ChapterNumberInfo>(
uniqueChapters: T[],
allChapters: T[],
): T[] {
const chapterNumberToChapters = Object.groupBy(allChapters, ({ chapterNumber }) => chapterNumber);
return uniqueChapters
.map((uniqueChapter) => chapterNumberToChapters[uniqueChapter.chapterNumber] ?? [uniqueChapter])
.flat();
}
static removeDuplicates<T extends ChapterIdInfo & ChapterScanlatorInfo & ChapterNumberInfo>(
currentChapter: T,
chapters: T[],
): T[] {
const chapterNumberToChapters = Object.groupBy(chapters, ({ chapterNumber }) => chapterNumber);
const uniqueChapters = Object.values(chapterNumberToChapters).map(
(groupedChapters) =>
// the result of groupBy can't result in undefined values
groupedChapters!.find((chapter) => chapter.id === currentChapter.id) ??
groupedChapters!.findLast((chapter) => chapter.scanlator === currentChapter.scanlator) ??
groupedChapters!.slice(-1)[0],
);
// keep the chapters in the same order as they were passed
return chapters
.map(({ id }) => uniqueChapters.find((chapter) => chapter.id === id))
.filter((chapter): chapter is T => !!chapter);
}
static getNextChapter<Chapter extends ChapterIdInfo & ChapterScanlatorInfo & ChapterNumberInfo & ChapterReadInfo>(
currentChapter: Chapter,
chapters: Chapter[],
{
offset = DirectionOffset.NEXT,
...options
}: { offset?: DirectionOffset; onlyUnread?: boolean; skipDupe?: boolean; skipDupeChapter?: Chapter } = {},
): Chapter | undefined {
const nextChapters = Chapters.getNextChapters(currentChapter, chapters, { offset, ...options });
const isNextChapterOffset = offset === DirectionOffset.NEXT;
const sliceStartIndex = isNextChapterOffset ? -1 : 0;
const sliceEndIndex = isNextChapterOffset ? undefined : 1;
return nextChapters.slice(sliceStartIndex, sliceEndIndex)[0];
}
static getNextChapters<Chapter extends ChapterIdInfo & ChapterScanlatorInfo & ChapterNumberInfo & ChapterReadInfo>(
fromChapter: Chapter,
chapters: Chapter[],
{
offset = DirectionOffset.NEXT,
onlyUnread = false,
skipDupe = false,
skipDupeChapter = fromChapter,
}: { offset?: DirectionOffset; onlyUnread?: boolean; skipDupe?: boolean; skipDupeChapter?: Chapter } = {},
): Chapter[] {
const fromChapterIndex = chapters.findIndex((chapter) => chapter.id === fromChapter.id);
const isNextChapterOffset = offset === DirectionOffset.NEXT;
const sliceStartIndex = isNextChapterOffset ? 0 : fromChapterIndex;
const sliceEndIndex = isNextChapterOffset ? fromChapterIndex + 1 : undefined;
const nextChaptersIncludingCurrent = chapters.slice(sliceStartIndex, sliceEndIndex);
const uniqueNextChapters = skipDupe
? Chapters.removeDuplicates(skipDupeChapter, nextChaptersIncludingCurrent)
: nextChaptersIncludingCurrent;
const nextChapters = uniqueNextChapters.toSpliced(isNextChapterOffset ? -1 : 0, 1);
return onlyUnread ? Chapters.getNonRead(nextChapters) : nextChapters;
}
static getReaderResumeMode(chapter: ChapterReadInfo): ReaderResumeMode {
if (chapter.isRead) {
return ReaderResumeMode.START;
}
return ReaderResumeMode.LAST_READ;
}
static getReaderOpenChapterLocationState(
chapter: ChapterReadInfo,
updateInitialChapter?: boolean,
): ReaderOpenChapterLocationState {
return {
resumeMode: Chapters.getReaderResumeMode(chapter),
updateInitialChapter,
};
}
/**
* Returns the chapters grouped by the passed key representing a timestamp.
*
* The timestamp gets mapped to a string via {@link getDateString}
*/
static groupByDate<
T extends Pick<ChapterType, 'lastReadAt'> | Pick<ChapterType, 'fetchedAt'> | Pick<ChapterType, 'uploadDate'>,
K extends keyof ExtractCommon<OmitNotMatching<ChapterType, 'lastReadAt' | 'fetchedAt' | 'uploadDate'>, T>,
>(chapters: T[], key: K): Record<string, T[]> {
return Object.groupBy(chapters, (chapter) => getDateString(epochToDate(Number(chapter[key])))) as Record<
string,
T[]
>;
}
static getMissingCount<Chapter extends ChapterNumberInfo>(chapters: Chapter[]): number {
const sortedChapters = chapters.toSorted((a, b) => a.chapterNumber - b.chapterNumber);
return sortedChapters.reduce(
(missingChapterCount, chapter, index) =>
missingChapterCount + Chapters.getGap(chapter, sortedChapters[index - 1]),
0,
);
}
static getGap<Chapter extends ChapterNumberInfo>(
chapterA: Chapter | undefined,
chapterB: Chapter | undefined,
): number {
if (!chapterA || !chapterB) {
return 0;
}
if (chapterA.chapterNumber === -1 || chapterB.chapterNumber === -1) {
return 0;
}
const higherChapterNumber = Math.max(chapterA.chapterNumber, chapterB.chapterNumber);
const lowerChapterNumber = Math.min(chapterA.chapterNumber, chapterB.chapterNumber);
return Math.max(0, Math.floor(higherChapterNumber) - Math.floor(lowerChapterNumber) - 1);
}
static getScanlators<Chapter extends ChapterScanlatorInfo>(chapters: Chapter[]): string[] {
return [
...new Set(
chapters.map((chapter) => chapter.scanlator).filter((scanlator) => typeof scanlator === 'string'),
),
];
}
}

View File

@@ -0,0 +1,136 @@
/*
* 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 } from 'react';
import { ChapterType } from '@/lib/graphql/generated/graphql.ts';
import { NullAndUndefined } from '@/Base.types.ts';
import {
ChapterBookmarkInfo,
ChapterDownloadInfo,
ChapterListOptions,
ChapterReadInfo,
ChapterScanlatorInfo,
} from '@/features/chapter/Chapter.types.ts';
import { MangaIdInfo } from '@/features/manga/Manga.types.ts';
import { GqlMetaHolder } from '@/features/metadata/Metadata.types.ts';
import { createUpdateMangaMetadata, useGetMangaMetadata } from '@/features/manga/services/MangaMetadata.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
export function unreadFilter(unread: NullAndUndefined<boolean>, { isRead: isChapterRead }: ChapterReadInfo) {
switch (unread) {
case true:
return !isChapterRead;
case false:
return isChapterRead;
default:
return true;
}
}
function downloadFilter(downloaded: NullAndUndefined<boolean>, { isDownloaded: chapterDownload }: ChapterDownloadInfo) {
switch (downloaded) {
case true:
return chapterDownload;
case false:
return !chapterDownload;
default:
return true;
}
}
function bookmarkedFilter(
bookmarked: NullAndUndefined<boolean>,
{ isBookmarked: chapterBookmarked }: ChapterBookmarkInfo,
) {
switch (bookmarked) {
case true:
return chapterBookmarked;
case false:
return !chapterBookmarked;
default:
return true;
}
}
function scanlatorFilter(excludedScanlators: string[], { scanlator }: ChapterScanlatorInfo): boolean {
return !scanlator || !excludedScanlators.includes(scanlator);
}
type TChapterSort = Pick<ChapterType, 'sourceOrder' | 'fetchedAt' | 'chapterNumber' | 'uploadDate'>;
const sortChapters = <T extends TChapterSort>(
chapters: T[],
{ sortBy, reverse }: Pick<ChapterListOptions, 'sortBy' | 'reverse'>,
): T[] => {
const sortedChapters: T[] = [...chapters];
switch (sortBy) {
case 'source':
sortedChapters.sort((a, b) => a.sourceOrder - b.sourceOrder);
break;
case 'fetchedAt':
sortedChapters.sort((a, b) => Number(a.fetchedAt ?? 0) - Number(b.fetchedAt ?? 0));
break;
case 'chapterNumber':
sortedChapters.sort((a, b) => a.chapterNumber - b.chapterNumber);
break;
case 'uploadedAt':
sortedChapters.sort((a, b) => Number(a.uploadDate ?? 0) - Number(b.uploadDate ?? 0));
break;
default:
// nothing to do
}
if (reverse) {
sortedChapters.reverse();
}
return sortedChapters;
};
type TChapterFilter = ChapterReadInfo & ChapterDownloadInfo & ChapterBookmarkInfo & ChapterScanlatorInfo;
export function filterChapters<Chapters extends TChapterFilter>(
chapters: Chapters[],
options: ChapterListOptions,
): Chapters[] {
return chapters.filter(
(chp) =>
unreadFilter(options.unread, chp) &&
downloadFilter(options.downloaded, chp) &&
bookmarkedFilter(options.bookmarked, chp) &&
scanlatorFilter(options.excludedScanlators, chp),
);
}
export function filterAndSortChapters<Chapters extends TChapterSort & TChapterFilter>(
chapters: Chapters[],
options: ChapterListOptions,
): Chapters[] {
const filtered = filterChapters(chapters, options);
return sortChapters(filtered, options);
}
export const isFilterActive = (options: ChapterListOptions) => {
const { unread, downloaded, bookmarked, excludedScanlators } = options;
return unread != null || downloaded != null || bookmarked != null || !!excludedScanlators.length;
};
export const useChapterListOptions = (manga: MangaIdInfo & GqlMetaHolder): ChapterListOptions => {
const { unread, downloaded, bookmarked, reverse, sortBy, showChapterNumber, excludedScanlators } =
useGetMangaMetadata(manga);
return useMemo(
() => ({ unread, downloaded, bookmarked, reverse, sortBy, showChapterNumber, excludedScanlators }),
[unread, downloaded, bookmarked, reverse, sortBy, showChapterNumber, excludedScanlators],
);
};
export const updateChapterListOptions = (
manga: MangaIdInfo & GqlMetaHolder,
handleError: (error: any) => void = defaultPromiseErrorHandler('createUpdateMangaMetadata'),
) => createUpdateMangaMetadata(manga, handleError);

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 Checkbox from '@mui/material/Checkbox';
import { useTranslation } from 'react-i18next';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
export const SelectableCollectionSelectAll = ({
areAllItemsSelected,
areNoItemsSelected,
onChange,
}: {
areAllItemsSelected: boolean;
areNoItemsSelected: boolean;
onChange: (checked: boolean) => void;
}) => {
const { t } = useTranslation();
return (
<CustomTooltip title={t(!areAllItemsSelected ? 'global.button.select_all' : 'global.button.clear')}>
<Checkbox
sx={{
padding: '8px',
color: 'inherit',
'&.Mui-checked, &.MuiCheckbox-indeterminate': {
color: 'inherit',
},
}}
checked={areAllItemsSelected}
indeterminate={!areNoItemsSelected && !areAllItemsSelected}
onChange={(_, checked) => onChange(checked)}
/>
</CustomTooltip>
);
};

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 Checkbox from '@mui/material/Checkbox';
import { useTranslation } from 'react-i18next';
import ClearIcon from '@mui/icons-material/Clear';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
import { SelectableCollectionSelectAll } from '@/features/collection/components/SelectableCollectionSelectAll.tsx';
export const SelectableCollectionSelectMode = ({
isActive,
areAllItemsSelected,
areNoItemsSelected,
onSelectAll,
onModeChange,
}: {
isActive: boolean;
areAllItemsSelected: boolean;
areNoItemsSelected: boolean;
onSelectAll: (selectAll: boolean) => void;
onModeChange: (checked: boolean) => void;
}) => {
const { t } = useTranslation();
return (
<>
{isActive && (
<SelectableCollectionSelectAll
areAllItemsSelected={areAllItemsSelected}
areNoItemsSelected={areNoItemsSelected}
onChange={onSelectAll}
/>
)}
<CustomTooltip title={t(!isActive ? 'global.button.select_all' : 'global.button.cancel')}>
<Checkbox
checkedIcon={<ClearIcon />}
sx={{
padding: '8px',
color: 'inherit',
'&.Mui-checked': {
color: 'inherit',
},
}}
checked={isActive}
onChange={(_, checked) => onModeChange(checked)}
/>
</CustomTooltip>
</>
);
};

View File

@@ -0,0 +1,64 @@
/*
* 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 MoreHoriz from '@mui/icons-material/MoreHoriz';
import Fab from '@mui/material/Fab';
import Box from '@mui/material/Box';
import { styled } from '@mui/material/styles';
import React, { type JSX } from 'react';
import { useTranslation } from 'react-i18next';
import PopupState, { bindMenu, bindTrigger } from 'material-ui-popup-state';
import { DEFAULT_FAB_STYLE } from '@/features/core/components/buttons/StyledFab.tsx';
import { Menu } from '@/features/core/components/menu/Menu.tsx';
import { TranslationKey } from '@/Base.types.ts';
interface SelectionFABProps {
children: (handleClose: () => void, setHideMenu: (hide: boolean) => void) => JSX.Element;
selectedItemsCount: number;
title: TranslationKey;
}
const FabContainer = styled(Box)(({ theme }) => ({
...DEFAULT_FAB_STYLE,
height: `calc(${DEFAULT_FAB_STYLE.height} + 1)`,
paddingTop: '8px',
zIndex: 1, // the "Checkbox" (MUI) component of the "ChapterCard" has z-index 1, which causes it to take over the mouse events
[theme.breakpoints.down('md')]: {
marginBottom: '64px',
},
}));
export const SelectionFAB: React.FC<SelectionFABProps> = ({ children, selectedItemsCount, title }) => {
const { t } = useTranslation();
return (
<PopupState variant="popover" popupId="selection-fab-menu">
{(popupState) => (
<>
<FabContainer {...bindTrigger(popupState)}>
<Fab variant="extended" color="primary" id="selectionMenuButton">
{`${selectedItemsCount} ${t(title, { count: selectedItemsCount })}`}
<MoreHoriz sx={{ ml: 1 }} />
</Fab>
</FabContainer>
<Menu
{...bindMenu(popupState)}
id="selectionMenu"
anchorOrigin={{ horizontal: 'right', vertical: 'top' }}
transformOrigin={{ horizontal: 'right', vertical: 'bottom' }}
MenuListProps={{
'aria-labelledby': 'selectionMenuButton',
}}
>
{(onClose, setHideMenu) => children(onClose, setHideMenu)}
</Menu>
</>
)}
</PopupState>
);
};

View File

@@ -0,0 +1,145 @@
/*
* 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 { useCallback, useRef, useState } from 'react';
export type SelectableCollectionReturnType<Id extends number | string, Key extends string = string> = {
selectedItemIds: Id[];
keySelectedItemIds: Id[];
areAllItemsSelected: boolean;
areNoItemsSelected: boolean;
areAllItemsForKeySelected: boolean;
areNoItemsForKeySelected: boolean;
handleSelection: (id: Id, selected: boolean, options?: { selectRange?: boolean; key?: Key }) => void;
handleSelectAll: (selectAll: boolean, ids: Id[], key?: Key) => void;
setSelectionForKey: (key: Key, ids: Id[]) => void;
getSelectionForKey: (key: Key) => Id[];
clearSelection: () => void;
reset: () => void;
};
export const useSelectableCollection = <Id extends number | string, Key extends string = 'default'>(
totalCount: number,
{
itemIds = [],
keyCount = totalCount,
currentKey,
initialState = {} as Record<Key, Id[]>,
}: {
itemIds?: Id[];
keyCount?: number;
currentKey: Key;
initialState?: Record<Key, Id[]>;
},
): SelectableCollectionReturnType<Id, Key> => {
const [keyToSelectedItemIds, setKeyToSelectedItemIds] = useState<Record<string, Id[]>>(initialState);
const lastSelectedItemInfoRef = useRef<{ id: Id; key: Key }>(undefined);
const selectedItemIds = [...new Set(Object.values(keyToSelectedItemIds).flat())];
const areAllItemsSelected = selectedItemIds.length === totalCount;
const areNoItemsSelected = !selectedItemIds.length;
const keySelectedItemIds = keyToSelectedItemIds[currentKey] ?? [];
const areAllItemsForKeySelected = keySelectedItemIds.length === keyCount;
const areNoItemsForKeySelected = keySelectedItemIds.length === 0;
if (areNoItemsForKeySelected) {
lastSelectedItemInfoRef.current = undefined;
}
const handleSelection: SelectableCollectionReturnType<Id, Key>['handleSelection'] = useCallback(
(id, selected, { selectRange = false, key = currentKey } = {}) => {
const deselect = !selected;
const { id: lastSelectedItemId, key: lastSelectedItemIdKey } = lastSelectedItemInfoRef.current ?? {};
lastSelectedItemInfoRef.current = { id, key };
const isSelectRange = selectRange && key === lastSelectedItemIdKey && lastSelectedItemId !== undefined;
const indexOfLastSelectedItemId = isSelectRange ? itemIds.indexOf(lastSelectedItemId) : -1;
const indexOfSelectedId = isSelectRange ? itemIds.indexOf(id) : -1;
const selectedIds = isSelectRange
? itemIds.slice(
Math.min(indexOfLastSelectedItemId, indexOfSelectedId),
Math.max(indexOfLastSelectedItemId, indexOfSelectedId) + 1,
)
: [id];
if (deselect) {
setKeyToSelectedItemIds((prevState) => ({
...prevState,
[key]: prevState[key]?.filter((selectedItemId) => !selectedIds.includes(selectedItemId)) ?? [],
}));
return;
}
setKeyToSelectedItemIds((prevState) => ({
...prevState,
[key]: [...new Set([...(prevState[key] ?? []), ...selectedIds])],
}));
},
[currentKey, itemIds],
);
const handleSelectAll = useCallback(
(selectAll: boolean, ids: Id[], key: Key = currentKey) => {
switch (selectAll) {
case true:
setKeyToSelectedItemIds((prevState) => ({
...prevState,
[key]: [...ids],
}));
break;
case false:
setKeyToSelectedItemIds((prevState) => ({
...prevState,
[key]: [],
}));
break;
default:
break;
}
},
[currentKey],
);
const setSelectionForKey = useCallback((key: Key, ids: Id[]) => {
setKeyToSelectedItemIds((prevState) => ({
...prevState,
[key]: [...ids],
}));
}, []);
const getSelectionForKey = useCallback((key: Key) => keyToSelectedItemIds[key], [keyToSelectedItemIds]);
const clearSelection = useCallback(() => {
setKeyToSelectedItemIds({});
}, []);
const reset = useCallback(() => {
clearSelection();
setKeyToSelectedItemIds(initialState);
}, [clearSelection, initialState]);
return {
selectedItemIds,
keySelectedItemIds,
handleSelection,
handleSelectAll,
areAllItemsSelected,
areNoItemsSelected,
areAllItemsForKeySelected,
areNoItemsForKeySelected,
setSelectionForKey,
getSelectionForKey,
clearSelection,
reset,
};
};

View File

@@ -0,0 +1,208 @@
/*
* 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 { SourceType } from '@/lib/graphql/generated/graphql.ts';
import { MangaIdInfo } from '@/features/manga/Manga.types.ts';
import { ChapterSourceOrderInfo } from '@/features/chapter/Chapter.types.ts';
import { BrowseTab } from '@/features/browse/Browse.types.ts';
import { SearchParam } from '@/features/core/Core.types.ts';
type AppRouteInfo = {
match: string;
path?: string | ((...args: any[]) => string);
};
type TAppRoutes = Record<string, AppRouteInfo & { childRoutes?: TAppRoutes }>;
const createParam = (name: string, value: any): string => (value ? `${name}=${encodeURIComponent(value)}` : '');
const createQueryParam = (query: string | null | undefined): string => createParam(SearchParam.QUERY, query);
const addParams = (path: string, ...params: string[]) => {
const joinedParams = params.filter(Boolean).join('&');
return `${path}${joinedParams ? `?${joinedParams}` : ''}`;
};
export const AppRoutes = {
root: {
match: '/',
path: '/',
},
matchAll: {
match: '*',
},
about: {
match: 'about',
path: '/about',
},
settings: {
path: '/settings',
match: 'settings',
childRoutes: {
categories: {
match: 'categories',
path: '/settings/categories',
},
reader: {
match: 'reader',
path: '/settings/reader',
},
library: {
match: 'library',
path: '/settings/library',
childRoutes: {
duplicates: {
match: 'duplicates',
path: '/settings/library/duplicates',
},
},
},
download: {
match: 'download',
path: '/settings/download',
},
backup: {
match: 'backup',
path: '/settings/backup',
},
server: {
match: 'server',
path: '/settings/server',
},
webui: {
match: 'webui',
path: '/settings/webui',
},
browse: {
match: 'browse',
path: '/settings/browse',
},
device: {
match: 'device',
path: '/settings/device',
},
tracking: {
match: 'tracking',
path: '/settings/tracking',
},
appearance: {
match: 'appearance',
path: '/settings/appearance',
},
history: {
match: 'history',
path: '/settings/history',
},
},
},
sources: {
match: 'sources',
path: '/sources',
childRoutes: {
browse: {
match: ':sourceId',
path: (sourceId: SourceType['id'], query?: string | null | undefined) =>
addParams(`/sources/${sourceId}`, createQueryParam(query)),
},
configure: {
match: ':sourceId/configure',
path: (sourceId: SourceType['id']) => `/sources/${sourceId}/configure`,
},
searchAll: {
match: 'all/search',
path: (query?: string | null | undefined) => addParams('/sources/all/search', createQueryParam(query)),
},
},
},
extension: {
match: 'extension',
path: '/extension',
childRoutes: {
info: {
match: ':pkgName',
path: (pkgName: string) => `/extension/${pkgName}`,
},
},
},
downloads: {
match: 'downloads',
path: '/downloads',
},
manga: {
match: 'manga/:id',
path: (mangaId: MangaIdInfo['id']) => `/manga/${mangaId}`,
childRoutes: {
reader: {
match: 'chapter/:chapterNum',
path: (mangaId: MangaIdInfo['id'], chapterNum: ChapterSourceOrderInfo['sourceOrder']) =>
`/manga/${mangaId}/chapter/${chapterNum}`,
},
},
},
library: {
match: 'library',
path: (tab?: string, search?: string) =>
addParams('/library', createParam(SearchParam.TAB, tab), createQueryParam(search)),
},
updates: {
match: 'updates',
path: '/updates',
},
history: {
match: 'history',
path: '/history',
},
recent: {
match: 'recent',
path: '/recent',
},
browse: {
match: 'browse',
path: (tab?: BrowseTab) => addParams('/browse', createParam(SearchParam.TAB, tab)),
},
migrate: {
match: 'migrate/source/:sourceId',
path: (sourceId: SourceType['id']) => `/migrate/source/${sourceId}`,
childRoutes: {
search: {
match: 'manga/:mangaId/search',
path: (sourceId: SourceType['id'], mangaId: MangaIdInfo['id'], query?: string | null | undefined) =>
addParams(`/migrate/source/${sourceId}/manga/${mangaId}/search`, createQueryParam(query)),
},
},
},
tracker: {
match: 'tracker/login/oauth',
path: '/tracker/login/oauth',
},
reader: {
match: '/manga/:mangaId/chapter/:chapterSourceOrder/*',
path: (mangaId: MangaIdInfo['id'], chapterSourceOrder: ChapterSourceOrderInfo['sourceOrder']) =>
`/manga/${mangaId}/chapter/${chapterSourceOrder}`,
},
more: {
match: '/more',
path: '/more',
},
} as const satisfies TAppRoutes;
type ExtractChildRouteStringPaths<T> = T extends { childRoutes: infer U } ? ExtractStringPaths<U[keyof U]> : never;
type ExtractStringPaths<T> = T extends { path: infer P }
? P extends string
? P | ExtractChildRouteStringPaths<T>
: ExtractChildRouteStringPaths<T>
: ExtractChildRouteStringPaths<T>;
export type StaticAppRoute = ExtractStringPaths<(typeof AppRoutes)[keyof typeof AppRoutes]>;

View File

@@ -0,0 +1,67 @@
/*
* 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 { ReactNode } from 'react';
import { TranslationKey } from '@/Base.types.ts';
export enum GridLayout {
Compact = 0,
Comfortable = 1,
List = 2,
}
interface DisplayDataTranslationKey {
isTitleString?: never;
title: TranslationKey;
icon: ReactNode;
}
interface DisplayDataString {
isTitleString: true;
title: string;
icon: ReactNode;
}
type DisplayData = DisplayDataTranslationKey | DisplayDataString;
export type ValueToDisplayData<Value extends string | number> = Record<Value, DisplayData>;
export interface MultiValueButtonBaseProps<Value extends string | number> {
tooltip?: string;
value: Value;
defaultValue?: Value;
values: Value[];
setValue: (value: Value) => void;
valueToDisplayData: ValueToDisplayData<Value>;
}
export interface MultiValueButtonDefaultableProps<Value extends string | number>
extends OptionalProperty<MultiValueButtonBaseProps<Value>, 'value'> {
isDefaultable?: boolean;
onDefault?: () => void;
}
export type MultiValueButtonProps<Value extends string | number> =
| (MultiValueButtonBaseProps<Value> & PropertiesNever<MultiValueButtonDefaultableProps<Value>>)
| MultiValueButtonDefaultableProps<Value>;
export enum ScrollOffset {
BACKWARD,
FORWARD,
}
export enum ScrollDirection {
X,
Y,
XY,
}
export enum SearchParam {
TAB = 'tab',
QUERY = 'query',
}

View File

@@ -0,0 +1,805 @@
/*
* 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 { I18nResourceCode } from '@/i18n';
export type ISOLanguage = {
name: string;
nativeName: string;
};
const PT_BR: ISOLanguage = {
name: 'Portuguese; Brasil',
nativeName: 'Português (Brasil)',
};
// full list: https://github.com/meikidd/iso-639-1/blob/master/src/data.js
export const IsoLanguages: { [languageCode in I18nResourceCode]: ISOLanguage } & Record<string, ISOLanguage> = {
// #############################
// ### ###
// ### START: manually added ###
// ### ###
// #############################
'es-419': {
name: 'Spanish; Castilian',
nativeName: 'Español (Latinoamérica)',
},
'pt-pt': {
name: 'Portuguese',
nativeName: 'Português (Portugal)',
},
'pt-br': PT_BR,
'pt-BR': PT_BR,
zh_Hans: {
name: 'Chinese (Simplified)',
nativeName: '中文 (HANS)',
},
zh_Hant: {
name: 'Chinese (Traditional)',
nativeName: '中文 (HANT)',
},
'zh-rhk': {
name: 'Chinese',
nativeName: '中文 (RHK)',
},
'zh-rtw': {
name: 'Chinese',
nativeName: '中文 (RTW)',
},
fil: {
name: 'Filipino',
nativeName: 'Filipino',
},
sh: {
name: 'Serbo-Croatian',
nativeName: 'srpskohrvatski',
},
'nb-NO': {
name: 'Norwegian Bokmål',
nativeName: 'Norsk bokmål',
},
// #############################
// ### ###
// ### END: manually added ###
// ### ###
// #############################
aa: {
name: 'Afar',
nativeName: 'Afaraf',
},
ab: {
name: 'Abkhaz',
nativeName: 'аҧсуа бызшәа',
},
ae: {
name: 'Avestan',
nativeName: 'avesta',
},
af: {
name: 'Afrikaans',
nativeName: 'Afrikaans',
},
ak: {
name: 'Akan',
nativeName: 'Akan',
},
am: {
name: 'Amharic',
nativeName: 'አማርኛ',
},
an: {
name: 'Aragonese',
nativeName: 'aragonés',
},
ar: {
name: 'Arabic',
nativeName: 'اَلْعَرَبِيَّةُ',
},
as: {
name: 'Assamese',
nativeName: 'অসমীয়া',
},
av: {
name: 'Avaric',
nativeName: 'авар мацӀ',
},
ay: {
name: 'Aymara',
nativeName: 'aymar aru',
},
az: {
name: 'Azerbaijani',
nativeName: 'azərbaycan dili',
},
ba: {
name: 'Bashkir',
nativeName: 'башҡорт теле',
},
be: {
name: 'Belarusian',
nativeName: 'беларуская мова',
},
bg: {
name: 'Bulgarian',
nativeName: 'български език',
},
bi: {
name: 'Bislama',
nativeName: 'Bislama',
},
bm: {
name: 'Bambara',
nativeName: 'bamanankan',
},
bn: {
name: 'Bengali',
nativeName: 'বাংলা',
},
bo: {
name: 'Tibetan',
nativeName: 'བོད་ཡིག',
},
br: {
name: 'Breton',
nativeName: 'brezhoneg',
},
bs: {
name: 'Bosnian',
nativeName: 'bosanski jezik',
},
ca: {
name: 'Catalan',
nativeName: 'Català',
},
ce: {
name: 'Chechen',
nativeName: 'нохчийн мотт',
},
ch: {
name: 'Chamorro',
nativeName: 'Chamoru',
},
co: {
name: 'Corsican',
nativeName: 'corsu',
},
cr: {
name: 'Cree',
nativeName: 'ᓀᐦᐃᔭᐍᐏᐣ',
},
cs: {
name: 'Czech',
nativeName: 'čeština',
},
cu: {
name: 'Old Church Slavonic',
nativeName: 'ѩзыкъ словѣньскъ',
},
cv: {
name: 'Chuvash',
nativeName: 'чӑваш чӗлхи',
},
cy: {
name: 'Welsh',
nativeName: 'Cymraeg',
},
da: {
name: 'Danish',
nativeName: 'Dansk',
},
de: {
name: 'German',
nativeName: 'Deutsch',
},
dv: {
name: 'Divehi',
nativeName: 'ދިވެހި',
},
dz: {
name: 'Dzongkha',
nativeName: 'རྫོང་ཁ',
},
ee: {
name: 'Ewe',
nativeName: 'Eʋegbe',
},
el: {
name: 'Greek',
nativeName: 'Ελληνικά',
},
en: {
name: 'English',
nativeName: 'English',
},
eo: {
name: 'Esperanto',
nativeName: 'Esperanto',
},
es: {
name: 'Spanish',
nativeName: 'Español',
},
et: {
name: 'Estonian',
nativeName: 'eesti',
},
eu: {
name: 'Basque',
nativeName: 'euskara',
},
fa: {
name: 'Persian',
nativeName: 'فارسی',
},
ff: {
name: 'Fula',
nativeName: 'Fulfulde',
},
fi: {
name: 'Finnish',
nativeName: 'suomi',
},
fj: {
name: 'Fijian',
nativeName: 'vosa Vakaviti',
},
fo: {
name: 'Faroese',
nativeName: 'Føroyskt',
},
fr: {
name: 'French',
nativeName: 'Français',
},
fy: {
name: 'Western Frisian',
nativeName: 'Frysk',
},
ga: {
name: 'Irish',
nativeName: 'Gaeilge',
},
gd: {
name: 'Scottish Gaelic',
nativeName: 'Gàidhlig',
},
gl: {
name: 'Galician',
nativeName: 'galego',
},
gn: {
name: 'Guaraní',
nativeName: "Avañe'ẽ",
},
gu: {
name: 'Gujarati',
nativeName: 'ગુજરાતી',
},
gv: {
name: 'Manx',
nativeName: 'Gaelg',
},
ha: {
name: 'Hausa',
nativeName: 'هَوُسَ',
},
he: {
name: 'Hebrew',
nativeName: 'עברית',
},
hi: {
name: 'Hindi',
nativeName: 'हिन्दी',
},
ho: {
name: 'Hiri Motu',
nativeName: 'Hiri Motu',
},
hr: {
name: 'Croatian',
nativeName: 'Hrvatski',
},
ht: {
name: 'Haitian',
nativeName: 'Kreyòl ayisyen',
},
hu: {
name: 'Hungarian',
nativeName: 'magyar',
},
hy: {
name: 'Armenian',
nativeName: 'Հայերեն',
},
hz: {
name: 'Herero',
nativeName: 'Otjiherero',
},
ia: {
name: 'Interlingua',
nativeName: 'Interlingua',
},
id: {
name: 'Indonesian',
nativeName: 'Bahasa Indonesia',
},
ie: {
name: 'Interlingue',
nativeName: 'Interlingue',
},
ig: {
name: 'Igbo',
nativeName: 'Asụsụ Igbo',
},
ii: {
name: 'Nuosu',
nativeName: 'ꆈꌠ꒿ Nuosuhxop',
},
ik: {
name: 'Inupiaq',
nativeName: 'Iñupiaq',
},
io: {
name: 'Ido',
nativeName: 'Ido',
},
is: {
name: 'Icelandic',
nativeName: 'Íslenska',
},
it: {
name: 'Italian',
nativeName: 'Italiano',
},
iu: {
name: 'Inuktitut',
nativeName: 'ᐃᓄᒃᑎᑐᑦ',
},
ja: {
name: 'Japanese',
nativeName: '日本語',
},
jv: {
name: 'Javanese',
nativeName: 'basa Jawa',
},
ka: {
name: 'Georgian',
nativeName: 'ქართული',
},
kg: {
name: 'Kongo',
nativeName: 'Kikongo',
},
ki: {
name: 'Kikuyu',
nativeName: 'Gĩkũyũ',
},
kj: {
name: 'Kwanyama',
nativeName: 'Kuanyama',
},
kk: {
name: 'Kazakh',
nativeName: 'қазақ тілі',
},
kl: {
name: 'Kalaallisut',
nativeName: 'kalaallisut',
},
km: {
name: 'Khmer',
nativeName: 'ខេមរភាសា',
},
kn: {
name: 'Kannada',
nativeName: 'ಕನ್ನಡ',
},
ko: {
name: 'Korean',
nativeName: '한국어',
},
kr: {
name: 'Kanuri',
nativeName: 'Kanuri',
},
ks: {
name: 'Kashmiri',
nativeName: 'कश्मीरी',
},
ku: {
name: 'Kurdish',
nativeName: 'Kurdî',
},
kv: {
name: 'Komi',
nativeName: 'коми кыв',
},
kw: {
name: 'Cornish',
nativeName: 'Kernewek',
},
ky: {
name: 'Kyrgyz',
nativeName: 'Кыргызча',
},
la: {
name: 'Latin',
nativeName: 'latine',
},
lb: {
name: 'Luxembourgish',
nativeName: 'Lëtzebuergesch',
},
lg: {
name: 'Ganda',
nativeName: 'Luganda',
},
li: {
name: 'Limburgish',
nativeName: 'Limburgs',
},
ln: {
name: 'Lingala',
nativeName: 'Lingála',
},
lo: {
name: 'Lao',
nativeName: 'ພາສາລາວ',
},
lt: {
name: 'Lithuanian',
nativeName: 'lietuvių kalba',
},
lu: {
name: 'Luba-Katanga',
nativeName: 'Kiluba',
},
lv: {
name: 'Latvian',
nativeName: 'latviešu valoda',
},
mg: {
name: 'Malagasy',
nativeName: 'fiteny malagasy',
},
mh: {
name: 'Marshallese',
nativeName: 'Kajin M̧ajeļ',
},
mi: {
name: 'Māori',
nativeName: 'te reo Māori',
},
mk: {
name: 'Macedonian',
nativeName: 'македонски јазик',
},
ml: {
name: 'Malayalam',
nativeName: 'മലയാളം',
},
mn: {
name: 'Mongolian',
nativeName: 'Монгол хэл',
},
mr: {
name: 'Marathi',
nativeName: 'मराठी',
},
ms: {
name: 'Malay',
nativeName: 'Bahasa Melayu',
},
mt: {
name: 'Maltese',
nativeName: 'Malti',
},
my: {
name: 'Burmese',
nativeName: 'ဗမာစာ',
},
na: {
name: 'Nauru',
nativeName: 'Dorerin Naoero',
},
nb: {
name: 'Norwegian Bokmål',
nativeName: 'Norsk bokmål',
},
nd: {
name: 'Northern Ndebele',
nativeName: 'isiNdebele',
},
ne: {
name: 'Nepali',
nativeName: 'नेपाली',
},
ng: {
name: 'Ndonga',
nativeName: 'Owambo',
},
nl: {
name: 'Dutch',
nativeName: 'Nederlands',
},
nn: {
name: 'Norwegian Nynorsk',
nativeName: 'Norsk nynorsk',
},
no: {
name: 'Norwegian',
nativeName: 'Norsk',
},
nr: {
name: 'Southern Ndebele',
nativeName: 'isiNdebele',
},
nv: {
name: 'Navajo',
nativeName: 'Diné bizaad',
},
ny: {
name: 'Chichewa',
nativeName: 'chiCheŵa',
},
oc: {
name: 'Occitan',
nativeName: 'occitan',
},
oj: {
name: 'Ojibwe',
nativeName: 'ᐊᓂᔑᓈᐯᒧᐎᓐ',
},
om: {
name: 'Oromo',
nativeName: 'Afaan Oromoo',
},
or: {
name: 'Oriya',
nativeName: 'ଓଡ଼ିଆ',
},
os: {
name: 'Ossetian',
nativeName: 'ирон æвзаг',
},
pa: {
name: 'Panjabi',
nativeName: 'ਪੰਜਾਬੀ',
},
pi: {
name: 'Pāli',
nativeName: 'पाऴि',
},
pl: {
name: 'Polish',
nativeName: 'Polski',
},
ps: {
name: 'Pashto',
nativeName: 'پښتو',
},
pt: {
name: 'Portuguese',
nativeName: 'Português',
},
qu: {
name: 'Quechua',
nativeName: 'Runa Simi',
// asdfasdfasdfasdf
},
rm: {
name: 'Romansh',
nativeName: 'rumantsch grischun',
},
rn: {
name: 'Kirundi',
nativeName: 'Ikirundi',
},
ro: {
name: 'Romanian',
nativeName: 'Română',
},
ru: {
name: 'Russian',
nativeName: 'Русский',
},
rw: {
name: 'Kinyarwanda',
nativeName: 'Ikinyarwanda',
},
sa: {
name: 'Sanskrit',
nativeName: 'संस्कृतम्',
},
sc: {
name: 'Sardinian',
nativeName: 'sardu',
},
sd: {
name: 'Sindhi',
nativeName: 'सिन्धी',
},
se: {
name: 'Northern Sami',
nativeName: 'Davvisámegiella',
},
sg: {
name: 'Sango',
nativeName: 'yângâ tî sängö',
},
si: {
name: 'Sinhala',
nativeName: 'සිංහල',
},
sk: {
name: 'Slovak',
nativeName: 'slovenčina',
},
sl: {
name: 'Slovenian',
nativeName: 'slovenščina',
},
sm: {
name: 'Samoan',
nativeName: "gagana fa'a Samoa",
},
sn: {
name: 'Shona',
nativeName: 'chiShona',
},
so: {
name: 'Somali',
nativeName: 'Soomaaliga',
},
sq: {
name: 'Albanian',
nativeName: 'Shqip',
},
sr: {
name: 'Serbian',
nativeName: 'српски језик',
},
ss: {
name: 'Swati',
nativeName: 'SiSwati',
},
st: {
name: 'Southern Sotho',
nativeName: 'Sesotho',
},
su: {
name: 'Sundanese',
nativeName: 'Basa Sunda',
},
sv: {
name: 'Swedish',
nativeName: 'Svenska',
},
sw: {
name: 'Swahili',
nativeName: 'Kiswahili',
},
ta: {
name: 'Tamil',
nativeName: 'தமிழ்',
},
te: {
name: 'Telugu',
nativeName: 'తెలుగు',
},
tg: {
name: 'Tajik',
nativeName: 'тоҷикӣ',
},
th: {
name: 'Thai',
nativeName: 'ไทย',
},
ti: {
name: 'Tigrinya',
nativeName: 'ትግርኛ',
},
tk: {
name: 'Turkmen',
nativeName: 'Türkmençe',
},
tl: {
name: 'Tagalog',
nativeName: 'Wikang Tagalog',
},
tn: {
name: 'Tswana',
nativeName: 'Setswana',
},
to: {
name: 'Tonga',
nativeName: 'faka Tonga',
},
tr: {
name: 'Turkish',
nativeName: 'Türkçe',
},
ts: {
name: 'Tsonga',
nativeName: 'Xitsonga',
},
tt: {
name: 'Tatar',
nativeName: 'татар теле',
},
tw: {
name: 'Twi',
nativeName: 'Twi',
},
ty: {
name: 'Tahitian',
nativeName: 'Reo Tahiti',
},
ug: {
name: 'Uyghur',
nativeName: 'ئۇيغۇرچە‎',
},
uk: {
name: 'Ukrainian',
nativeName: 'Українська',
},
ur: {
name: 'Urdu',
nativeName: 'اردو',
},
uz: {
name: 'Uzbek',
nativeName: 'Ўзбек',
},
ve: {
name: 'Venda',
nativeName: 'Tshivenḓa',
},
vi: {
name: 'Vietnamese',
nativeName: 'Tiếng Việt',
},
vo: {
name: 'Volapük',
nativeName: 'Volapük',
},
wa: {
name: 'Walloon',
nativeName: 'walon',
},
wo: {
name: 'Wolof',
nativeName: 'Wollof',
},
xh: {
name: 'Xhosa',
nativeName: 'isiXhosa',
},
yi: {
name: 'Yiddish',
nativeName: 'ייִדיש',
},
yo: {
name: 'Yoruba',
nativeName: 'Yorùbá',
},
za: {
name: 'Zhuang',
nativeName: 'Saɯ cueŋƅ',
},
zh: {
name: 'Chinese',
nativeName: '中文',
},
zu: {
name: 'Zulu',
nativeName: 'isiZulu',
},
};

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 '@/features/core/components/CustomTooltip.tsx';
import { SearchTextField } from '@/features/core/components/inputs/SearchTextField.tsx';
import { SearchParam } from '@/features/core/Core.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 '@/features/core/components/CustomTooltip.tsx';
import { GridLayout } from '@/features/core/Core.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 '@/features/core/utils/ApplyStyles.ts';
import { useIntersectionObserver } from '@/features/core/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,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 { useEffect, useState } from 'react';
import IconButton from '@mui/material/IconButton';
import RefreshIcon from '@mui/icons-material/Refresh';
import { useTranslation } from 'react-i18next';
import PopupState, { bindMenu, bindTrigger } from 'material-ui-popup-state';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import ClearIcon from '@mui/icons-material/Clear';
import Stack from '@mui/material/Stack';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { makeToast } from '@/features/core/utils/Toast.ts';
import { Progress } from '@/features/core/components/feedback/Progress.tsx';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { dateTimeFormatter } from '@/util/DateHelper.ts';
import { MediaQuery } from '@/features/core/utils/MediaQuery.tsx';
import { CategoryIdInfo } from '@/features/category/Category.types.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
let lastRunningState = false;
export function UpdateChecker({
categoryId,
handleFinishedUpdate,
}: {
categoryId?: CategoryIdInfo['id'];
handleFinishedUpdate?: () => void;
}) {
const { t } = useTranslation();
const isTouchDevice = MediaQuery.useIsTouchDevice();
const [isHovered, setIsHovered] = useState(false);
const { data: lastUpdateTimestampData, refetch: reFetchLastTimestamp } =
requestManager.useGetLastGlobalUpdateTimestamp();
const lastUpdateTimestamp = lastUpdateTimestampData?.lastUpdateTimestamp.timestamp;
const { data: updaterData } = requestManager.useGetGlobalUpdateSummary();
const status = updaterData?.libraryUpdateStatus;
const isRunning = !!status?.jobsInfo.isRunning;
const progress = status ? (status.jobsInfo.finishedJobs / status.jobsInfo.totalJobs) * 100 : 0;
useEffect(() => {
if (!lastRunningState && isRunning) {
lastRunningState = true;
}
const isUpdateFinished = lastRunningState && progress === 100;
if (!isUpdateFinished) {
return;
}
lastRunningState = false;
handleFinishedUpdate?.();
// this re-fetch is necessary since a running update could have been triggered by the server or another client
reFetchLastTimestamp().catch(defaultPromiseErrorHandler('UpdateChecker::reFetchLastTimestamp'));
}, [isRunning]);
const startUpdate = async (category?: CategoryIdInfo['id']) => {
try {
lastRunningState = true;
await requestManager.startGlobalUpdate(category !== undefined ? [category] : undefined).response;
reFetchLastTimestamp().catch(defaultPromiseErrorHandler('UpdateChecker::reFetchLastTimestamp'));
} catch (e) {
lastRunningState = false;
makeToast(t('global.error.label.update_failed'), 'error', getErrorMessage(e));
}
};
const stopUpdate = async () => {
try {
await requestManager.resetGlobalUpdate();
} catch (e) {
makeToast(t('library.error.label.stop_global_update'), 'error', getErrorMessage(e));
}
};
const onClick = async (category?: CategoryIdInfo['id']) => {
if (isRunning) {
stopUpdate();
} else {
startUpdate(category);
}
};
return (
<PopupState variant="popover" popupId="library-update-checker-menu">
{(popupState) => (
<>
<CustomTooltip
title={
isRunning
? t('library.action.label.stop_update')
: t('library.settings.global_update.label.last_update_tooltip', {
date: lastUpdateTimestamp ? dateTimeFormatter.format(+lastUpdateTimestamp) : '-',
})
}
>
<IconButton
sx={{ position: 'relative' }}
{...(categoryId !== undefined && !isRunning
? bindTrigger(popupState)
: { onClick: () => onClick() })}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
color="inherit"
>
{!isRunning ? (
<RefreshIcon />
) : (
<>
<ClearIcon sx={{ opacity: Number(isTouchDevice || isHovered) }} />
<Stack sx={{ position: 'absolute' }}>
<Progress
progress={progress}
showText={!isTouchDevice && !isHovered}
progressProps={{ color: 'inherit' }}
/>
</Stack>
</>
)}
</IconButton>
</CustomTooltip>
<Menu {...bindMenu(popupState)}>
<MenuItem
onClick={() => {
popupState.close();
onClick();
}}
>
{t('library.action.label.update_library')}
</MenuItem>
<MenuItem
onClick={() => {
popupState.close();
onClick(categoryId);
}}
>
{t('library.action.label.update_category')}
</MenuItem>
</Menu>
</>
)}
</PopupState>
);
}

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 '@/features/core/components/CustomTooltip.tsx';
import { MultiValueButtonProps } from '@/features/core/Core.types.ts';
import { Superscript } from '@/features/core/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 '@/features/core/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 '@/features/core/components/CustomTooltip.tsx';
import { MultiValueButtonProps } from '@/features/core/Core.types.ts';
import { getNextRotationValue } from '@/features/core/utils/ValueRotationButton.utils.ts';
import { Superscript } from '@/features/core/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 { TranslationKey } from '@/Base.types.ts';
import { ChapterIdInfo } from '@/features/chapter/Chapter.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 '@/features/core/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 '@/features/core/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 '@/features/core/utils/AwaitableDialog.tsx';
import { TranslationKey } from '@/Base.types.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { MediaQuery } from '@/features/core/utils/MediaQuery.tsx';
import { extractGraphqlExceptionInfo } from '@/lib/HelperFunctions.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 '@/features/core/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 '@/features/core/components/CustomTooltip.tsx';
import { translateExtensionLanguage } from '@/features/extension/Extensions.utils.ts';
import { languageSortComparator, toUniqueLanguageCodes } from '@/features/core/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 '@/features/core/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 '@/features/core/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 '@/features/core/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 '@/features/core/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.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 '@/features/core/components/menu/IconMenuItem.tsx';
import { getOptionForDirection } from '@/features/theme/services/ThemeCreator.ts';
import { MediaQuery } from '@/features/core/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 '@/features/core/components/tabs/TabPanel.tsx';
import { OptionsPanel } from '@/features/core/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 '@/features/core/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 '@/features/core/components/CustomTooltip.tsx';
import { TextSetting, TextSettingProps } from '@/features/core/components/settings/text/TextSetting.tsx';
import { TextSettingDialog } from '@/features/core/components/settings/text/TextSettingDialog.tsx';
import { makeToast } from '@/features/core/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,154 @@
/*
* 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 '@/features/core/components/inputs/Select.tsx';
import { TranslationKey } from '@/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,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 ListItemText from '@mui/material/ListItemText';
import ListItemButton from '@mui/material/ListItemButton';
import { useState } from 'react';
import {
TextSettingDialog,
TextSettingDialogProps,
} from '@/features/core/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 '@/features/core/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/contexts/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,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 Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { Trans, useTranslation } from 'react-i18next';
import { TranslationKey } from '@/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>
);
};

Some files were not shown because too many files have changed in this diff Show More