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

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