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,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 { SxProps, Theme } from '@mui/material/styles';
import { PublishingStatus, PublishingType } from '@/features/tracker/Tracker.types.ts';
import { TranslationKey } from '@/Base.types.ts';
export const DIALOG_PADDING: number = 2;
export const CARD_BACKGROUND: SxProps<Theme> = {
backgroundColor: 'transparent',
boxShadow: 'unset',
backgroundImage: 'unset',
};
export const CARD_STYLING: SxProps<Theme> = {
padding: DIALOG_PADDING,
...CARD_BACKGROUND,
};
export const PUBLISHING_TYPE_TO_TRANSLATION: Record<PublishingType, TranslationKey> = {
[PublishingType.UNKNOWN]: 'tracking.publishing.type.unknown',
[PublishingType.MANGA]: 'tracking.publishing.type.manga',
[PublishingType.NOVEL]: 'tracking.publishing.type.novel',
[PublishingType.ONE_SHOT]: 'tracking.publishing.type.one_shot',
[PublishingType.DOUJINSHI]: 'tracking.publishing.type.doujinshi',
[PublishingType.MANHWA]: 'tracking.publishing.type.manhwa',
[PublishingType.MANHUA]: 'tracking.publishing.type.manhua',
[PublishingType.OEL]: 'tracking.publishing.type.oel',
};
export const PUBLISHING_STATUS_TO_TRANSLATION: Record<PublishingStatus, TranslationKey> = {
[PublishingStatus.FINISHED]: 'tracking.publishing.status.finished',
[PublishingStatus.RELEASING]: 'tracking.publishing.status.releasing',
[PublishingStatus.NOT_YET_RELEASED]: 'tracking.publishing.status.not_yet_released',
[PublishingStatus.CANCELLED]: 'tracking.publishing.status.cancelled',
[PublishingStatus.HIATUS]: 'tracking.publishing.status.hiatus',
[PublishingStatus.CURRENTLY_PUBLISHING]: 'tracking.publishing.status.currently_publishing',
[PublishingStatus.NOT_YET_PUBLISHED]: 'tracking.publishing.status.not_yet_published',
};
export const UNSET_DATE = '0';

View File

