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