67
resources/client/channels/carousel/channel-content-carousel.tsx
Executable file
67
resources/client/channels/carousel/channel-content-carousel.tsx
Executable file
@@ -0,0 +1,67 @@
|
||||
import React, {Fragment} from 'react';
|
||||
import {ChannelContentProps} from '@app/channels/channel-content';
|
||||
import {ChannelHeader} from '@app/channels/channel-header/channel-header';
|
||||
import {ChannelContentGridItem} from '@app/channels/content-grid/channel-content-grid-item';
|
||||
import {useCarousel} from '@app/channels/carousel/use-carousel';
|
||||
import clsx from 'clsx';
|
||||
import {ContentGridProps} from '@app/channels/content-grid/content-grid-layout';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {KeyboardArrowLeftIcon} from '@common/icons/material/KeyboardArrowLeft';
|
||||
import {KeyboardArrowRightIcon} from '@common/icons/material/KeyboardArrowRight';
|
||||
|
||||
interface Props extends ChannelContentProps {
|
||||
variant?: ContentGridProps['variant'];
|
||||
}
|
||||
export function ChannelContentCarousel(props: Props) {
|
||||
const {channel, variant} = props;
|
||||
const {
|
||||
scrollContainerRef,
|
||||
canScrollForward,
|
||||
canScrollBackward,
|
||||
scrollToPreviousPage,
|
||||
scrollToNextPage,
|
||||
containerClassName,
|
||||
itemClassName,
|
||||
} = useCarousel();
|
||||
|
||||
const gridClassName =
|
||||
variant === 'landscape'
|
||||
? 'content-grid-landscape'
|
||||
: 'content-grid-portrait';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ChannelHeader
|
||||
{...props}
|
||||
actions={
|
||||
<Fragment>
|
||||
<IconButton
|
||||
disabled={!canScrollBackward}
|
||||
onClick={() => scrollToPreviousPage()}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<KeyboardArrowLeftIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={!canScrollForward}
|
||||
onClick={() => scrollToNextPage()}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<KeyboardArrowRightIcon />
|
||||
</IconButton>
|
||||
</Fragment>
|
||||
}
|
||||
/>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={clsx(containerClassName, gridClassName)}
|
||||
>
|
||||
{channel.content?.data.map(item => (
|
||||
<div className={itemClassName} key={`${item.id}-${item.model_type}`}>
|
||||
<ChannelContentGridItem item={item} variant={variant} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
resources/client/channels/carousel/use-carousel.ts
Executable file
112
resources/client/channels/carousel/use-carousel.ts
Executable file
@@ -0,0 +1,112 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import debounce from 'just-debounce-it';
|
||||
import {useLayoutEffect} from '@react-aria/utils';
|
||||
|
||||
interface Options {
|
||||
rotate?: boolean;
|
||||
}
|
||||
|
||||
const containerClassName =
|
||||
'content-carousel content-grid relative w-full grid grid-flow-col grid-rows-[auto] overflow-x-auto overflow-y-hidden gap-24 snap-always snap-x snap-mandatory hidden-scrollbar scroll-smooth';
|
||||
const itemClassName = 'snap-start snap-normal';
|
||||
|
||||
export function useCarousel({rotate = false}: Options = {}) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const itemWidth = useRef<number>(0);
|
||||
const perPage = useRef<number>(5);
|
||||
|
||||
const [canScrollBackward, setCanScrollBackward] = useState(rotate);
|
||||
const [canScrollForward, setCanScrollForward] = useState(true);
|
||||
const [activePage, setActivePage] = useState(0);
|
||||
|
||||
const updateNavStatus = useCallback(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (el && itemWidth.current) {
|
||||
if (!rotate) {
|
||||
setCanScrollForward(
|
||||
el.scrollWidth - 1 > el.scrollLeft + el.clientWidth
|
||||
);
|
||||
setCanScrollBackward(el.scrollLeft > 0);
|
||||
}
|
||||
const pageWidth = el.clientWidth;
|
||||
const activePage = Math.round(el.scrollLeft / pageWidth);
|
||||
setActivePage(activePage);
|
||||
}
|
||||
}, [rotate]);
|
||||
|
||||
// enable/disable navigation buttons based on element scroll offset
|
||||
useEffect(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
const handleScroll = debounce(() => updateNavStatus(), 100);
|
||||
if (el) {
|
||||
el.addEventListener('scroll', handleScroll);
|
||||
}
|
||||
return () => el?.removeEventListener('scroll', handleScroll);
|
||||
}, [updateNavStatus]);
|
||||
|
||||
// get width for first grid item
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (el) {
|
||||
perPage.current = Number(
|
||||
getComputedStyle(el).getPropertyValue('--nVisibleItems')
|
||||
);
|
||||
const firstGridItem = el.children.item(0);
|
||||
const observer = new ResizeObserver(entries => {
|
||||
itemWidth.current = entries[0].contentRect.width;
|
||||
updateNavStatus();
|
||||
});
|
||||
if (firstGridItem) {
|
||||
observer.observe(firstGridItem);
|
||||
}
|
||||
return () => observer.unobserve(el);
|
||||
}
|
||||
}, [updateNavStatus]);
|
||||
|
||||
const scrollToIndex = useCallback((index: number) => {
|
||||
if (scrollContainerRef.current) {
|
||||
setActivePage(index);
|
||||
const amount = itemWidth.current * index;
|
||||
scrollContainerRef.current.scrollTo({left: amount});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scrollToPreviousPage = useCallback(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
const pageWidth = scrollContainerRef.current.clientWidth;
|
||||
const currentScroll = scrollContainerRef.current.scrollLeft;
|
||||
const scrollLeft =
|
||||
!currentScroll && rotate
|
||||
? scrollContainerRef.current.scrollWidth - pageWidth
|
||||
: currentScroll - pageWidth;
|
||||
scrollContainerRef.current.scrollTo({
|
||||
left: scrollLeft,
|
||||
});
|
||||
}
|
||||
}, [rotate]);
|
||||
|
||||
const scrollToNextPage = useCallback(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
const pageWidth = scrollContainerRef.current.clientWidth;
|
||||
const currentScroll = scrollContainerRef.current.scrollLeft;
|
||||
const scrollLeft =
|
||||
rotate &&
|
||||
currentScroll + pageWidth >= scrollContainerRef.current.scrollWidth
|
||||
? 0
|
||||
: (activePage + 1) * pageWidth;
|
||||
scrollContainerRef.current.scrollTo({left: scrollLeft});
|
||||
}
|
||||
}, [activePage, rotate]);
|
||||
|
||||
return {
|
||||
scrollContainerRef,
|
||||
scrollToIndex,
|
||||
scrollToPreviousPage,
|
||||
scrollToNextPage,
|
||||
canScrollForward,
|
||||
canScrollBackward,
|
||||
activePage,
|
||||
containerClassName,
|
||||
itemClassName,
|
||||
};
|
||||
}
|
||||
83
resources/client/channels/channel-content-list-item.tsx
Executable file
83
resources/client/channels/channel-content-list-item.tsx
Executable file
@@ -0,0 +1,83 @@
|
||||
import {TITLE_MODEL} from '@app/titles/models/title';
|
||||
import {TitlePoster} from '@app/titles/title-poster/title-poster';
|
||||
import {TitleLink} from '@app/titles/title-link';
|
||||
import {ChannelContentModel} from '@app/admin/channels/channel-content-config';
|
||||
import {NEWS_ARTICLE_MODEL} from '@app/titles/models/news-article';
|
||||
import {NewsArticleImage} from '@app/news/news-article-image';
|
||||
import {NewsArticleLink} from '@app/news/news-article-link';
|
||||
import {BulletSeparatedItems} from '@app/titles/bullet-separated-items';
|
||||
import {FormattedDate} from '@common/i18n/formatted-date';
|
||||
import {NewsArticleSourceLink} from '@app/news/news-article-source-link';
|
||||
import {PERSON_MODEL} from '@app/titles/models/person';
|
||||
import {PersonPoster} from '@app/people/person-poster/person-poster';
|
||||
import {PersonLink} from '@app/people/person-link';
|
||||
import {KnownForCompact} from '@app/people/known-for-compact';
|
||||
import React from 'react';
|
||||
import {FormattedDuration} from '@common/i18n/formatted-duration';
|
||||
import {InteractableRating} from '@app/reviews/interactable-rating';
|
||||
|
||||
interface Props {
|
||||
item: ChannelContentModel;
|
||||
}
|
||||
export function ChannelContentListItem({item}: Props) {
|
||||
switch (item.model_type) {
|
||||
case TITLE_MODEL:
|
||||
return (
|
||||
<div className="flex items-start gap-24 mb-24">
|
||||
<TitlePoster title={item} srcSize="md" size="w-128" showPlayButton />
|
||||
<div className="flex-auto min-w-0 pt-12">
|
||||
<TitleLink title={item} className="font-medium" />
|
||||
<BulletSeparatedItems className="text-sm mt-4">
|
||||
{item.runtime ? (
|
||||
<FormattedDuration minutes={item.runtime} verbose />
|
||||
) : null}
|
||||
{item.certification && (
|
||||
<span className="uppercase">{item.certification}</span>
|
||||
)}
|
||||
</BulletSeparatedItems>
|
||||
{item.rating && item.status !== 'upcoming' ? (
|
||||
<InteractableRating size="md" title={item} className="my-12" />
|
||||
) : (
|
||||
<div className="my-12">
|
||||
<FormattedDate date={item.release_date} />
|
||||
</div>
|
||||
)}
|
||||
{item.description ? (
|
||||
<p className="text-sm">{item.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case PERSON_MODEL:
|
||||
return (
|
||||
<div className="flex items-start gap-24 mb-24">
|
||||
<PersonPoster person={item} srcSize="md" size="w-128" />
|
||||
<div className="flex-auto min-w-0 pt-12">
|
||||
<PersonLink person={item} className="block font-medium text-lg" />
|
||||
{item.primary_credit ? (
|
||||
<div className="text-sm mt-4">
|
||||
<KnownForCompact person={item} />
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-sm mt-12">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case NEWS_ARTICLE_MODEL:
|
||||
return (
|
||||
<div className="flex items-start gap-14 mb-44">
|
||||
<NewsArticleImage article={item} className="aspect-poster max-w-90" />
|
||||
<div className="mt-6 text-base">
|
||||
<NewsArticleLink article={item} className="font-medium" />
|
||||
<p className="text-sm mt-10">{item.body}</p>
|
||||
<BulletSeparatedItems className="text-xs mt-10">
|
||||
<FormattedDate date={item.created_at} />
|
||||
<NewsArticleSourceLink article={item} />
|
||||
</BulletSeparatedItems>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
91
resources/client/channels/channel-content-list.tsx
Executable file
91
resources/client/channels/channel-content-list.tsx
Executable file
@@ -0,0 +1,91 @@
|
||||
import {InfiniteScrollSentinel} from '@common/ui/infinite-scroll/infinite-scroll-sentinel';
|
||||
import React, {Fragment, ReactNode} from 'react';
|
||||
import {ChannelContentProps} from '@app/channels/channel-content';
|
||||
import {useInfiniteChannelContent} from '@common/channels/requests/use-infinite-channel-content';
|
||||
import {ChannelHeader} from '@app/channels/channel-header/channel-header';
|
||||
import {ChannelContentModel} from '@app/admin/channels/channel-content-config';
|
||||
import {ChannelContentListItem} from '@app/channels/channel-content-list-item';
|
||||
import {useChannelContent} from '@common/channels/requests/use-channel-content';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
PaginationControls,
|
||||
PaginationControlsType,
|
||||
} from '@common/ui/navigation/pagination-controls';
|
||||
|
||||
export function ChannelContentList(props: ChannelContentProps) {
|
||||
const isInfiniteScroll =
|
||||
!props.isNested &&
|
||||
(!props.channel.config.paginationType ||
|
||||
props.channel.config.paginationType === 'infiniteScroll');
|
||||
return (
|
||||
<Fragment>
|
||||
<ChannelHeader {...props} />
|
||||
{isInfiniteScroll ? (
|
||||
<InfiniteScrollList {...props} />
|
||||
) : (
|
||||
<PaginatedList {...props} />
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function InfiniteScrollList({channel}: ChannelContentProps) {
|
||||
const query = useInfiniteChannelContent<ChannelContentModel>(channel);
|
||||
return (
|
||||
<Content
|
||||
content={query.items}
|
||||
className={clsx('transition-opacity', query.isReloading && 'opacity-70')}
|
||||
>
|
||||
<InfiniteScrollSentinel query={query} />
|
||||
</Content>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginatedList({channel, isNested}: ChannelContentProps) {
|
||||
const shouldPaginate = !isNested;
|
||||
const query = useChannelContent(channel, null, {paginate: shouldPaginate});
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'transition-opacity',
|
||||
query.isPlaceholderData && 'opacity-70',
|
||||
)}
|
||||
>
|
||||
{shouldPaginate && (
|
||||
<PaginationControls
|
||||
pagination={query.data}
|
||||
type={channel.config.paginationType as PaginationControlsType}
|
||||
className="mb-24"
|
||||
/>
|
||||
)}
|
||||
<Content content={query.data?.data} />
|
||||
{shouldPaginate && (
|
||||
<PaginationControls
|
||||
pagination={query.data}
|
||||
type={channel.config.paginationType as PaginationControlsType}
|
||||
className="mt-24"
|
||||
scrollToTop
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ContentProps {
|
||||
content: ChannelContentModel[] | undefined;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
function Content({content = [], children, className}: ContentProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{content.map(item => (
|
||||
<ChannelContentListItem
|
||||
key={`${item.id}-${item.model_type}`}
|
||||
item={item}
|
||||
/>
|
||||
))}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
resources/client/channels/channel-content-news.tsx
Executable file
99
resources/client/channels/channel-content-news.tsx
Executable file
@@ -0,0 +1,99 @@
|
||||
import {ChannelContentProps} from '@app/channels/channel-content';
|
||||
import React from 'react';
|
||||
import {NewsArticle} from '@app/titles/models/news-article';
|
||||
import {BulletSeparatedItems} from '@app/titles/bullet-separated-items';
|
||||
import {ChannelHeader} from '@app/channels/channel-header/channel-header';
|
||||
import {NewsArticleImage} from '@app/news/news-article-image';
|
||||
import {NewsArticleLink} from '@app/news/news-article-link';
|
||||
import {FormattedDate} from '@common/i18n/formatted-date';
|
||||
import {NewsArticleSourceLink} from '@app/news/news-article-source-link';
|
||||
import {NewsArticleByline} from '@app/news/news-article-byline';
|
||||
import {useChannelContent} from '@common/channels/requests/use-channel-content';
|
||||
import {Channel, ChannelContentItem} from '@common/channels/channel';
|
||||
import {
|
||||
PaginationControls,
|
||||
PaginationControlsType,
|
||||
} from '@common/ui/navigation/pagination-controls';
|
||||
|
||||
export function ChannelContentNews({
|
||||
channel,
|
||||
isNested,
|
||||
}: ChannelContentProps<NewsArticle>) {
|
||||
const shouldPaginate = !isNested;
|
||||
const query = useChannelContent<ChannelContentItem<NewsArticle>>(
|
||||
channel,
|
||||
null,
|
||||
{
|
||||
paginate: shouldPaginate,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ChannelHeader channel={channel as Channel} isNested={isNested} />
|
||||
{shouldPaginate && (
|
||||
<PaginationControls
|
||||
pagination={query.data}
|
||||
type={channel.config.paginationType as PaginationControlsType}
|
||||
className="mb-34"
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-34">
|
||||
<div className="w-240 flex-shrink-0">
|
||||
{query.data?.data
|
||||
.slice(0, 3)
|
||||
.map(article => (
|
||||
<LeftColArticle
|
||||
key={article.id}
|
||||
article={article}
|
||||
className="mb-14"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-auto">
|
||||
{query.data?.data.slice(3, 12).map(article => (
|
||||
<div key={article.id} className="mb-12 flex items-center gap-14">
|
||||
<NewsArticleImage article={article} size="w-84 h-84" />
|
||||
<div className="flex-auto">
|
||||
<NewsArticleLink article={article} className="font-semibold" />
|
||||
<BulletSeparatedItems className="text-sm">
|
||||
<FormattedDate date={article.created_at} />
|
||||
<NewsArticleByline article={article} />
|
||||
<NewsArticleSourceLink article={article} />
|
||||
</BulletSeparatedItems>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{shouldPaginate && (
|
||||
<PaginationControls
|
||||
pagination={query.data}
|
||||
type={channel.config.paginationType as PaginationControlsType}
|
||||
className="mt-34"
|
||||
scrollToTop
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LeftColArticleProps {
|
||||
article: NewsArticle;
|
||||
className?: string;
|
||||
}
|
||||
function LeftColArticle({article, className}: LeftColArticleProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<NewsArticleImage article={article} size="aspect-video w-full" />
|
||||
<NewsArticleLink
|
||||
article={article}
|
||||
className="mt-10 block text-sm font-semibold"
|
||||
/>
|
||||
<div className="mt-8 text-xs text-muted">
|
||||
<NewsArticleByline article={article} />
|
||||
<NewsArticleSourceLink article={article} className="mt-4" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
resources/client/channels/channel-content-slider.tsx
Executable file
186
resources/client/channels/channel-content-slider.tsx
Executable file
@@ -0,0 +1,186 @@
|
||||
import {ChannelContentProps} from '@app/channels/channel-content';
|
||||
import React, {Fragment} from 'react';
|
||||
import {useCarousel} from '@app/channels/carousel/use-carousel';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {TitleRating} from '@app/reviews/title-rating';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {MediaPlayIcon} from '@common/icons/media/media-play';
|
||||
import {TitleLink} from '@app/titles/title-link';
|
||||
import {TitleBackdrop} from '@app/titles/title-poster/title-backdrop';
|
||||
import {TitlePoster} from '@app/titles/title-poster/title-poster';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {ChevronLeftIcon} from '@common/icons/material/ChevronLeft';
|
||||
import {ChevronRightIcon} from '@common/icons/material/ChevronRight';
|
||||
import {ChannelHeader} from '@app/channels/channel-header/channel-header';
|
||||
import {AnimatePresence, m} from 'framer-motion';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {getWatchLink} from '@app/videos/watch-page/get-watch-link';
|
||||
import {useChannelContent} from '@common/channels/requests/use-channel-content';
|
||||
import {Channel, ChannelContentItem} from '@common/channels/channel';
|
||||
|
||||
export function ChannelContentSlider({
|
||||
channel,
|
||||
isNested,
|
||||
}: ChannelContentProps<Title>) {
|
||||
const {
|
||||
scrollContainerRef,
|
||||
activePage,
|
||||
canScrollBackward,
|
||||
canScrollForward,
|
||||
scrollToNextPage,
|
||||
scrollToPreviousPage,
|
||||
} = useCarousel({rotate: true});
|
||||
const {data: pagination} =
|
||||
useChannelContent<ChannelContentItem<Title>>(channel);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<ChannelHeader
|
||||
channel={channel as Channel}
|
||||
isNested={isNested}
|
||||
margin="mb-18"
|
||||
/>
|
||||
<div className="gap-24 md:flex">
|
||||
<div className="relative flex-auto">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="hidden-scrollbar flex h-full select-none snap-x snap-mandatory snap-always items-center overflow-x-auto"
|
||||
>
|
||||
{pagination?.data.map((item, index) => (
|
||||
<Slide key={item.id} item={item} index={index} />
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute top-10 z-20 w-full md:top-[170px]">
|
||||
<div className="absolute left-8 hidden md:left-14 md:block">
|
||||
<IconButton
|
||||
variant="outline"
|
||||
size="lg"
|
||||
color="white"
|
||||
disabled={!canScrollBackward}
|
||||
onClick={() => scrollToPreviousPage()}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="absolute right-8 hidden md:right-14 md:block">
|
||||
<IconButton
|
||||
variant="outline"
|
||||
size="lg"
|
||||
color="white"
|
||||
disabled={!canScrollForward}
|
||||
onClick={() => scrollToNextPage()}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UpNext titles={pagination?.data ?? []} activePage={activePage} />
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
interface SlideProps {
|
||||
item: Title;
|
||||
index: number;
|
||||
}
|
||||
function Slide({item, index}: SlideProps) {
|
||||
return (
|
||||
<div className="relative h-full w-full flex-shrink-0 snap-start snap-normal overflow-hidden rounded">
|
||||
<TitleBackdrop
|
||||
title={item}
|
||||
lazy={index > 0}
|
||||
className="min-h-240 md:min-h-0"
|
||||
wrapperClassName="h-full"
|
||||
/>
|
||||
<div className="absolute inset-0 isolate flex h-full w-full items-center justify-start gap-24 rounded p-30 text-white md:items-end">
|
||||
<div className="absolute left-0 h-full w-full bg-gradient-to-b from-black/40 max-md:top-0 md:bottom-0 md:h-3/4 md:bg-gradient-to-t md:from-black/100" />
|
||||
<TitlePoster
|
||||
title={item}
|
||||
size="max-h-320"
|
||||
srcSize="md"
|
||||
className="z-10 shadow-md max-md:hidden"
|
||||
/>
|
||||
<div className="z-10 text-lg md:max-w-620">
|
||||
<TitleRating score={item.rating} />
|
||||
<div className="my-8 text-2xl md:text-5xl">
|
||||
<TitleLink title={item} />
|
||||
</div>
|
||||
{item.description && (
|
||||
<p className="max-md:hidden">{item.description}</p>
|
||||
)}
|
||||
{item.primary_video && (
|
||||
<Button
|
||||
variant="flat"
|
||||
color="primary"
|
||||
startIcon={<MediaPlayIcon />}
|
||||
radius="rounded-full"
|
||||
className="mt-24 md:min-h-42 md:min-w-144"
|
||||
elementType={Link}
|
||||
to={getWatchLink(item.primary_video)}
|
||||
>
|
||||
{item.primary_video.category === 'full' ? (
|
||||
<Trans message="Watch now" />
|
||||
) : (
|
||||
<Trans message="Play trailer" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface UpNextProps {
|
||||
titles: Title[];
|
||||
activePage: number;
|
||||
}
|
||||
function UpNext({titles, activePage}: UpNextProps) {
|
||||
const itemCount = titles.length;
|
||||
const start = activePage + 1;
|
||||
const end = start + 3;
|
||||
const items = titles.slice(start, end);
|
||||
if (end > itemCount) {
|
||||
items.push(...titles.slice(0, end - itemCount));
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<div className="w-1/4 max-w-200 flex-shrink-0 max-md:hidden">
|
||||
<div className="mb-12 text-lg font-semibold">
|
||||
<Trans message="Up next" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-24">
|
||||
{items.map(item => (
|
||||
<m.div
|
||||
key={item.id}
|
||||
className="relative flex-auto"
|
||||
initial={{opacity: 0}}
|
||||
animate={{opacity: 1}}
|
||||
exit={{opacity: 0}}
|
||||
transition={{duration: 0.2}}
|
||||
>
|
||||
<TitleBackdrop
|
||||
title={item}
|
||||
className="mb-6 rounded"
|
||||
size="w-full"
|
||||
srcSize="md"
|
||||
wrapWithLink
|
||||
showPlayButton
|
||||
/>
|
||||
<div className="mb-2 overflow-hidden overflow-ellipsis whitespace-nowrap text-sm">
|
||||
<TitleLink title={item} className="text-base font-medium" />
|
||||
</div>
|
||||
<div>
|
||||
<TitleRating score={item.rating} className="text-sm" />
|
||||
</div>
|
||||
</m.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
111
resources/client/channels/channel-content.tsx
Executable file
111
resources/client/channels/channel-content.tsx
Executable file
@@ -0,0 +1,111 @@
|
||||
import React, {Fragment} from 'react';
|
||||
import {Channel, CHANNEL_MODEL} from '@common/channels/channel';
|
||||
import {ChannelContentGrid} from '@app/channels/content-grid/channel-content-grid';
|
||||
import {ChannelHeader} from '@app/channels/channel-header/channel-header';
|
||||
import {
|
||||
ChannelContentModel,
|
||||
Layout,
|
||||
} from '@app/admin/channels/channel-content-config';
|
||||
import {ChannelContentCarousel} from '@app/channels/carousel/channel-content-carousel';
|
||||
import {ChannelContentSlider} from '@app/channels/channel-content-slider';
|
||||
import {ChannelContentNews} from '@app/channels/channel-content-news';
|
||||
import {ChannelContentList} from '@app/channels/channel-content-list';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {NewsArticle} from '@app/titles/models/news-article';
|
||||
import {useChannelLayouts} from '@app/channels/channel-header/use-channel-layouts';
|
||||
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {SvgImage} from '@common/ui/images/svg-image/svg-image';
|
||||
import todoImage from '@app/admin/lists/todo.svg';
|
||||
|
||||
export interface ChannelContentProps<
|
||||
T extends ChannelContentModel = ChannelContentModel
|
||||
> {
|
||||
channel: Channel<T>;
|
||||
isNested: boolean;
|
||||
}
|
||||
export function ChannelContent(props: ChannelContentProps) {
|
||||
// only show no results message in non nested channels
|
||||
if (props.isNested && !props.channel.content?.data.length) {
|
||||
return null;
|
||||
}
|
||||
if (props.channel.config.contentModel === CHANNEL_MODEL) {
|
||||
return <NestedChannels {...(props as ChannelContentProps<Channel>)} />;
|
||||
} else {
|
||||
return (
|
||||
<Fragment>
|
||||
<ChannelLayout {...props} />
|
||||
<NoResultsMessage channel={props.channel} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface NestedChannelsProps {
|
||||
channel: ChannelContentProps['channel'];
|
||||
}
|
||||
function NoResultsMessage({channel}: NestedChannelsProps) {
|
||||
if (channel.content?.data.length === 0) {
|
||||
return (
|
||||
<IllustratedMessage
|
||||
className="mt-60"
|
||||
image={<SvgImage src={todoImage} />}
|
||||
title={
|
||||
channel.type === 'list' ? (
|
||||
<Trans message="This list does not have any content yet." />
|
||||
) : (
|
||||
<Trans message="This channel does not have any content yet." />
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ChannelLayout(props: ChannelContentProps) {
|
||||
const {channel, isNested} = props;
|
||||
const {selectedLayout} = useChannelLayouts(channel);
|
||||
const layout = (
|
||||
isNested ? channel.config.nestedLayout : selectedLayout
|
||||
) as Layout;
|
||||
|
||||
switch (layout) {
|
||||
case 'grid':
|
||||
return <ChannelContentGrid {...props} variant="portrait" />;
|
||||
case 'landscapeGrid':
|
||||
return <ChannelContentGrid {...props} variant="landscape" />;
|
||||
case 'list':
|
||||
return <ChannelContentList {...props} />;
|
||||
case 'carousel':
|
||||
return <ChannelContentCarousel {...props} variant="portrait" />;
|
||||
case 'landscapeCarousel':
|
||||
return <ChannelContentCarousel {...props} variant="landscape" />;
|
||||
case 'slider':
|
||||
return (
|
||||
<ChannelContentSlider {...(props as ChannelContentProps<Title>)} />
|
||||
);
|
||||
case 'news':
|
||||
return (
|
||||
<ChannelContentNews {...(props as ChannelContentProps<NewsArticle>)} />
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function NestedChannels({channel, isNested}: ChannelContentProps) {
|
||||
return (
|
||||
<Fragment>
|
||||
<ChannelHeader channel={channel} isNested={isNested} />
|
||||
{channel.content?.data.map(nestedChannel => (
|
||||
<div key={nestedChannel.id} className="mb-40 md:mb-50">
|
||||
<ChannelContent
|
||||
channel={nestedChannel as Channel<Channel>}
|
||||
isNested
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
138
resources/client/channels/channel-header/channel-header.tsx
Executable file
138
resources/client/channels/channel-header/channel-header.tsx
Executable file
@@ -0,0 +1,138 @@
|
||||
import React, {Fragment, ReactNode} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {Channel} from '@common/channels/channel';
|
||||
import {FilterList} from '@common/datatable/filters/filter-list/filter-list';
|
||||
import {AnimatePresence, m} from 'framer-motion';
|
||||
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
|
||||
import {useBackendFilterUrlParams} from '@common/datatable/filters/backend-filter-url-params';
|
||||
import {MOVIE_MODEL, SERIES_MODEL, TITLE_MODEL} from '@app/titles/models/title';
|
||||
import {ChannelSortButton} from '@app/channels/channel-header/channel-sort-button';
|
||||
import {AddFilterButton} from '@common/datatable/filters/add-filter-button';
|
||||
import {TuneIcon} from '@common/icons/material/Tune';
|
||||
import {SiteSectionHeading} from '@app/titles/site-section-heading';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {useParams} from 'react-router-dom';
|
||||
import {ChannelLayoutButton} from '@app/channels/channel-header/channel-layout-button';
|
||||
import {useTitleIndexFilters} from '@app/titles/use-title-index-filters';
|
||||
import {FilterListSkeleton} from '@common/datatable/filters/filter-list/filter-list-skeleton';
|
||||
import {UserListByline} from '@app/user-lists/user-list-byline';
|
||||
import {UserListDetails} from '@app/user-lists/user-list-details';
|
||||
|
||||
const FilterModelTypes = [TITLE_MODEL, MOVIE_MODEL, SERIES_MODEL];
|
||||
|
||||
interface Props {
|
||||
channel: Channel;
|
||||
margin?: string;
|
||||
isNested: boolean;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
export function ChannelHeader({
|
||||
channel,
|
||||
isNested,
|
||||
actions,
|
||||
margin = isNested ? 'mb-16 md:mb-30' : 'mb-20 md:mb-40',
|
||||
}: Props) {
|
||||
const shouldShowFilterButton =
|
||||
!isNested &&
|
||||
FilterModelTypes.includes(channel.config.contentModel) &&
|
||||
channel.config.contentType === 'listAll';
|
||||
|
||||
const {encodedFilters} = useBackendFilterUrlParams();
|
||||
const {filters, filtersLoading} = useTitleIndexFilters({
|
||||
disabled: !shouldShowFilterButton,
|
||||
});
|
||||
|
||||
if (channel.config.hideTitle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={clsx(margin)}>
|
||||
<ChannelTitle
|
||||
channel={channel}
|
||||
isNested={isNested}
|
||||
actions={
|
||||
<Fragment>
|
||||
{actions}
|
||||
{!isNested && <ChannelSortButton channel={channel} />}
|
||||
{shouldShowFilterButton && (
|
||||
<AddFilterButton
|
||||
icon={<TuneIcon />}
|
||||
color={null}
|
||||
variant="text"
|
||||
disabled={filtersLoading}
|
||||
filters={filters}
|
||||
/>
|
||||
)}
|
||||
{!isNested && <ChannelLayoutButton channel={channel} />}
|
||||
</Fragment>
|
||||
}
|
||||
/>
|
||||
{shouldShowFilterButton && (
|
||||
<div className="mt-14">
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{filtersLoading && encodedFilters ? (
|
||||
<FilterListSkeleton />
|
||||
) : (
|
||||
<m.div key="filter-list" {...opacityAnimation}>
|
||||
<FilterList filters={filters} />
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChannelTitleProps {
|
||||
channel: Channel;
|
||||
isNested: boolean;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
function ChannelTitle({channel, isNested, actions}: ChannelTitleProps) {
|
||||
const {restriction: urlParam} = useParams();
|
||||
if (channel.config.hideTitle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const link =
|
||||
channel.config.restriction && urlParam
|
||||
? `/${channel.slug}/${urlParam}`
|
||||
: `/${channel.slug}`;
|
||||
|
||||
return (
|
||||
<SiteSectionHeading
|
||||
className="flex-auto"
|
||||
margin="m-0"
|
||||
description={<ChannelDescription channel={channel} />}
|
||||
actions={actions}
|
||||
headingType={isNested ? 'h2' : 'h1'}
|
||||
descriptionFontSize={isNested ? 'text-sm' : undefined}
|
||||
fontWeight={isNested ? 'font-normal' : undefined}
|
||||
link={isNested ? link : undefined}
|
||||
>
|
||||
<Trans message={channel.name} />
|
||||
</SiteSectionHeading>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChannelDescriptionProps {
|
||||
channel: Channel;
|
||||
}
|
||||
function ChannelDescription({channel}: ChannelDescriptionProps) {
|
||||
if (channel.type === 'channel') {
|
||||
return <Fragment>{channel.description}</Fragment>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-18 items-center text-sm md:flex">
|
||||
{channel.user && <UserListByline user={channel.user} />}
|
||||
<UserListDetails
|
||||
list={channel}
|
||||
className="ml-auto max-md:mt-14"
|
||||
showShareButton
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
resources/client/channels/channel-header/channel-layout-button.tsx
Executable file
60
resources/client/channels/channel-header/channel-layout-button.tsx
Executable file
@@ -0,0 +1,60 @@
|
||||
import {Channel} from '@common/channels/channel';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuTrigger,
|
||||
} from '@common/ui/navigation/menu/menu-trigger';
|
||||
import {useChannelLayouts} from '@app/channels/channel-header/use-channel-layouts';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {GridViewIcon} from '@common/icons/material/GridView';
|
||||
|
||||
interface Props {
|
||||
channel: Channel;
|
||||
}
|
||||
export function ChannelLayoutButton({channel}: Props) {
|
||||
const {selectedLayout, setSelectedLayout, availableLayouts} =
|
||||
useChannelLayouts(channel);
|
||||
|
||||
if (availableLayouts?.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const layoutConfig = availableLayouts?.find(
|
||||
method => method.key === selectedLayout
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuTrigger
|
||||
selectionMode="single"
|
||||
showCheckmark
|
||||
selectedValue={selectedLayout}
|
||||
onSelectionChange={newValue => setSelectedLayout(newValue as string)}
|
||||
>
|
||||
<span role="button" aria-label="Toggle menu">
|
||||
<IconButton className="md:hidden" role="presentation">
|
||||
{layoutConfig?.icon || <GridViewIcon />}
|
||||
</IconButton>
|
||||
<Button
|
||||
role="presentation"
|
||||
className="max-md:hidden"
|
||||
startIcon={layoutConfig?.icon || <GridViewIcon />}
|
||||
>
|
||||
{layoutConfig?.label ? (
|
||||
<Trans {...layoutConfig.label} />
|
||||
) : (
|
||||
<Trans message="Popularity" />
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
<Menu>
|
||||
{availableLayouts?.map(method => (
|
||||
<MenuItem key={method.key} value={method.key}>
|
||||
<Trans {...method.label} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
);
|
||||
}
|
||||
97
resources/client/channels/channel-header/channel-sort-button.tsx
Executable file
97
resources/client/channels/channel-header/channel-sort-button.tsx
Executable file
@@ -0,0 +1,97 @@
|
||||
import {Channel, ChannelContentItem} from '@common/channels/channel';
|
||||
import {
|
||||
channelContentConfig,
|
||||
Sort,
|
||||
} from '@app/admin/channels/channel-content-config';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {SortIcon} from '@common/icons/material/Sort';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import React from 'react';
|
||||
import {useSearchParams} from 'react-router-dom';
|
||||
import {
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuTrigger,
|
||||
} from '@common/ui/navigation/menu/menu-trigger';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
|
||||
interface ChannelSortButtonProps<T = ChannelContentItem> {
|
||||
channel: Channel;
|
||||
}
|
||||
export function ChannelSortButton<T = ChannelContentItem>({
|
||||
channel,
|
||||
}: ChannelSortButtonProps<T>) {
|
||||
const config = channelContentConfig.models[channel.config.contentModel];
|
||||
const sortMethods =
|
||||
config?.sortMethods.map(method => ({
|
||||
key: method,
|
||||
label: channelContentConfig.sortingMethods[method].label,
|
||||
})) || [];
|
||||
|
||||
if (channel.config.contentType === 'manual') {
|
||||
sortMethods.unshift({
|
||||
key: Sort.curated,
|
||||
label: message('Default order'),
|
||||
});
|
||||
}
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const selectedValue =
|
||||
searchParams.get('order') || channel.config.contentOrder;
|
||||
|
||||
if (sortMethods?.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = sortMethods?.find(
|
||||
method => method.key === selectedValue
|
||||
)?.label;
|
||||
|
||||
return (
|
||||
<MenuTrigger
|
||||
selectionMode="single"
|
||||
showCheckmark
|
||||
selectedValue={selectedValue}
|
||||
onSelectionChange={newValue => {
|
||||
// order by date added to channel, if content is cured
|
||||
if (
|
||||
newValue === Sort.recent &&
|
||||
channel.config.contentType === 'manual'
|
||||
) {
|
||||
newValue = 'channelables.created_at:desc';
|
||||
}
|
||||
|
||||
setSearchParams(
|
||||
prev => {
|
||||
prev.set('order', newValue as string);
|
||||
return prev;
|
||||
},
|
||||
{
|
||||
replace: true,
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span role="button" aria-label="Toggle menu">
|
||||
<IconButton className="md:hidden" role="presentation">
|
||||
<SortIcon />
|
||||
</IconButton>
|
||||
<Button
|
||||
startIcon={<SortIcon />}
|
||||
className="max-md:hidden"
|
||||
role="presentation"
|
||||
>
|
||||
{label ? <Trans {...label} /> : <Trans message="Popularity" />}
|
||||
</Button>
|
||||
</span>
|
||||
<Menu>
|
||||
{sortMethods?.map(method => (
|
||||
<MenuItem key={method.key} value={method.key}>
|
||||
<Trans {...method.label} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
);
|
||||
}
|
||||
175
resources/client/channels/channel-header/get-title-channel-filters.ts
Executable file
175
resources/client/channels/channel-header/get-title-channel-filters.ts
Executable file
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
ALL_PRIMITIVE_OPERATORS,
|
||||
BackendFilter,
|
||||
FilterControlType,
|
||||
FilterOperator,
|
||||
} from '@common/datatable/filters/backend-filter';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {FetchValueListsResponse} from '@common/http/value-lists';
|
||||
import {dateRangeToAbsoluteRange} from '@common/ui/forms/input-field/date/date-range-picker/form-date-range-picker';
|
||||
import {
|
||||
DateRangePreset,
|
||||
DateRangePresets,
|
||||
} from '@common/ui/forms/input-field/date/date-range-picker/dialog/date-range-presets';
|
||||
import {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';
|
||||
import {now, parseDateTime} from '@internationalized/date';
|
||||
import {getUserTimezone} from '@common/i18n/get-user-timezone';
|
||||
import {Channel} from '@common/channels/channel';
|
||||
import {GENRE_MODEL} from '@app/titles/models/genre';
|
||||
import {PRODUCTION_COUNTRY_MODEL} from '@app/titles/models/production-country';
|
||||
|
||||
interface Props {
|
||||
languages: FetchValueListsResponse['titleFilterLanguages'];
|
||||
countries: FetchValueListsResponse['productionCountries'];
|
||||
genres: FetchValueListsResponse['genres'];
|
||||
ageRatings: FetchValueListsResponse['titleFilterAgeRatings'];
|
||||
restriction?: Channel['restriction'];
|
||||
}
|
||||
|
||||
export const getTitleChannelFilters = ({
|
||||
languages,
|
||||
countries,
|
||||
genres,
|
||||
ageRatings,
|
||||
restriction,
|
||||
}: Props): BackendFilter[] => {
|
||||
return [
|
||||
restriction?.model_type !== GENRE_MODEL
|
||||
? {
|
||||
key: 'genres',
|
||||
label: message('Genres'),
|
||||
defaultOperator: FilterOperator.hasAll,
|
||||
control: {
|
||||
type: FilterControlType.ChipField,
|
||||
placeholder: message('Pick genres'),
|
||||
defaultValue: [],
|
||||
options: genres.map(genre => ({
|
||||
label: message(genre.name),
|
||||
key: genre.value,
|
||||
value: genre.value,
|
||||
})),
|
||||
},
|
||||
}
|
||||
: null,
|
||||
{
|
||||
key: 'release_date',
|
||||
label: message('Release date'),
|
||||
defaultOperator: FilterOperator.between,
|
||||
control: {
|
||||
type: FilterControlType.DateRangePicker,
|
||||
defaultValue: dateRangeToAbsoluteRange(
|
||||
(DateRangePresets[9] as Required<DateRangePreset>).getRangeValue()
|
||||
),
|
||||
min: parseDateTime('1900-01-01'),
|
||||
max: now(getUserTimezone()).add({years: 5}),
|
||||
},
|
||||
},
|
||||
{
|
||||
control: {
|
||||
type: FilterControlType.Input,
|
||||
inputType: 'number',
|
||||
minValue: 1,
|
||||
maxValue: 10,
|
||||
defaultValue: 7,
|
||||
},
|
||||
key:
|
||||
getBootstrapData().settings.content.title_provider !== 'tmdb'
|
||||
? 'tmdb_vote_average'
|
||||
: 'local_vote_average',
|
||||
label: message('User rating'),
|
||||
defaultOperator: FilterOperator.gte,
|
||||
operators: ALL_PRIMITIVE_OPERATORS,
|
||||
},
|
||||
{
|
||||
key: 'runtime',
|
||||
label: message('Runtime'),
|
||||
description: message('Runtime in minutes'),
|
||||
defaultOperator: FilterOperator.lte,
|
||||
operators: ALL_PRIMITIVE_OPERATORS,
|
||||
control: {
|
||||
type: FilterControlType.Input,
|
||||
inputType: 'number',
|
||||
minValue: 1,
|
||||
maxValue: 255,
|
||||
defaultValue: 180,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'language',
|
||||
label: message('Original language'),
|
||||
defaultOperator: FilterOperator.eq,
|
||||
control: {
|
||||
type: FilterControlType.Select,
|
||||
placeholder: message('Pick a language'),
|
||||
searchPlaceholder: message('Search for language'),
|
||||
showSearchField: true,
|
||||
options: languages.map(({name, value}) => ({
|
||||
label: message(name),
|
||||
key: value,
|
||||
value: value,
|
||||
})),
|
||||
},
|
||||
},
|
||||
restriction?.model_type !== PRODUCTION_COUNTRY_MODEL
|
||||
? {
|
||||
control: {
|
||||
type: FilterControlType.ChipField,
|
||||
placeholder: message('Pick countries'),
|
||||
defaultValue: [],
|
||||
options: countries?.map(({name, value}) => ({
|
||||
label: message(name),
|
||||
key: value,
|
||||
value: value,
|
||||
})),
|
||||
},
|
||||
key: 'productionCountries',
|
||||
label: message('Production countries'),
|
||||
defaultOperator: FilterOperator.hasAll,
|
||||
}
|
||||
: null,
|
||||
{
|
||||
key: 'certification',
|
||||
label: message('Age rating'),
|
||||
defaultOperator: FilterOperator.eq,
|
||||
control: {
|
||||
type: FilterControlType.Select,
|
||||
placeholder: message('Pick an age rating'),
|
||||
showSearchField: true,
|
||||
searchPlaceholder: message('Search for age rating'),
|
||||
options: ageRatings.map(({name, value}) => ({
|
||||
label: message(name),
|
||||
key: value,
|
||||
value: value,
|
||||
})),
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'budget',
|
||||
label: message('Budget'),
|
||||
description: message('Budget in US dollars'),
|
||||
defaultOperator: FilterOperator.lte,
|
||||
operators: ALL_PRIMITIVE_OPERATORS,
|
||||
control: {
|
||||
type: FilterControlType.Input,
|
||||
inputType: 'number',
|
||||
minValue: 1,
|
||||
maxValue: 1000000000,
|
||||
defaultValue: 100000000,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'revenue',
|
||||
label: message('Revenue'),
|
||||
description: message('Revenue in US dollars'),
|
||||
defaultOperator: FilterOperator.lte,
|
||||
operators: ALL_PRIMITIVE_OPERATORS,
|
||||
control: {
|
||||
type: FilterControlType.Input,
|
||||
inputType: 'number',
|
||||
minValue: 1,
|
||||
maxValue: 1000000000,
|
||||
defaultValue: 100000000,
|
||||
},
|
||||
},
|
||||
].filter(Boolean) as BackendFilter[];
|
||||
};
|
||||
21
resources/client/channels/channel-header/use-channel-layouts.ts
Executable file
21
resources/client/channels/channel-header/use-channel-layouts.ts
Executable file
@@ -0,0 +1,21 @@
|
||||
import {Channel} from '@common/channels/channel';
|
||||
import {channelContentConfig} from '@app/admin/channels/channel-content-config';
|
||||
import {useCookie} from '@common/utils/hooks/use-cookie';
|
||||
|
||||
export function useChannelLayouts(channel: Channel) {
|
||||
const config = channelContentConfig.models[channel.config.contentModel];
|
||||
const availableLayouts = config?.layoutMethods
|
||||
.filter(m => channelContentConfig.userSelectableLayouts.includes(m))
|
||||
.map(method => ({
|
||||
key: method,
|
||||
label: channelContentConfig.layoutMethods[method].label,
|
||||
icon: channelContentConfig.layoutMethods[method].icon,
|
||||
}));
|
||||
|
||||
const [selectedLayout, setSelectedLayout] = useCookie(
|
||||
`channel-layout-${channel.config.contentModel}`,
|
||||
channel.config.selectedLayout || channel.config.layout
|
||||
);
|
||||
|
||||
return {selectedLayout, setSelectedLayout, availableLayouts};
|
||||
}
|
||||
46
resources/client/channels/channel-page.tsx
Executable file
46
resources/client/channels/channel-page.tsx
Executable file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import {PageStatus} from '@common/http/page-status';
|
||||
import {useChannel} from '@common/channels/requests/use-channel';
|
||||
import {ChannelContent} from '@app/channels/channel-content';
|
||||
import {SitePageLayout} from '@app/site-page-layout';
|
||||
import {Channel} from '@common/channels/channel';
|
||||
import {ChannelContentModel} from '@app/admin/channels/channel-content-config';
|
||||
import {PageMetaTags} from '@common/http/page-meta-tags';
|
||||
|
||||
interface ChannelPageProps {
|
||||
slugOrId?: string | number;
|
||||
type?: 'list' | 'channel';
|
||||
}
|
||||
export function ChannelPage({slugOrId, type = 'channel'}: ChannelPageProps) {
|
||||
const query = useChannel(slugOrId, 'channelPage', {channelType: type});
|
||||
|
||||
let content = null;
|
||||
|
||||
if (query.data) {
|
||||
content = (
|
||||
<div>
|
||||
<PageMetaTags query={query} />
|
||||
<div className="pb-24">
|
||||
<div className="container mx-auto p-14 @container md:p-24">
|
||||
<ChannelContent
|
||||
channel={query.data.channel as Channel<ChannelContentModel>}
|
||||
// set key to force re-render when channel changes
|
||||
key={query.data.channel.id}
|
||||
isNested={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<PageStatus
|
||||
query={query}
|
||||
loaderClassName="absolute inset-0 m-auto"
|
||||
loaderIsScreen={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <SitePageLayout>{content}</SitePageLayout>;
|
||||
}
|
||||
51
resources/client/channels/content-grid/channel-content-grid-item.tsx
Executable file
51
resources/client/channels/content-grid/channel-content-grid-item.tsx
Executable file
@@ -0,0 +1,51 @@
|
||||
import {TITLE_MODEL} from '@app/titles/models/title';
|
||||
import {ChannelContentModel} from '@app/admin/channels/channel-content-config';
|
||||
import {NEWS_ARTICLE_MODEL} from '@app/titles/models/news-article';
|
||||
import {ContentGridProps} from '@app/channels/content-grid/content-grid-layout';
|
||||
import {Person, PERSON_MODEL} from '@app/titles/models/person';
|
||||
import {PersonPoster} from '@app/people/person-poster/person-poster';
|
||||
import {PersonLink} from '@app/people/person-link';
|
||||
import {PersonAge} from '@app/people/person-age';
|
||||
import {NewsArticleGridItem} from '@app/news/news-article-grid-item';
|
||||
import {
|
||||
TitleLandscapeGridItem,
|
||||
TitlePortraitGridItem,
|
||||
} from '@app/channels/content-grid/title-grid-item';
|
||||
|
||||
interface Props {
|
||||
item: ChannelContentModel;
|
||||
variant?: ContentGridProps['variant'];
|
||||
}
|
||||
export function ChannelContentGridItem({item, variant}: Props) {
|
||||
switch (item.model_type) {
|
||||
case TITLE_MODEL:
|
||||
return variant === 'landscape' ? (
|
||||
<TitleLandscapeGridItem item={item} />
|
||||
) : (
|
||||
<TitlePortraitGridItem item={item} />
|
||||
);
|
||||
case PERSON_MODEL:
|
||||
return <PersonGridItem item={item} />;
|
||||
case NEWS_ARTICLE_MODEL:
|
||||
return <NewsArticleGridItem article={item} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface PersonGridItemProps {
|
||||
item: Person;
|
||||
}
|
||||
function PersonGridItem({item}: PersonGridItemProps) {
|
||||
return (
|
||||
<div>
|
||||
<PersonPoster person={item} srcSize="md" size="w-full" rounded />
|
||||
<div className="mt-10 text-center text-sm">
|
||||
<PersonLink person={item} className="block text-base font-medium" />
|
||||
<div>
|
||||
<PersonAge person={item} showRange />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
resources/client/channels/content-grid/channel-content-grid.tsx
Executable file
97
resources/client/channels/content-grid/channel-content-grid.tsx
Executable file
@@ -0,0 +1,97 @@
|
||||
import {InfiniteScrollSentinel} from '@common/ui/infinite-scroll/infinite-scroll-sentinel';
|
||||
import React, {Fragment} from 'react';
|
||||
import {ChannelContentProps} from '@app/channels/channel-content';
|
||||
import {useInfiniteChannelContent} from '@common/channels/requests/use-infinite-channel-content';
|
||||
import {ChannelHeader} from '@app/channels/channel-header/channel-header';
|
||||
import {
|
||||
ContentGridLayout,
|
||||
ContentGridProps,
|
||||
} from '@app/channels/content-grid/content-grid-layout';
|
||||
import {ChannelContentGridItem} from '@app/channels/content-grid/channel-content-grid-item';
|
||||
import {ChannelContentModel} from '@app/admin/channels/channel-content-config';
|
||||
import {useChannelContent} from '@common/channels/requests/use-channel-content';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
PaginationControls,
|
||||
PaginationControlsType,
|
||||
} from '@common/ui/navigation/pagination-controls';
|
||||
|
||||
interface ChannelContentGridProps extends ChannelContentProps {
|
||||
variant?: ContentGridProps['variant'];
|
||||
}
|
||||
export function ChannelContentGrid(props: ChannelContentGridProps) {
|
||||
const isInfiniteScroll =
|
||||
!props.isNested &&
|
||||
(!props.channel.config.paginationType ||
|
||||
props.channel.config.paginationType === 'infiniteScroll');
|
||||
return (
|
||||
<Fragment>
|
||||
<ChannelHeader {...props} />
|
||||
{isInfiniteScroll ? (
|
||||
<InfiniteScrollGrid {...props} />
|
||||
) : (
|
||||
<PaginatedGrid {...props} />
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function InfiniteScrollGrid({channel, variant}: ChannelContentGridProps) {
|
||||
const query = useInfiniteChannelContent<ChannelContentModel>(channel);
|
||||
return (
|
||||
<div
|
||||
className={clsx('transition-opacity', query.isReloading && 'opacity-70')}
|
||||
>
|
||||
<ContentGrid content={query.items} variant={variant} />
|
||||
<InfiniteScrollSentinel query={query} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginatedGrid({channel, variant, isNested}: ChannelContentGridProps) {
|
||||
const shouldPaginate = !isNested;
|
||||
const query = useChannelContent(channel, null, {paginate: shouldPaginate});
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'transition-opacity',
|
||||
query.isPlaceholderData && 'opacity-70',
|
||||
)}
|
||||
>
|
||||
{shouldPaginate && (
|
||||
<PaginationControls
|
||||
pagination={query.data}
|
||||
type={channel.config.paginationType as PaginationControlsType}
|
||||
className="mb-24"
|
||||
/>
|
||||
)}
|
||||
<ContentGrid content={query.data?.data} variant={variant} />
|
||||
{shouldPaginate && (
|
||||
<PaginationControls
|
||||
pagination={query.data}
|
||||
type={channel.config.paginationType as PaginationControlsType}
|
||||
className="mt-24"
|
||||
scrollToTop
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ContentProps {
|
||||
content: ChannelContentModel[] | undefined;
|
||||
variant: ContentGridProps['variant'];
|
||||
}
|
||||
export function ContentGrid({content = [], variant}: ContentProps) {
|
||||
return (
|
||||
<ContentGridLayout variant={variant}>
|
||||
{content.map(item => (
|
||||
<ChannelContentGridItem
|
||||
key={`${item.id}-${item.model_type}`}
|
||||
item={item}
|
||||
variant={variant}
|
||||
/>
|
||||
))}
|
||||
</ContentGridLayout>
|
||||
);
|
||||
}
|
||||
27
resources/client/channels/content-grid/content-grid-landscape.css
vendored
Executable file
27
resources/client/channels/content-grid/content-grid-landscape.css
vendored
Executable file
@@ -0,0 +1,27 @@
|
||||
.content-grid-landscape {
|
||||
--nVisibleItems: 1;
|
||||
}
|
||||
|
||||
@media (min-width: 280px) {
|
||||
.content-grid-landscape {
|
||||
--nVisibleItems: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 786px) {
|
||||
.content-grid-landscape {
|
||||
--nVisibleItems: 3;
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: 600px) {
|
||||
.content-grid-landscape {
|
||||
--nVisibleItems: 3;
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: 1200px) {
|
||||
.content-grid-landscape {
|
||||
--nVisibleItems: 4;
|
||||
}
|
||||
}
|
||||
30
resources/client/channels/content-grid/content-grid-layout.tsx
Executable file
30
resources/client/channels/content-grid/content-grid-layout.tsx
Executable file
@@ -0,0 +1,30 @@
|
||||
import {ReactNode} from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface ContentGridProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
variant?: 'portrait' | 'landscape';
|
||||
gridCols?: string;
|
||||
}
|
||||
export function ContentGridLayout({
|
||||
children,
|
||||
className,
|
||||
variant,
|
||||
gridCols = 'grid-cols-[repeat(var(--nVisibleItems),minmax(0,1fr))]',
|
||||
}: ContentGridProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'grid gap-24',
|
||||
gridCols,
|
||||
className,
|
||||
variant === 'landscape'
|
||||
? 'content-grid-landscape'
|
||||
: 'content-grid-portrait'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
resources/client/channels/content-grid/content-grid-portrait.css
vendored
Executable file
52
resources/client/channels/content-grid/content-grid-portrait.css
vendored
Executable file
@@ -0,0 +1,52 @@
|
||||
.content-grid-portrait {
|
||||
--nVisibleItems: 1;
|
||||
}
|
||||
|
||||
@media (min-width: 280px) {
|
||||
.content-grid-portrait {
|
||||
--nVisibleItems: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 786px) {
|
||||
.content-grid-portrait {
|
||||
--nVisibleItems: 3;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.content-grid-portrait {
|
||||
--nVisibleItems: 4;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.content-grid-portrait {
|
||||
--nVisibleItems: 6;
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: 400px) {
|
||||
.content-grid-portrait {
|
||||
--nVisibleItems: 3;
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: 600px) {
|
||||
.content-grid-portrait {
|
||||
--nVisibleItems: 4;
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: 900px) {
|
||||
.content-grid-portrait {
|
||||
--nVisibleItems: 5;
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: 1200px) {
|
||||
.content-grid-portrait {
|
||||
--nVisibleItems: 6;
|
||||
}
|
||||
}
|
||||
|
||||
37
resources/client/channels/content-grid/episode-grid-item.tsx
Executable file
37
resources/client/channels/content-grid/episode-grid-item.tsx
Executable file
@@ -0,0 +1,37 @@
|
||||
import {TitleRating} from '@app/reviews/title-rating';
|
||||
import React from 'react';
|
||||
import {Episode} from '@app/titles/models/episode';
|
||||
import {EpisodePoster} from '@app/episodes/episode-poster/episode-poster';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {TitleLinkWithEpisodeNumber} from '@app/titles/title-link';
|
||||
|
||||
export interface EpisodePortraitGridItemProps {
|
||||
item: Episode;
|
||||
title: Title;
|
||||
rating?: number;
|
||||
}
|
||||
export function EpisodePortraitGridItem({
|
||||
item,
|
||||
title,
|
||||
rating,
|
||||
}: EpisodePortraitGridItemProps) {
|
||||
return (
|
||||
<div>
|
||||
<EpisodePoster
|
||||
episode={item}
|
||||
title={title}
|
||||
srcSize="lg"
|
||||
aspect="aspect-poster"
|
||||
showPlayButton
|
||||
/>
|
||||
<div className="mt-10 text-sm">
|
||||
<TitleRating score={rating ?? item.rating} className="mb-4" />
|
||||
<TitleLinkWithEpisodeNumber
|
||||
title={title}
|
||||
episode={item}
|
||||
className="block font-medium text-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
resources/client/channels/content-grid/title-grid-item.tsx
Executable file
86
resources/client/channels/content-grid/title-grid-item.tsx
Executable file
@@ -0,0 +1,86 @@
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {TitlePoster} from '@app/titles/title-poster/title-poster';
|
||||
import {TitleRating} from '@app/reviews/title-rating';
|
||||
import {TitleLink} from '@app/titles/title-link';
|
||||
import {ReactNode} from 'react';
|
||||
import {TitleBackdrop} from '@app/titles/title-poster/title-backdrop';
|
||||
import {BulletSeparatedItems} from '@app/titles/bullet-separated-items';
|
||||
import {FormattedDate} from '@common/i18n/formatted-date';
|
||||
|
||||
export interface TitlePortraitGridItemProps {
|
||||
item: Title;
|
||||
rating?: number;
|
||||
description?: ReactNode;
|
||||
}
|
||||
export function TitlePortraitGridItem({
|
||||
item,
|
||||
rating,
|
||||
description,
|
||||
}: TitlePortraitGridItemProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="relative">
|
||||
<TitlePoster title={item} srcSize="md" showPlayButton />
|
||||
</div>
|
||||
<div className="mt-10 text-sm">
|
||||
<RatingOrReleaseDate title={item} rating={rating} className="mb-4" />
|
||||
<TitleLink title={item} className="block text-base font-medium" />
|
||||
{description ? <div className="mt-4">{description}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TitleLandscapeGridItem({item}: TitlePortraitGridItemProps) {
|
||||
return (
|
||||
<div>
|
||||
<TitleBackdrop
|
||||
title={item}
|
||||
srcSize="lg"
|
||||
size="w-full"
|
||||
className="rounded"
|
||||
wrapWithLink
|
||||
showPlayButton
|
||||
/>
|
||||
<div className="mt-10 text-sm">
|
||||
<TitleLink
|
||||
title={item}
|
||||
className="mb-4 block text-base font-semibold"
|
||||
/>
|
||||
<BulletSeparatedItems className="mb-4">
|
||||
{item.release_date && <FormattedDate date={item.release_date} />}
|
||||
{item.certification && (
|
||||
<div className="uppercase">{item.certification}</div>
|
||||
)}
|
||||
</BulletSeparatedItems>
|
||||
<TitleRating score={item.rating} className="mb-4" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RatingOrReleaseDateProps {
|
||||
title: Title;
|
||||
rating?: number;
|
||||
className?: string;
|
||||
}
|
||||
function RatingOrReleaseDate({
|
||||
title,
|
||||
rating,
|
||||
className,
|
||||
}: RatingOrReleaseDateProps) {
|
||||
if (!rating) {
|
||||
rating = title.rating;
|
||||
}
|
||||
if (rating) {
|
||||
return <TitleRating score={rating} className={className} />;
|
||||
}
|
||||
if (title.release_date) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<FormattedDate date={title.release_date} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user