@@ -0,0 +1,71 @@
/*
* 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 {
TrackerType,
TrackRecordSearchFieldsFragment,
TrackRecordType,
TrackSearchType,
} from '@/lib/graphql/generated/graphql.ts';
export type MetadataTrackingSettings = {
updateProgressAfterReading: boolean;
updateProgressManualMarkRead: boolean;
};
export enum PublishingType {
UNKNOWN = 'unknown',
MANGA = 'manga',
NOVEL = 'novel',
ONE_SHOT = 'one_shot',
DOUJINSHI = 'doujinshi',
MANHWA = 'manhwa',
MANHUA = 'manhua',
OEL = 'oel',
}
export enum PublishingStatus {
FINISHED = 'finished',
RELEASING = 'releasing',
NOT_YET_RELEASED = 'not_yet_released',
CANCELLED = 'cancelled',
HIATUS = 'hiatus',
CURRENTLY_PUBLISHING = 'currently_publishing',
NOT_YET_PUBLISHED = 'not_yet_published',
}
export enum Tracker {
MYANIMELIST = 1,
}
export type TTrackRecordBase = Pick<TrackRecordType, 'id' | 'remoteId' | 'title'>;
export type TTrackRecordBind = TTrackRecordBase &
Pick<
TrackRecordType,
| 'trackerId'
| 'remoteUrl'
| 'status'
| 'lastChapterRead'
| 'totalChapters'
| 'score'
| 'displayScore'
| 'startDate'
| 'finishDate'
| 'private'
>;
export type TTrackerManga = TrackRecordSearchFieldsFragment;
export type TTrackerBase = Pick<TrackerType, 'id' | 'name' | 'icon' | 'isLoggedIn' | 'isTokenExpired'>;
export type TTrackerSearch = TTrackerBase & Pick<TrackerType, 'authUrl'>;
export type TTrackerBind = TTrackerBase &
Pick<TrackerType, 'icon' | 'supportsTrackDeletion' | 'supportsPrivateTracking' | 'scores' | 'statuses'>;
export type TrackerIdInfo = Pick<TrackerType, 'id'>;
export type LoggedInInfo = Pick<TrackerType, 'isLoggedIn' | 'isTokenExpired'>;
export type TrackRecordTrackerInfo = Pick<TrackRecordType, 'trackerId'>;
export type TrackSearchPublishingTypeInfo = Pick<TrackSearchType, 'publishingType'>;
export type TrackSearchPublishingStatusInfo = Pick<TrackSearchType, 'publishingStatus'>;

View File

@@ -0,0 +1,152 @@
/*
* 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 { useNavigate } from 'react-router-dom';
import Box from '@mui/material/Box';
import { useEffect, useMemo, useRef, useState } from 'react';
import DialogContent from '@mui/material/DialogContent';
import { useTranslation } from 'react-i18next';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { LoadingPlaceholder } from '@/features/core/components/feedback/LoadingPlaceholder.tsx';
import { Trackers } from '@/features/tracker/services/Trackers.ts';
import { TrackerCard, TrackerMode } from '@/features/tracker/components/cards/TrackerCard.tsx';
import { makeToast } from '@/features/core/utils/Toast.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { GetMangaTrackRecordsQuery, GetTrackersBindQuery, MangaType } from '@/lib/graphql/generated/graphql.ts';
import { GET_TRACKERS_BIND } from '@/lib/graphql/queries/TrackerQuery.ts';
import { GET_MANGA_TRACK_RECORDS } from '@/lib/graphql/queries/MangaQuery.ts';
import { MangaIdInfo } from '@/features/manga/Manga.types.ts';
import { AppRoutes } from '@/features/core/AppRoute.constants.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { EmptyView } from '@/features/core/components/feedback/EmptyView.tsx';
const getTrackerMode = (id: number, trackersInUse: number[], searchModeForTracker?: number): TrackerMode => {
if (id === searchModeForTracker) {
return TrackerMode.SEARCH;
}
if (trackersInUse.includes(id)) {
return TrackerMode.INFO;
}
return TrackerMode.UNTRACKED;
};
export const TrackManga = ({ manga }: { manga: MangaIdInfo & Pick<MangaType, 'title'> }) => {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchModeForTracker, setSearchModeForTracker] = useState<number>();
const trackerList = requestManager.useGetTrackerList<GetTrackersBindQuery>(GET_TRACKERS_BIND, {
notifyOnNetworkStatusChange: true,
});
const trackers = trackerList.data?.trackers.nodes ?? [];
const mangaTrackRecordsList = requestManager.useGetManga<GetMangaTrackRecordsQuery>(
GET_MANGA_TRACK_RECORDS,
manga.id,
);
const mangaTrackRecords = mangaTrackRecordsList.data?.manga.trackRecords.nodes ?? [];
const loggedInTrackers = Trackers.getLoggedIn(trackers);
const trackersInUse = Trackers.getLoggedIn(Trackers.getTrackers(mangaTrackRecords, trackers));
const trackersInUseIds = Trackers.getIds(trackersInUse);
const isSearchActive = searchModeForTracker !== undefined;
const OptionalDialogContent = useMemo(() => (isSearchActive ? Box : DialogContent), [isSearchActive]);
const loading = trackerList.loading || mangaTrackRecordsList.loading;
const error = trackerList.error ?? mangaTrackRecordsList.error;
useEffect(() => {
if (!loading && !error && !trackersInUse.length && !loggedInTrackers.length) {
navigate(AppRoutes.settings.childRoutes.tracking.path);
}
}, [loading]);
const fetchedLatestTrackDataRef = useRef(false);
useEffect(() => {
if (!mangaTrackRecords.length || fetchedLatestTrackDataRef.current) {
return;
}
fetchedLatestTrackDataRef.current = true;
Promise.all(
mangaTrackRecords
.filter((trackRecord) => trackersInUseIds.includes(trackRecord.trackerId))
.map((trackRecord) => requestManager.fetchTrackBind(trackRecord.id).response),
).catch((e) => makeToast(t('tracking.error.label.could_not_fetch_track_info'), 'error', getErrorMessage(e)));
}, [mangaTrackRecords]);
const trackerComponents = useMemo(
() =>
loggedInTrackers.map((tracker) => {
const mode = getTrackerMode(tracker.id, trackersInUseIds, searchModeForTracker);
const trackRecord = Trackers.getTrackRecordFor(tracker, mangaTrackRecords);
const isSearchForTracker = mode === TrackerMode.SEARCH;
if (isSearchActive && !isSearchForTracker) {
return null;
}
return (
<TrackerCard
key={tracker.id}
tracker={tracker}
manga={manga}
trackRecord={trackRecord}
mode={mode}
setSearchMode={(id) => setSearchModeForTracker(id)}
/>
);
}),
[trackersInUseIds, searchModeForTracker, mangaTrackRecords],
);
if (error) {
return (
<EmptyView
message={t('global.error.label.failed_to_load_data')}
messageExtra={getErrorMessage(error)}
retry={() => {
if (trackerList.error) {
trackerList.refetch().catch(defaultPromiseErrorHandler('TrackManga::refetch: trackerList'));
}
if (mangaTrackRecordsList.error) {
mangaTrackRecordsList
.refetch()
.catch(defaultPromiseErrorHandler('TrackManga::refetch: mangaTrackRecordsList'));
}
}}
/>
);
}
if (loading) {
return <LoadingPlaceholder />;
}
if (!isSearchActive) {
return (
<OptionalDialogContent
sx={{
padding: 0,
// MUI adds a bottom padding to the last child of type CardContent which can only be removed via actual css styling
// do it here, so it is done in one place for all track related CardContent components
'.MuiPaper-root .MuiCardContent-root': { paddingBottom: '0' },
}}
>
{trackerComponents}
</OptionalDialogContent>
);
}
return trackerComponents;
};

View File

@@ -0,0 +1,254 @@
/*
* 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 { useTranslation } from 'react-i18next';
import List from '@mui/material/List';
import Stack from '@mui/material/Stack';
import { useEffect, useMemo, useState } from 'react';
import IconButton from '@mui/material/IconButton';
import ArrowBack from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import InputAdornment from '@mui/material/InputAdornment';
import InfoIcon from '@mui/icons-material/Info';
import PopupState, { bindPopover, bindTrigger } from 'material-ui-popup-state';
import Popover from '@mui/material/Popover';
import Typography from '@mui/material/Typography';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { EmptyViewAbsoluteCentered } from '@/features/core/components/feedback/EmptyViewAbsoluteCentered.tsx';
import { LoadingPlaceholder } from '@/features/core/components/feedback/LoadingPlaceholder.tsx';
import { SearchTextField } from '@/features/core/components/inputs/SearchTextField.tsx';
import { makeToast } from '@/features/core/utils/Toast.ts';
import { TrackerMangaCard } from '@/features/tracker/components/cards/TrackerMangaCard.tsx';
import { DIALOG_PADDING } from '@/features/tracker/Tracker.constants.ts';
import { useGetOptionForDirection } from '@/features/theme/services/ThemeCreator.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { MangaType } from '@/lib/graphql/generated/graphql.ts';
import { MangaIdInfo } from '@/features/manga/Manga.types.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { applyStyles } from '@/features/core/utils/ApplyStyles.ts';
import { Tracker, TrackerIdInfo, TTrackerBind } from '@/features/tracker/Tracker.types.ts';
import { CustomButtonIcon } from '@/features/core/components/buttons/CustomButtonIcon.tsx';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
const TrackButton = ({
mangaId,
selectedTrackerRemoteId,
trackerId,
closeSearchMode,
supportsPrivateTracking,
}: {
trackerId: TrackerIdInfo['id'];
mangaId: MangaIdInfo['id'];
selectedTrackerRemoteId: string | undefined;
closeSearchMode: () => void;
supportsPrivateTracking: boolean;
}) => {
const { t } = useTranslation();
const [bindTracker, bindTrackerMutation] = requestManager.useBindTracker();
const trackManga = (asPrivate: boolean) => {
if (selectedTrackerRemoteId === undefined) {
return;
}
bindTracker({
variables: { input: { mangaId, remoteId: selectedTrackerRemoteId, trackerId, private: asPrivate } },
})
.then(() => {
makeToast(t('manga.action.track.add.label.success'), 'success');
closeSearchMode();
})
.catch((e) => makeToast(t('manga.action.track.add.label.error'), 'error', getErrorMessage(e)));
};
return (
<Stack
direction="row"
sx={{
justifyContent: 'center',
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
paddingBottom: DIALOG_PADDING,
gap: 2,
px: DIALOG_PADDING,
}}
>
<Button
disabled={bindTrackerMutation.loading}
size="large"
variant="contained"
onClick={() => trackManga(false)}
sx={{ flexBasis: '65%' }}
>
{t('manga.action.track.add.label.action')}
</Button>
{supportsPrivateTracking && (
<CustomTooltip
title={t('tracking.action.button.track_privately')}
disabled={bindTrackerMutation.loading}
>
<CustomButtonIcon
disabled={bindTrackerMutation.loading}
sx={{ flexBasis: '10%', maxWidth: '100px' }}
variant="contained"
onClick={() => trackManga(true)}
>
<VisibilityOffIcon />
</CustomButtonIcon>
</CustomTooltip>
)}
</Stack>
);
};
export const TrackerSearch = ({
manga,
tracker,
closeSearchMode,
trackedId,
}: {
manga: MangaIdInfo & Pick<MangaType, 'title'>;
tracker: TTrackerBind;
closeSearchMode: () => void;
trackedId?: string;
}) => {
const { t } = useTranslation();
const getOptionForDirection = useGetOptionForDirection();
const [searchString, setSearchString] = useState<string>(manga.title);
const [tmpSearchString, setTmpSearchString] = useState(searchString);
const [selectedTrackerRemoteId, setSelectedTrackerRemoteId] = useState<string | undefined>(trackedId);
const trackerSearch = requestManager.useTrackerSearch(tracker.id, searchString, {
notifyOnNetworkStatusChange: true,
});
const searchResults = trackerSearch.data?.searchTracker.trackSearches ?? [];
const hasResults = !!searchResults.length;
const hasNoResults = !trackerSearch.loading && !trackerSearch.error && !hasResults;
const hasError = !!trackerSearch.error && !trackerSearch.loading;
useEffect(() => {
setSelectedTrackerRemoteId(trackedId);
return () =>
trackerSearch.abortRequest(new Error(`MangaTrackerSearchCard(${tracker.id}, ${manga.id}): search changed`));
}, [searchString]);
const showTrackButton =
useMemo(
() =>
!!selectedTrackerRemoteId &&
!!searchResults.find((searchResult) => searchResult.remoteId === selectedTrackerRemoteId),
[selectedTrackerRemoteId, searchResults],
) && !hasError;
return (
<>
<DialogTitle sx={{ padding: DIALOG_PADDING }}>
<Stack
direction="row"
sx={{
gap: '10px',
alignItems: 'center',
}}
>
<IconButton onClick={closeSearchMode}>
{getOptionForDirection(<ArrowBack />, <ArrowForwardIcon />)}
</IconButton>
<SearchTextField
sx={{ width: '100%' }}
variant="standard"
value={tmpSearchString}
onChange={(e) => setTmpSearchString(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setSearchString(tmpSearchString);
}
}}
onCancel={() => setTmpSearchString('')}
InputProps={{
startAdornment: tracker.id === Tracker.MYANIMELIST && (
<InputAdornment position="start">
<PopupState variant="popover" popupId="tracker-search-info">
{(popupState) => (
<>
<IconButton {...bindTrigger(popupState)} color="inherit">
<InfoIcon />
</IconButton>
<Popover
{...bindPopover(popupState)}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
>
<Typography sx={{ padding: 1, whiteSpace: 'pre-line' }}>
{t('tracking.my_anime_list.search.label.hint')}
</Typography>
</Popover>
</>
)}
</PopupState>
</InputAdornment>
),
}}
/>
</Stack>
</DialogTitle>
<DialogContent
dividers
sx={{
padding: DIALOG_PADDING,
height: '100vh',
...applyStyles(hasNoResults || hasError, { position: 'relative' }),
}}
>
{hasNoResults && <EmptyViewAbsoluteCentered message={t('manga.error.label.no_mangas_found')} />}
{trackerSearch.loading && <LoadingPlaceholder />}
{hasError && (
<EmptyViewAbsoluteCentered
message={t('global.error.label.failed_to_load_data')}
messageExtra={getErrorMessage(trackerSearch.error)}
retry={() =>
trackerSearch.refetch().catch(defaultPromiseErrorHandler('TrackerSearch::refetch'))
}
/>
)}
<List sx={{ padding: 0 }}>
{hasResults &&
searchResults.map((trackerManga) => (
<TrackerMangaCard
key={trackerManga.id}
manga={trackerManga}
selected={trackerManga.remoteId === selectedTrackerRemoteId}
onSelect={() => setSelectedTrackerRemoteId(trackerManga.remoteId)}
/>
))}
</List>
{showTrackButton && (
<TrackButton
mangaId={manga.id}
trackerId={tracker.id}
closeSearchMode={closeSearchMode}
selectedTrackerRemoteId={selectedTrackerRemoteId}
supportsPrivateTracking={tracker.supportsPrivateTracking}
/>
)}
</DialogContent>
</>
);
};

View File

@@ -0,0 +1,156 @@
/*
* 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 PopupState, { bindDialog, bindTrigger } from 'material-ui-popup-state';
import ListItemButton from '@mui/material/ListItemButton';
import Chip from '@mui/material/Chip';
import ListItemAvatar from '@mui/material/ListItemAvatar';
import ListItemSecondaryAction from '@mui/material/ListItemSecondaryAction';
import Avatar from '@mui/material/Avatar';
import ListItemText from '@mui/material/ListItemText';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import TextField from '@mui/material/TextField';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import { PasswordTextField } from '@/features/core/components/inputs/PasswordTextField.tsx';
import { makeToast } from '@/features/core/utils/Toast.ts';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { Trackers } from '@/features/tracker/services/Trackers.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { useLocalStorage } from '@/features/core/hooks/useStorage.tsx';
import { TTrackerSearch } from '@/features/tracker/Tracker.types.ts';
export const SettingsTrackerCard = ({ tracker }: { tracker: TTrackerSearch }) => {
const { t } = useTranslation();
const [serverAddress] = useLocalStorage('serverBaseURL', import.meta.env.VITE_SERVER_URL_DEFAULT);
const [loginTrackerCredentials, { loading: isCredentialLoginInProgress }] =
requestManager.useLoginToTrackerCredentials();
const [logoutFromTracker] = requestManager.useLogoutFromTracker();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const isOAuthLogin = !tracker.isLoggedIn && !!tracker.authUrl;
const handleLogout = async () => {
try {
await logoutFromTracker({ variables: { trackerId: tracker.id } });
} catch (e) {
makeToast(t('tracking.action.logout.label.failure', { name: tracker.name }), 'error', getErrorMessage(e));
}
};
const handleLogin = async () => {
if (isOAuthLogin) {
const state = {
redirectUrl: `${serverAddress}/tracker/login/oauth`,
clientName: 'Suwayomi-WebUI',
trackerId: tracker.id,
trackerName: tracker.name,
};
window.open(`${tracker.authUrl}&state=${JSON.stringify(state)}`, '_self');
return;
}
try {
await loginTrackerCredentials({ variables: { input: { trackerId: tracker.id, username, password } } });
} catch (e) {
makeToast(t('tracking.action.login.label.failure', { name: tracker.name }), 'error', getErrorMessage(e));
}
};
const onClick = (openPopup: () => void) => {
if (!isOAuthLogin) {
openPopup();
return;
}
handleLogin();
};
return (
<PopupState variant="popover" popupId="tracker-dialog">
{(popupState) => (
<>
<ListItemButton {...bindTrigger(popupState)} onClick={() => onClick(popupState.open)}>
<ListItemAvatar sx={{ paddingRight: '20px' }}>
<Avatar
alt={`${tracker.name}`}
src={requestManager.getValidImgUrlFor(tracker.icon)}
variant="rounded"
sx={{ width: 64, height: 64 }}
/>
</ListItemAvatar>
<ListItemText primary={tracker.name} />
{Trackers.isLoggedIn(tracker) && (
<ListItemSecondaryAction>
<Chip label={t('global.label.logged_in')} color="success" />
</ListItemSecondaryAction>
)}
</ListItemButton>
<Dialog
{...bindDialog(popupState)}
open={(Trackers.isLoggedIn(tracker) || !tracker.authUrl) && popupState.isOpen}
disableRestoreFocus
>
<DialogTitle>
{t(
Trackers.isLoggedIn(tracker)
? 'tracking.settings.dialog.title.log_out'
: 'tracking.settings.dialog.title.log_in',
{ name: tracker.name },
)}
</DialogTitle>
{!isOAuthLogin && !tracker.isLoggedIn && (
<DialogContent>
<TextField
autoFocus
margin="dense"
id="username"
name="username"
label={t('global.label.username')}
type="text"
fullWidth
variant="standard"
onChange={(e) => setUsername(e.target.value)}
/>
<PasswordTextField
margin="dense"
fullWidth
variant="standard"
onChange={(e) => setPassword(e.target.value)}
/>
</DialogContent>
)}
<DialogActions>
<Button onClick={popupState.close}>{t('global.button.cancel')}</Button>
<Button
variant="contained"
disabled={
!isOAuthLogin &&
!tracker.isLoggedIn &&
(isCredentialLoginInProgress || !username.length || !password.length)
}
onClick={() => (Trackers.isLoggedIn(tracker) ? handleLogout() : handleLogin())}
>
{t(Trackers.isLoggedIn(tracker) ? 'global.button.log_out' : 'global.button.log_in')}
</Button>
</DialogActions>
</Dialog>
</>
)}
</PopupState>
);
};

View File

@@ -0,0 +1,370 @@
/*
* 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 Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Avatar from '@mui/material/Avatar';
import { useTranslation } from 'react-i18next';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
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 Divider from '@mui/material/Divider';
import FormGroup from '@mui/material/FormGroup';
import IconButton from '@mui/material/IconButton';
import Link from '@mui/material/Link';
import ListItemButton from '@mui/material/ListItemButton';
import MenuItem from '@mui/material/MenuItem';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import PopupState, { bindDialog, bindMenu, bindTrigger } from 'material-ui-popup-state';
import { useMemo, useState } from 'react';
import Badge from '@mui/material/Badge';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { Trackers } from '@/features/tracker/services/Trackers.ts';
import { ListPreference } from '@/features/source/components/sourceConfiguration/ListPreference.tsx';
import { NumberSetting } from '@/features/core/components/settings/NumberSetting.tsx';
import { DateSetting } from '@/features/core/components/settings/DateSetting.tsx';
import { makeToast } from '@/features/core/utils/Toast.ts';
import { Menu } from '@/features/core/components/menu/Menu.tsx';
import { CARD_STYLING, UNSET_DATE } from '@/features/tracker/Tracker.constants.ts';
import { TypographyMaxLines } from '@/features/core/components/texts/TypographyMaxLines.tsx';
import { SelectSetting, SelectSettingValue } from '@/features/core/components/settings/SelectSetting.tsx';
import { CheckboxInput } from '@/features/core/components/inputs/CheckboxInput.tsx';
import { TrackRecordType } from '@/lib/graphql/generated/graphql.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { TTrackerBind, TTrackRecordBind } from '@/features/tracker/Tracker.types.ts';
const TrackerActiveLink = ({ children, url }: { children: React.ReactNode; url: string }) => (
<Link href={url} rel="noreferrer" target="_blank" underline="none" color="inherit">
{children}
</Link>
);
type TTrackerActive = Pick<TTrackerBind, 'id' | 'name' | 'icon' | 'supportsTrackDeletion' | 'supportsPrivateTracking'>;
const TrackerActiveRemoveBind = ({
trackerRecordId,
tracker,
onClick,
onClose,
}: {
trackerRecordId: TrackRecordType['id'];
tracker: TTrackerActive;
onClick: () => void;
onClose: () => void;
}) => {
const { t } = useTranslation();
const [removeRemoteTracking, setRemoveRemoteTracking] = useState(false);
const removeBind = () => {
onClose();
requestManager
.unbindTracker(trackerRecordId, removeRemoteTracking)
.response.then(() => makeToast(t('manga.action.track.remove.label.success'), 'success'))
.catch((e) => makeToast(t('manga.action.track.remove.label.error'), 'error', getErrorMessage(e)));
};
return (
<PopupState variant="dialog" popupId={`tracker-active-menu-remove-button-${tracker.id}`}>
{(popupState) => (
<>
<MenuItem
{...bindTrigger(popupState)}
onClick={() => {
onClick();
popupState.open();
}}
>
{t('global.button.remove')}
</MenuItem>
<Dialog
{...bindDialog(popupState)}
onClose={() => {
onClose();
popupState.close();
}}
>
<DialogTitle>
{t('manga.action.track.remove.dialog.label.title', { tracker: tracker.name })}
</DialogTitle>
<DialogContent dividers>
<Typography>{t('manga.action.track.remove.dialog.label.description')}</Typography>
{tracker.supportsTrackDeletion && (
<FormGroup>
<CheckboxInput
disabled={false}
label={t('manga.action.track.remove.dialog.label.delete_remote_track', {
tracker: tracker.name,
})}
checked={removeRemoteTracking}
onChange={(_, checked) => setRemoveRemoteTracking(checked)}
/>
</FormGroup>
)}
</DialogContent>
<DialogActions>
<Button
autoFocus
onClick={() => {
popupState.close();
onClose();
}}
>
{t('global.button.cancel')}
</Button>
<Button
onClick={() => {
popupState.close();
onClose();
removeBind();
}}
>
{t('global.button.ok')}
</Button>
</DialogActions>
</Dialog>
</>
)}
</PopupState>
);
};
const TrackerUpdatePrivateStatus = ({
trackRecordId,
isPrivate,
closeMenu,
supportsPrivateTracking,
}: {
trackRecordId: TrackRecordType['id'];
isPrivate: boolean;
closeMenu: () => void;
supportsPrivateTracking: TTrackerActive['supportsPrivateTracking'];
}) => {
const { t } = useTranslation();
if (!supportsPrivateTracking) {
return null;
}
return (
<MenuItem
onClick={() => {
requestManager
.updateTrackerBind(trackRecordId, { private: !isPrivate })
.response.catch((e) =>
makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)),
);
closeMenu();
}}
>
{t(isPrivate ? 'tracking.action.button.track_publicly' : 'tracking.action.button.track_privately')}
</MenuItem>
);
};
type TTrackRecordActive = Pick<TTrackRecordBind, 'id' | 'remoteUrl' | 'title' | 'private'>;
const TrackerActiveHeader = ({
trackRecord,
tracker,
openSearch,
}: {
trackRecord: TTrackRecordActive;
tracker: TTrackerActive;
openSearch: () => void;
}) => {
const { t } = useTranslation();
return (
<Stack
direction="row"
sx={{
alignItems: 'stretch',
paddingBottom: 2,
}}
>
<Badge
badgeContent={
trackRecord.private ? (
<Stack sx={{ p: '2px 6px', backgroundColor: 'primary.main', borderRadius: 100 }}>
<VisibilityOffIcon fontSize="small" sx={{ color: 'primary.contrastText' }} />
</Stack>
) : null
}
>
<TrackerActiveLink url={trackRecord.remoteUrl}>
<Avatar
alt={`${tracker.name}`}
src={requestManager.getValidImgUrlFor(tracker.icon)}
variant="rounded"
sx={{ width: 64, height: 64 }}
/>
</TrackerActiveLink>
</Badge>
<ListItemButton sx={{ flexGrow: 1 }} onClick={openSearch}>
<CustomTooltip title={trackRecord.title}>
<TypographyMaxLines flexGrow={1} lines={1}>
{trackRecord.title}
</TypographyMaxLines>
</CustomTooltip>
</ListItemButton>
<Stack
sx={{
justifyContent: 'center',
}}
>
<PopupState variant="popover" popupId={`tracker-active-menu-popup-${tracker.id}`}>
{(popupState) => (
<>
<IconButton {...bindTrigger(popupState)}>
<MoreVertIcon />
</IconButton>
<Menu {...bindMenu(popupState)} id={`tracker-active-menu-${tracker.id}`}>
{(onClose, setHideMenu) => [
<TrackerActiveLink
key={`tracker-active-menu-item-browser-${tracker.id}`}
url={trackRecord.remoteUrl}
>
<MenuItem onClick={() => onClose()}>
{t('global.label.open_in_browser')}
</MenuItem>
</TrackerActiveLink>,
<TrackerActiveRemoveBind
key={`tracker-active-menu-item-remove-${tracker.id}`}
trackerRecordId={trackRecord.id}
tracker={tracker}
onClick={() => setHideMenu(true)}
onClose={onClose}
/>,
<TrackerUpdatePrivateStatus
trackRecordId={trackRecord.id}
isPrivate={trackRecord.private}
closeMenu={onClose}
supportsPrivateTracking={tracker.supportsPrivateTracking}
/>,
]}
</Menu>
</>
)}
</PopupState>
</Stack>
</Stack>
);
};
const TrackerActiveCardInfoRow = ({ children }: { children: React.ReactNode }) => (
<Stack direction="row" sx={{ textAlignLast: 'center' }}>
{children}
</Stack>
);
const isUnsetScore = (score: string | number): boolean => !Math.trunc(Number(score));
export const TrackerActiveCard = ({
trackRecord,
tracker,
onClick,
}: {
trackRecord: TTrackRecordBind;
tracker: TTrackerBind;
onClick: () => void;
}) => {
const { t } = useTranslation();
const isScoreUnset = isUnsetScore(trackRecord.displayScore);
const currentScore = isScoreUnset ? tracker.scores[0] : trackRecord.displayScore;
const selectSettingValues = useMemo(
() =>
tracker.scores.map(
(score) =>
[score, { text: isUnsetScore(score) ? '-' : score }] satisfies SelectSettingValue<
TTrackerBind['scores'][number]
>,
),
[tracker.scores],
);
const updateTrackerBind = (patch: Parameters<typeof requestManager.updateTrackerBind>[1]) => {
requestManager
.updateTrackerBind(trackRecord.id, patch)
.response.catch((e) =>
makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)),
);
};
return (
<Card sx={CARD_STYLING}>
<CardContent sx={{ padding: 0 }}>
<TrackerActiveHeader trackRecord={trackRecord} tracker={tracker} openSearch={onClick} />
<Card sx={{ backgroundColor: 'background.default' }}>
<CardContent sx={{ padding: '0' }}>
<Box sx={{ padding: 1 }}>
<TrackerActiveCardInfoRow>
<ListPreference
ListPreferenceTitle={t('manga.label.status')}
entries={tracker.statuses.map((status) => status.name)}
key="status"
type="ListPreference"
entryValues={tracker.statuses.map((status) => `${status.value}`)}
ListPreferenceCurrentValue={`${trackRecord.status}`}
updateValue={(_, status) => updateTrackerBind({ status: Number(status) })}
summary="%s"
/>
<Divider orientation="vertical" flexItem />
<NumberSetting
settingTitle={t('chapter.title_other')}
dialogTitle={t('chapter.title_other')}
settingValue={`${trackRecord.lastChapterRead}/${trackRecord.totalChapters}`}
value={trackRecord.lastChapterRead}
minValue={0}
maxValue={Number.MAX_SAFE_INTEGER}
valueUnit=""
handleUpdate={(lastChapterRead) => updateTrackerBind({ lastChapterRead })}
/>
<Divider orientation="vertical" flexItem />
<SelectSetting<string>
settingName={t('tracking.track_record.label.score')}
value={currentScore}
values={selectSettingValues}
handleChange={(score) => updateTrackerBind({ scoreString: score })}
/>
</TrackerActiveCardInfoRow>
<Divider />
<TrackerActiveCardInfoRow>
<DateSetting
settingName={t('tracking.track_record.label.start_date')}
value={Trackers.getDateString(trackRecord.startDate)}
remove
handleChange={(startDate) =>
updateTrackerBind({ startDate: startDate ?? UNSET_DATE })
}
/>
<Divider orientation="vertical" flexItem />
<DateSetting
settingName={t('tracking.track_record.label.finish_date')}
value={Trackers.getDateString(trackRecord.finishDate)}
remove
handleChange={(finishDate) =>
updateTrackerBind({ finishDate: finishDate ?? UNSET_DATE })
}
/>
</TrackerActiveCardInfoRow>
</Box>
</CardContent>
</Card>
</CardContent>
</Card>
);
};

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 { TrackerUntrackedCard } from '@/features/tracker/components/cards/TrackerUntrackedCard.tsx';
import { TrackerSearch } from '@/features/tracker/components/TrackerSearch.tsx';
import { TrackerActiveCard } from '@/features/tracker/components/cards/TrackerActiveCard.tsx';
import { MangaType } from '@/lib/graphql/generated/graphql.ts';
import { MangaIdInfo } from '@/features/manga/Manga.types.ts';
import { TTrackerBind, TTrackRecordBind } from '@/features/tracker/Tracker.types.ts';
export enum TrackerMode {
UNTRACKED,
SEARCH,
INFO,
}
export const TrackerCard = ({
tracker,
manga,
trackRecord,
mode,
setSearchMode,
}: {
tracker: TTrackerBind;
manga: MangaIdInfo & Pick<MangaType, 'title'>;
trackRecord?: TTrackRecordBind;
mode: TrackerMode;
setSearchMode: (id?: number) => void;
}) => {
if (mode === TrackerMode.UNTRACKED) {
return <TrackerUntrackedCard tracker={tracker} onClick={() => setSearchMode(tracker.id)} />;
}
if (mode === TrackerMode.SEARCH) {
return (
<TrackerSearch
manga={manga}
tracker={tracker}
trackedId={trackRecord?.remoteId}
closeSearchMode={() => setSearchMode(undefined)}
/>
);
}
if (mode === TrackerMode.INFO && !trackRecord) {
throw new Error(`TrackerCard: unable to find track record for tracker "${tracker.id}" of manga "${manga.id}"}`);
}
return (
<TrackerActiveCard
tracker={tracker}
trackRecord={trackRecord!}
onClick={() => {
setSearchMode(tracker.id);
}}
/>
);
};

View File

@@ -0,0 +1,204 @@
/*
* 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 Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Button from '@mui/material/Button';
import { useTranslation } from 'react-i18next';
import Box from '@mui/material/Box';
import CardActionArea from '@mui/material/CardActionArea';
import CardMedia from '@mui/material/CardMedia';
import Collapse from '@mui/material/Collapse';
import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { useLayoutEffect, useRef, useState } from 'react';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
import { SpinnerImage } from '@/features/core/components/SpinnerImage.tsx';
import { TypographyMaxLines } from '@/features/core/components/texts/TypographyMaxLines.tsx';
import { Metadata } from '@/features/core/components/texts/Metadata.tsx';
import { MediaQuery } from '@/features/core/utils/MediaQuery.tsx';
import { Trackers } from '@/features/tracker/services/Trackers.ts';
import { MANGA_COVER_ASPECT_RATIO } from '@/features/manga/Manga.constants.ts';
import { MUIUtil } from '@/lib/mui/MUI.util.ts';
import {
PUBLISHING_STATUS_TO_TRANSLATION,
PUBLISHING_TYPE_TO_TRANSLATION,
} from '@/features/tracker/Tracker.constants.ts';
import { TTrackerManga } from '@/features/tracker/Tracker.types.ts';
const TrackerMangaCardTitle = ({ title, selected }: { title: string; selected: boolean }) => (
<Stack
direction="row"
sx={{
gap: '5px',
justifyContent: 'space-between',
}}
>
<CustomTooltip title={title}>
<TypographyMaxLines variant="h5" component="h1">
{title}
</TypographyMaxLines>
</CustomTooltip>
<CheckCircleIcon sx={{ visibility: selected ? 'visible' : 'hidden' }} color="primary" />
</Stack>
);
const SUMMARY_COLLAPSED_SIZE = 50;
const TrackerMangaCardSummary = ({ summary }: { summary: string }) => {
const { t } = useTranslation();
const summaryRef = useRef<HTMLParagraphElement>(null);
const [isSummaryExpanded, setIsSummaryExpanded] = useState(false);
const [showSummaryExpandButton, setShowSummaryExpandButton] = useState(false);
const summaryCollapsedSize = showSummaryExpandButton ? SUMMARY_COLLAPSED_SIZE : 0;
useLayoutEffect(() => {
const shouldCollapseSummary = (summaryRef.current?.clientHeight ?? 0) > SUMMARY_COLLAPSED_SIZE;
setShowSummaryExpandButton(shouldCollapseSummary);
setIsSummaryExpanded(!shouldCollapseSummary);
}, []);
return (
<>
{summary.length && (
<Collapse collapsedSize={summaryCollapsedSize} in={isSummaryExpanded}>
<Typography ref={summaryRef} variant="body1" component="p" sx={{ whiteSpace: 'pre-line' }}>
{summary}
</Typography>
</Collapse>
)}
{summary.length && showSummaryExpandButton && (
<Button
component="div"
{...MUIUtil.preventRippleProp()}
onClick={(e) => {
e.stopPropagation();
setIsSummaryExpanded(!isSummaryExpanded);
}}
>
{t(isSummaryExpanded ? 'global.button.show_less' : 'global.button.show_more')}
</Button>
)}
</>
);
};
const TrackerMangaCardLink = ({ children, url }: { children: React.ReactNode; url: string }) => (
<Link
{...MUIUtil.preventRippleProp()}
href={url}
rel="noreferrer"
target="_blank"
underline="none"
color="inherit"
onClick={(e) => e.stopPropagation()}
>
{children}
</Link>
);
export const TrackerMangaCard = ({
manga,
selected,
onSelect,
}: {
manga: TTrackerManga;
selected: boolean;
onSelect: () => void;
}) => {
const { t } = useTranslation();
const isMobileWidth = MediaQuery.useIsMobileWidth();
return (
<Card
sx={{
backgroundColor: 'background.default',
marginBottom: 2,
'&:last-child': { marginBottom: 8 },
}}
>
<CardActionArea onClick={onSelect}>
<CardContent sx={{ padding: '0', borderRadius: 'inherit' }}>
<Box
sx={{
padding: 1,
border: '3px solid',
borderRadius: 'inherit',
borderColor: selected ? 'primary.main' : 'transparent',
}}
>
<Stack
direction="row"
sx={{
gap: 2,
marginBottom: 2,
}}
>
<CardMedia
sx={{
aspectRatio: MANGA_COVER_ASPECT_RATIO,
minWidth: '100px',
width: '150px',
borderRadius: 1,
overflow: 'hidden',
}}
>
<TrackerMangaCardLink url={manga.trackingUrl}>
<SpinnerImage
useFetchApi={false}
disableCors
alt={manga.title}
src={manga.coverUrl}
spinnerStyle={{ width: '100%', height: '100%' }}
imgStyle={{
width: '100%',
height: isMobileWidth ? undefined : '100%',
maxHeight: '100%',
objectFit: 'cover',
}}
/>
</TrackerMangaCardLink>
</CardMedia>
<Stack direction="column" sx={{ width: '100%' }}>
<TrackerMangaCardLink url={manga.trackingUrl}>
<TrackerMangaCardTitle title={manga.title} selected={selected} />
</TrackerMangaCardLink>
{manga.publishingType && (
<Metadata
title={t('global.label.type')}
value={t(PUBLISHING_TYPE_TO_TRANSLATION[Trackers.getPublishingType(manga)])}
/>
)}
{manga.startDate && (
<Metadata title={t('global.label.started')} value={manga.startDate} />
)}
{manga.publishingStatus && (
<Metadata
title={t('manga.label.status')}
value={t(PUBLISHING_STATUS_TO_TRANSLATION[Trackers.getPublishingStatus(manga)])}
/>
)}
{manga.score > 0 && (
<Metadata title={t('tracking.track_record.label.score')} value={manga.score} />
)}
{manga.totalChapters > 0 && (
<Metadata title={t('chapter.title_other')} value={manga.totalChapters} />
)}
</Stack>
</Stack>
<TrackerMangaCardSummary summary={manga.summary} />
</Box>
</CardContent>
</CardActionArea>
</Card>
);
};

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 Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button';
import { useTranslation } from 'react-i18next';
import Stack from '@mui/material/Stack';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { CARD_STYLING } from '@/features/tracker/Tracker.constants.ts';
import { TTrackerBase } from '@/features/tracker/Tracker.types.ts';
export const TrackerUntrackedCard = ({
tracker,
onClick,
}: {
tracker: Pick<TTrackerBase, 'name' | 'icon'>;
onClick: () => void;
}) => {
const { t } = useTranslation();
return (
<Card sx={CARD_STYLING}>
<CardContent sx={{ padding: '0' }}>
<Stack
direction="row"
sx={{
gap: 3,
}}
>
<Avatar
alt={`${tracker.name}`}
src={requestManager.getValidImgUrlFor(tracker.icon)}
variant="rounded"
sx={{ width: 64, height: 64 }}
/>
<Button sx={{ flexGrow: '1' }} onClick={onClick}>
{t('tracking.action.button.add_tracking')}
</Button>
</Stack>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,54 @@
/*
* 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 } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { makeToast } from '@/features/core/utils/Toast.ts';
import { AppRoutes } from '@/features/core/AppRoute.constants.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
export const TrackerOAuthLogin = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const url = new URL(window.location.href);
const { trackerId, trackerName }: { trackerId: number; trackerName: string } = JSON.parse(
url.searchParams.get('state') ?? '{}',
);
const [loginTrackerOAuth, { loading: isLoginInProgress }] = requestManager.useLoginToTrackerOauth();
useEffect(() => {
const login = async () => {
try {
await loginTrackerOAuth({
variables: {
input: {
callbackUrl: window.location.href,
trackerId,
},
},
});
} catch (e) {
makeToast(t('tracking.action.login.label.failure', { name: trackerName }), 'error', getErrorMessage(e));
}
navigate(AppRoutes.settings.childRoutes.tracking.path, { replace: true });
};
login();
}, [trackerId]);
if (isLoginInProgress) {
return t('tracking.action.login.label.progress', { name: trackerName });
}
return null;
};

View File

@@ -0,0 +1,120 @@
/*
* 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 List from '@mui/material/List';
import ListSubheader from '@mui/material/ListSubheader';
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 { EmptyViewAbsoluteCentered } from '@/features/core/components/feedback/EmptyViewAbsoluteCentered.tsx';
import { LoadingPlaceholder } from '@/features/core/components/feedback/LoadingPlaceholder.tsx';
import { SettingsTrackerCard } from '@/features/tracker/components/cards/SettingsTrackerCard.tsx';
import {
createUpdateMetadataServerSettings,
useMetadataServerSettings,
} from '@/features/settings/services/ServerSettingsMetadata.ts';
import { makeToast } from '@/features/core/utils/Toast.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { GET_TRACKERS_SETTINGS } from '@/lib/graphql/queries/TrackerQuery.ts';
import { GetTrackersSettingsQuery } from '@/lib/graphql/generated/graphql.ts';
import { MetadataTrackingSettings } from '@/features/tracker/Tracker.types.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { useAppTitle } from '@/features/navigation-bar/hooks/useAppTitle.ts';
export const TrackingSettings = () => {
const { t } = useTranslation();
useAppTitle(t('tracking.title'));
const {
settings: { updateProgressAfterReading, updateProgressManualMarkRead },
loading: areMetadataServerSettingsLoading,
request: { error: metadataServerSettingsError, refetch: refetchServerMetadataSettings },
} = useMetadataServerSettings();
const updateTrackingSettings = createUpdateMetadataServerSettings<keyof MetadataTrackingSettings>((e) =>
makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)),
);
const {
data,
loading: areTrackersLoading,
error: trackersError,
refetch: refetchTrackersList,
} = requestManager.useGetTrackerList<GetTrackersSettingsQuery>(GET_TRACKERS_SETTINGS, {
notifyOnNetworkStatusChange: true,
});
const trackers = data?.trackers.nodes ?? [];
const loading = areMetadataServerSettingsLoading || areTrackersLoading;
const error = metadataServerSettingsError ?? trackersError;
if (error) {
return (
<EmptyViewAbsoluteCentered
message={t('global.error.label.failed_to_load_data')}
messageExtra={getErrorMessage(error)}
retry={() => {
if (metadataServerSettingsError) {
refetchServerMetadataSettings().catch(
defaultPromiseErrorHandler('TrackingSettings::refetchMetadataServerSettings'),
);
}
if (trackersError) {
refetchTrackersList().catch(
defaultPromiseErrorHandler('TrackingSettings::refetchTrackersList'),
);
}
}}
/>
);
}
if (loading) {
return <LoadingPlaceholder />;
}
return (
<>
<List sx={{ pt: 0 }}>
<ListItem>
<ListItemText primary={t('tracking.settings.label.update_progress_reading')} />
<Switch
edge="end"
checked={updateProgressAfterReading}
onChange={(e) => updateTrackingSettings('updateProgressAfterReading', e.target.checked)}
/>
</ListItem>
<ListItem>
<ListItemText
primary={t('tracking.settings.label.update_progress_manual')}
secondary={t('tracking.settings.label.update_progress_reading_description')}
/>
<Switch
edge="end"
checked={updateProgressManualMarkRead}
onChange={(e) => updateTrackingSettings('updateProgressManualMarkRead', e.target.checked)}
/>
</ListItem>
</List>
<List
subheader={
<ListSubheader component="div" id="tracking-trackers">
{t('tracking.settings.title.trackers')}
</ListSubheader>
}
>
{trackers.map((tracker) => (
<SettingsTrackerCard key={tracker.id} tracker={tracker} />
))}
</List>
</>
);
};

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 {
LoggedInInfo,
PublishingStatus,
PublishingType,
TrackerIdInfo,
TrackRecordTrackerInfo,
TrackSearchPublishingStatusInfo,
TrackSearchPublishingTypeInfo,
} from '@/features/tracker/Tracker.types.ts';
import { UNSET_DATE } from '@/features/tracker/Tracker.constants.ts';
export class Trackers {
static getIds(trackers: TrackerIdInfo[]): number[] {
return trackers.map((tracker) => tracker.id);
}
static isUnsetDate(date: string): boolean {
return date === UNSET_DATE;
}
static getDateString(date: string): string | undefined {
return this.isUnsetDate(date) ? undefined : date;
}
static isLoggedIn<Tracker extends LoggedInInfo>(tracker: Tracker): boolean {
return tracker.isLoggedIn && !tracker.isTokenExpired;
}
static getLoggedIn<Tracker extends LoggedInInfo>(trackers: Tracker[]): Tracker[] {
return trackers.filter(this.isLoggedIn);
}
static getTrackers<TrackRecord extends TrackRecordTrackerInfo, Tracker extends TrackerIdInfo>(
trackRecords: TrackRecord[],
trackers: Tracker[],
): Tracker[] {
return trackRecords
.map((trackRecord) => trackers.find((tracker) => tracker.id === trackRecord.trackerId))
.filter((tracker) => !!tracker);
}
static getTrackRecordFor<TrackRecord extends TrackRecordTrackerInfo>(
tracker: TrackerIdInfo,
trackRecords: TrackRecord[],
): TrackRecord | undefined {
return trackRecords.find((trackRecord) => trackRecord.trackerId === tracker.id);
}
static getPublishingType<TrackSearch extends TrackSearchPublishingTypeInfo>(
trackSearch: TrackSearch,
): PublishingType {
const type = trackSearch.publishingType.toLowerCase().replaceAll(/[ |-]/g, '_');
switch (type) {
case PublishingType.UNKNOWN:
return PublishingType.UNKNOWN;
case PublishingType.MANGA:
return PublishingType.MANGA;
case PublishingType.NOVEL:
return PublishingType.NOVEL;
case PublishingType.ONE_SHOT:
return PublishingType.ONE_SHOT;
case PublishingType.DOUJINSHI:
return PublishingType.DOUJINSHI;
case PublishingType.MANHWA:
return PublishingType.MANHWA;
case PublishingType.MANHUA:
return PublishingType.MANHUA;
case PublishingType.OEL:
return PublishingType.OEL;
default:
return trackSearch.publishingType as PublishingType;
}
}
static getPublishingStatus<TrackSearch extends TrackSearchPublishingStatusInfo>(
trackSearch: TrackSearch,
): PublishingStatus {
const status = trackSearch.publishingStatus.toLowerCase().replaceAll(' ', '_');
switch (status) {
case PublishingStatus.FINISHED:
return PublishingStatus.FINISHED;
case PublishingStatus.RELEASING:
return PublishingStatus.RELEASING;
case PublishingStatus.NOT_YET_RELEASED:
return PublishingStatus.NOT_YET_RELEASED;
case PublishingStatus.CANCELLED:
return PublishingStatus.CANCELLED;
case PublishingStatus.HIATUS:
return PublishingStatus.HIATUS;
case PublishingStatus.CURRENTLY_PUBLISHING:
return PublishingStatus.CURRENTLY_PUBLISHING;
case PublishingStatus.NOT_YET_PUBLISHED:
return PublishingStatus.NOT_YET_PUBLISHED;
default:
return trackSearch.publishingStatus as PublishingStatus;
}
}
}