Move tracker files into new folder

This commit is contained in:
schroda
2024-10-05 21:32:31 +02:00
parent a206b2c650
commit 7cf3849911
17 changed files with 40 additions and 29 deletions

View File

@@ -17,7 +17,7 @@ import { ThreeStateCheckboxInput } from '@/modules/core/components/inputs/ThreeS
import { GridLayout } from '@/modules/library/contexts/LibraryOptionsContext.tsx';
import { OptionsTabs } from '@/modules/core/components/OptionsTabs.tsx';
import { requestManager } from '@/lib/requests/requests/RequestManager.ts';
import { Trackers } from '@/lib/data/Trackers.ts';
import { Trackers } from '@/modules/tracker/services/Trackers.ts';
import { GetTrackersSettingsQuery, MangaStatus } from '@/lib/graphql/generated/graphql.ts';
import { GET_TRACKERS_SETTINGS } from '@/lib/graphql/queries/TrackerQuery.ts';
import { statusToTranslationKey } from '@/modules/manga/services/Mangas.ts';

View File

@@ -35,7 +35,7 @@ import {
createShouldShowMenuItem,
} from '@/modules/core/components/menu/Menu.utils.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { TrackManga } from '@/components/tracker/TrackManga.tsx';
import { TrackManga } from '@/modules/tracker/components/TrackManga.tsx';
import { useCategorySelect } from '@/components/navbar/action/useCategorySelect.tsx';
import { ChaptersDownloadActionMenuItems } from '@/modules/chapter/components/actions/ChaptersDownloadActionMenuItems.tsx';
import { NestedMenuItem } from '@/modules/core/components/menu/NestedMenuItem.tsx';

View File

@@ -14,8 +14,8 @@ import Dialog from '@mui/material/Dialog';
import CheckIcon from '@mui/icons-material/Check';
import { requestManager } from '@/lib/requests/requests/RequestManager.ts';
import { makeToast } from '@/lib/ui/Toast.ts';
import { TrackManga } from '@/components/tracker/TrackManga.tsx';
import { Trackers } from '@/lib/data/Trackers.ts';
import { TrackManga } from '@/modules/tracker/components/TrackManga.tsx';
import { Trackers } from '@/modules/tracker/services/Trackers.ts';
import { CustomIconButton } from '@/modules/core/components/buttons/CustomIconButton.tsx';
import { GetTrackersSettingsQuery, MangaType } from '@/lib/graphql/generated/graphql.ts';
import { GET_TRACKERS_SETTINGS } from '@/lib/graphql/queries/TrackerQuery.ts';

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 { SxProps, Theme } from '@mui/material/styles';
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,
};

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 MetadataTrackingSettings = {
updateProgressAfterReading: boolean;
updateProgressManualMarkRead: boolean;
};

View File

