feat: season/episode list on series details (#2967)

This commit is contained in:
Ryan Cohen
2022-08-24 13:09:10 +09:00
committed by GitHub
parent 67f3a3829e
commit 8a2acb7f2b
17 changed files with 377 additions and 12 deletions

View File

@@ -0,0 +1,62 @@
import Badge from '@app/components/Common/Badge';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
const messages = defineMessages({
airedrelative: 'Aired {relativeTime}',
airsrelative: 'Airs {relativeTime}',
});
type AirDateBadgeProps = {
airDate: string;
};
const AirDateBadge = ({ airDate }: AirDateBadgeProps) => {
const WEEK = 1000 * 60 * 60 * 24 * 8;
const intl = useIntl();
const dAirDate = new Date(airDate);
const nowDate = new Date();
const alreadyAired = dAirDate.getTime() < nowDate.getTime();
const compareWeek = new Date(
alreadyAired ? Date.now() - WEEK : Date.now() + WEEK
);
let showRelative = false;
if (
(alreadyAired && dAirDate.getTime() > compareWeek.getTime()) ||
(!alreadyAired && dAirDate.getTime() < compareWeek.getTime())
) {
showRelative = true;
}
return (
<div className="flex items-center space-x-2">
<Badge badgeType="light">
{intl.formatDate(dAirDate, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</Badge>
{showRelative && (
<Badge badgeType="light">
{intl.formatMessage(
alreadyAired ? messages.airedrelative : messages.airsrelative,
{
relativeTime: (
<FormattedRelativeTime
value={(dAirDate.getTime() - Date.now()) / 1000}
numeric="auto"
updateIntervalInSeconds={1}
/>
),
}
)}
</Badge>
)}
</div>
);
};
export default AirDateBadge;

View File

@@ -1,7 +1,14 @@
import Link from 'next/link';
interface BadgeProps {
badgeType?: 'default' | 'primary' | 'danger' | 'warning' | 'success';
badgeType?:
| 'default'
| 'primary'
| 'danger'
| 'warning'
| 'success'
| 'dark'
| 'light';
className?: string;
href?: string;
children: React.ReactNode;
@@ -42,6 +49,18 @@ const Badge = ({
badgeStyle.push('hover:bg-green-400');
}
break;
case 'dark':
badgeStyle.push('bg-gray-900 !text-gray-400');
if (href) {
badgeStyle.push('hover:bg-gray-800');
}
break;
case 'light':
badgeStyle.push('bg-gray-700 !text-gray-300');
if (href) {
badgeStyle.push('hover:bg-gray-600');
}
break;
default:
badgeStyle.push('bg-indigo-500 !text-indigo-100');
if (href) {

View File

@@ -0,0 +1,62 @@
import AirDateBadge from '@app/components/AirDateBadge';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import type { SeasonWithEpisodes } from '@server/models/Tv';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages({
somethingwentwrong: 'Something went wrong while retrieving season data.',
});
type SeasonProps = {
seasonNumber: number;
tvId: number;
};
const Season = ({ seasonNumber, tvId }: SeasonProps) => {
const intl = useIntl();
const { data, error } = useSWR<SeasonWithEpisodes>(
`/api/v1/tv/${tvId}/season/${seasonNumber}`
);
if (!data && !error) {
return <LoadingSpinner />;
}
if (!data) {
return <div>{intl.formatMessage(messages.somethingwentwrong)}</div>;
}
return (
<div className="flex flex-col justify-center divide-y divide-gray-700">
{data.episodes
.slice()
.reverse()
.map((episode) => {
return (
<div
className="flex flex-col space-y-4 py-4 xl:flex-row xl:space-y-4 xl:space-x-4"
key={`season-${seasonNumber}-episode-${episode.episodeNumber}`}
>
<div className="flex-1">
<div className="flex flex-col space-y-2 xl:flex-row xl:items-center xl:space-y-0 xl:space-x-2">
<h3 className="text-lg">{episode.name}</h3>
<AirDateBadge airDate={episode.airDate} />
</div>
{episode.overview && <p>{episode.overview}</p>}
</div>
{episode.stillPath && (
<img
className="h-auto w-full rounded-lg xl:h-32 xl:w-auto"
src={`https://image.tmdb.org/t/p/original/${episode.stillPath}`}
alt=""
/>
)}
</div>
);
})}
</div>
);
};
export default Season;

View File

@@ -3,6 +3,7 @@ import RTAudRotten from '@app/assets/rt_aud_rotten.svg';
import RTFresh from '@app/assets/rt_fresh.svg';
import RTRotten from '@app/assets/rt_rotten.svg';
import TmdbLogo from '@app/assets/tmdb_logo.svg';
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
@@ -19,12 +20,14 @@ import RequestButton from '@app/components/RequestButton';
import RequestModal from '@app/components/RequestModal';
import Slider from '@app/components/Slider';
import StatusBadge from '@app/components/StatusBadge';
import Season from '@app/components/TvDetails/Season';
import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import { sortCrewPriority } from '@app/utils/creditHelpers';
import { Disclosure, Transition } from '@headlessui/react';
import {
ArrowCircleRightIcon,
CogIcon,
@@ -32,10 +35,11 @@ import {
FilmIcon,
PlayIcon,
} from '@heroicons/react/outline';
import { ChevronUpIcon } from '@heroicons/react/solid';
import type { RTRating } from '@server/api/rottentomatoes';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import { IssueStatus } from '@server/constants/issue';
import { MediaStatus } from '@server/constants/media';
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import type { Crew } from '@server/models/common';
import type { TvDetails as TvDetailsType } from '@server/models/Tv';
import { hasFlag } from 'country-flag-icons';
@@ -71,6 +75,10 @@ const messages = defineMessages({
'Production {countryCount, plural, one {Country} other {Countries}}',
reportissue: 'Report an Issue',
manageseries: 'Manage Series',
seasonstitle: 'Seasons',
episodeCount: '{episodeCount, plural, one {# Episode} other {# Episodes}}',
seasonnumber: 'Season {seasonNumber}',
status4k: '4K {status}',
});
interface TvDetailsProps {
@@ -476,6 +484,174 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
</div>
</>
)}
<h2 className="py-4">{intl.formatMessage(messages.seasonstitle)}</h2>
<div className="flex w-full flex-col space-y-2">
{data.seasons
.slice()
.reverse()
.filter((season) => season.seasonNumber !== 0)
.map((season) => {
const show4k =
settings.currentSettings.series4kEnabled &&
hasPermission(
[
Permission.MANAGE_REQUESTS,
Permission.REQUEST_4K,
Permission.REQUEST_4K_TV,
],
{
type: 'or',
}
);
const mSeason = (data.mediaInfo?.seasons ?? []).find(
(s) =>
season.seasonNumber === s.seasonNumber &&
s.status !== MediaStatus.UNKNOWN
);
const mSeason4k = (data.mediaInfo?.seasons ?? []).find(
(s) =>
season.seasonNumber === s.seasonNumber &&
s.status4k !== MediaStatus.UNKNOWN
);
const request = (data.mediaInfo?.requests ?? []).find(
(r) =>
!!r.seasons.find(
(s) => s.seasonNumber === season.seasonNumber
) && !r.is4k
);
const request4k = (data.mediaInfo?.requests ?? []).find(
(r) =>
!!r.seasons.find(
(s) => s.seasonNumber === season.seasonNumber
) && r.is4k
);
return (
<Disclosure key={`season-discoslure-${season.seasonNumber}`}>
{({ open }) => (
<>
<Disclosure.Button
className={`mt-2 flex w-full items-center justify-between border-gray-700 bg-gray-800 px-4 py-2 text-gray-200 ${
open
? 'rounded-t-md border-t border-l border-r'
: 'rounded-md border'
}`}
>
<div className="flex flex-1 items-center space-x-2 text-lg">
<span>
{intl.formatMessage(messages.seasonnumber, {
seasonNumber: season.seasonNumber,
})}
</span>
<Badge badgeType="dark">
{intl.formatMessage(messages.episodeCount, {
episodeCount: season.episodeCount,
})}
</Badge>
</div>
{((!mSeason &&
request?.status === MediaRequestStatus.APPROVED) ||
mSeason?.status === MediaStatus.PROCESSING) && (
<Badge badgeType="primary">
{intl.formatMessage(globalMessages.requested)}
</Badge>
)}
{((!mSeason &&
request?.status === MediaRequestStatus.PENDING) ||
mSeason?.status === MediaStatus.PENDING) && (
<Badge badgeType="warning">
{intl.formatMessage(globalMessages.pending)}
</Badge>
)}
{mSeason?.status ===
MediaStatus.PARTIALLY_AVAILABLE && (
<Badge badgeType="success">
{intl.formatMessage(
globalMessages.partiallyavailable
)}
</Badge>
)}
{mSeason?.status === MediaStatus.AVAILABLE && (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.available)}
</Badge>
)}
{((!mSeason4k &&
request4k?.status ===
MediaRequestStatus.APPROVED) ||
mSeason4k?.status4k === MediaStatus.PROCESSING) &&
show4k && (
<Badge badgeType="primary">
{intl.formatMessage(messages.status4k, {
status: intl.formatMessage(
globalMessages.requested
),
})}
</Badge>
)}
{((!mSeason4k &&
request4k?.status === MediaRequestStatus.PENDING) ||
mSeason?.status4k === MediaStatus.PENDING) &&
show4k && (
<Badge badgeType="warning">
{intl.formatMessage(messages.status4k, {
status: intl.formatMessage(
globalMessages.pending
),
})}
</Badge>
)}
{mSeason4k?.status4k ===
MediaStatus.PARTIALLY_AVAILABLE &&
show4k && (
<Badge badgeType="success">
{intl.formatMessage(messages.status4k, {
status: intl.formatMessage(
globalMessages.partiallyavailable
),
})}
</Badge>
)}
{mSeason4k?.status4k === MediaStatus.AVAILABLE &&
show4k && (
<Badge badgeType="success">
{intl.formatMessage(messages.status4k, {
status: intl.formatMessage(
globalMessages.available
),
})}
</Badge>
)}
<ChevronUpIcon
className={`${
open ? 'rotate-180 transform' : ''
} h-5 w-5 text-gray-500`}
/>
</Disclosure.Button>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
// Not sure why this transition is adding a margin without this here
style={{ margin: '0px' }}
>
<Disclosure.Panel className="w-full rounded-b-md border-b border-l border-r border-gray-700 px-4 pb-2">
<Season
tvId={data.id}
seasonNumber={season.seasonNumber}
/>
</Disclosure.Panel>
</Transition>
</>
)}
</Disclosure>
);
})}
</div>
</div>
<div className="media-overview-right">
<div className="media-facts">