Rename folder "modules" to "features"
This commit is contained in:
156
src/features/tracker/components/cards/SettingsTrackerCard.tsx
Normal file
156
src/features/tracker/components/cards/SettingsTrackerCard.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* 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 '@/features/core/components/inputs/PasswordTextField.tsx';
|
||||
import { makeToast } from '@/features/core/utils/Toast.ts';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { Trackers } from '@/features/tracker/services/Trackers.ts';
|
||||
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
|
||||
import { useLocalStorage } from '@/features/core/hooks/useStorage.tsx';
|
||||
import { TTrackerSearch } from '@/features/tracker/Tracker.types.ts';
|
||||
|
||||
export const SettingsTrackerCard = ({ tracker }: { tracker: TTrackerSearch }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [serverAddress] = useLocalStorage('serverBaseURL', import.meta.env.VITE_SERVER_URL_DEFAULT);
|
||||
|
||||
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', getErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (isOAuthLogin) {
|
||||
const state = {
|
||||
redirectUrl: `${serverAddress}/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', getErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const onClick = (openPopup: () => void) => {
|
||||
if (!isOAuthLogin) {
|
||||
openPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
handleLogin();
|
||||
};
|
||||
|
||||
return (
|
||||
<PopupState variant="popover" popupId="tracker-dialog">
|
||||
{(popupState) => (
|
||||
<>
|
||||
<ListItemButton {...bindTrigger(popupState)} onClick={() => 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>
|
||||
);
|
||||
};
|
||||
370
src/features/tracker/components/cards/TrackerActiveCard.tsx
Normal file
370
src/features/tracker/components/cards/TrackerActiveCard.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
/*
|
||||
* 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 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 Badge from '@mui/material/Badge';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { Trackers } from '@/features/tracker/services/Trackers.ts';
|
||||
import { ListPreference } from '@/features/source/components/sourceConfiguration/ListPreference.tsx';
|
||||
import { NumberSetting } from '@/features/core/components/settings/NumberSetting.tsx';
|
||||
import { DateSetting } from '@/features/core/components/settings/DateSetting.tsx';
|
||||
import { makeToast } from '@/features/core/utils/Toast.ts';
|
||||
import { Menu } from '@/features/core/components/menu/Menu.tsx';
|
||||
import { CARD_STYLING, UNSET_DATE } from '@/features/tracker/Tracker.constants.ts';
|
||||
import { TypographyMaxLines } from '@/features/core/components/texts/TypographyMaxLines.tsx';
|
||||
import { SelectSetting, SelectSettingValue } from '@/features/core/components/settings/SelectSetting.tsx';
|
||||
import { CheckboxInput } from '@/features/core/components/inputs/CheckboxInput.tsx';
|
||||
import { TrackRecordType } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
|
||||
import { TTrackerBind, TTrackRecordBind } from '@/features/tracker/Tracker.types.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' | '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'))
|
||||
.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>
|
||||
);
|
||||
};
|
||||
|
||||
const TrackerUpdatePrivateStatus = ({
|
||||
trackRecordId,
|
||||
isPrivate,
|
||||
closeMenu,
|
||||
supportsPrivateTracking,
|
||||
}: {
|
||||
trackRecordId: TrackRecordType['id'];
|
||||
isPrivate: boolean;
|
||||
closeMenu: () => void;
|
||||
supportsPrivateTracking: TTrackerActive['supportsPrivateTracking'];
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!supportsPrivateTracking) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
type TTrackRecordActive = Pick<TTrackRecordBind, 'id' | 'remoteUrl' | 'title' | 'private'>;
|
||||
const TrackerActiveHeader = ({
|
||||
trackRecord,
|
||||
tracker,
|
||||
openSearch,
|
||||
}: {
|
||||
trackRecord: TTrackRecordActive;
|
||||
tracker: TTrackerActive;
|
||||
openSearch: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
alignItems: 'stretch',
|
||||
paddingBottom: 2,
|
||||
}}
|
||||
>
|
||||
<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}>
|
||||
<Avatar
|
||||
alt={`${tracker.name}`}
|
||||
src={requestManager.getValidImgUrlFor(tracker.icon)}
|
||||
variant="rounded"
|
||||
sx={{ width: 64, height: 64 }}
|
||||
/>
|
||||
</TrackerActiveLink>
|
||||
</Badge>
|
||||
|
||||
<ListItemButton sx={{ flexGrow: 1 }} onClick={openSearch}>
|
||||
<CustomTooltip title={trackRecord.title}>
|
||||
<TypographyMaxLines flexGrow={1} lines={1}>
|
||||
{trackRecord.title}
|
||||
</TypographyMaxLines>
|
||||
</CustomTooltip>
|
||||
</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}
|
||||
/>,
|
||||
<TrackerUpdatePrivateStatus
|
||||
trackRecordId={trackRecord.id}
|
||||
isPrivate={trackRecord.private}
|
||||
closeMenu={onClose}
|
||||
supportsPrivateTracking={tracker.supportsPrivateTracking}
|
||||
/>,
|
||||
]}
|
||||
</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((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} />
|
||||
<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}
|
||||
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>
|
||||
);
|
||||
};
|
||||
64
src/features/tracker/components/cards/TrackerCard.tsx
Normal file
64
src/features/tracker/components/cards/TrackerCard.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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 '@/features/tracker/components/cards/TrackerUntrackedCard.tsx';
|
||||
import { TrackerSearch } from '@/features/tracker/components/TrackerSearch.tsx';
|
||||
import { TrackerActiveCard } from '@/features/tracker/components/cards/TrackerActiveCard.tsx';
|
||||
import { MangaType } from '@/lib/graphql/generated/graphql.ts';
|
||||
|
||||
import { MangaIdInfo } from '@/features/manga/Manga.types.ts';
|
||||
import { TTrackerBind, TTrackRecordBind } from '@/features/tracker/Tracker.types.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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
204
src/features/tracker/components/cards/TrackerMangaCard.tsx
Normal file
204
src/features/tracker/components/cards/TrackerMangaCard.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* 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 Typography from '@mui/material/Typography';
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx';
|
||||
import { SpinnerImage } from '@/features/core/components/SpinnerImage.tsx';
|
||||
import { TypographyMaxLines } from '@/features/core/components/texts/TypographyMaxLines.tsx';
|
||||
import { Metadata } from '@/features/core/components/texts/Metadata.tsx';
|
||||
import { MediaQuery } from '@/features/core/utils/MediaQuery.tsx';
|
||||
import { Trackers } from '@/features/tracker/services/Trackers.ts';
|
||||
import { MANGA_COVER_ASPECT_RATIO } from '@/features/manga/Manga.constants.ts';
|
||||
import { MUIUtil } from '@/lib/mui/MUI.util.ts';
|
||||
import {
|
||||
PUBLISHING_STATUS_TO_TRANSLATION,
|
||||
PUBLISHING_TYPE_TO_TRANSLATION,
|
||||
} from '@/features/tracker/Tracker.constants.ts';
|
||||
import { TTrackerManga } from '@/features/tracker/Tracker.types.ts';
|
||||
|
||||
const TrackerMangaCardTitle = ({ title, selected }: { title: string; selected: boolean }) => (
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
gap: '5px',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<CustomTooltip title={title}>
|
||||
<TypographyMaxLines variant="h5" component="h1">
|
||||
{title}
|
||||
</TypographyMaxLines>
|
||||
</CustomTooltip>
|
||||
<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" sx={{ whiteSpace: 'pre-line' }}>
|
||||
{summary}
|
||||
</Typography>
|
||||
</Collapse>
|
||||
)}
|
||||
{summary.length && showSummaryExpandButton && (
|
||||
<Button
|
||||
component="div"
|
||||
{...MUIUtil.preventRippleProp()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsSummaryExpanded(!isSummaryExpanded);
|
||||
}}
|
||||
>
|
||||
{t(isSummaryExpanded ? 'global.button.show_less' : 'global.button.show_more')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const TrackerMangaCardLink = ({ children, url }: { children: React.ReactNode; url: string }) => (
|
||||
<Link
|
||||
{...MUIUtil.preventRippleProp()}
|
||||
href={url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
underline="none"
|
||||
color="inherit"
|
||||
onClick={(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: MANGA_COVER_ASPECT_RATIO,
|
||||
minWidth: '100px',
|
||||
width: '150px',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<TrackerMangaCardLink url={manga.trackingUrl}>
|
||||
<SpinnerImage
|
||||
useFetchApi={false}
|
||||
disableCors
|
||||
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>
|
||||
{manga.publishingType && (
|
||||
<Metadata
|
||||
title={t('global.label.type')}
|
||||
value={t(PUBLISHING_TYPE_TO_TRANSLATION[Trackers.getPublishingType(manga)])}
|
||||
/>
|
||||
)}
|
||||
{manga.startDate && (
|
||||
<Metadata title={t('global.label.started')} value={manga.startDate} />
|
||||
)}
|
||||
{manga.publishingStatus && (
|
||||
<Metadata
|
||||
title={t('manga.label.status')}
|
||||
value={t(PUBLISHING_STATUS_TO_TRANSLATION[Trackers.getPublishingStatus(manga)])}
|
||||
/>
|
||||
)}
|
||||
{manga.score > 0 && (
|
||||
<Metadata title={t('tracking.track_record.label.score')} value={manga.score} />
|
||||
)}
|
||||
{manga.totalChapters > 0 && (
|
||||
<Metadata title={t('chapter.title_other')} value={manga.totalChapters} />
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
<TrackerMangaCardSummary summary={manga.summary} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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/RequestManager.ts';
|
||||
import { CARD_STYLING } from '@/features/tracker/Tracker.constants.ts';
|
||||
|
||||
import { TTrackerBase } from '@/features/tracker/Tracker.types.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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user