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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user