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

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

View File

@@ -0,0 +1,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>
);
}

View 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,
};
}

View 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;
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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[];
};

View 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};
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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;
}
}

View 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>
);
}

View 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;
}
}

View 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>
);
}

View 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;
}