Files
suwayomi-material-you-webui/src/features/tracker/components/cards/TrackerActiveCard.tsx

375 lines
16 KiB
TypeScript
Raw Normal View History

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