first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
import {Episode} from '@app/titles/models/episode';
import {Trans} from '@common/i18n/trans';
interface Props {
episode?: Episode;
seasonNum?: number;
episodeNum?: number;
className?: string;
}
export function CompactSeasonEpisode({
episode,
seasonNum,
episodeNum,
className,
}: Props) {
if (!seasonNum && episode) {
seasonNum = episode.season_number;
}
if (!episodeNum && episode) {
episodeNum = episode.episode_number;
}
if (seasonNum && episodeNum) {
return (
<span className={className}>
<Trans
message="s:seasone:episode"
values={{
season: prefixWithZero(seasonNum),
episode: prefixWithZero(episodeNum),
}}
/>
</span>
);
}
if (seasonNum) {
return (
<span className={className}>
<Trans
message="s:season"
values={{
season: prefixWithZero(seasonNum),
}}
/>
</span>
);
}
if (episodeNum) {
return (
<span className={className}>
<Trans
message="e:episode"
values={{
episode: prefixWithZero(episodeNum),
}}
/>
</span>
);
}
return null;
}
function prefixWithZero(value: number): string {
return value < 10 ? `0${value}` : `${value}`;
}

View File

@@ -0,0 +1,44 @@
import React, {useMemo} from 'react';
import {Title} from '@app/titles/models/title';
import {getSeasonLink, SeasonLinkProps} from '@app/seasons/season-link';
import {Episode} from '@app/titles/models/episode';
import {BaseMediaLink} from '@app/base-media-link';
interface Props extends Omit<SeasonLinkProps, 'seasonNumber'> {
episodeNumber?: number;
seasonNumber?: number;
episode?: Episode;
}
export function EpisodeLink({
title,
seasonNumber,
episodeNumber,
episode,
children,
color = 'inherit',
...linkProps
}: Props) {
const link = useMemo(() => {
return getEpisodeLink(
title,
seasonNumber || episode?.episode_number || 1,
episodeNumber || episode?.episode_number || 1
);
}, [title, seasonNumber, episodeNumber, episode]);
return (
<BaseMediaLink {...linkProps} link={link}>
{children ?? <span>{episode?.name}</span>}
</BaseMediaLink>
);
}
export function getEpisodeLink(
title: Title,
seasonNumber: number | string,
episodeNumber: number | string,
{absolute}: {absolute?: boolean} = {}
): string {
const seasonLink = getSeasonLink(title, seasonNumber, {absolute});
return `${seasonLink}/episode/${episodeNumber}`;
}

View File

@@ -0,0 +1,70 @@
import {TitlePageHeaderLayout} from '@app/titles/pages/title-page/title-page-header-layout';
import {BulletSeparatedItems} from '@app/titles/bullet-separated-items';
import {Trans} from '@common/i18n/trans';
import {FormattedDate} from '@common/i18n/formatted-date';
import {FormattedDuration} from '@common/i18n/formatted-duration';
import {InteractableRating} from '@app/reviews/interactable-rating';
import {Breadcrumb} from '@common/ui/breadcrumbs/breadcrumb';
import {BreadcrumbItem} from '@common/ui/breadcrumbs/breadcrumb-item';
import {getTitleLink} from '@app/titles/title-link';
import {getSeasonLink} from '@app/seasons/season-link';
import React from 'react';
import {Title} from '@app/titles/models/title';
import {Episode} from '@app/titles/models/episode';
import {useNavigate} from '@common/utils/hooks/use-navigate';
import {TitlePoster} from '@app/titles/title-poster/title-poster';
interface Props {
title: Title;
episode: Episode;
showPoster?: boolean;
}
export function EpisodePageHeader({title, episode, showPoster}: Props) {
const navigate = useNavigate();
const runtime = episode.runtime || title.runtime;
return (
<TitlePageHeaderLayout
poster={
showPoster ? (
<TitlePoster title={title} size="w-80" srcSize="sm" />
) : undefined
}
name={episode.name}
description={
<BulletSeparatedItems className="my-10 md:my-0">
<Trans
message="Aired :date"
values={{
date: <FormattedDate date={episode.release_date} />,
}}
/>
<span className="uppercase">{title.certification}</span>
{runtime ? <FormattedDuration minutes={runtime} verbose /> : null}
</BulletSeparatedItems>
}
right={<InteractableRating title={title} episode={episode} />}
>
<Breadcrumb isNavigation>
<BreadcrumbItem onSelected={() => navigate(getTitleLink(title))}>
{title.name}
</BreadcrumbItem>
<BreadcrumbItem
onSelected={() =>
navigate(getSeasonLink(title, episode.season_number))
}
>
<Trans
message="Season :number"
values={{number: episode.season_number}}
/>
</BreadcrumbItem>
<BreadcrumbItem>
<Trans
message="Episode :number"
values={{number: episode.episode_number}}
/>
</BreadcrumbItem>
</Breadcrumb>
</TitlePageHeaderLayout>
);
}

