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,54 @@
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 {TitlePageHeader} from '@app/titles/pages/title-page/title-page-header';
import {SitePageLayout} from '@app/site-page-layout';
import {GetTitleResponse, useTitle} from '@app/titles/requests/use-title';
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';
export function TitleFullCreditsPage() {
const query = useTitle('titleCreditsPage');
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: GetTitleResponse;
}
function PageContent({
data: {title, credits: groupedCredits = {}},
}: PageContentProps) {
return (
<div>
<TitlePageHeaderImage title={title} />
<div className="container mx-auto mt-24 px-14 md:mt-40 md:px-24">
<TitlePageHeader title={title} showPoster />
<div className="mt-48 @container">
<SiteSectionHeading headingType="h2" className="mb-40">
<Trans message="Full cast and crew" />
</SiteSectionHeading>
{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,50 @@
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 {Title} from '@app/titles/models/title';
import {TitlePageHeader} from '@app/titles/pages/title-page/title-page-header';
import {SitePageLayout} from '@app/site-page-layout';
import {TitlePageImageGrid} from '@app/titles/pages/title-page/sections/title-page-image-grid';
import {SiteSectionHeading} from '@app/titles/site-section-heading';
import {Trans} from '@common/i18n/trans';
import {useTitle} from '@app/titles/requests/use-title';
export function TitleImagesPage() {
const query = useTitle('titlePage');
const content = query.data ? (
<Fragment>
<PageMetaTags query={query} />
<PageContent title={query.data.title} />;
</Fragment>
) : (
<PageStatus query={query} loaderClassName="absolute inset-0 m-auto" />
);
return <SitePageLayout>{content}</SitePageLayout>;
}
interface PageContentProps {
title: Title;
}
function PageContent({title}: PageContentProps) {
return (
<div>
<TitlePageHeaderImage title={title} />
<div className="container mx-auto mt-24 px-14 md:mt-40 md:px-24">
<TitlePageHeader title={title} showPoster />
<TitlePageImageGrid
images={title.images}
srcSize="lg"
count={24}
heading={
<SiteSectionHeading>
<Trans message="Image gallery" />
</SiteSectionHeading>
}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import {useTitleNews} from '@app/titles/pages/title-page/sections/title-news/use-title-news';
import React from 'react';
import {NewsArticleGridItem} from '@app/news/news-article-grid-item';
import {SiteSectionHeading} from '@app/titles/site-section-heading';
import {Trans} from '@common/i18n/trans';
import {Title} from '@app/titles/models/title';
interface Props {
title: Title;
}
export function TitleNews({title}: Props) {
const {data, isLoading} = useTitleNews(title.id);
if (!isLoading && !data?.news_articles.length) {
return null;
}
return (
<section className="mt-48">
<SiteSectionHeading>
<Trans message="Related news" />
</SiteSectionHeading>
<div className="grid grid-cols-2 gap-24">
{data?.news_articles.map(article => (
<NewsArticleGridItem key={article.id} article={article} />
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,20 @@
import {useQuery} from '@tanstack/react-query';
import {apiClient} from '@common/http/query-client';
import {NewsArticle} from '@app/titles/models/news-article';
interface Response {
news_articles: NewsArticle[];
}
export function useTitleNews(titleId: number | string) {
return useQuery({
queryKey: ['titles', `${titleId}`, 'news'],
queryFn: () => fetchNews(titleId),
});
}
function fetchNews(titleId: number | string) {
return apiClient
.get<Response>(`titles/${titleId}/news`)
.then(response => response.data);
}

View File

@@ -0,0 +1,32 @@
import {TitleCredit} from '@app/titles/models/title';
import {Trans} from '@common/i18n/trans';
import {SiteSectionHeading} from '@app/titles/site-section-heading';
import {Button} from '@common/ui/buttons/button';
import {Link} from 'react-router-dom';
import {ArrowForwardIcon} from '@common/icons/material/ArrowForward';
import {TitleCreditsGrid} from '@app/titles/pages/title-page/title-credits-grid/title-credits-grid';
interface Props {
credits: TitleCredit[] | undefined;
}
export function TitlePageCast({credits = []}: Props) {
const cast = credits.filter(credit => credit.pivot.department === 'actors');
return (
<div className="mt-48">
<SiteSectionHeading>
<Trans message="Cast" />
</SiteSectionHeading>
<TitleCreditsGrid credits={cast} />
<Button
className="mt-24"
variant="outline"
color="primary"
elementType={Link}
to="full-credits"
endIcon={<ArrowForwardIcon />}
>
<Trans message="All cast and crew" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,258 @@
import React, {Fragment, ReactNode, useState} from 'react';
import {useSeasonEpisodes} from '@app/titles/requests/use-season-episodes';
import {SiteSectionHeading} from '@app/titles/site-section-heading';
import {Trans} from '@common/i18n/trans';
import {ContentGridLayout} from '@app/channels/content-grid/content-grid-layout';
import {EpisodePoster} from '@app/episodes/episode-poster/episode-poster';
import {Link, useParams} from 'react-router-dom';
import {getWatchLink} from '@app/videos/watch-page/get-watch-link';
import {Episode} from '@app/titles/models/episode';
import {Title} from '@app/titles/models/title';
import {CompactSeasonEpisode} from '@app/episodes/compact-season-episode';
import {
Menu,
MenuItem,
MenuTrigger,
} from '@common/ui/navigation/menu/menu-trigger';
import {Button} from '@common/ui/buttons/button';
import {message} from '@common/i18n/message';
import {SortIcon} from '@common/icons/material/Sort';
import {ExpandMoreIcon} from '@common/icons/material/ExpandMore';
import {FormattedDate} from '@common/i18n/formatted-date';
import {Skeleton} from '@common/ui/skeleton/skeleton';
import {AnimatePresence, m} from 'framer-motion';
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
import {UseInfiniteDataResult} from '@common/ui/infinite-scroll/use-infinite-data';
import {InfiniteScrollSentinel} from '@common/ui/infinite-scroll/infinite-scroll-sentinel';
import {FormattedDuration} from '@common/i18n/formatted-duration';
import {GetTitleResponse} from '@app/titles/requests/use-title';
interface Props {
data: GetTitleResponse;
label?: ReactNode;
showSeasonSelector?: boolean;
}
export function TitlePageEpisodeGrid({data, label, showSeasonSelector}: Props) {
const {season} = useParams();
const [selectedSeason, setSelectedSeason] = useState<number>(
season ? parseInt(season) : 1,
);
const query = useSeasonEpisodes(
data.episodes,
{
perPage: 21,
excludeDescription: 'true',
},
{
season: selectedSeason,
willSortOrFilter: true,
defaultOrderBy: 'episode_number',
defaultOrderDir: 'asc',
titleId: data.title.id,
},
);
const {isInitialLoading, items, sortDescriptor, setSortDescriptor} = query;
return (
<div className="mt-48">
<SiteSectionHeading
actions={
<Fragment>
{showSeasonSelector && (
<SeasonSelector
selectedSeason={selectedSeason}
onSeasonChange={setSelectedSeason}
seasonCount={data.title.seasons_count}
/>
)}
<SortButton
value={`${sortDescriptor.orderBy}:${sortDescriptor?.orderDir}`}
onValueChange={value => {
const [orderBy, orderDir] = value.split(':');
setSortDescriptor({
orderBy,
orderDir: orderDir as 'asc' | 'desc',
});
}}
/>
</Fragment>
}
>
{label || <Trans message="Episodes" />}
</SiteSectionHeading>
<AnimatePresence initial={false} mode="wait">
{isInitialLoading ? (
<SkeletonGrid />
) : (
<EpisodeGrid episodes={items} title={data.title} query={query} />
)}
</AnimatePresence>
</div>
);
}
interface GridItemProps {
episode: Episode;
title: Title;
}
function GridItem({episode, title}: GridItemProps) {
const runtime = episode.runtime || title.runtime;
const name = (
<Fragment>
<CompactSeasonEpisode className="uppercase" episode={episode} /> -{' '}
{episode.name}
</Fragment>
);
return (
<div>
<div className="relative">
<EpisodePoster
episode={episode}
title={title}
srcSize="md"
showPlayButton
rightAction={
runtime ? (
<span className="rounded bg-black/50 p-4 text-xs font-medium text-white">
<FormattedDuration minutes={runtime} verbose />
</span>
) : null
}
/>
</div>
<div className="mt-10">
{episode.release_date && (
<div className="mb-2 text-sm text-muted">
<FormattedDate date={episode.release_date} />
</div>
)}
<div className="overflow-hidden overflow-ellipsis whitespace-nowrap text-base">
{episode.primary_video ? (
<Link
className="rounded outline-none hover:underline focus-visible:ring focus-visible:ring-offset-2"
to={getWatchLink(episode.primary_video)}
>
{name}
</Link>
) : (
name
)}
</div>
</div>
</div>
);
}
interface EpisodeGridProps {
episodes: Episode[];
title: Title;
query: UseInfiniteDataResult<Episode>;
}
function EpisodeGrid({title, episodes, query}: EpisodeGridProps) {
return (
<m.div key="episode-grid" {...opacityAnimation}>
<ContentGridLayout variant="landscape">
{episodes.map(episode => (
<GridItem key={episode.id} episode={episode} title={title} />
))}
</ContentGridLayout>
<InfiniteScrollSentinel
query={query}
variant="loadMore"
size="sm"
loaderMarginTop="mt-16"
/>
</m.div>
);
}
function SkeletonGrid() {
return (
<m.div key="episode-grid" {...opacityAnimation}>
<ContentGridLayout variant="landscape">
{[...new Array(6).keys()].map(number => (
<div key={number}>
<Skeleton variant="rect" size="aspect-video" animation="pulsate" />
<div className="mt-10 min-h-44">
<Skeleton variant="text" />
<Skeleton variant="text" />
</div>
</div>
))}
</ContentGridLayout>
</m.div>
);
}
interface SeasonSelectorProps {
selectedSeason: number;
onSeasonChange: (newSeason: number) => void;
seasonCount: number;
}
function SeasonSelector({
selectedSeason,
onSeasonChange,
seasonCount,
}: SeasonSelectorProps) {
return (
<MenuTrigger
selectedValue={selectedSeason}
onSelectionChange={newValue => onSeasonChange(newValue as number)}
selectionMode="single"
>
<Button variant="outline" startIcon={<ExpandMoreIcon />} className="mr-4">
<Trans message="Season :number" values={{number: selectedSeason}} />
</Button>
<Menu>
{[...new Array(seasonCount).keys()].map(number => {
const seasonNumber = number + 1;
return (
<MenuItem value={seasonNumber} key={seasonNumber}>
<Trans message="Season :number" values={{number: seasonNumber}} />
</MenuItem>
);
})}
</Menu>
</MenuTrigger>
);
}
const SortOptions = [
{
value: 'episode_number:desc',
label: message('Newest'),
},
{
value: 'episode_number:asc',
label: message('Oldest'),
},
];
interface SortButtonProps {
value: string;
onValueChange: (newValue: string) => void;
}
function SortButton({value, onValueChange}: SortButtonProps) {
let selectedOption = SortOptions.find(option => option.value === value);
if (!selectedOption) {
selectedOption = SortOptions[0];
}
return (
<MenuTrigger
selectedValue={value}
onSelectionChange={newValue => onValueChange(newValue as string)}
selectionMode="single"
>
<Button variant="outline" startIcon={<SortIcon />}>
<Trans {...selectedOption.label} />
</Button>
<Menu>
{SortOptions.map(option => (
<MenuItem value={option.value} key={option.value}>
<Trans {...option.label} />
</MenuItem>
))}
</Menu>
</MenuTrigger>
);
}

View File

@@ -0,0 +1,61 @@
import {TitleImage} from '@app/titles/models/title-image';
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
import {ImageZoomDialog} from '@common/ui/overlays/dialog/image-zoom-dialog';
import {ButtonBase} from '@common/ui/buttons/button-base';
import {useTrans} from '@common/i18n/use-trans';
import {message} from '@common/i18n/message';
import {ImageSize, useImageSrc} from '@app/images/use-image-src';
import {ReactNode} from 'react';
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
interface Props {
images: TitleImage[];
count?: number;
heading?: ReactNode;
srcSize?: ImageSize;
}
export function TitlePageImageGrid({images, count, heading, srcSize}: Props) {
const isMobile = useIsMobileMediaQuery();
const {trans} = useTrans();
if (!images?.length) return null;
if (!count) {
count = isMobile ? 6 : 5;
}
return (
<div className="mt-48">
{heading}
<div className="grid grid-cols-3 gap-12 md:grid-cols-5 md:gap-24">
{images.slice(0, count).map((image, index) => (
<DialogTrigger type="modal" key={image.id}>
<ButtonBase
aria-label={trans(message('Image :index', {values: {index}}))}
>
<ImageItem image={image} srcSize={srcSize} />
</ButtonBase>
<ImageZoomDialog
images={images.map(img => img.url)}
defaultActiveIndex={index}
/>
</DialogTrigger>
))}
</div>
</div>
);
}
interface ImageProps {
image: TitleImage;
srcSize?: ImageSize;
}
function ImageItem({image, srcSize = 'md'}: ImageProps) {
const src = useImageSrc(image.url, {size: srcSize});
return (
<img
className="aspect-square w-full cursor-pointer rounded object-cover"
src={src}
alt=""
/>
);
}

View File

@@ -0,0 +1,61 @@
import {Title} from '@app/titles/models/title';
import {useReviews} from '@app/reviews/requests/use-reviews';
import {SiteSectionHeading} from '@app/titles/site-section-heading';
import {TitleRating} from '@app/reviews/title-rating';
import {Trans} from '@common/i18n/trans';
import {ReviewList} from '@app/reviews/review-list/review-list';
import {useLocalStorage} from '@common/utils/hooks/local-storage';
import {ReviewListSortButton} from '@app/reviews/review-list/review-list-sort-button';
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
import React from 'react';
import {FormattedDate} from '@common/i18n/formatted-date';
interface Props {
title: Title;
}
export function TitlePageReviewList({title}: Props) {
const [sort, setSort] = useLocalStorage(
`reviewSort.${title.model_type}`,
'created_at:desc'
);
const query = useReviews(title);
return (
<div className="mt-48">
<SiteSectionHeading
titleAppend={
query.totalItems ? <span>({query.totalItems})</span> : null
}
actions={
<div className="flex items-center gap-24">
<TitleRating score={title.rating} className="max-md:hidden" />
<ReviewListSortButton
value={sort}
onValueChange={newValue => setSort(newValue)}
/>
</div>
}
>
<Trans message="Reviews" />
</SiteSectionHeading>
<ReviewList
reviewable={title}
showAccountRequiredMessage={title.status !== 'upcoming'}
noResultsMessage={
title.status === 'upcoming' ? (
<IllustratedMessage
className="mt-24"
size="sm"
title={<Trans message="This title is not released yet" />}
description={
<Trans
message="Come back after :date to see the reviews"
values={{date: <FormattedDate date={title.release_date} />}}
/>
}
/>
) : undefined
}
/>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import {SiteSectionHeading} from '@app/titles/site-section-heading';
import {Trans} from '@common/i18n/trans';
import {FormattedDate} from '@common/i18n/formatted-date';
import {SeasonPoster} from '@app/seasons/season-poster';
import {SeasonLink} from '@app/seasons/season-link';
import {GetTitleResponse} from '@app/titles/requests/use-title';
import {InfiniteScrollSentinel} from '@common/ui/infinite-scroll/infinite-scroll-sentinel';
import {useTitleSeasons} from '@app/titles/requests/use-title-seasons';
interface Props {
data: GetTitleResponse;
}
export function TitlePageSeasonGrid({data: {title, seasons}}: Props) {
const query = useTitleSeasons(title.id, seasons);
return (
<div className="mt-48">
<SiteSectionHeading
titleAppend={seasons?.total ? `(${seasons.total})` : undefined}
>
<Trans message="Seasons" />
</SiteSectionHeading>
<div>
<div className="grid grid-cols-4 gap-14 sm:grid-cols-6 lg:grid-cols-8">
{query.items.map(season => (
<div key={season.id}>
<SeasonPoster
title={title}
season={season}
srcSize="sm"
className="aspect-poster flex-shrink-0"
/>
<div className="mt-4">
<SeasonLink
className="text-sm"
title={title}
seasonNumber={season.number}
color="primary"
/>
<div className="text-xs text-muted">
<FormattedDate
date={season.release_date}
options={{year: 'numeric'}}
/>
</div>
</div>
</div>
))}
</div>
<InfiniteScrollSentinel
query={query}
variant="loadMore"
loaderMarginTop="mt-14"
size="sm"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
export const TitlePageSections = [
'episodes',
'seasons',
'videos',
'images',
'reviews',
'cast',
'news',
'related',
] as const;

View File

@@ -0,0 +1,34 @@
import {Title} from '@app/titles/models/title';
import {Episode} from '@app/titles/models/episode';
import {getEpisodeLink} from '@app/episodes/episode-link';
import {getTitleLink} from '@app/titles/title-link';
import {VideoGrid} from '@app/titles/video-grid';
import {SiteSectionHeading} from '@app/titles/site-section-heading';
import {Trans} from '@common/i18n/trans';
interface Props {
title: Title;
episode?: Episode;
}
export function TitlePageVideoGrid({title, episode}: Props) {
const videos = episode ? episode.videos : title.videos;
const link = episode
? `${getEpisodeLink(
title,
episode.season_number,
episode.episode_number
)}/episodes/${episode.id}/videos`
: `${getTitleLink(title)}/videos`;
return (
<VideoGrid
videos={videos}
title={title}
episode={episode}
heading={
<SiteSectionHeading link={link}>
<Trans message="Videos" />
</SiteSectionHeading>
}
/>
);
}

View File

@@ -0,0 +1,27 @@
.title-credits-grid {
@apply grid-cols-1;
}
@screen md {
.title-credits-grid {
@apply grid-cols-2;
}
}
@screen lg {
.title-credits-grid {
@apply grid-cols-3;
}
}
@container (min-width: 500px) {
.title-credits-grid {
@apply grid-cols-2;
}
}
@container (min-width: 900px) {
.title-credits-grid {
@apply grid-cols-3;
}
}

View File

@@ -0,0 +1,60 @@
import {PersonPoster} from '@app/people/person-poster/person-poster';
import {PersonLink} from '@app/people/person-link';
import {TitleCredit} from '@app/titles/models/title';
import clsx from 'clsx';
import {Fragment} from 'react';
import {Trans} from '@common/i18n/trans';
interface Props {
credits: TitleCredit[];
className?: string;
}
export function TitleCreditsGrid({credits, className}: Props) {
if (!credits.length) {
return (
<div className="text-muted italic">
<Trans message="We've no cast information for this title yet." />
</div>
);
}
return (
<div
className={clsx('grid gap-14 md:gap-20 title-credits-grid', className)}
>
{credits.map(credit => (
<div
key={credit.pivot.id}
className="flex items-center gap-14 md:gap-20"
>
<PersonPoster
rounded
person={credit}
size="w-70 md:w-96"
srcSize="md"
/>
<div className="max-md:text-sm">
<PersonLink className="block font-bold" person={credit} />
<div className="text-muted">
<Description credit={credit} />
</div>
</div>
</div>
))}
</div>
);
}
interface DescriptionProps {
credit: TitleCredit;
}
function Description({credit}: DescriptionProps) {
if (credit.pivot.department === 'actors') {
return <Fragment>{credit.pivot.character}</Fragment>;
}
return (
<span className="capitalize">
<Trans message={credit.pivot.job} />
</span>
);
}

View File

@@ -0,0 +1,29 @@
import {Fragment, ReactElement, ReactNode} from 'react';
import clsx from 'clsx';
interface Props {
poster: ReactElement;
children: ReactNode;
className?: string;
}
export function TitlePageAsideLayout({poster, children, className}: Props) {
return (
<div className={clsx('top-40 flex-shrink-0 md:sticky md:w-1/4', className)}>
{poster}
<div className="flex-auto max-md:ml-16 max-md:text-sm">{children}</div>
</div>
);
}
interface DetailItemProps {
label: ReactNode;
children: ReactNode;
}
export function DetailItem({label, children}: DetailItemProps) {
return (
<Fragment>
<dt className="font-semibold">{label}</dt>
<dl className="mb-12 md:mb-24">{children}</dl>
</Fragment>
);
}

View File

@@ -0,0 +1,130 @@
import {Title} from '@app/titles/models/title';
import {TitlePoster} from '@app/titles/title-poster/title-poster';
import {Trans} from '@common/i18n/trans';
import {FormattedCurrency} from '@common/i18n/formatted-currency';
import {WatchlistButton} from '@app/user-lists/watchlist-button';
import {
DetailItem,
TitlePageAsideLayout,
} from '@app/titles/pages/title-page/title-page-aside-layout';
import {KeywordLink} from '@app/titles/keyword-link';
import {ProductionCountryLink} from '@app/titles/production-country-link';
import {WatchNowButton} from '@app/titles/pages/title-page/watch-now-button';
import {useIsStreamingMode} from '@app/videos/use-is-streaming-mode';
import {getTitleLink} from '@app/titles/title-link';
import {ShareMenuTrigger} from '@app/sharing/share-menu-trigger';
import {Button} from '@common/ui/buttons/button';
import React from 'react';
import {ShareIcon} from '@common/icons/material/Share';
import {IconButton} from '@common/ui/buttons/icon-button';
import {EditIcon} from '@common/icons/material/Edit';
import {Link} from 'react-router-dom';
import {useAuth} from '@common/auth/use-auth';
import {GetTitleResponse} from '@app/titles/requests/use-title';
interface Props {
data: GetTitleResponse;
className?: string;
}
export function TitlePageAside({data: {title, language}, className}: Props) {
const isStreamingMode = useIsStreamingMode();
const {hasPermission} = useAuth();
return (
<TitlePageAsideLayout
className={className}
poster={
<div className="relative">
<TitlePoster title={title} size="w-full" srcSize="lg" />
{hasPermission('titles.update') && (
<IconButton
elementType={Link}
to={`/admin/titles/${title.id}/edit`}
className="absolute bottom-6 right-4"
color="white"
>
<EditIcon />
</IconButton>
)}
</div>
}
>
{isStreamingMode && title.primary_video && (
<WatchNowButton video={title.primary_video} variant="flat" />
)}
<WatchlistButton
item={title}
variant={isStreamingMode ? 'outline' : 'flat'}
/>
<ShareButton title={title} />
<dl className="mt-14">
{language && (
<DetailItem label={<Trans message="Original language" />}>
<Trans message={language} />
</DetailItem>
)}
{title.original_title !== title.name && (
<DetailItem label={<Trans message="Original title" />}>
{title.original_title}
</DetailItem>
)}
{title.budget ? (
<DetailItem label={<Trans message="Budget" />}>
<FormattedCurrency value={title.budget} currency="usd" />
</DetailItem>
) : null}
{title.revenue ? (
<DetailItem label={<Trans message="Revenue" />}>
<FormattedCurrency value={title.revenue} currency="usd" />
</DetailItem>
) : null}
{title.production_countries?.length ? (
<DetailItem label={<Trans message="Production countries" />}>
<ul className="mt-12 flex flex-wrap gap-8">
{title.production_countries.map(country => (
<li
key={country.id}
className="w-max rounded-full border px-10 py-4 text-xs"
>
<ProductionCountryLink country={country} />
</li>
))}
</ul>
</DetailItem>
) : null}
{title.keywords?.length ? (
<DetailItem label={<Trans message="Keywords" />}>
<ul className="mt-12 flex flex-wrap gap-8">
{title.keywords.map(keyword => (
<li
key={keyword.id}
className="w-max rounded-full border px-10 py-4 text-xs"
>
<KeywordLink keyword={keyword} />
</li>
))}
</ul>
</DetailItem>
) : null}
</dl>
</TitlePageAsideLayout>
);
}
interface ShareButtonProps {
title: Title;
}
function ShareButton({title}: ShareButtonProps) {
const link = getTitleLink(title, {absolute: true});
return (
<ShareMenuTrigger link={link}>
<Button
variant="outline"
color="primary"
startIcon={<ShareIcon />}
className="mt-14 min-h-40 w-full"
>
<Trans message="Share" />
</Button>
</ShareMenuTrigger>
);
}

View File

@@ -0,0 +1,68 @@
import {Title} from '@app/titles/models/title';
import {Episode} from '@app/titles/models/episode';
import {TitleBackdrop} from '@app/titles/title-poster/title-backdrop';
import {useSettings} from '@common/core/settings/use-settings';
import {IconButton} from '@common/ui/buttons/icon-button';
import {MediaPlayIcon} from '@common/icons/media/media-play';
import {Link} from 'react-router-dom';
import {getWatchLink} from '@app/videos/watch-page/get-watch-link';
import {Season} from '@app/titles/models/season';
interface Props {
title: Title;
season?: Season;
episode?: Episode;
}
export function TitlePageHeaderImage({title, season, episode}: Props) {
const {streaming} = useSettings();
const watchItem = episode || season || title;
const backdropUrl = episode?.poster || title.backdrop;
if (!backdropUrl) {
return null;
}
const backdrop = (
<TitleBackdrop
title={title}
episode={episode}
size="w-full h-full"
className="object-top"
lazy={false}
/>
);
return (
<header className="relative isolate max-h-320 overflow-hidden bg-black md:max-h-400 lg:max-h-450">
<div className="container relative left-0 right-0 top-0 z-20 mx-auto h-full w-full px-24">
{backdrop}
</div>
<div className="h-[calc(100% + 20px)] absolute left-1/2 top-1/2 z-10 w-[calc(100%+100px)] -translate-x-1/2 -translate-y-1/2 bg-black opacity-50 blur-md">
{backdrop}
</div>
<div className="pointer-events-none absolute left-0 top-0 z-30 h-full w-full bg-gradient-to-b from-black/20 md:from-black/40" />
{streaming?.show_header_play && watchItem?.primary_video ? (
<PlayButton item={watchItem} />
) : null}
</header>
);
}
interface PlayButtonProps {
item: Season | Episode | Title;
}
function PlayButton({item}: PlayButtonProps) {
const link = getWatchLink(item.primary_video!);
return (
<IconButton
radius="rounded-full"
color="white"
variant="raised"
size="lg"
className="absolute inset-0 z-40 m-auto"
elementType={Link}
to={link}
>
<MediaPlayIcon />
</IconButton>
);
}

View File

@@ -0,0 +1,30 @@
import {ReactNode} from 'react';
interface Props {
name: ReactNode;
poster?: ReactNode;
description?: ReactNode;
right?: ReactNode;
children?: ReactNode;
}
export function TitlePageHeaderLayout({
name,
description,
children,
right,
poster,
}: Props) {
return (
<div className="mb-24 items-center justify-between gap-24 lg:flex">
{poster}
<div className="flex-auto">
{children}
<h1 className="mb-12 text-4xl md:mb-8 md:text-5xl">{name}</h1>
{description && (
<div className="text-base font-normal">{description}</div>
)}
</div>
{right}
</div>
);
}

View File

@@ -0,0 +1,40 @@
import {InteractableRating} from '@app/reviews/interactable-rating';
import {Title} from '@app/titles/models/title';
import {TitlePageHeaderLayout} from '@app/titles/pages/title-page/title-page-header-layout';
import {FormattedDate} from '@common/i18n/formatted-date';
import {FormattedDuration} from '@common/i18n/formatted-duration';
import {BulletSeparatedItems} from '@app/titles/bullet-separated-items';
import {TitlePoster} from '@app/titles/title-poster/title-poster';
import {TitleLink} from '@app/titles/title-link';
import React from 'react';
interface Props {
title: Title;
showPoster?: boolean;
}
export function TitlePageHeader({title, showPoster = false}: Props) {
return (
<TitlePageHeaderLayout
name={<TitleLink title={title} />}
poster={
showPoster ? (
<TitlePoster title={title} size="w-80" srcSize="sm" />
) : null
}
description={
<div>
<BulletSeparatedItems>
<FormattedDate date={title.release_date} />
{title.certification && (
<div className="uppercase">{title.certification}</div>
)}
{title.runtime && (
<FormattedDuration minutes={title.runtime} verbose />
)}
</BulletSeparatedItems>
</div>
}
right={<InteractableRating title={title} />}
/>
);
}

View File

@@ -0,0 +1,101 @@
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 {TitlePageImageGrid} from '@app/titles/pages/title-page/sections/title-page-image-grid';
import {TitlePageCast} from '@app/titles/pages/title-page/sections/title-page-cast';
import {RelatedTitlesPanel} from '@app/titles/related-titles-panel';
import {TitlePageSeasonGrid} from '@app/titles/pages/title-page/sections/title-page-season-grid';
import {CompactCredits} from '@app/titles/compact-credits';
import {SiteSectionHeading} from '@app/titles/site-section-heading';
import {Trans} from '@common/i18n/trans';
import {getTitleLink} from '@app/titles/title-link';
import {getGenreLink} from '@app/titles/genre-link';
import {TitleNews} from '@app/titles/pages/title-page/sections/title-news/title-news';
import {TruncatedDescription} from '@common/ui/truncated-description';
import clsx from 'clsx';
import {TitlePageReviewList} from '@app/titles/pages/title-page/sections/title-page-review-list';
import {GetTitleResponse} from '@app/titles/requests/use-title';
import {TitlePageVideoGrid} from '@app/titles/pages/title-page/sections/title-page-video-grid';
import {useSettings} from '@common/core/settings/use-settings';
import {useAuth} from '@common/auth/use-auth';
import {TitlePageEpisodeGrid} from '@app/titles/pages/title-page/sections/title-page-episode-grid';
import {TitlePageSections} from '@app/titles/pages/title-page/sections/title-page-sections';
import {Title} from '@app/titles/models/title';
import {AdHost} from '@common/admin/ads/ad-host';
interface Props {
data: GetTitleResponse;
className?: string;
}
export function TitlePageMainContent({data, className}: Props) {
const {title, credits} = data;
const {title_page} = useSettings();
return (
<main className={clsx(className, '@container')}>
{title.genres?.length ? (
<ChipList>
{title.genres.map(genre => (
<Chip
className="capitalize"
elementType={Link}
to={getGenreLink(genre)}
key={genre.id}
>
<Trans message={genre.display_name || genre.name} />
</Chip>
))}
</ChipList>
) : null}
{title.tagline && (
<blockquote className="mt-16">{title.tagline}</blockquote>
)}
<TruncatedDescription className="mt-16" description={title.description} />
<CompactCredits credits={credits} />
<AdHost slot="title_top" className="pt-48" />
{title_page?.sections?.map(name => (
<TitlePageSection key={name} name={name} title={title} data={data} />
))}
</main>
);
}
interface TitlePageSectionProps {
title: Title;
data: GetTitleResponse;
name: (typeof TitlePageSections)[number];
}
function TitlePageSection({name, title, data}: TitlePageSectionProps) {
const {titles} = useSettings();
const {hasPermission} = useAuth();
switch (name) {
case 'episodes':
return title.is_series ? (
<TitlePageEpisodeGrid data={data} showSeasonSelector />
) : null;
case 'seasons':
return title.is_series ? <TitlePageSeasonGrid data={data} /> : null;
case 'videos':
return <TitlePageVideoGrid title={title} />;
case 'images':
return (
<TitlePageImageGrid
images={title.images}
heading={
<SiteSectionHeading link={`${getTitleLink(title)}/images`}>
<Trans message="Images" />
</SiteSectionHeading>
}
/>
);
case 'reviews':
return titles.enable_reviews && hasPermission('reviews.view') ? (
<TitlePageReviewList title={title} />
) : null;
case 'cast':
return <TitlePageCast credits={data.credits?.actors} />;
case 'news':
return <TitleNews title={title} />;
case 'related':
return <RelatedTitlesPanel title={title} />;
}
}

View File

@@ -0,0 +1,44 @@
import {GetTitleResponse, useTitle} from '@app/titles/requests/use-title';
import {PageStatus} from '@common/http/page-status';
import {PageMetaTags} from '@common/http/page-meta-tags';
import React, {Fragment} from 'react';
import {TitlePageMainContent} from '@app/titles/pages/title-page/title-page-main-content';
import {TitlePageHeader} from '@app/titles/pages/title-page/title-page-header';
import {TitlePageHeaderImage} from '@app/titles/pages/title-page/title-page-header-image';
import {TitlePageAside} from '@app/titles/pages/title-page/title-page-aside';
import {SitePageLayout} from '@app/site-page-layout';
export function TitlePage() {
const query = useTitle('titlePage');
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: GetTitleResponse;
}
function PageContent({data}: PageContentProps) {
return (
<Fragment>
<TitlePageHeaderImage title={data.title} />
<div className="container mx-auto mt-24 px-14 md:mt-40 md:px-24">
<div className="items-start gap-54 md:flex">
<TitlePageAside data={data} className="max-lg:hidden" />
<div className="flex-auto">
<TitlePageHeader title={data.title} />
<TitlePageMainContent data={data} />
</div>
</div>
</div>
</Fragment>
);
}

View File

@@ -0,0 +1,48 @@
import {MediaPlayIcon} from '@common/icons/media/media-play';
import {Trans} from '@common/i18n/trans';
import {Button, ButtonProps} from '@common/ui/buttons/button';
import {Link} from 'react-router-dom';
import {getWatchLink} from '@app/videos/watch-page/get-watch-link';
import {Video} from '@app/titles/models/video';
import {CompactSeasonEpisode} from '@app/episodes/compact-season-episode';
interface Props {
variant?: ButtonProps['variant'];
color?: ButtonProps['color'];
size?: string;
video: Video;
defaultLabel?: boolean;
}
export function WatchNowButton({
video,
variant = 'outline',
color = 'primary',
size = 'w-full min-h-40 mt-14',
defaultLabel,
}: Props) {
const label =
video.episode_num && !defaultLabel ? (
<span className="inline-flex gap-4">
<Trans message="Start watching" />
<CompactSeasonEpisode
seasonNum={video.season_num}
episodeNum={video.episode_num}
/>
</span>
) : (
<Trans message="Watch now" />
);
return (
<Button
to={getWatchLink(video)}
elementType={Link}
startIcon={<MediaPlayIcon />}
color={color}
variant={variant}
className={size}
>
{label}
</Button>
);
}

View File

@@ -0,0 +1,50 @@
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 {Title} from '@app/titles/models/title';
import {VideoGrid} from '@app/titles/video-grid';
import {TitlePageHeader} from '@app/titles/pages/title-page/title-page-header';
import {Trans} from '@common/i18n/trans';
import {SiteSectionHeading} from '@app/titles/site-section-heading';
import {SitePageLayout} from '@app/site-page-layout';
import {useTitle} from '@app/titles/requests/use-title';
export function TitleVideosPage() {
const query = useTitle('titlePage');
const content = query.data ? (
<Fragment>
<PageMetaTags query={query} />
<PageContent title={query.data.title} />;
</Fragment>
) : (
<PageStatus query={query} loaderClassName="absolute inset-0 m-auto" />
);
return <SitePageLayout>{content}</SitePageLayout>;
}
interface PageContentProps {
title: Title;
}
function PageContent({title}: PageContentProps) {
return (
<div>
<TitlePageHeaderImage title={title} />
<div className="container mx-auto mt-24 px-14 md:mt-40 md:px-24">
<TitlePageHeader title={title} showPoster />
<VideoGrid
videos={title.videos}
title={title}
count={24}
heading={
<SiteSectionHeading>
<Trans message="Video gallery" />
</SiteSectionHeading>
}
/>
</div>
</div>
);
}