91
resources/client/seasons/episode-list-item.tsx
Executable file
91
resources/client/seasons/episode-list-item.tsx
Executable file
@@ -0,0 +1,91 @@
|
||||
import {EpisodePoster} from '@app/episodes/episode-poster/episode-poster';
|
||||
import {CompactSeasonEpisode} from '@app/episodes/compact-season-episode';
|
||||
import {EpisodeLink} from '@app/episodes/episode-link';
|
||||
import {InteractableRating} from '@app/reviews/interactable-rating';
|
||||
import React, {ReactNode} from 'react';
|
||||
import {Episode} from '@app/titles/models/episode';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {TitleRating} from '@app/reviews/title-rating';
|
||||
import clsx from 'clsx';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {FormattedDate} from '@common/i18n/formatted-date';
|
||||
|
||||
interface Props {
|
||||
episode: Episode;
|
||||
title: Title;
|
||||
allowRating?: boolean;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
showPlayButton?: boolean;
|
||||
centerPlayButton?: boolean;
|
||||
}
|
||||
export function EpisodeListItem({
|
||||
episode,
|
||||
title,
|
||||
allowRating = true,
|
||||
className,
|
||||
children,
|
||||
showPlayButton,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className={clsx('flex items-center gap-20', className)}>
|
||||
<div className="relative w-288 flex-shrink-0 overflow-hidden rounded max-md:hidden">
|
||||
<EpisodePoster
|
||||
title={title}
|
||||
episode={episode}
|
||||
seasonNumber={episode.season_number}
|
||||
lazy={true}
|
||||
srcSize="md"
|
||||
showPlayButton={showPlayButton}
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 w-full bg-black/50 p-6 text-center text-sm text-white">
|
||||
<CompactSeasonEpisode episode={episode} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-auto">
|
||||
<EpisodeLink
|
||||
title={title}
|
||||
seasonNumber={episode.season_number}
|
||||
episode={episode}
|
||||
color="primary"
|
||||
className="text-base font-semibold"
|
||||
/>
|
||||
<div className="mt-4 text-xs text-muted">
|
||||
<FormattedDate date={episode.release_date} preset="long" />
|
||||
</div>
|
||||
<div className="my-12">
|
||||
<EpisodeRating
|
||||
title={title}
|
||||
episode={episode}
|
||||
allowRating={allowRating}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{episode.description || (
|
||||
<span className="italic">
|
||||
<Trans message="We have no overview for this episode yet." />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EpisodeRatingProps {
|
||||
title: Title;
|
||||
episode: Episode;
|
||||
allowRating: boolean;
|
||||
}
|
||||
function EpisodeRating({title, episode, allowRating}: EpisodeRatingProps) {
|
||||
if (episode.status === 'upcoming') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return allowRating ? (
|
||||
<InteractableRating title={title} episode={episode} />
|
||||
) : (
|
||||
<TitleRating score={episode.rating} />
|
||||
);
|
||||
}
|
||||
31
resources/client/seasons/requests/use-season-episode-numbers.ts
Executable file
31
resources/client/seasons/requests/use-season-episode-numbers.ts
Executable file
@@ -0,0 +1,31 @@
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {useParams} from 'react-router-dom';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
episodeNumbers: number[];
|
||||
}
|
||||
|
||||
export function useSeasonEpisodeNumbers() {
|
||||
const {titleId, season} = useParams();
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
'titles',
|
||||
`${titleId}`,
|
||||
'seasons',
|
||||
`${season}`,
|
||||
'episodeNumbers',
|
||||
],
|
||||
queryFn: () => fetchEpisodeNumbers(titleId!, season!),
|
||||
});
|
||||
}
|
||||
|
||||
function fetchEpisodeNumbers(
|
||||
titleId: number | string,
|
||||
seasonNumber: number | string,
|
||||
) {
|
||||
return apiClient
|
||||
.get<Response>(`titles/${titleId}/seasons/${seasonNumber}/episode-numbers`)
|
||||
.then(response => response.data);
|
||||
}
|
||||
60
resources/client/seasons/requests/use-season.ts
Executable file
60
resources/client/seasons/requests/use-season.ts
Executable file
@@ -0,0 +1,60 @@
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {useParams} from 'react-router-dom';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {Season} from '@app/titles/models/season';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {Episode} from '@app/titles/models/episode';
|
||||
import {PaginationResponse} from '@common/http/backend-response/pagination-response';
|
||||
import {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';
|
||||
|
||||
export interface GetSeasonResponse extends BackendResponse {
|
||||
season: Season;
|
||||
title: Title;
|
||||
episodes?: PaginationResponse<Episode>;
|
||||
}
|
||||
|
||||
interface Params {
|
||||
skipUpdating?: boolean;
|
||||
truncateDescriptions?: boolean;
|
||||
load?: string;
|
||||
}
|
||||
|
||||
export const seasonQueryKey = (
|
||||
titleId: number | string,
|
||||
season: number | string,
|
||||
params?: any,
|
||||
) => {
|
||||
const key = ['titles', `${titleId}`, 'seasons', `${season}`];
|
||||
if (params) {
|
||||
key.push(params);
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
export function useSeason(loader: 'seasonPage' | 'season' | 'editSeasonPage') {
|
||||
const {titleId, season} = useParams();
|
||||
return useQuery({
|
||||
queryKey: seasonQueryKey(titleId!, season!, loader),
|
||||
queryFn: () => fetchSeason(titleId!, season!, loader),
|
||||
initialData: () => {
|
||||
const data = getBootstrapData().loaders?.[loader];
|
||||
if (data?.title?.id == titleId && data?.season?.number == season) {
|
||||
return data;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function fetchSeason(
|
||||
titleId: number | string,
|
||||
seasonNumber: number | string,
|
||||
loader: string,
|
||||
) {
|
||||
return apiClient
|
||||
.get<GetSeasonResponse>(`titles/${titleId}/seasons/${seasonNumber}`, {
|
||||
params: {loader},
|
||||
})
|
||||
.then(response => response.data);
|
||||
}
|
||||
38
resources/client/seasons/season-link.tsx
Executable file
38
resources/client/seasons/season-link.tsx
Executable file
@@ -0,0 +1,38 @@
|
||||
import React, {useMemo} from 'react';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {getTitleLink} from '@app/titles/title-link';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {BaseMediaLink, BaseMediaLinkProps} from '@app/base-media-link';
|
||||
|
||||
export interface SeasonLinkProps extends Omit<BaseMediaLinkProps, 'link'> {
|
||||
title: Title;
|
||||
seasonNumber: number;
|
||||
}
|
||||
export function SeasonLink({
|
||||
title,
|
||||
seasonNumber,
|
||||
children,
|
||||
color = 'inherit',
|
||||
...linkProps
|
||||
}: SeasonLinkProps) {
|
||||
const link = useMemo(() => {
|
||||
return getSeasonLink(title, seasonNumber);
|
||||
}, [title, seasonNumber]);
|
||||
|
||||
return (
|
||||
<BaseMediaLink {...linkProps} link={link}>
|
||||
{children ?? (
|
||||
<Trans message="Season :number" values={{number: seasonNumber}} />
|
||||
)}
|
||||
</BaseMediaLink>
|
||||
);
|
||||
}
|
||||
|
||||
export function getSeasonLink(
|
||||
title: Title,
|
||||
seasonNumber: number | string,
|
||||
{absolute}: {absolute?: boolean} = {}
|
||||
): string {
|
||||
const titleLink = getTitleLink(title, {absolute});
|
||||
return `${titleLink}/season/${seasonNumber}`;
|
||||
}
|
||||
113
resources/client/seasons/season-page.tsx
Executable file
113
resources/client/seasons/season-page.tsx
Executable file
@@ -0,0 +1,113 @@
|
||||
import React, {Fragment} from 'react';
|
||||
import {PageMetaTags} from '@common/http/page-meta-tags';
|
||||
import {PageStatus} from '@common/http/page-status';
|
||||
import {GetSeasonResponse, useSeason} from '@app/seasons/requests/use-season';
|
||||
import {TitlePageHeaderImage} from '@app/titles/pages/title-page/title-page-header-image';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {TitlePoster} from '@app/titles/title-poster/title-poster';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {TitleLink} from '@app/titles/title-link';
|
||||
import {SeasonLink} from '@app/seasons/season-link';
|
||||
import {useParams} from 'react-router-dom';
|
||||
import clsx from 'clsx';
|
||||
import {SitePageLayout} from '@app/site-page-layout';
|
||||
import {EpisodeListItem} from '@app/seasons/episode-list-item';
|
||||
import {useSeasonEpisodes} from '@app/titles/requests/use-season-episodes';
|
||||
import {InfiniteScrollSentinel} from '@common/ui/infinite-scroll/infinite-scroll-sentinel';
|
||||
|
||||
export function SeasonPage() {
|
||||
const query = useSeason('seasonPage');
|
||||
const content = query.data ? (
|
||||
<Fragment>
|
||||
<PageMetaTags query={query} />
|
||||
<PageContent data={query.data} />
|
||||
</Fragment>
|
||||
) : (
|
||||
<PageStatus query={query} loaderClassName="absolute inset-0 m-auto" />
|
||||
);
|
||||
|
||||
return <SitePageLayout>{content}</SitePageLayout>;
|
||||
}
|
||||
|
||||
interface PageContentProps {
|
||||
data: GetSeasonResponse;
|
||||
}
|
||||
function PageContent({data}: PageContentProps) {
|
||||
const {title, season} = data;
|
||||
return (
|
||||
<div>
|
||||
<TitlePageHeaderImage title={title} season={season} />
|
||||
<div className="container mx-auto mt-24 px-14 md:mt-40 md:px-24">
|
||||
<div className="mb-24 flex items-center gap-12">
|
||||
<TitlePoster size="w-70" srcSize="sm" title={title} />
|
||||
<div>
|
||||
<TitleLink title={title} color="primary" className="text-xl" />
|
||||
<div className="text-lg">
|
||||
<Trans message="Episode list" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SeasonList title={title} />
|
||||
<EpisodeList data={data} />
|
||||
<SeasonList title={title} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SeasonListProps {
|
||||
title: Title;
|
||||
}
|
||||
function SeasonList({title}: SeasonListProps) {
|
||||
const {season} = useParams();
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 text-base font-semibold">
|
||||
<Trans message="Seasons" />:
|
||||
</div>
|
||||
<div className="mb-34 flex items-center gap-10">
|
||||
{[...new Array(title.seasons_count).keys()].map(index => {
|
||||
const number = index + 1;
|
||||
const isActive = season === `${number}`;
|
||||
return (
|
||||
<SeasonLink
|
||||
key={number}
|
||||
title={title}
|
||||
seasonNumber={number}
|
||||
className={clsx(
|
||||
'flex h-30 w-30 flex-shrink-0 items-center justify-center rounded border text-base',
|
||||
isActive
|
||||
? 'pointer-events-none bg-primary text-white'
|
||||
: 'text-primary'
|
||||
)}
|
||||
>
|
||||
{number}
|
||||
</SeasonLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EpisodeListProps {
|
||||
data: GetSeasonResponse;
|
||||
}
|
||||
function EpisodeList({data: {episodes, title}}: EpisodeListProps) {
|
||||
const query = useSeasonEpisodes(episodes);
|
||||
return (
|
||||
<main>
|
||||
{query.items.map(episode => (
|
||||
<EpisodeListItem
|
||||
key={episode.id}
|
||||
episode={episode}
|
||||
title={title}
|
||||
allowRating
|
||||
showPlayButton
|
||||
className="mb-34"
|
||||
/>
|
||||
))}
|
||||
<InfiniteScrollSentinel query={query} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
70
resources/client/seasons/season-poster.tsx
Executable file
70
resources/client/seasons/season-poster.tsx
Executable file
@@ -0,0 +1,70 @@
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {message} from '@common/i18n/message';
|
||||
import clsx from 'clsx';
|
||||
import {ImageSize, useImageSrc} from '@app/images/use-image-src';
|
||||
import {Season} from '@app/titles/models/season';
|
||||
import {SeasonLink} from '@app/seasons/season-link';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {MovieIcon} from '@common/icons/material/Movie';
|
||||
|
||||
interface Props {
|
||||
title: Title;
|
||||
season: Season;
|
||||
className?: string;
|
||||
size?: string;
|
||||
lazy?: boolean;
|
||||
srcSize?: ImageSize;
|
||||
link?: string;
|
||||
}
|
||||
export function SeasonPoster({
|
||||
title,
|
||||
season,
|
||||
className,
|
||||
size = 'w-full',
|
||||
srcSize,
|
||||
lazy = true,
|
||||
link,
|
||||
}: Props) {
|
||||
const {trans} = useTrans();
|
||||
const src = useImageSrc(season.poster || title.poster, {size: srcSize});
|
||||
|
||||
const imageClassName = clsx(
|
||||
className,
|
||||
size,
|
||||
'object-cover bg-fg-base/4 aspect-poster rounded',
|
||||
!src ? 'flex items-center justify-center' : 'block'
|
||||
);
|
||||
|
||||
const image = src ? (
|
||||
<img
|
||||
className={imageClassName}
|
||||
draggable={false}
|
||||
loading={lazy ? 'lazy' : 'eager'}
|
||||
src={src}
|
||||
alt={trans(
|
||||
message('Poster for season :number of :title', {
|
||||
values: {number: season.number, title: title.name},
|
||||
})
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<span className={imageClassName}>
|
||||
<MovieIcon className="max-w-[60%] text-divider" size="text-6xl" />
|
||||
</span>
|
||||
);
|
||||
|
||||
return link ? (
|
||||
<Link to={link} className="flex-shrink-0">
|
||||
{image}
|
||||
</Link>
|
||||
) : (
|
||||
<SeasonLink
|
||||
title={title}
|
||||
seasonNumber={season.number}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{image}
|
||||
</SeasonLink>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user