24
resources/client/titles/bullet-separated-items.tsx
Executable file
24
resources/client/titles/bullet-separated-items.tsx
Executable file
@@ -0,0 +1,24 @@
|
||||
import React, {Children, Fragment, ReactNode} from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface BulletSeparatedItemsProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BulletSeparatedItems({
|
||||
children,
|
||||
className,
|
||||
}: BulletSeparatedItemsProps) {
|
||||
const items = Children.toArray(children);
|
||||
return (
|
||||
<div className={clsx('flex items-center gap-4 overflow-hidden', className)}>
|
||||
{items.map((child, index) => (
|
||||
<Fragment key={index}>
|
||||
<div>{child}</div>
|
||||
{index < items.length - 1 ? <div>•</div> : null}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
resources/client/titles/compact-credits.tsx
Executable file
96
resources/client/titles/compact-credits.tsx
Executable file
@@ -0,0 +1,96 @@
|
||||
import {memo, ReactNode} from 'react';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {BulletSeparatedItems} from '@app/titles/bullet-separated-items';
|
||||
import {PersonLink} from '@app/people/person-link';
|
||||
import {GroupTitleCredits} from '@app/titles/requests/use-title';
|
||||
|
||||
interface Props {
|
||||
credits: GroupTitleCredits | undefined;
|
||||
}
|
||||
export const CompactCredits = memo(({credits = {}}: Props) => (
|
||||
<div className="mt-16 flex flex-col gap-14 border-t pt-16">
|
||||
{credits.creators?.length ? (
|
||||
<PeopleDetail label={<Trans message="Created by" />}>
|
||||
<BulletSeparatedItems className="hidden-scrollbar overflow-x-auto">
|
||||
{credits.creators.slice(0, 3).map(creator => (
|
||||
<PersonLink
|
||||
person={creator}
|
||||
key={creator.id}
|
||||
color="primary"
|
||||
className="whitespace-nowrap"
|
||||
/>
|
||||
))}
|
||||
</BulletSeparatedItems>
|
||||
</PeopleDetail>
|
||||
) : null}
|
||||
{credits.directing?.length ? (
|
||||
<PeopleDetail
|
||||
label={
|
||||
<Trans
|
||||
message="[one Director|other Directors]"
|
||||
values={{count: credits.directing.length}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<BulletSeparatedItems className="hidden-scrollbar overflow-x-auto">
|
||||
{credits.directing.slice(0, 3).map(director => (
|
||||
<PersonLink
|
||||
person={director}
|
||||
key={director.id}
|
||||
color="primary"
|
||||
className="whitespace-nowrap"
|
||||
/>
|
||||
))}
|
||||
</BulletSeparatedItems>
|
||||
</PeopleDetail>
|
||||
) : null}
|
||||
{credits.writing?.length ? (
|
||||
<PeopleDetail
|
||||
label={
|
||||
<Trans
|
||||
message="[one Writer|other Writers]"
|
||||
values={{count: credits.writing.length}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<BulletSeparatedItems className="hidden-scrollbar overflow-x-auto">
|
||||
{credits.writing.slice(0, 3).map(writer => (
|
||||
<PersonLink
|
||||
person={writer}
|
||||
key={writer.id}
|
||||
color="primary"
|
||||
className="whitespace-nowrap"
|
||||
/>
|
||||
))}
|
||||
</BulletSeparatedItems>
|
||||
</PeopleDetail>
|
||||
) : null}
|
||||
{credits.actors?.length ? (
|
||||
<PeopleDetail label={<Trans message="Stars" />}>
|
||||
<BulletSeparatedItems className="hidden-scrollbar overflow-x-auto">
|
||||
{credits.actors.slice(0, 3).map(actor => (
|
||||
<PersonLink
|
||||
person={actor}
|
||||
key={actor.id}
|
||||
color="primary"
|
||||
className="whitespace-nowrap"
|
||||
/>
|
||||
))}
|
||||
</BulletSeparatedItems>
|
||||
</PeopleDetail>
|
||||
) : null}
|
||||
</div>
|
||||
));
|
||||
|
||||
interface PeopleDetailProps {
|
||||
label: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
function PeopleDetail({label, children}: PeopleDetailProps) {
|
||||
return (
|
||||
<div className="flex-shrink-0 gap-24 md:flex">
|
||||
<div className="min-w-84 font-bold">{label}</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
resources/client/titles/genre-link.tsx
Executable file
27
resources/client/titles/genre-link.tsx
Executable file
@@ -0,0 +1,27 @@
|
||||
import React, {useMemo} from 'react';
|
||||
import {Genre} from '@app/titles/models/genre';
|
||||
import {
|
||||
BaseMediaLink,
|
||||
BaseMediaLinkProps,
|
||||
getBaseMediaLink,
|
||||
} from '@app/base-media-link';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
|
||||
interface Props extends Omit<BaseMediaLinkProps, 'link'> {
|
||||
genre: Genre;
|
||||
}
|
||||
export function GenreLink({genre, children, ...otherProps}: Props) {
|
||||
const link = useMemo(() => getGenreLink(genre), [genre]);
|
||||
return (
|
||||
<BaseMediaLink {...otherProps} link={link}>
|
||||
{children ?? <Trans message={genre.display_name || genre.name} />}
|
||||
</BaseMediaLink>
|
||||
);
|
||||
}
|
||||
|
||||
export function getGenreLink(
|
||||
genre: Genre,
|
||||
{absolute}: {absolute?: boolean} = {}
|
||||
): string {
|
||||
return getBaseMediaLink(`/genre/${genre.name}`, {absolute});
|
||||
}
|
||||
27
resources/client/titles/keyword-link.tsx
Executable file
27
resources/client/titles/keyword-link.tsx
Executable file
@@ -0,0 +1,27 @@
|
||||
import React, {useMemo} from 'react';
|
||||
import {
|
||||
BaseMediaLink,
|
||||
BaseMediaLinkProps,
|
||||
getBaseMediaLink,
|
||||
} from '@app/base-media-link';
|
||||
import {Keyword} from '@app/titles/models/keyword';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
|
||||
interface Props extends Omit<BaseMediaLinkProps, 'link'> {
|
||||
keyword: Keyword;
|
||||
}
|
||||
export function KeywordLink({keyword, children, ...otherProps}: Props) {
|
||||
const link = useMemo(() => getKeywordLink(keyword), [keyword]);
|
||||
return (
|
||||
<BaseMediaLink {...otherProps} link={link}>
|
||||
{children ?? <Trans message={keyword.display_name || keyword.name} />}
|
||||
</BaseMediaLink>
|
||||
);
|
||||
}
|
||||
|
||||
export function getKeywordLink(
|
||||
keyword: Keyword,
|
||||
{absolute}: {absolute?: boolean} = {}
|
||||
): string {
|
||||
return getBaseMediaLink(`/keyword/${keyword.name}`, {absolute});
|
||||
}
|
||||
24
resources/client/titles/models/episode.ts
Executable file
24
resources/client/titles/models/episode.ts
Executable file
@@ -0,0 +1,24 @@
|
||||
import {Video} from './video';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
|
||||
export const EPISODE_MODEL = 'episode';
|
||||
|
||||
export interface Episode {
|
||||
id: number;
|
||||
name: string;
|
||||
model_type: typeof EPISODE_MODEL;
|
||||
status: 'released' | 'upcoming';
|
||||
poster: string;
|
||||
runtime: number;
|
||||
popularity: number;
|
||||
rating: number;
|
||||
description: string;
|
||||
season_number: number;
|
||||
episode_number: number;
|
||||
title?: Title;
|
||||
title_id: number;
|
||||
release_date: string;
|
||||
year: number;
|
||||
videos?: Video[];
|
||||
primary_video: Video;
|
||||
}
|
||||
10
resources/client/titles/models/genre.ts
Executable file
10
resources/client/titles/models/genre.ts
Executable file
@@ -0,0 +1,10 @@
|
||||
export const GENRE_MODEL = 'genre';
|
||||
|
||||
export interface Genre {
|
||||
id: number;
|
||||
name: string;
|
||||
display_name: string;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
model_type: typeof GENRE_MODEL;
|
||||
}
|
||||
10
resources/client/titles/models/keyword.ts
Executable file
10
resources/client/titles/models/keyword.ts
Executable file
@@ -0,0 +1,10 @@
|
||||
export const KEYWORD_MODEL = 'keyword';
|
||||
|
||||
export interface Keyword {
|
||||
id: number;
|
||||
name: string;
|
||||
display_name: string;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
model_type: typeof KEYWORD_MODEL;
|
||||
}
|
||||
15
resources/client/titles/models/news-article.ts
Executable file
15
resources/client/titles/models/news-article.ts
Executable file
@@ -0,0 +1,15 @@
|
||||
export const NEWS_ARTICLE_MODEL = 'newsArticle';
|
||||
|
||||
export interface NewsArticle {
|
||||
id: number;
|
||||
title?: string;
|
||||
body: string;
|
||||
slug: string;
|
||||
image: string;
|
||||
byline?: string;
|
||||
source?: string;
|
||||
source_url?: string;
|
||||
model_type: typeof NEWS_ARTICLE_MODEL;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
20
resources/client/titles/models/person.ts
Executable file
20
resources/client/titles/models/person.ts
Executable file
@@ -0,0 +1,20 @@
|
||||
import {PersonCredit} from './title';
|
||||
|
||||
export const PERSON_MODEL = 'person';
|
||||
|
||||
export interface Person {
|
||||
id: number;
|
||||
name: string;
|
||||
poster?: string;
|
||||
known_for?: string;
|
||||
gender?: string;
|
||||
birth_date: string;
|
||||
death_date: string;
|
||||
birth_place: string;
|
||||
primary_credit?: PersonCredit;
|
||||
views?: number;
|
||||
popularity?: number;
|
||||
updated_at?: string;
|
||||
description: string;
|
||||
model_type: typeof PERSON_MODEL;
|
||||
}
|
||||
10
resources/client/titles/models/production-country.ts
Executable file
10
resources/client/titles/models/production-country.ts
Executable file
@@ -0,0 +1,10 @@
|
||||
export const PRODUCTION_COUNTRY_MODEL = 'production_country';
|
||||
|
||||
export interface ProductionCountry {
|
||||
id: number;
|
||||
name: string;
|
||||
display_name: string;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
model_type: typeof PRODUCTION_COUNTRY_MODEL;
|
||||
}
|
||||
22
resources/client/titles/models/review.ts
Executable file
22
resources/client/titles/models/review.ts
Executable file
@@ -0,0 +1,22 @@
|
||||
import {User} from '@common/auth/user';
|
||||
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
|
||||
|
||||
export interface Review {
|
||||
id: number;
|
||||
score: number;
|
||||
reviewable_id: number;
|
||||
reviewable_type: string;
|
||||
user_id: number;
|
||||
user?: User;
|
||||
current_user_feedback?: boolean;
|
||||
current_user_reported?: boolean;
|
||||
helpful_count: number;
|
||||
not_helpful_count: number;
|
||||
reviewable: NormalizedModel;
|
||||
title?: string;
|
||||
body?: string;
|
||||
reports_count?: number;
|
||||
model_type: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
17
resources/client/titles/models/season.ts
Executable file
17
resources/client/titles/models/season.ts
Executable file
@@ -0,0 +1,17 @@
|
||||
import {PersonCredit, Title} from './title';
|
||||
import {Video} from '@app/titles/models/video';
|
||||
|
||||
export const SEASON_MODEL = 'season';
|
||||
|
||||
export interface Season {
|
||||
id: number;
|
||||
model_type: typeof SEASON_MODEL;
|
||||
poster: string;
|
||||
number: number;
|
||||
title?: Title;
|
||||
title_id: number;
|
||||
release_date: string;
|
||||
credits: PersonCredit[];
|
||||
episodes_count: number;
|
||||
primary_video?: Video;
|
||||
}
|
||||
6
resources/client/titles/models/title-image.ts
Executable file
6
resources/client/titles/models/title-image.ts
Executable file
@@ -0,0 +1,6 @@
|
||||
export interface TitleImage {
|
||||
id: number;
|
||||
url: string;
|
||||
type: 'backdrop' | 'poster';
|
||||
source: 'local' | 'tmdb';
|
||||
}
|
||||
70
resources/client/titles/models/title.ts
Executable file
70
resources/client/titles/models/title.ts
Executable file
@@ -0,0 +1,70 @@
|
||||
import {Video} from './video';
|
||||
import {TitleImage} from './title-image';
|
||||
import {Episode} from './episode';
|
||||
import {Season} from './season';
|
||||
import {Review} from './review';
|
||||
import {Genre} from '@app/titles/models/genre';
|
||||
import {Keyword} from '@app/titles/models/keyword';
|
||||
import {ProductionCountry} from '@app/titles/models/production-country';
|
||||
import {Person} from '@app/titles/models/person';
|
||||
|
||||
export interface EpisodeCredit extends Episode {
|
||||
pivot: TitleCreditPivot;
|
||||
}
|
||||
|
||||
export interface TitleCredit extends Person {
|
||||
pivot: TitleCreditPivot;
|
||||
}
|
||||
|
||||
export interface PersonCredit extends Title {
|
||||
credited_episode_count?: number;
|
||||
episodes: EpisodeCredit[];
|
||||
pivot: TitleCreditPivot;
|
||||
}
|
||||
|
||||
export interface TitleCreditPivot {
|
||||
id: number;
|
||||
job: string;
|
||||
department: 'directing' | 'writing' | 'actors' | 'creators';
|
||||
character: string;
|
||||
}
|
||||
|
||||
export const TITLE_MODEL = 'title';
|
||||
export const MOVIE_MODEL = 'movie';
|
||||
export const SERIES_MODEL = 'series';
|
||||
|
||||
export interface Title {
|
||||
id: number;
|
||||
name: string;
|
||||
original_title: string;
|
||||
model_type: typeof TITLE_MODEL;
|
||||
is_series: boolean;
|
||||
status: 'released' | 'upcoming' | 'ongoing' | 'ended';
|
||||
description: string;
|
||||
tagline: string;
|
||||
runtime: number;
|
||||
rating: number;
|
||||
budget: number;
|
||||
poster?: string;
|
||||
backdrop: string;
|
||||
revenue: number;
|
||||
views: number;
|
||||
popularity: number;
|
||||
seasons_count: number;
|
||||
release_date: string;
|
||||
year: number;
|
||||
genres: Genre[];
|
||||
keywords: Keyword[];
|
||||
production_countries: ProductionCountry[];
|
||||
videos: Video[];
|
||||
all_videos?: Video[];
|
||||
primary_video: Video;
|
||||
primary_video_count?: number;
|
||||
certification?: string;
|
||||
images: TitleImage[];
|
||||
season?: Season;
|
||||
seasons?: Season[];
|
||||
reviews?: Review[];
|
||||
language: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
42
resources/client/titles/models/video.ts
Executable file
42
resources/client/titles/models/video.ts
Executable file
@@ -0,0 +1,42 @@
|
||||
import {Title} from './title';
|
||||
import {VotableModel} from '@common/votes/votable-model';
|
||||
import {Episode} from '@app/titles/models/episode';
|
||||
|
||||
export const VIDEO_MODEL_TYPE = 'video';
|
||||
|
||||
export interface Video extends VotableModel {
|
||||
name: string;
|
||||
description?: string;
|
||||
src: string;
|
||||
type: 'video' | 'stream' | 'embed' | 'external';
|
||||
category: 'full' | 'trailer' | 'clip' | 'featurette' | 'teaser';
|
||||
thumbnail?: string;
|
||||
origin: 'local' | 'tmdb';
|
||||
quality: string;
|
||||
approved: boolean;
|
||||
title?: Title;
|
||||
episode?: Episode;
|
||||
user_id: number;
|
||||
season_num: number;
|
||||
episode_num: number;
|
||||
title_id: number;
|
||||
captions?: VideoCaption[];
|
||||
language?: string;
|
||||
updated_at?: string;
|
||||
created_at?: string;
|
||||
plays_count?: number;
|
||||
reports_count?: number;
|
||||
current_user_reported?: boolean;
|
||||
latest_play?: {
|
||||
time_watched?: number;
|
||||
};
|
||||
model_type: typeof VIDEO_MODEL_TYPE;
|
||||
}
|
||||
|
||||
export interface VideoCaption {
|
||||
id: number;
|
||||
name: string;
|
||||
language: string;
|
||||
order: number;
|
||||
url: string;
|
||||
}
|
||||
54
resources/client/titles/pages/title-full-credits-page.tsx
Executable file
54
resources/client/titles/pages/title-full-credits-page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
50
resources/client/titles/pages/title-images-page.tsx
Executable file
50
resources/client/titles/pages/title-images-page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
30
resources/client/titles/pages/title-page/sections/title-news/title-news.tsx
Executable file
30
resources/client/titles/pages/title-page/sections/title-news/title-news.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
32
resources/client/titles/pages/title-page/sections/title-page-cast.tsx
Executable file
32
resources/client/titles/pages/title-page/sections/title-page-cast.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
258
resources/client/titles/pages/title-page/sections/title-page-episode-grid.tsx
Executable file
258
resources/client/titles/pages/title-page/sections/title-page-episode-grid.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
61
resources/client/titles/pages/title-page/sections/title-page-image-grid.tsx
Executable file
61
resources/client/titles/pages/title-page/sections/title-page-image-grid.tsx
Executable 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=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
61
resources/client/titles/pages/title-page/sections/title-page-review-list.tsx
Executable file
61
resources/client/titles/pages/title-page/sections/title-page-review-list.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
58
resources/client/titles/pages/title-page/sections/title-page-season-grid.tsx
Executable file
58
resources/client/titles/pages/title-page/sections/title-page-season-grid.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
10
resources/client/titles/pages/title-page/sections/title-page-sections.ts
Executable file
10
resources/client/titles/pages/title-page/sections/title-page-sections.ts
Executable file
@@ -0,0 +1,10 @@
|
||||
export const TitlePageSections = [
|
||||
'episodes',
|
||||
'seasons',
|
||||
'videos',
|
||||
'images',
|
||||
'reviews',
|
||||
'cast',
|
||||
'news',
|
||||
'related',
|
||||
] as const;
|
||||
34
resources/client/titles/pages/title-page/sections/title-page-video-grid.tsx
Executable file
34
resources/client/titles/pages/title-page/sections/title-page-video-grid.tsx
Executable 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
27
resources/client/titles/pages/title-page/title-credits-grid/title-credits-grid.css
vendored
Executable file
27
resources/client/titles/pages/title-page/title-credits-grid/title-credits-grid.css
vendored
Executable 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
29
resources/client/titles/pages/title-page/title-page-aside-layout.tsx
Executable file
29
resources/client/titles/pages/title-page/title-page-aside-layout.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
130
resources/client/titles/pages/title-page/title-page-aside.tsx
Executable file
130
resources/client/titles/pages/title-page/title-page-aside.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
68
resources/client/titles/pages/title-page/title-page-header-image.tsx
Executable file
68
resources/client/titles/pages/title-page/title-page-header-image.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
30
resources/client/titles/pages/title-page/title-page-header-layout.tsx
Executable file
30
resources/client/titles/pages/title-page/title-page-header-layout.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
40
resources/client/titles/pages/title-page/title-page-header.tsx
Executable file
40
resources/client/titles/pages/title-page/title-page-header.tsx
Executable 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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
101
resources/client/titles/pages/title-page/title-page-main-content.tsx
Executable file
101
resources/client/titles/pages/title-page/title-page-main-content.tsx
Executable 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} />;
|
||||
}
|
||||
}
|
||||
44
resources/client/titles/pages/title-page/title-page.tsx
Executable file
44
resources/client/titles/pages/title-page/title-page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
48
resources/client/titles/pages/title-page/watch-now-button.tsx
Executable file
48
resources/client/titles/pages/title-page/watch-now-button.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
50
resources/client/titles/pages/title-videos-page.tsx
Executable file
50
resources/client/titles/pages/title-videos-page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
30
resources/client/titles/production-country-link.tsx
Executable file
30
resources/client/titles/production-country-link.tsx
Executable file
@@ -0,0 +1,30 @@
|
||||
import React, {useMemo} from 'react';
|
||||
import {
|
||||
BaseMediaLink,
|
||||
BaseMediaLinkProps,
|
||||
getBaseMediaLink,
|
||||
} from '@app/base-media-link';
|
||||
import {ProductionCountry} from '@app/titles/models/production-country';
|
||||
|
||||
interface Props extends Omit<BaseMediaLinkProps, 'link'> {
|
||||
country: ProductionCountry;
|
||||
}
|
||||
export function ProductionCountryLink({
|
||||
country,
|
||||
children,
|
||||
...otherProps
|
||||
}: Props) {
|
||||
const link = useMemo(() => getKeywordLink(country), [country]);
|
||||
return (
|
||||
<BaseMediaLink {...otherProps} link={link}>
|
||||
{children ?? (country.display_name || country.name)}
|
||||
</BaseMediaLink>
|
||||
);
|
||||
}
|
||||
|
||||
export function getKeywordLink(
|
||||
country: ProductionCountry,
|
||||
{absolute}: {absolute?: boolean} = {}
|
||||
): string {
|
||||
return getBaseMediaLink(`/production-countries/${country.name}`, {absolute});
|
||||
}
|
||||
76
resources/client/titles/related-titles-panel.tsx
Executable file
76
resources/client/titles/related-titles-panel.tsx
Executable file
@@ -0,0 +1,76 @@
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {SiteSectionHeading} from '@app/titles/site-section-heading';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {useRelatedTitles} from '@app/titles/requests/use-related-titles';
|
||||
import {useCarousel} from '@app/channels/carousel/use-carousel';
|
||||
import React, {Fragment} from 'react';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {KeyboardArrowLeftIcon} from '@common/icons/material/KeyboardArrowLeft';
|
||||
import {KeyboardArrowRightIcon} from '@common/icons/material/KeyboardArrowRight';
|
||||
import clsx from 'clsx';
|
||||
import {TitlePortraitGridItem} from '@app/channels/content-grid/title-grid-item';
|
||||
|
||||
interface Props {
|
||||
title: Title;
|
||||
}
|
||||
export function RelatedTitlesPanel({title}: Props) {
|
||||
const {data} = useRelatedTitles(title.id);
|
||||
|
||||
if (!data || data.titles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <RelatedTitlesCarousel titles={data.titles} />;
|
||||
}
|
||||
|
||||
interface RelatedTitlesCarouselProps {
|
||||
titles: Title[];
|
||||
}
|
||||
function RelatedTitlesCarousel({titles}: RelatedTitlesCarouselProps) {
|
||||
const {
|
||||
scrollContainerRef,
|
||||
canScrollForward,
|
||||
canScrollBackward,
|
||||
scrollToPreviousPage,
|
||||
scrollToNextPage,
|
||||
containerClassName,
|
||||
itemClassName,
|
||||
} = useCarousel();
|
||||
|
||||
return (
|
||||
<div className="mt-48">
|
||||
<SiteSectionHeading
|
||||
actions={
|
||||
<Fragment>
|
||||
<IconButton
|
||||
disabled={!canScrollBackward}
|
||||
onClick={() => scrollToPreviousPage()}
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<KeyboardArrowLeftIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={!canScrollForward}
|
||||
onClick={() => scrollToNextPage()}
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<KeyboardArrowRightIcon />
|
||||
</IconButton>
|
||||
</Fragment>
|
||||
}
|
||||
>
|
||||
<Trans message="More like this" />
|
||||
</SiteSectionHeading>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={clsx(containerClassName, 'content-grid-portrait')}
|
||||
>
|
||||
{titles.map(item => (
|
||||
<div className={itemClassName} key={item.id}>
|
||||
<TitlePortraitGridItem item={item} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
resources/client/titles/requests/use-related-titles.ts
Executable file
21
resources/client/titles/requests/use-related-titles.ts
Executable file
@@ -0,0 +1,21 @@
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
titles: Title[];
|
||||
}
|
||||
|
||||
export function useRelatedTitles(titleId: number) {
|
||||
return useQuery({
|
||||
queryKey: ['titles', titleId, 'related'],
|
||||
queryFn: () => fetchRelatedTitles(titleId!),
|
||||
});
|
||||
}
|
||||
|
||||
function fetchRelatedTitles(titleId: number | string) {
|
||||
return apiClient
|
||||
.get<Response>(`titles/${titleId}/related`)
|
||||
.then(response => response.data);
|
||||
}
|
||||
32
resources/client/titles/requests/use-season-episodes.ts
Executable file
32
resources/client/titles/requests/use-season-episodes.ts
Executable file
@@ -0,0 +1,32 @@
|
||||
import {useInfiniteData} from '@common/ui/infinite-scroll/use-infinite-data';
|
||||
import {PaginationResponse} from '@common/http/backend-response/pagination-response';
|
||||
import {Episode} from '@app/titles/models/episode';
|
||||
import {useParams} from 'react-router-dom';
|
||||
import {seasonQueryKey} from '@app/seasons/requests/use-season';
|
||||
|
||||
interface Props {
|
||||
titleId?: number | string;
|
||||
season?: number | string;
|
||||
willSortOrFilter?: boolean;
|
||||
defaultOrderBy?: string;
|
||||
defaultOrderDir?: 'desc' | 'asc';
|
||||
}
|
||||
|
||||
export function useSeasonEpisodes(
|
||||
initialPage?: PaginationResponse<Episode>,
|
||||
queryParams?: Record<string, string | number>,
|
||||
props: Props = {}
|
||||
) {
|
||||
const urlParams = useParams();
|
||||
const titleId = props.titleId || urlParams.titleId;
|
||||
const season = props.season || urlParams.season;
|
||||
return useInfiniteData<Episode>({
|
||||
initialPage,
|
||||
willSortOrFilter: props.willSortOrFilter,
|
||||
defaultOrderBy: props.defaultOrderBy,
|
||||
defaultOrderDir: props.defaultOrderDir,
|
||||
endpoint: `titles/${titleId}/seasons/${season}/episodes`,
|
||||
queryKey: [...seasonQueryKey(titleId!, season!), 'episodes'],
|
||||
queryParams,
|
||||
});
|
||||
}
|
||||
22
resources/client/titles/requests/use-title-seasons.ts
Executable file
22
resources/client/titles/requests/use-title-seasons.ts
Executable file
@@ -0,0 +1,22 @@
|
||||
import {useInfiniteData} from '@common/ui/infinite-scroll/use-infinite-data';
|
||||
import {Season} from '@app/titles/models/season';
|
||||
import {PaginationResponse} from '@common/http/backend-response/pagination-response';
|
||||
|
||||
export const titleSeasonsQueryKey = (titleId: number | string) => [
|
||||
'title',
|
||||
`${titleId}`,
|
||||
'seasons',
|
||||
];
|
||||
|
||||
export function useTitleSeasons(
|
||||
titleId: string | number,
|
||||
initialPage?: PaginationResponse<Season>,
|
||||
queryParams?: Record<string, string | number>
|
||||
) {
|
||||
return useInfiniteData<Season>({
|
||||
initialPage,
|
||||
endpoint: `titles/${titleId}/seasons`,
|
||||
queryKey: titleSeasonsQueryKey(titleId),
|
||||
queryParams,
|
||||
});
|
||||
}
|
||||
44
resources/client/titles/requests/use-title.ts
Executable file
44
resources/client/titles/requests/use-title.ts
Executable file
@@ -0,0 +1,44 @@
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {useParams} from 'react-router-dom';
|
||||
import {Title, TitleCredit, TitleCreditPivot} from '@app/titles/models/title';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {LengthAwarePaginationResponse} from '@common/http/backend-response/pagination-response';
|
||||
import {Season} from '@app/titles/models/season';
|
||||
import {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';
|
||||
import {Episode} from '@app/titles/models/episode';
|
||||
|
||||
export type GroupTitleCredits = Partial<
|
||||
Record<TitleCreditPivot['department'], TitleCredit[]>
|
||||
>;
|
||||
|
||||
export interface GetTitleResponse extends BackendResponse {
|
||||
title: Title;
|
||||
seasons?: LengthAwarePaginationResponse<Season>;
|
||||
episodes?: LengthAwarePaginationResponse<Episode>;
|
||||
credits?: GroupTitleCredits;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export function useTitle(
|
||||
loader: 'title' | 'titlePage' | 'titleCreditsPage' | 'editTitlePage',
|
||||
) {
|
||||
const {titleId} = useParams();
|
||||
return useQuery({
|
||||
queryKey: ['titles', `${titleId}`, loader],
|
||||
queryFn: () => fetchTitle(titleId!, loader),
|
||||
initialData: () => {
|
||||
const data = getBootstrapData().loaders?.[loader];
|
||||
if (data?.title?.id == titleId) {
|
||||
return data;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function fetchTitle(titleId: number | string, loader: string) {
|
||||
return apiClient
|
||||
.get<GetTitleResponse>(`titles/${titleId}`, {params: {loader}})
|
||||
.then(response => response.data);
|
||||
}
|
||||
33
resources/client/titles/requests/use-titles-autocomplete.ts
Executable file
33
resources/client/titles/requests/use-titles-autocomplete.ts
Executable file
@@ -0,0 +1,33 @@
|
||||
import {keepPreviousData, useQuery} from '@tanstack/react-query';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
|
||||
|
||||
interface AutocompleteTitle extends NormalizedModel {
|
||||
seasons_count: number;
|
||||
episode_numbers: number[];
|
||||
}
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
titles: AutocompleteTitle[];
|
||||
}
|
||||
|
||||
interface Params {
|
||||
searchQuery: string;
|
||||
selectedTitleId?: number | string;
|
||||
seasonNumber?: number | string;
|
||||
}
|
||||
|
||||
export function useTitlesAutocomplete(params: Params) {
|
||||
return useQuery({
|
||||
queryKey: ['titles', 'autocomplete', params],
|
||||
queryFn: () => autocompleteTitles(params),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
function autocompleteTitles(params: Params) {
|
||||
return apiClient
|
||||
.get<Response>(`titles/autocomplete`, {params})
|
||||
.then(response => response.data);
|
||||
}
|
||||
85
resources/client/titles/site-section-heading.tsx
Executable file
85
resources/client/titles/site-section-heading.tsx
Executable file
@@ -0,0 +1,85 @@
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {KeyboardArrowRightIcon} from '@common/icons/material/KeyboardArrowRight';
|
||||
import {ReactNode} from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
titleAppend?: ReactNode;
|
||||
link?: string;
|
||||
fontSize?: string;
|
||||
fontWeight?: string;
|
||||
margin?: string;
|
||||
headingType?: 'h1' | 'h2' | 'div';
|
||||
className?: string;
|
||||
description?: ReactNode;
|
||||
descriptionFontSize?: string;
|
||||
actions?: ReactNode;
|
||||
hideBorder?: boolean;
|
||||
}
|
||||
export function SiteSectionHeading({
|
||||
children,
|
||||
titleAppend,
|
||||
link,
|
||||
fontSize = 'text-2xl md:text-3xl',
|
||||
fontWeight = 'font-bold',
|
||||
margin = 'mb-20',
|
||||
className,
|
||||
headingType: HeadingType = 'h2',
|
||||
description,
|
||||
descriptionFontSize = 'text-base',
|
||||
actions,
|
||||
hideBorder,
|
||||
}: Props) {
|
||||
const title = link ? (
|
||||
<Link
|
||||
to={link}
|
||||
className="rounded outline-none transition-colors hover:underline focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
) : (
|
||||
children
|
||||
);
|
||||
|
||||
return (
|
||||
<section className={clsx(className, margin)}>
|
||||
<div className="flex items-center gap-44 max-md:overflow-x-auto">
|
||||
<div className="flex-auto">
|
||||
<div
|
||||
className={clsx(
|
||||
'relative flex items-center gap-4',
|
||||
!hideBorder &&
|
||||
'pl-14 before:absolute before:left-0 before:h-5/6 before:w-4 before:rounded before:bg-primary'
|
||||
)}
|
||||
>
|
||||
<HeadingType className={clsx(fontSize, fontWeight)}>
|
||||
{title}
|
||||
</HeadingType>
|
||||
{titleAppend && (
|
||||
<span className="pt-4 text-base text-muted">{titleAppend}</span>
|
||||
)}
|
||||
{link && (
|
||||
<IconButton
|
||||
elementType={Link}
|
||||
to={link}
|
||||
size="sm"
|
||||
iconSize="lg"
|
||||
className="mt-4 max-md:hidden"
|
||||
>
|
||||
<KeyboardArrowRightIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex flex-shrink-0 items-center gap-4">{actions}</div>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<div className={clsx('mt-6', descriptionFontSize)}>{description}</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
67
resources/client/titles/title-link.tsx
Executable file
67
resources/client/titles/title-link.tsx
Executable file
@@ -0,0 +1,67 @@
|
||||
import React, {useMemo} from 'react';
|
||||
import {slugifyString} from '@common/utils/string/slugify-string';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {
|
||||
BaseMediaLink,
|
||||
BaseMediaLinkProps,
|
||||
getBaseMediaLink,
|
||||
} from '@app/base-media-link';
|
||||
import {getEpisodeLink} from '@app/episodes/episode-link';
|
||||
import {getSeasonLink} from '@app/seasons/season-link';
|
||||
import {Episode} from '@app/titles/models/episode';
|
||||
import {CompactSeasonEpisode} from '@app/episodes/compact-season-episode';
|
||||
|
||||
interface Props extends Omit<BaseMediaLinkProps, 'link'> {
|
||||
title: Title;
|
||||
}
|
||||
export function TitleLink({title, children, ...linkProps}: Props) {
|
||||
const link = useMemo(() => {
|
||||
return getTitleLink(title);
|
||||
}, [title]);
|
||||
|
||||
return (
|
||||
<BaseMediaLink {...linkProps} link={link}>
|
||||
{children ?? title.name}
|
||||
</BaseMediaLink>
|
||||
);
|
||||
}
|
||||
|
||||
interface WithEpisodeProps extends Props {
|
||||
episode: Episode;
|
||||
}
|
||||
export function TitleLinkWithEpisodeNumber({
|
||||
title,
|
||||
episode,
|
||||
children,
|
||||
...linkProps
|
||||
}: WithEpisodeProps) {
|
||||
const link = useMemo(() => {
|
||||
return getEpisodeLink(title, episode.season_number, episode.episode_number);
|
||||
}, [title, episode]);
|
||||
|
||||
return (
|
||||
<BaseMediaLink {...linkProps} link={link}>
|
||||
{title.name} (<CompactSeasonEpisode episode={episode} />)
|
||||
</BaseMediaLink>
|
||||
);
|
||||
}
|
||||
|
||||
interface Options {
|
||||
absolute?: boolean;
|
||||
season?: number | string;
|
||||
episode?: number | string;
|
||||
}
|
||||
|
||||
export function getTitleLink(
|
||||
title: Title,
|
||||
{absolute, season, episode}: Options = {}
|
||||
): string {
|
||||
if (episode && season) {
|
||||
return getEpisodeLink(title, season, episode, {absolute});
|
||||
} else if (season) {
|
||||
return getSeasonLink(title, season, {absolute});
|
||||
}
|
||||
return getBaseMediaLink(`/titles/${title.id}/${slugifyString(title.name)}`, {
|
||||
absolute,
|
||||
});
|
||||
}
|
||||
140
resources/client/titles/title-poster/title-backdrop.tsx
Executable file
140
resources/client/titles/title-poster/title-backdrop.tsx
Executable file
@@ -0,0 +1,140 @@
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {message} from '@common/i18n/message';
|
||||
import clsx from 'clsx';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {
|
||||
ImageSize,
|
||||
useImageSrc,
|
||||
useImageSrcSet,
|
||||
} from '@app/images/use-image-src';
|
||||
import {Episode} from '@app/titles/models/episode';
|
||||
import {TitleLink} from '@app/titles/title-link';
|
||||
import {EpisodeLink} from '@app/episodes/episode-link';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {Link} from 'react-router-dom';
|
||||
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';
|
||||
|
||||
// can provide either url for backdrop directly or
|
||||
// title/episode object if main backdrop for it should be used
|
||||
interface Props {
|
||||
src?: string;
|
||||
title?: Title;
|
||||
episode?: Episode;
|
||||
className?: string;
|
||||
size?: string;
|
||||
lazy?: boolean;
|
||||
srcSize?: ImageSize;
|
||||
wrapWithLink?: boolean;
|
||||
showPlayButton?: boolean;
|
||||
wrapperClassName?: string;
|
||||
}
|
||||
export function TitleBackdrop({
|
||||
src: initialSrc,
|
||||
title,
|
||||
episode,
|
||||
className,
|
||||
size,
|
||||
srcSize,
|
||||
lazy = true,
|
||||
wrapWithLink = false,
|
||||
showPlayButton,
|
||||
wrapperClassName,
|
||||
}: Props) {
|
||||
const {trans} = useTrans();
|
||||
const primaryVideo = episode?.primary_video || title?.primary_video;
|
||||
if (!primaryVideo) {
|
||||
showPlayButton = false;
|
||||
}
|
||||
|
||||
if (!initialSrc && episode) {
|
||||
initialSrc = episode?.poster;
|
||||
}
|
||||
if (!initialSrc && title) {
|
||||
initialSrc = title.backdrop;
|
||||
}
|
||||
|
||||
const src = useImageSrc(initialSrc, {size: srcSize});
|
||||
const item = episode || title;
|
||||
const srcset = useImageSrcSet(initialSrc);
|
||||
|
||||
const imageClassName = clsx(
|
||||
className,
|
||||
size,
|
||||
'aspect-video bg-fg-base/4 object-cover',
|
||||
!src ? 'flex items-center justify-center' : 'block',
|
||||
);
|
||||
|
||||
let img = src ? (
|
||||
<img
|
||||
className={imageClassName}
|
||||
draggable={false}
|
||||
decoding="async"
|
||||
sizes={!srcSize ? `100vw` : undefined}
|
||||
loading={lazy ? 'lazy' : 'eager'}
|
||||
src={src}
|
||||
srcSet={!srcSize ? srcset : undefined}
|
||||
alt={
|
||||
item
|
||||
? trans(
|
||||
message('Backdrop for :name', {
|
||||
values: {name: item.name},
|
||||
}),
|
||||
)
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className={imageClassName}>
|
||||
<MovieIcon className="max-w-[60%] text-divider" size="text-6xl" />
|
||||
</span>
|
||||
);
|
||||
|
||||
const playButton = showPlayButton ? (
|
||||
<div className="absolute bottom-14 left-14">
|
||||
<IconButton
|
||||
color="white"
|
||||
variant="flat"
|
||||
className="shadow-md"
|
||||
radius="rounded-full"
|
||||
elementType={Link}
|
||||
to={getWatchLink(primaryVideo!)}
|
||||
aria-label="Play"
|
||||
>
|
||||
<MediaPlayIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (wrapWithLink) {
|
||||
if (episode) {
|
||||
img = (
|
||||
<EpisodeLink
|
||||
episode={episode}
|
||||
title={title!}
|
||||
seasonNumber={episode.season_number}
|
||||
displayContents
|
||||
>
|
||||
{img}
|
||||
</EpisodeLink>
|
||||
);
|
||||
} else if (title) {
|
||||
img = (
|
||||
<TitleLink title={title} displayContents>
|
||||
{img}
|
||||
</TitleLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx('group relative flex-shrink-0', wrapperClassName)}>
|
||||
{img}
|
||||
{playButton}
|
||||
{wrapWithLink && (
|
||||
<div className="pointer-events-none absolute inset-0 bg-black opacity-0 transition-opacity group-hover:opacity-10" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
resources/client/titles/title-poster/title-poster.tsx
Executable file
97
resources/client/titles/title-poster/title-poster.tsx
Executable file
@@ -0,0 +1,97 @@
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {message} from '@common/i18n/message';
|
||||
import clsx from 'clsx';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {TitleLink} from '@app/titles/title-link';
|
||||
import {ImageSize, useImageSrc} from '@app/images/use-image-src';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {getWatchLink} from '@app/videos/watch-page/get-watch-link';
|
||||
import {MediaPlayIcon} from '@common/icons/media/media-play';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {Fragment} from 'react';
|
||||
import {MovieIcon} from '@common/icons/material/Movie';
|
||||
|
||||
interface Props {
|
||||
title: Title;
|
||||
className?: string;
|
||||
size?: string;
|
||||
lazy?: boolean;
|
||||
srcSize?: ImageSize;
|
||||
aspect?: string;
|
||||
showPlayButton?: boolean;
|
||||
link?: string;
|
||||
}
|
||||
export function TitlePoster({
|
||||
title,
|
||||
className,
|
||||
size = 'w-full',
|
||||
srcSize,
|
||||
lazy = true,
|
||||
aspect = 'aspect-poster',
|
||||
showPlayButton,
|
||||
link,
|
||||
}: Props) {
|
||||
const {trans} = useTrans();
|
||||
const src = useImageSrc(title?.poster, {size: srcSize});
|
||||
if (!title.primary_video) {
|
||||
showPlayButton = false;
|
||||
}
|
||||
|
||||
const imageClassName = clsx(
|
||||
'h-full w-full rounded bg-fg-base/4 object-cover',
|
||||
!src ? 'flex items-center justify-center' : 'block',
|
||||
);
|
||||
|
||||
const image = src ? (
|
||||
<img
|
||||
className={imageClassName}
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
loading={lazy ? 'lazy' : 'eager'}
|
||||
src={src}
|
||||
alt={trans(message('Poster for :name', {values: {name: title.name}}))}
|
||||
/>
|
||||
) : (
|
||||
<span className={clsx(imageClassName, 'overflow-hidden')}>
|
||||
<MovieIcon className="max-w-[60%] text-divider" size="text-6xl" />
|
||||
</span>
|
||||
);
|
||||
|
||||
const linkChildren = (
|
||||
<Fragment>
|
||||
{image}
|
||||
<span className="pointer-events-none absolute inset-0 block bg-black opacity-0 transition-opacity group-hover:opacity-10" />
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(size, aspect, className, 'group relative flex-shrink-0')}
|
||||
>
|
||||
{link ? (
|
||||
<Link to={link} className="contents">
|
||||
{linkChildren}
|
||||
</Link>
|
||||
) : (
|
||||
<TitleLink title={title} displayContents>
|
||||
{linkChildren}
|
||||
</TitleLink>
|
||||
)}
|
||||
{showPlayButton ? (
|
||||
<div className="absolute bottom-14 left-14">
|
||||
<IconButton
|
||||
color="white"
|
||||
variant="flat"
|
||||
className="shadow-md"
|
||||
radius="rounded-full"
|
||||
elementType={Link}
|
||||
to={getWatchLink(title.primary_video)}
|
||||
aria-label={`Play ${title.name}`}
|
||||
>
|
||||
<MediaPlayIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
resources/client/titles/title-select.tsx
Executable file
118
resources/client/titles/title-select.tsx
Executable file
@@ -0,0 +1,118 @@
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import React, {useState} from 'react';
|
||||
import {useTitlesAutocomplete} from '@app/titles/requests/use-titles-autocomplete';
|
||||
import {FormSelect} from '@common/ui/forms/select/select';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {Option} from '@common/ui/forms/combobox/combobox';
|
||||
import {Avatar} from '@common/ui/images/avatar';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
seasonName?: string;
|
||||
episodeName?: string;
|
||||
disableTitleField?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
export function TitleSelect({
|
||||
name,
|
||||
seasonName,
|
||||
episodeName,
|
||||
disableTitleField,
|
||||
className,
|
||||
}: Props) {
|
||||
const {trans} = useTrans();
|
||||
const form = useFormContext();
|
||||
const selectedTitleId = form.watch(name);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const selectedSeason = seasonName ? form.watch(seasonName) : undefined;
|
||||
const query = useTitlesAutocomplete({
|
||||
searchQuery,
|
||||
selectedTitleId,
|
||||
seasonNumber: selectedSeason,
|
||||
});
|
||||
const isLoading = query.isLoading || query.isPlaceholderData;
|
||||
|
||||
const selectedTitle = query.data?.titles.find(t => t.id === selectedTitleId);
|
||||
const seasonCount = selectedTitle?.seasons_count || 0;
|
||||
const episodeNumbers = selectedTitle?.episode_numbers || [];
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<FormSelect
|
||||
selectionMode="single"
|
||||
name={name}
|
||||
label={<Trans message="Title" />}
|
||||
placeholder={trans(message('Select a title'))}
|
||||
showSearchField
|
||||
searchPlaceholder={trans(message('Search titles'))}
|
||||
inputValue={searchQuery}
|
||||
onInputValueChange={setSearchQuery}
|
||||
isAsync
|
||||
isLoading={isLoading}
|
||||
required
|
||||
disabled={disableTitleField}
|
||||
>
|
||||
{query.data?.titles.map(title => (
|
||||
<Option
|
||||
key={title.id}
|
||||
value={title.id}
|
||||
description={title.description}
|
||||
startIcon={<Avatar src={title.image} />}
|
||||
>
|
||||
{title.name}
|
||||
</Option>
|
||||
))}
|
||||
</FormSelect>
|
||||
{seasonCount > 0 && seasonName && (
|
||||
<FormSelect
|
||||
className="mt-12"
|
||||
name={seasonName}
|
||||
placeholder={trans(message('Select a season (optional)'))}
|
||||
selectionMode="single"
|
||||
label={<Trans message="Season" />}
|
||||
>
|
||||
<Option
|
||||
key="none"
|
||||
value=""
|
||||
onSelected={() => form.resetField(seasonName)}
|
||||
>
|
||||
<Trans message="None" />
|
||||
</Option>
|
||||
{[...new Array(seasonCount).keys()].map(i => {
|
||||
const number = i + 1;
|
||||
return (
|
||||
<Option key={number} value={number}>
|
||||
<Trans message="Season :number" values={{number}} />
|
||||
</Option>
|
||||
);
|
||||
})}
|
||||
</FormSelect>
|
||||
)}
|
||||
{!!episodeNumbers.length && episodeName && (
|
||||
<FormSelect
|
||||
className="mt-12"
|
||||
name={episodeName}
|
||||
placeholder={trans(message('Select an episode (optional)'))}
|
||||
selectionMode="single"
|
||||
label={<Trans message="Episode" />}
|
||||
>
|
||||
<Option
|
||||
key="none"
|
||||
value=""
|
||||
onSelected={() => form.resetField(episodeName)}
|
||||
>
|
||||
<Trans message="None" />
|
||||
</Option>
|
||||
{episodeNumbers.map(number => (
|
||||
<Option key={number} value={number}>
|
||||
<Trans message="Episode :number" values={{number}} />
|
||||
</Option>
|
||||
))}
|
||||
</FormSelect>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
resources/client/titles/use-title-index-filters.ts
Executable file
30
resources/client/titles/use-title-index-filters.ts
Executable file
@@ -0,0 +1,30 @@
|
||||
import {useValueLists} from '@common/http/value-lists';
|
||||
import {useMemo} from 'react';
|
||||
import {getTitleChannelFilters} from '@app/channels/channel-header/get-title-channel-filters';
|
||||
|
||||
interface Options {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function useTitleIndexFilters(options: Options = {}) {
|
||||
const {data, isLoading, fetchStatus} = useValueLists(
|
||||
[
|
||||
'titleFilterLanguages',
|
||||
'productionCountries',
|
||||
'genres',
|
||||
'titleFilterAgeRatings',
|
||||
],
|
||||
undefined,
|
||||
options
|
||||
);
|
||||
const filters = useMemo(() => {
|
||||
return getTitleChannelFilters({
|
||||
countries: data?.productionCountries || [],
|
||||
languages: data?.titleFilterLanguages || [],
|
||||
genres: data?.genres || [],
|
||||
ageRatings: data?.titleFilterAgeRatings || [],
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
return {filters, filtersLoading: isLoading && fetchStatus !== 'idle'};
|
||||
}
|
||||
103
resources/client/titles/video-grid.tsx
Executable file
103
resources/client/titles/video-grid.tsx
Executable file
@@ -0,0 +1,103 @@
|
||||
import {Video} from '@app/titles/models/video';
|
||||
import {PlayCircleIcon} from '@common/icons/material/PlayCircle';
|
||||
import {ReactNode} from 'react';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {getWatchLink} from '@app/videos/watch-page/get-watch-link';
|
||||
import {Skeleton} from '@common/ui/skeleton/skeleton';
|
||||
import clsx from 'clsx';
|
||||
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
|
||||
import {VideoThumbnail} from '@app/videos/video-thumbnail';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {Episode} from '@app/titles/models/episode';
|
||||
|
||||
interface Props {
|
||||
videos?: Video[];
|
||||
heading?: ReactNode;
|
||||
count?: number;
|
||||
title?: Title;
|
||||
episode?: Episode;
|
||||
}
|
||||
export function VideoGrid({videos, heading, count, title, episode}: Props) {
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
if (!videos?.length) return null;
|
||||
|
||||
if (!count) {
|
||||
count = isMobile ? 4 : 3;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-48">
|
||||
{heading}
|
||||
<div className="grid grid-cols-2 gap-12 md:grid-cols-3 md:gap-24">
|
||||
{videos.slice(0, count).map(video => (
|
||||
<VideoGridItem
|
||||
key={video.id}
|
||||
video={video}
|
||||
title={title}
|
||||
episode={episode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VideoGridItemProps {
|
||||
video: Video;
|
||||
className?: string;
|
||||
title?: Title;
|
||||
episode?: Episode;
|
||||
name?: ReactNode;
|
||||
showCategory?: boolean;
|
||||
forceTitleBackdrop?: boolean;
|
||||
}
|
||||
export function VideoGridItem({
|
||||
video,
|
||||
className,
|
||||
title,
|
||||
episode,
|
||||
name,
|
||||
showCategory = true,
|
||||
forceTitleBackdrop = false,
|
||||
}: VideoGridItemProps) {
|
||||
const link = getWatchLink(video);
|
||||
return (
|
||||
<div key={video.id} className={className}>
|
||||
<Link to={link} className="relative isolate block">
|
||||
<VideoThumbnail
|
||||
video={video}
|
||||
title={title}
|
||||
episode={episode}
|
||||
srcSize="lg"
|
||||
forceTitleBackdrop={forceTitleBackdrop}
|
||||
/>
|
||||
<VideoGridItemBottomGradient />
|
||||
<span className="absolute bottom-0 left-0 z-30 flex items-center gap-x-6 p-10 text-white">
|
||||
<PlayCircleIcon size={showCategory ? 'md' : 'lg'} />
|
||||
{showCategory && <span className="capitalize">{video.category}</span>}
|
||||
</span>
|
||||
</Link>
|
||||
<Link to={link} className="mt-12 block hover:underline">
|
||||
{name || video.name}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VideoGridItemSkeletonProps {
|
||||
className?: string;
|
||||
}
|
||||
export function VideoGridItemSkeleton({className}: VideoGridItemSkeletonProps) {
|
||||
return (
|
||||
<div className={clsx(className, 'h-[228px]')}>
|
||||
<Skeleton variant="rect" size="w-full aspect-video" animation="pulsate" />
|
||||
<Skeleton variant="text" size="w-3/4 mt-12 h-20" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VideoGridItemBottomGradient() {
|
||||
return (
|
||||
<div className="pointer-events-none absolute bottom-0 z-20 h-full w-full bg-gradient-to-t from-black to-40%" />
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user