Implement showing/updating/removing tracker bindings

This commit is contained in:
schroda
2024-03-17 18:42:48 +01:00
parent 66c57a542b
commit 5dbf1ce30c
10 changed files with 556 additions and 17 deletions

View File

@@ -16,7 +16,7 @@ export const Menu = ({
...props
}: Omit<MenuProps, 'children' | 'onClose'> &
Required<Pick<MenuProps, 'onClose'>> & {
children: (onClose: () => void, setHideMenu: (hide: boolean) => void) => JSX.Element;
children: (onClose: () => void, setHideMenu: (hide: boolean) => void) => JSX.Element | JSX.Element[];
}) => {
const [shouldHideMenu, setShouldHideMenu] = useState(false);

View File

@@ -0,0 +1,151 @@
/*
* 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, Dialog, DialogTitle, ListItemText, Stack } from '@mui/material';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import ListItemButton from '@mui/material/ListItemButton';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import dayjs from 'dayjs';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { loadDayJsLocale } from '@/util/language.tsx';
export const DateSetting = ({
settingName,
value,
defaultValue,
handleChange,
remove,
}: {
settingName: string;
value?: string;
defaultValue?: string;
handleChange: (path?: string | null) => void;
remove?: boolean;
}) => {
const { t, i18n } = useTranslation();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [dialogValue, setDialogValue] = useState(value ?? defaultValue);
const [locale, setLocale] = useState('en');
const currentLocale = i18n.language;
useEffect(() => {
loadDayJsLocale(currentLocale).then((wasLoaded) => setLocale(wasLoaded ? currentLocale : 'en'));
}, [currentLocale]);
useEffect(() => {
if (!value) {
return;
}
setDialogValue(value);
}, [value]);
const closeDialog = useCallback(
(resetValue: boolean) => {
setIsDialogOpen(false);
if (resetValue) {
setDialogValue(value ?? defaultValue);
}
},
[value],
);
const closeDialogWithReset = useCallback(() => closeDialog(true), [closeDialog]);
const updateSetting = useCallback(
(newValue?: string, shouldCloseDialog: boolean = true) => {
if (shouldCloseDialog) {
closeDialog(false);
}
const didValueChange = value !== newValue;
if (!didValueChange) {
return;
}
handleChange(newValue);
},
[value, handleChange, closeDialog],
);
return (
<>
<ListItemButton onClick={() => setIsDialogOpen(true)}>
<ListItemText
primary={settingName}
secondary={value ? dayjs(Number(value)).locale(currentLocale).format('L') : '-'}
secondaryTypographyProps={{ style: { display: 'flex', flexDirection: 'column' } }}
/>
</ListItemButton>
<Dialog open={isDialogOpen} onClose={closeDialog}>
<DialogContent>
<DialogTitle sx={{ paddingLeft: 0 }}>{settingName}</DialogTitle>
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale={locale}>
<DatePicker
value={dialogValue ? dayjs(Number(dialogValue)) : null}
onChange={(date) => {
if (!date) return;
setDialogValue(date.valueOf().toString());
}}
/>
</LocalizationProvider>
</DialogContent>
<DialogActions>
<Stack sx={{ width: '100%' }} direction="row" justifyContent="space-between" alignItems="end">
<Stack>
{defaultValue !== undefined && (
<Button
onClick={() => {
setDialogValue(defaultValue);
updateSetting(defaultValue, false);
}}
color="primary"
>
{t('global.button.reset_to_default')}
</Button>
)}
{remove && (
<Button
onClick={() => {
setDialogValue(undefined);
updateSetting(undefined, false);
}}
color="primary"
>
{t('global.button.remove')}
</Button>
)}
</Stack>
<Stack direction="row">
<Button onClick={closeDialogWithReset} color="primary">
{t('global.button.cancel')}
</Button>
<Button
onClick={() => {
updateSetting(dialogValue);
}}
color="primary"
>
{t('global.button.ok')}
</Button>
</Stack>
</Stack>
</DialogActions>
</Dialog>
</>
);
};

View File

@@ -0,0 +1,288 @@
/*
* 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,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
IconButton,
Link,
ListItemButton,
MenuItem,
Stack,
Typography,
} from '@mui/material';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import PopupState, { bindDialog, bindMenu, bindTrigger } from 'material-ui-popup-state';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { Trackers, TTrackRecord, UNSET_DATE } from '@/lib/data/Trackers.ts';
import { ListPreference } from '@/components/sourceConfiguration/ListPreference.tsx';
import { NumberSetting } from '@/components/settings/NumberSetting.tsx';
import { DateSetting } from '@/components/settings/DateSetting.tsx';
import { makeToast } from '@/components/util/Toast.tsx';
import { Menu } from '@/components/menu/Menu';
const TrackerActiveLink = ({ children, url }: { children: React.ReactNode; url: string }) => (
<Link href={url} rel="noreferrer" target="_blank" underline="none" color="inherit">
{children}
</Link>
);
const TrackerActiveRemoveBind = ({
trackRecord: { tracker, ...trackRecord },
onClick,
onClose,
}: {
trackRecord: TTrackRecord;
onClick: () => void;
onClose: () => void;
}) => {
const { t } = useTranslation();
// TODO - enable once server supports removing track binding on tracker source
// const [removeRemoteTracking, setRemoveRemoteTracking] = useState(false);
const removeBind = () => {
onClose();
requestManager
.updateTrackerBind(trackRecord.id, { unbind: true })
.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>
{/* TODO - enable once server supports removing track binding on tracker source */}
{/* <FormGroup> */}
{/* <CheckboxInput */}
{/* disabled={false} */}
{/* label={t('chapter.title')} */}
{/* 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, ...trackRecord },
openSearch,
}: {
trackRecord: TTrackRecord;
openSearch: () => void;
}) => {
const { t } = useTranslation();
return (
<Stack direction="row" alignItems="stretch" sx={{ paddingBottom: '15px' }}>
<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}>
<Typography flexGrow={1}>{trackRecord.title}</Typography>
</ListItemButton>
<Stack 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}`}
trackRecord={{ tracker, ...trackRecord }}
onClick={() => setHideMenu(true)}
onClose={onClose}
/>,
]}
</Menu>
</>
)}
</PopupState>
</Stack>
</Stack>
);
};
const TrackerActiveCardInfoRow = ({ children }: { children: React.ReactNode }) => (
<Stack direction="row" sx={{ textAlignLast: 'center' }}>
{children}
</Stack>
);
export const TrackerActiveCard = ({
trackRecord: { tracker, ...trackRecord },
onClick,
}: {
trackRecord: TTrackRecord;
onClick: () => void;
}) => {
const { t } = useTranslation();
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={{ backgroundColor: 'transparent', boxShadow: 'unset', backgroundImage: 'unset' }}>
<CardContent sx={{ padding: '0' }}>
<TrackerActiveHeader trackRecord={{ tracker, ...trackRecord }} openSearch={onClick} />
<Card>
<CardContent sx={{ padding: 0 }}>
<Box sx={{ padding: 2 }}>
<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={trackRecord.totalChapters}
valueUnit=""
handleUpdate={(lastChapterRead) => updateTrackerBind({ lastChapterRead })}
/>
<Divider orientation="vertical" flexItem />
<NumberSetting
settingTitle={t('tracking.track_record.label.score')}
dialogTitle={t('manga.label.status')}
settingValue={trackRecord.displayScore}
value={trackRecord.score ?? 0}
minValue={0}
maxValue={Number(tracker.scores.slice(-1)[0])}
stepSize={Number(
(Number(tracker.scores[2] ?? 0) - Number(tracker.scores[1] ?? 0)).toFixed(1),
)}
valueUnit=""
handleUpdate={(newScore) =>
updateTrackerBind({
scoreString:
newScore === 0
? tracker.scores[0]
: tracker.scores.find((score) => Number(score) === newScore),
})
}
/>
</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

@@ -9,7 +9,7 @@
import { TBaseTracker, TTrackRecord } from '@/lib/data/Trackers.ts';
import { TrackerUntrackedCard } from '@/components/tracker/TrackerUntrackedCard.tsx';
import { TrackerSearch } from '@/components/tracker/TrackerSearch.tsx';
import { TManga } from '@/typings.ts';
import { TrackerActiveCard } from '@/components/tracker/TrackerActiveCard.tsx';
export enum TrackerMode {
UNTRACKED,
@@ -45,5 +45,16 @@ export const TrackerCard = ({
);
}
return 'tracked';
if (mode === TrackerMode.INFO && !trackRecord) {
throw new Error(`TrackerCard: unable to find track record for tracker "${tracker.id}" of manga "${mangaId}"}`);
}
return (
<TrackerActiveCard
trackRecord={trackRecord!}
onClick={() => {
setSearchMode(tracker.id);
}}
/>
);
};