Move tracker files into new folder
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
22
src/modules/tracker/Tracker.constants.ts
Normal file
22
src/modules/tracker/Tracker.constants.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { 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,
|
||||
};
|
||||
12
src/modules/tracker/Tracker.types.ts
Normal file
12
src/modules/tracker/Tracker.types.ts
Normal 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;
|
||||
};
|
||||
142
src/modules/tracker/components/TrackManga.tsx
Normal file
142
src/modules/tracker/components/TrackManga.tsx
Normal 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;
|
||||
};
|
||||
197
src/modules/tracker/components/TrackerSearch.tsx
Normal file
197
src/modules/tracker/components/TrackerSearch.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
155
src/modules/tracker/components/cards/SettingsTrackerCard.tsx
Normal file
155
src/modules/tracker/components/cards/SettingsTrackerCard.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { 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>
|
||||
);
|
||||
};
|
||||
321
src/modules/tracker/components/cards/TrackerActiveCard.tsx
Normal file
321
src/modules/tracker/components/cards/TrackerActiveCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
63
src/modules/tracker/components/cards/TrackerCard.tsx
Normal file
63
src/modules/tracker/components/cards/TrackerCard.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
181
src/modules/tracker/components/cards/TrackerMangaCard.tsx
Normal file
181
src/modules/tracker/components/cards/TrackerMangaCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
52
src/modules/tracker/screens/TrackerOAuthLogin.tsx
Normal file
52
src/modules/tracker/screens/TrackerOAuthLogin.tsx
Normal 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;
|
||||
};
|
||||
124
src/modules/tracker/screens/TrackingSettings.tsx
Normal file
124
src/modules/tracker/screens/TrackingSettings.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
93
src/modules/tracker/services/Trackers.ts
Normal file
93
src/modules/tracker/services/Trackers.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user