View File

@@ -0,0 +1,150 @@
import {PageStatus} from '@common/http/page-status';
import {Title} from '@app/titles/models/title';
import {PageMetaTags} from '@common/http/page-meta-tags';
import React, {Fragment} from 'react';
import {TitlePageHeaderImage} from '@app/titles/pages/title-page/title-page-header-image';
import {
GetEpisodeResponse,
useEpisode,
} from '@app/episodes/requests/use-episode';
import {ChipList} from '@common/ui/forms/input-field/chip-field/chip-list';
import {Chip} from '@common/ui/forms/input-field/chip-field/chip';
import {Link} from 'react-router-dom';
import {TitlePageCast} from '@app/titles/pages/title-page/sections/title-page-cast';
import {RelatedTitlesPanel} from '@app/titles/related-titles-panel';
import {CompactCredits} from '@app/titles/compact-credits';
import {TitlePageAsideLayout} from '@app/titles/pages/title-page/title-page-aside-layout';
import {WatchlistButton} from '@app/user-lists/watchlist-button';
import {TitlePoster} from '@app/titles/title-poster/title-poster';
import {getGenreLink} from '@app/titles/genre-link';
import {SitePageLayout} from '@app/site-page-layout';
import {EpisodePageHeader} from '@app/episodes/episode-page-header';
import {TruncatedDescription} from '@common/ui/truncated-description';
import {TitlePageVideoGrid} from '@app/titles/pages/title-page/sections/title-page-video-grid';
import {useIsStreamingMode} from '@app/videos/use-is-streaming-mode';
import {WatchNowButton} from '@app/titles/pages/title-page/watch-now-button';
import {Episode} from '@app/titles/models/episode';
import {TitlePageSections} from '@app/titles/pages/title-page/sections/title-page-sections';
import {TitlePageEpisodeGrid} from '@app/titles/pages/title-page/sections/title-page-episode-grid';
import {Trans} from '@common/i18n/trans';
import {useSettings} from '@common/core/settings/use-settings';
export function EpisodePage() {
const query = useEpisode('episodePage');
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: GetEpisodeResponse;
}
function PageContent({data}: PageContentProps) {
const {episode, title} = data;
return (
<div>
<TitlePageHeaderImage title={title} episode={episode} />
<div className="container mx-auto mt-12 px-14 md:mt-40 md:px-24">
<div className="items-start gap-54 md:flex">
<Aside title={title} episode={episode} />
<div className="flex-auto">
<EpisodePageHeader title={title} episode={episode} />
<MainContent data={data} />
</div>
</div>
</div>
</div>
);
}
interface MainContentProps {
data: GetEpisodeResponse;
}
function MainContent({data}: MainContentProps) {
const {episode, title, credits} = data;
const {title_page} = useSettings();
return (
<main className="@container">
{title.genres?.length ? (
<ChipList>
{title.genres.map(genre => (
<Chip
className="capitalize"
elementType={Link}
to={getGenreLink(genre)}
key={genre.id}
>
{genre.display_name || genre.name}
</Chip>
))}
</ChipList>
) : null}
<TruncatedDescription
className="mt-16"
description={episode.description}
/>
<CompactCredits credits={credits} />
{title_page?.sections.map(name => (
<EpisodePageSection key={name} name={name} data={data} />
))}
</main>
);
}
interface EpisodePageSectionProps {
data: GetEpisodeResponse;
name: (typeof TitlePageSections)[number];
}
function EpisodePageSection({name, data}: EpisodePageSectionProps) {
switch (name) {
case 'videos':
return <TitlePageVideoGrid title={data.title} episode={data.episode} />;
case 'cast':
return <TitlePageCast credits={data.credits?.actors} />;
case 'related':
return <RelatedTitlesPanel title={data.title} />;
case 'episodes':
return (
<TitlePageEpisodeGrid
data={data}
label={<Trans message="Other episodes" />}
/>
);
default:
return null;
}
}
interface AsideProps {
title: Title;
episode: Episode;
}
function Aside({title, episode}: AsideProps) {
const isStreamingMode = useIsStreamingMode();
return (
<TitlePageAsideLayout
className="max-md:hidden"
poster={<TitlePoster title={title} size="w-full" srcSize="lg" />}
>
{isStreamingMode && episode.primary_video && (
<WatchNowButton
video={episode.primary_video}
variant="flat"
defaultLabel
/>
)}
<WatchlistButton
item={title}
variant={isStreamingMode ? 'outline' : 'flat'}
/>
</TitlePageAsideLayout>
);
}

