import Spinner from '@app/assets/spinner.svg'; import BlacklistModal from '@app/components/BlacklistModal'; import Button from '@app/components/Common/Button'; import CachedImage from '@app/components/Common/CachedImage'; import StatusBadgeMini from '@app/components/Common/StatusBadgeMini'; import Tooltip from '@app/components/Common/Tooltip'; import RequestModal from '@app/components/RequestModal'; import ErrorCard from '@app/components/TitleCard/ErrorCard'; import Placeholder from '@app/components/TitleCard/Placeholder'; import { useIsTouch } from '@app/hooks/useIsTouch'; import { Permission, UserType, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; import { withProperties } from '@app/utils/typeHelpers'; import { Transition } from '@headlessui/react'; import { ArrowDownTrayIcon, EyeIcon, EyeSlashIcon, MinusCircleIcon, StarIcon, } from '@heroicons/react/24/outline'; import { MediaStatus } from '@server/constants/media'; import type { Watchlist } from '@server/entity/Watchlist'; import type { MediaType } from '@server/models/Search'; import Link from 'next/link'; import { Fragment, useCallback, useEffect, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import { mutate } from 'swr'; interface TitleCardProps { id: number; image?: string; summary?: string; year?: string; title: string; userScore?: number; mediaType: MediaType; status?: MediaStatus; canExpand?: boolean; inProgress?: boolean; isAddedToWatchlist?: number | boolean; mutateParent?: () => void; } const messages = defineMessages('components.TitleCard', { addToWatchList: 'Add to watchlist', watchlistSuccess: '{title} added to watchlist successfully!', watchlistDeleted: '{title} Removed from watchlist successfully!', watchlistCancel: 'watchlist for {title} canceled.', watchlistError: 'Something went wrong. Please try again.', }); const TitleCard = ({ id, image, summary, year, title, status, mediaType, isAddedToWatchlist = false, inProgress = false, canExpand = false, mutateParent, }: TitleCardProps) => { const isTouch = useIsTouch(); const intl = useIntl(); const { user, hasPermission } = useUser(); const [isUpdating, setIsUpdating] = useState(false); const [currentStatus, setCurrentStatus] = useState(status); const [showDetail, setShowDetail] = useState(false); const [showRequestModal, setShowRequestModal] = useState(false); const { addToast } = useToasts(); const [toggleWatchlist, setToggleWatchlist] = useState( !isAddedToWatchlist ); const [showBlacklistModal, setShowBlacklistModal] = useState(false); const cardRef = useRef(null); // Just to get the year from the date if (year) { year = year.slice(0, 4); } useEffect(() => { setCurrentStatus(status); }, [status]); const requestComplete = useCallback((newStatus: MediaStatus) => { setCurrentStatus(newStatus); setShowRequestModal(false); }, []); const requestUpdating = useCallback( (status: boolean) => setIsUpdating(status), [] ); const closeBlacklistModal = useCallback( () => setShowBlacklistModal(false), [] ); const onClickWatchlistBtn = async (): Promise => { setIsUpdating(true); try { const res = await fetch('/api/v1/watchlist', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ tmdbId: id, mediaType, title, }), }); if (!res.ok) throw new Error(); const data: Watchlist = await res.json(); mutate('/api/v1/discover/watchlist'); if (data) { addToast( {intl.formatMessage(messages.watchlistSuccess, { title, strong: (msg: React.ReactNode) => {msg}, })} , { appearance: 'success', autoDismiss: true } ); } } catch (e) { addToast(intl.formatMessage(messages.watchlistError), { appearance: 'error', autoDismiss: true, }); } finally { setIsUpdating(false); setToggleWatchlist((prevState) => !prevState); } }; const onClickDeleteWatchlistBtn = async (): Promise => { setIsUpdating(true); try { const res = await fetch('/api/v1/watchlist/' + id, { method: 'DELETE', }); if (!res.ok) throw new Error(); if (res.status === 204) { addToast( {intl.formatMessage(messages.watchlistDeleted, { title, strong: (msg: React.ReactNode) => {msg}, })} , { appearance: 'info', autoDismiss: true } ); } } catch (e) { addToast(intl.formatMessage(messages.watchlistError), { appearance: 'error', autoDismiss: true, }); } finally { setIsUpdating(false); mutate('/api/v1/discover/watchlist'); if (mutateParent) { mutateParent(); } setToggleWatchlist((prevState) => !prevState); } }; const onClickHideItemBtn = async (): Promise => { setIsUpdating(true); const topNode = cardRef.current; if (topNode) { const res = await fetch('/api/v1/blacklist', { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify({ tmdbId: id, mediaType, title, user: user?.id, }), }); if (res.status === 201) { addToast( {intl.formatMessage(globalMessages.blacklistSuccess, { title, strong: (msg: React.ReactNode) => {msg}, })} , { appearance: 'success', autoDismiss: true } ); setCurrentStatus(MediaStatus.BLACKLISTED); } else if (res.status === 412) { addToast( {intl.formatMessage(globalMessages.blacklistDuplicateError, { title, strong: (msg: React.ReactNode) => {msg}, })} , { appearance: 'info', autoDismiss: true } ); } else { addToast(intl.formatMessage(globalMessages.blacklistError), { appearance: 'error', autoDismiss: true, }); } setIsUpdating(false); closeBlacklistModal(); } else { addToast(intl.formatMessage(globalMessages.blacklistError), { appearance: 'error', autoDismiss: true, }); } }; const onClickShowBlacklistBtn = async (): Promise => { setIsUpdating(true); const topNode = cardRef.current; if (topNode) { const res = await fetch('/api/v1/blacklist/' + id, { method: 'DELETE', }); if (res.status === 204) { addToast( {intl.formatMessage(globalMessages.removeFromBlacklistSuccess, { title, strong: (msg: React.ReactNode) => {msg}, })} , { appearance: 'success', autoDismiss: true } ); setCurrentStatus(MediaStatus.UNKNOWN); } else { addToast(intl.formatMessage(globalMessages.blacklistError), { appearance: 'error', autoDismiss: true, }); } } else { addToast(intl.formatMessage(globalMessages.blacklistError), { appearance: 'error', autoDismiss: true, }); } setIsUpdating(false); }; const closeModal = useCallback(() => setShowRequestModal(false), []); const showRequestButton = hasPermission( [ Permission.REQUEST, mediaType === 'movie' || mediaType === 'collection' ? Permission.REQUEST_MOVIE : Permission.REQUEST_TV, ], { type: 'or' } ); const showHideButton = hasPermission([Permission.MANAGE_BLACKLIST], { type: 'or', }); return (
{ if (!isTouch) { setShowDetail(true); } }} onMouseLeave={() => setShowDetail(false)} onClick={() => setShowDetail(true)} onKeyDown={(e) => { if (e.key === 'Enter') { setShowDetail(true); } }} role="link" tabIndex={0} >
{mediaType === 'movie' ? intl.formatMessage(globalMessages.movie) : mediaType === 'collection' ? intl.formatMessage(globalMessages.collection) : intl.formatMessage(globalMessages.tvshow)}
{showDetail && currentStatus !== MediaStatus.BLACKLISTED && user?.userType !== UserType.PLEX && (
{toggleWatchlist ? ( ) : ( )} {showHideButton && currentStatus !== MediaStatus.PROCESSING && currentStatus !== MediaStatus.AVAILABLE && currentStatus !== MediaStatus.PARTIALLY_AVAILABLE && currentStatus !== MediaStatus.PENDING && ( )}
)} {showDetail && showHideButton && currentStatus == MediaStatus.BLACKLISTED && ( )} {currentStatus && currentStatus !== MediaStatus.UNKNOWN && (
)}
{year &&
{year}
}

{title}

{summary}
{showRequestButton && (!currentStatus || currentStatus === MediaStatus.UNKNOWN) && ( )}
); }; export default withProperties(TitleCard, { Placeholder, ErrorCard });