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