View File

@@ -0,0 +1,113 @@
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 {Episode} from '@app/titles/models/episode';
import {EpisodeLink} from '@app/episodes/episode-link';
import {Title} from '@app/titles/models/title';
import {ReactNode} from 'react';
import {Link} from 'react-router-dom';
import {IconButton} from '@common/ui/buttons/icon-button';
import {getWatchLink} from '@app/videos/watch-page/get-watch-link';
import {MediaPlayIcon} from '@common/icons/media/media-play';
import {MovieIcon} from '@common/icons/material/Movie';
interface Props {
title: Title;
episode: Episode;
seasonNumber?: number;
className?: string;
size?: string;
lazy?: boolean;
srcSize?: ImageSize;
children?: ReactNode;
aspect?: string;
link?: string;
wrapWithLink?: boolean;
showPlayButton?: boolean;
rightAction?: ReactNode;
}
export function EpisodePoster({
episode,
title,
seasonNumber,
className,
size,
srcSize,
lazy = true,
children,
aspect = 'aspect-video',
link,
wrapWithLink = true,
showPlayButton,
rightAction,
}: Props) {
const {trans} = useTrans();
const src = useImageSrc(episode.poster, {size: srcSize});
const imageClassName = clsx(
'w-full h-full object-cover bg-fg-base/4',
!src ? 'flex items-center justify-center' : 'block',
);
let image = src ? (
<img
className={imageClassName}
draggable={false}
loading={lazy ? 'lazy' : 'eager'}
src={src}
alt={trans(message('Poster for :name', {values: {name: episode.name}}))}
/>
) : (
<span className={imageClassName}>
<MovieIcon className="max-w-[60%] text-divider" size="text-6xl" />
</span>
);
const playButton =
showPlayButton && episode.primary_video ? (
<IconButton
color="white"
variant="flat"
className="absolute bottom-12 left-12 z-10 shadow-md"
elementType={Link}
radius="rounded-full"
to={getWatchLink(episode.primary_video)}
>
<MediaPlayIcon />
</IconButton>
) : null;
if (wrapWithLink) {
image = link ? (
<Link to={link}>{image}</Link>
) : (
<EpisodeLink
title={title}
episode={episode}
seasonNumber={episode.season_number ?? seasonNumber}
displayContents
>
{image}
</EpisodeLink>
);
}
return (
<div
className={clsx('group relative flex-shrink-0', size, aspect, className)}
>
{image}
{playButton}
{children && <div className="absolute bottom-14 left-14">{children}</div>}
{wrapWithLink && (
<div className="pointer-events-none absolute inset-0 bg-black opacity-0 transition-opacity group-hover:opacity-10" />
)}
{rightAction && (
<div className="absolute bottom-12 right-12 z-10 shadow-md">
{rightAction}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,58 @@
import React, {Fragment} from 'react';
import {PageMetaTags} from '@common/http/page-meta-tags';
import {PageStatus} from '@common/http/page-status';
import {TitlePageHeaderImage} from '@app/titles/pages/title-page/title-page-header-image';
import {SitePageLayout} from '@app/site-page-layout';
import {SiteSectionHeading} from '@app/titles/site-section-heading';
import {Trans} from '@common/i18n/trans';
import {TitleCreditsGrid} from '@app/titles/pages/title-page/title-credits-grid/title-credits-grid';
import {
GetEpisodeResponse,
useEpisode,
} from '@app/episodes/requests/use-episode';
import {EpisodePageHeader} from '@app/episodes/episode-page-header';
export function EpisodeFullCreditsPage() {
const query = useEpisode('episodeCreditsPage');
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: GetEpisodeResponse;
}
function PageContent({
data: {title, episode, credits: groupedCredits},
}: PageContentProps) {
return (
<div>
<TitlePageHeaderImage title={title} episode={episode} />
<div className="container mx-auto mt-24 px-14 md:mt-40 md:px-24">
<EpisodePageHeader title={title} episode={episode} showPoster />
<div className="mt-48 @container">
<SiteSectionHeading headingType="h2" className="mb-40">
<Trans message="Full cast and crew" />
</SiteSectionHeading>
{groupedCredits &&
Object.entries(groupedCredits).map(([department, credits]) => (
<div key={department}>
<h3 className="mb-16 text-2xl font-bold capitalize">
<Trans message={department} />
</h3>
<TitleCreditsGrid credits={credits} className="mb-68" />
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {useMutation} from '@tanstack/react-query';
import {apiClient, queryClient} from '@common/http/query-client';
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
import {UseFormReturn} from 'react-hook-form';
import {onFormQueryError} from '@common/errors/on-form-query-error';
import {seasonQueryKey} from '@app/seasons/requests/use-season';
import {Episode} from '@app/titles/models/episode';
interface Response extends BackendResponse {
episode: Episode;
}
export interface CreateEpisodePayload {
name: string;
description: string;
release_date: string;
runtime: number;
popularity: number;
poster: string;
}
export function useCreateEpisode(
form: UseFormReturn<CreateEpisodePayload>,
titleId: number,
season: number | string,
) {
return useMutation({
mutationFn: (payload: CreateEpisodePayload) =>
createEpisode(titleId, season, payload),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: seasonQueryKey(titleId, season),
});
},
onError: r => (form ? onFormQueryError(r, form) : showHttpErrorToast(r)),
});
}
function createEpisode(
titleId: number,
season: number | string,
payload: CreateEpisodePayload,
): Promise<Response> {
return apiClient
.post(`titles/${titleId}/seasons/${season}/episodes`, payload)
.then(r => r.data);
}

View File

@@ -0,0 +1,27 @@
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {useMutation} from '@tanstack/react-query';
import {apiClient, queryClient} from '@common/http/query-client';
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
import {toast} from '@common/ui/toast/toast';
import {message} from '@common/i18n/message';
import {Episode} from '@app/titles/models/episode';
import {seasonQueryKey} from '@app/seasons/requests/use-season';
interface Response extends BackendResponse {}
export function useDeleteEpisode(episode: Episode) {
return useMutation({
mutationFn: () => deleteEpisode(episode.id),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: seasonQueryKey(episode.title_id, episode.season_number),
});
toast(message('Episode deleted'));
},
onError: r => showHttpErrorToast(r),
});
}
function deleteEpisode(seasonId: number | string): Promise<Response> {
return apiClient.delete(`episodes/${seasonId}`).then(r => r.data);
}

View File

@@ -0,0 +1,55 @@
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 {Title} from '@app/titles/models/title';
import {Episode} from '@app/titles/models/episode';
import {seasonQueryKey} from '@app/seasons/requests/use-season';
import {GroupTitleCredits} from '@app/titles/requests/use-title';
import {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';
export interface GetEpisodeResponse extends BackendResponse {
episode: Episode;
title: Title;
credits?: GroupTitleCredits;
}
export function useEpisode(
loader: 'episodePage' | 'episodeCreditsPage' | 'episode',
) {
const {titleId, season, episode} = useParams();
return useQuery({
queryKey: [
...seasonQueryKey(titleId!, season!),
'episodes',
`${episode}`,
loader,
],
queryFn: () => fetchEpisode(titleId!, season!, episode!, loader),
initialData: () => {
const data = getBootstrapData().loaders?.[loader];
if (
data?.title.id == titleId &&
data?.episode.season_number == season &&
data?.episode.episode_number == episode
) {
return data;
}
return undefined;
},
});
}
function fetchEpisode(
titleId: string,
seasonNumber: string,
episodeNumber: string,
loader: string,
) {
return apiClient
.get<GetEpisodeResponse>(
`titles/${titleId}/seasons/${seasonNumber}/episodes/${episodeNumber}`,
{params: {loader}},
)
.then(response => response.data);
}

View File

@@ -0,0 +1,41 @@
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {useMutation} from '@tanstack/react-query';
import {apiClient, queryClient} from '@common/http/query-client';
import {UseFormReturn} from 'react-hook-form';
import {onFormQueryError} from '@common/errors/on-form-query-error';
import {seasonQueryKey} from '@app/seasons/requests/use-season';
import {CreateEpisodePayload} from '@app/episodes/requests/use-create-episode';
import {Episode} from '@app/titles/models/episode';
interface Response extends BackendResponse {
episode: Episode;
}
export function useUpdateEpisode(
titleId: number | string,
season: number | string,
episode: number | string,
form: UseFormReturn<CreateEpisodePayload>,
) {
return useMutation({
mutationFn: (payload: CreateEpisodePayload) =>
updateEpisode(titleId, season, episode, payload),
onSuccess: async ({episode}) => {
await queryClient.invalidateQueries({
queryKey: seasonQueryKey(episode.title_id, episode.season_number),
});
},
onError: r => onFormQueryError(r, form),
});
}
function updateEpisode(
titleId: number | string,
season: number | string,
episode: number | string,
payload: CreateEpisodePayload,
): Promise<Response> {
return apiClient
.put(`titles/${titleId}/seasons/${season}/episodes/${episode}`, payload)
.then(r => r.data);
}