@@ -0,0 +1,142 @@
/*
* 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, useState } from 'react';
import DialogContent from '@mui/material/DialogContent';
import { useTranslation } from 'react-i18next';
import { requestManager } from '@/lib/requests/requests/RequestManager.ts';
import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx';
import { LoadingPlaceholder } from '@/modules/core/components/placeholder/LoadingPlaceholder.tsx';
import { Trackers } from '@/modules/tracker/services/Trackers.ts';
import { TrackerCard, TrackerMode } from '@/modules/tracker/components/cards/TrackerCard.tsx';
import { makeToast } from '@/lib/ui/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 { MangaIdInfo } from '@/modules/manga/services/Mangas.ts';
import { GET_MANGA_TRACK_RECORDS } from '@/lib/graphql/queries/MangaQuery.ts';
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('/settings/trackingSettings');
}
}, [loading]);
useEffect(() => {
Promise.all(
mangaTrackRecords.map((trackRecord) => requestManager.fetchTrackBind(trackRecord.id).response),
).catch(() => makeToast(t('tracking.error.label.could_not_fetch_track_info'), 'error'));
}, [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 (
<EmptyViewAbsoluteCentered
message={t('global.error.label.failed_to_load_data')}
messageExtra={error.message}
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,197 @@
/*
* 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 { requestManager } from '@/lib/requests/requests/RequestManager.ts';
import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx';
import { LoadingPlaceholder } from '@/modules/core/components/placeholder/LoadingPlaceholder.tsx';
import { Tracker, TTrackerBase } from '@/modules/tracker/services/Trackers.ts';
import { SearchTextField } from '@/modules/core/components/inputs/SearchTextField.tsx';
import { makeToast } from '@/lib/ui/Toast.ts';
import { TrackerMangaCard } from '@/modules/tracker/components/cards/TrackerMangaCard.tsx';
import { DIALOG_PADDING } from '@/modules/tracker/Tracker.constants.ts';
import { useGetOptionForDirection } from '@/theme.tsx';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { MangaType } from '@/lib/graphql/generated/graphql.ts';
import { MangaIdInfo } from '@/modules/manga/services/Mangas.ts';
export const TrackerSearch = ({
manga,
tracker,
closeSearchMode,
trackedId,
}: {
manga: MangaIdInfo & Pick<MangaType, 'title'>;
tracker: Pick<TTrackerBase, 'id'>;
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, { addAbortSignal: true });
const searchResults = trackerSearch.data?.searchTracker.trackSearches ?? [];
const hasResults = !!searchResults.length;
useEffect(() => {
setSelectedTrackerRemoteId(trackedId);
return () =>
trackerSearch.abortRequest(new Error(`MangaTrackerSearchCard(${tracker.id}, ${manga.id}): search changed`));
}, [searchString]);
const [bindTracker, bindTrackerMutation] = requestManager.useBindTracker();
const showTrackButton =
useMemo(
() =>
!!selectedTrackerRemoteId &&
!!searchResults.find((searchResult) => searchResult.remoteId === selectedTrackerRemoteId),
[selectedTrackerRemoteId, searchResults],
) &&
!trackerSearch.loading &&
!trackerSearch.error;
const trackManga = () => {
if (selectedTrackerRemoteId === undefined) {
return;
}
bindTracker({ variables: { mangaId: manga.id, remoteId: selectedTrackerRemoteId, trackerId: tracker.id } })
.then(() => {
makeToast(t('manga.action.track.add.label.success'), 'success');
closeSearchMode();
})
.catch(() => makeToast(t('manga.action.track.add.label.error'), 'error'));
};
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' }}>
{!trackerSearch.loading && !trackerSearch.error && !hasResults && (
<EmptyViewAbsoluteCentered message={t('manga.error.label.no_mangas_found')} />
)}
{trackerSearch.loading && <LoadingPlaceholder />}
{trackerSearch.error && !trackerSearch.loading && (
<EmptyViewAbsoluteCentered
message={t('global.error.label.failed_to_load_data')}
messageExtra={trackerSearch.error.message}
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 && (
<Stack
direction="row"
sx={{
justifyContent: 'center',
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
paddingBottom: DIALOG_PADDING,
}}
>
<Button
disabled={bindTrackerMutation.loading}
size="large"
variant="contained"
onClick={trackManga}
sx={{ width: '75%' }}
>
{t('manga.action.track.add.label.action')}
</Button>
</Stack>
)}
</DialogContent>
</>
);
};

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 { 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 '@/modules/core/components/inputs/PasswordTextField.tsx';
import { makeToast } from '@/lib/ui/Toast.ts';
import { requestManager } from '@/lib/requests/requests/RequestManager.ts';
import { Trackers, TTrackerSearch } from '@/modules/tracker/services/Trackers.ts';
export const SettingsTrackerCard = ({ tracker }: { tracker: TTrackerSearch }) => {
const { t } = useTranslation();
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');
}
};
const handleLogin = async () => {
if (isOAuthLogin) {
const state = {
redirectUrl: `${window.location.origin}/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');
}
};
const onClick = (openPopup: () => void) => {
if (!isOAuthLogin) {
openPopup();
return;
}
handleLogin();
};
return (
<PopupState variant="popover" popupId="tracker-dialog">
{(popupState) => (
<>
<ListItemButton
{...bindTrigger(popupState)}
onClick={() => onClick(popupState.open)}
onTouchStart={() => 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,321 @@
/*
* 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 Tooltip from '@mui/material/Tooltip';
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 { requestManager } from '@/lib/requests/requests/RequestManager.ts';
import { Trackers, TTrackerBind, TTrackRecordBind, UNSET_DATE } from '@/modules/tracker/services/Trackers.ts';
import { ListPreference } from '@/modules/source/components/sourceConfiguration/ListPreference.tsx';
import { NumberSetting } from '@/modules/core/components/settings/NumberSetting.tsx';
import { DateSetting } from '@/modules/core/components/settings/DateSetting.tsx';
import { makeToast } from '@/lib/ui/Toast.ts';
import { Menu } from '@/modules/core/components/menu/Menu.tsx';
import { CARD_STYLING } from '@/modules/tracker/Tracker.constants.ts';
import { TypographyMaxLines } from '@/modules/core/components/TypographyMaxLines.tsx';
import { SelectSetting, SelectSettingValue } from '@/modules/core/components/settings/SelectSetting.tsx';
import { CheckboxInput } from '@/modules/core/components/inputs/CheckboxInput.tsx';
import { TrackRecordType } from '@/lib/graphql/generated/graphql.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'>;
type TTrackRecordActive = Pick<TTrackRecordBind, 'id' | 'remoteUrl' | 'title'>;
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(() => makeToast(t('manga.action.track.remove.label.error'), 'error'));
};
return (
<PopupState variant="dialog" popupId={`tracker-active-menu-remove-button-${tracker.id}`}>
{(popupState) => (
<>
<MenuItem
{...bindTrigger(popupState)}
onClick={() => {
onClick();
popupState.open();
}}
onTouchStart={() => {
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 TrackerActiveHeader = ({
trackRecord,
tracker,
openSearch,
}: {
trackRecord: TTrackRecordActive;
tracker: TTrackerActive;
openSearch: () => void;
}) => {
const { t } = useTranslation();
return (
<Stack
direction="row"
sx={{
alignItems: 'stretch',
paddingBottom: 2,
}}
>
<TrackerActiveLink url={trackRecord.remoteUrl}>
<Avatar
alt={`${tracker.name}`}
src={requestManager.getValidImgUrlFor(tracker.icon)}
variant="rounded"
sx={{ width: 64, height: 64 }}
/>
</TrackerActiveLink>
<ListItemButton sx={{ flexGrow: 1 }} onClick={openSearch}>
<Tooltip title={trackRecord.title}>
<TypographyMaxLines flexGrow={1} lines={1}>
{trackRecord.title}
</TypographyMaxLines>
</Tooltip>
</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}
/>,
]}
</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(() => makeToast(t('global.error.label.failed_to_save_changes'), 'error'));
};
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: status as unknown as number })
}
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={Infinity}
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,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 { TrackerUntrackedCard } from '@/modules/tracker/components/cards/TrackerUntrackedCard.tsx';
import { TrackerSearch } from '@/modules/tracker/components/TrackerSearch.tsx';
import { TrackerActiveCard } from '@/modules/tracker/components/cards/TrackerActiveCard.tsx';
import { TTrackerBind, TTrackRecordBind } from '@/modules/tracker/services/Trackers.ts';
import { MangaType } from '@/lib/graphql/generated/graphql.ts';
import { MangaIdInfo } from '@/modules/manga/services/Mangas.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,181 @@
/*
* 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 Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { useLayoutEffect, useRef, useState } from 'react';
import parseHtml from 'html-react-parser';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import sanitizeHtml from 'sanitize-html';
import { SpinnerImage } from '@/modules/core/components/SpinnerImage.tsx';
import { TypographyMaxLines } from '@/modules/core/components/TypographyMaxLines.tsx';
import { Metadata } from '@/modules/core/components/Metadata.tsx';
import { MediaQuery } from '@/lib/ui/MediaQuery.tsx';
import { TTrackerManga } from '@/modules/tracker/services/Trackers.ts';
const TrackerMangaCardTitle = ({ title, selected }: { title: string; selected: boolean }) => (
<Stack
direction="row"
sx={{
gap: '5px',
justifyContent: 'space-between',
}}
>
<Tooltip title={title}>
<TypographyMaxLines variant="h5" component="h1">
{title}
</TypographyMaxLines>
</Tooltip>
<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">
{parseHtml(sanitizeHtml(summary, { disallowedTagsMode: 'escape' }))}
</Typography>
</Collapse>
)}
{summary.length && showSummaryExpandButton && (
<Button
component="div"
onClick={(e) => {
e.stopPropagation();
setIsSummaryExpanded(!isSummaryExpanded);
}}
onTouchStart={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{t(isSummaryExpanded ? 'global.button.show_less' : 'global.button.show_more')}
</Button>
)}
</>
);
};
const TrackerMangaCardLink = ({ children, url }: { children: React.ReactNode; url: string }) => (
<Link
href={url}
rel="noreferrer"
target="_blank"
underline="none"
color="inherit"
onClick={(e) => e.stopPropagation()}
onMouseDown={(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: '225/350',
minWidth: '100px',
width: '150px',
borderRadius: 1,
overflow: 'hidden',
}}
>
<TrackerMangaCardLink url={manga.trackingUrl}>
<SpinnerImage
useFetchApi={false}
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>
<Metadata title={t('global.label.type')} value={manga.publishingType} />
<Metadata title={t('global.label.started')} value={manga.startDate} />
<Metadata title={t('manga.label.status')} value={manga.publishingStatus} />
</Stack>
</Stack>
<TrackerMangaCardSummary summary={manga.summary} />
</Box>
</CardContent>
</CardActionArea>
</Card>
);
};

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 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/requests/RequestManager.ts';
import { CARD_STYLING } from '@/modules/tracker/Tracker.constants.ts';
import { TTrackerBase } from '@/modules/tracker/services/Trackers.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,52 @@
/*
* 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/requests/RequestManager.ts';
import { makeToast } from '@/lib/ui/Toast.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');
}
navigate('/settings/trackingSettings', { replace: true });
};
login();
}, [trackerId]);
if (isLoginInProgress) {
return t('tracking.action.login.label.progress', { name: trackerName });
}
return null;
};

View File

@@ -0,0 +1,124 @@
/*
* 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 { useContext, useLayoutEffect } from 'react';
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 { NavBarContext } from '@/components/context/NavbarContext.tsx';
import { requestManager } from '@/lib/requests/requests/RequestManager.ts';
import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx';
import { LoadingPlaceholder } from '@/modules/core/components/placeholder/LoadingPlaceholder.tsx';
import { SettingsTrackerCard } from '@/modules/tracker/components/cards/SettingsTrackerCard.tsx';
import {
createUpdateMetadataServerSettings,
useMetadataServerSettings,
} from '@/lib/metadata/metadataServerSettings.ts';
import { makeToast } from '@/lib/ui/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 '@/modules/tracker/Tracker.types.ts';
export const TrackingSettings = () => {
const { t } = useTranslation();
const { setTitle } = useContext(NavBarContext);
useLayoutEffect(() => {
setTitle(t('tracking.settings.title.settings'));
}, [t]);
const {
settings: { updateProgressAfterReading, updateProgressManualMarkRead },
loading: areMetadataServerSettingsLoading,
request: { error: metadataServerSettingsError, refetch: refetchServerMetadataSettings },
} = useMetadataServerSettings();
const updateTrackingSettings = createUpdateMetadataServerSettings<keyof MetadataTrackingSettings>(() =>
makeToast(t('global.error.label.failed_to_save_changes'), 'error'),
);
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={error.message}
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,93 @@
/*
* 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, TrackRecordType, TrackSearchType } from '@/lib/graphql/generated/graphql.ts';
export enum Tracker {
MYANIMELIST = 1,
}
export const UNSET_DATE = '0';
export type TTrackRecordBase = Pick<TrackRecordType, 'id' | 'remoteId' | 'title'>;
export type TTrackRecordBind = TTrackRecordBase &
Pick<
TrackRecordType,
| 'trackerId'
| 'remoteUrl'
| 'status'
| 'lastChapterRead'
| 'totalChapters'
| 'score'
| 'displayScore'
| 'startDate'
| 'finishDate'
>;
export type TTrackerManga = Pick<
TrackSearchType,
| 'id'
| 'remoteId'
| 'trackingUrl'
| 'title'
| 'coverUrl'
| 'publishingType'
| 'startDate'
| 'publishingStatus'
| 'summary'
>;
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' | 'scores' | 'statuses'>;
type TrackerIdInfo = Pick<TrackerType, 'id'>;
type LoggedInInfo = Pick<TrackerType, 'isLoggedIn' | 'isTokenExpired'>;
type TrackRecordTrackerInfo = Pick<TrackRecordType, 'trackerId'>;
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);
}
}