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