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,38 @@
import {useSettings} from '@common/core/settings/use-settings';
import {useFormContext} from 'react-hook-form';
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
import {channelContentConfig} from '@app/admin/channels/channel-content-config';
import {ContentAutoUpdateField} from '@common/admin/channels/channel-editor/controls/content-auto-update-field';
import {FormSelect, Option} from '@common/ui/forms/select/select';
import {Trans} from '@common/i18n/trans';
import React from 'react';
interface Props {
className?: string;
}
export function ChannelAutoUpdateField({className}: Props) {
const {tmdb_is_setup} = useSettings();
const {watch} = useFormContext<UpdateChannelPayload>();
const methodConfig =
channelContentConfig.autoUpdateMethods[watch('config.autoUpdateMethod')!];
return (
<ContentAutoUpdateField config={channelContentConfig} className={className}>
{!methodConfig?.provider && tmdb_is_setup && (
<FormSelect
selectionMode="single"
className="mt-24 flex-auto md:mt-0"
name="config.autoUpdateProvider"
label={<Trans message="Fetch content from" />}
required
>
<Option value="tmdb">
<Trans message="TheMovieDB" />
</Option>
<Option value="local">
<Trans message="Local database" />
</Option>
</FormSelect>
)}
</ContentAutoUpdateField>
);
}

View File

@@ -0,0 +1,254 @@
import {message} from '@common/i18n/message';
import {
MOVIE_MODEL,
SERIES_MODEL,
Title,
TITLE_MODEL,
} from '@app/titles/models/title';
import {NEWS_ARTICLE_MODEL, NewsArticle} from '@app/titles/models/news-article';
import {Channel, CHANNEL_MODEL} from '@common/channels/channel';
import {ChannelContentConfig} from '@common/admin/channels/channel-editor/channel-content-config';
import {Person, PERSON_MODEL} from '@app/titles/models/person';
import {GridViewIcon} from '@common/icons/material/GridView';
import {ViewWeekIcon} from '@common/icons/material/ViewWeek';
import {ViewListIcon} from '@common/icons/material/ViewList';
export enum Sort {
popular = 'popularity:desc',
recent = 'created_at:desc',
rating = 'rating:desc',
curated = 'channelables.order:asc',
name = 'name:asc',
birthdayDesc = 'birth_date:desc',
birthdayAsc = 'birth_date:asc',
budget = 'budget:desc',
revenue = 'revenue:desc',
}
export enum Layout {
grid = 'grid',
landscapeGrid = 'landscapeGrid',
list = 'list',
news = 'news',
carousel = 'carousel',
landscapeCarousel = 'landscapeCarousel',
slider = 'slider',
}
enum Auto {
latestVideos = 'latestVideos',
mostPopular = 'mostPopular',
topRated = 'topRated',
upcoming = 'upcoming',
nowPlaying = 'nowPlaying',
airingToday = 'airingToday',
airingThisWeek = 'airingThisWeek',
trendingPeople = 'trendingPeople',
discover = 'discover',
}
const contentModels: ChannelContentConfig['models'] = {
[MOVIE_MODEL]: {
label: message('Movies'),
sortMethods: [
Sort.popular,
Sort.recent,
Sort.rating,
Sort.budget,
Sort.revenue,
],
layoutMethods: [
Layout.grid,
Layout.landscapeGrid,
Layout.list,
Layout.carousel,
Layout.landscapeCarousel,
Layout.slider,
],
autoUpdateMethods: [
Auto.latestVideos,
Auto.mostPopular,
Auto.topRated,
Auto.upcoming,
Auto.nowPlaying,
Auto.discover,
],
},
[SERIES_MODEL]: {
label: message('TV series'),
sortMethods: [
Sort.popular,
Sort.recent,
Sort.rating,
Sort.budget,
Sort.revenue,
],
layoutMethods: [
Layout.grid,
Layout.landscapeGrid,
Layout.list,
Layout.carousel,
Layout.landscapeCarousel,
Layout.slider,
],
autoUpdateMethods: [
Auto.latestVideos,
Auto.mostPopular,
Auto.topRated,
Auto.airingThisWeek,
Auto.airingToday,
Auto.discover,
],
},
[TITLE_MODEL]: {
label: message('Titles (movies and series)'),
sortMethods: [
Sort.popular,
Sort.recent,
Sort.rating,
Sort.budget,
Sort.revenue,
],
layoutMethods: [
Layout.grid,
Layout.landscapeGrid,
Layout.list,
Layout.carousel,
Layout.landscapeCarousel,
Layout.slider,
],
autoUpdateMethods: [Auto.latestVideos],
},
[NEWS_ARTICLE_MODEL]: {
label: message('News articles'),
sortMethods: [Sort.recent],
layoutMethods: [Layout.news, Layout.landscapeCarousel, Layout.list],
},
[PERSON_MODEL]: {
label: message('People'),
sortMethods: [
Sort.popular,
Sort.recent,
Sort.name,
Sort.birthdayDesc,
Sort.birthdayAsc,
],
layoutMethods: [Layout.grid, Layout.list, Layout.carousel],
autoUpdateMethods: [Auto.trendingPeople],
},
[CHANNEL_MODEL]: {
label: message('Channels'),
sortMethods: [],
layoutMethods: [Layout.list],
},
};
const contentSortingMethods: Record<
Sort,
ChannelContentConfig['sortingMethods']['any']
> = {
[Sort.popular]: {
label: message('Most popular first'),
},
[Sort.recent]: {
label: message('Recently added first'),
},
[Sort.rating]: {
label: message('Highest rated first'),
},
[Sort.curated]: {
label: message('Curated (reorder below)'),
contentTypes: ['manual'],
},
[Sort.name]: {
label: message('Name (A-Z)'),
contentTypes: ['manual'],
},
[Sort.birthdayDesc]: {
label: message('Youngest first'),
},
[Sort.birthdayAsc]: {
label: message('Oldest first'),
},
[Sort.budget]: {
label: message('Biggest budget first'),
},
[Sort.revenue]: {
label: message('Biggest revenue first'),
},
};
const contentLayoutMethods: Record<
Layout,
ChannelContentConfig['layoutMethods']['any']
> = {
[Layout.grid]: {
label: message('Grid'),
icon: <GridViewIcon />,
},
[Layout.landscapeGrid]: {
label: message('Landscape'),
icon: <ViewWeekIcon />,
},
[Layout.list]: {
label: message('List'),
icon: <ViewListIcon />,
},
[Layout.carousel]: {
label: message('Carousel (portrait)'),
},
[Layout.landscapeCarousel]: {
label: message('Carousel (landscape)'),
},
[Layout.slider]: {
label: message('Slider'),
},
[Layout.news]: {
label: message('News'),
},
};
const contentAutoUpdateMethods: Record<
Auto,
ChannelContentConfig['autoUpdateMethods']['any']
> = {
[Auto.discover]: {
label: message('Discover (TMDB only)'),
provider: 'tmdb',
},
[Auto.mostPopular]: {
label: message('Most popular'),
},
[Auto.topRated]: {
label: message('Top rated'),
},
[Auto.upcoming]: {
label: message('Upcoming'),
},
[Auto.nowPlaying]: {
label: message('In theaters'),
},
[Auto.airingToday]: {
label: message('Airing today'),
},
[Auto.airingThisWeek]: {
label: message('Airing this week'),
},
[Auto.trendingPeople]: {
label: message('Trending people'),
},
[Auto.latestVideos]: {
label: message('Most recently published videos'),
provider: 'local',
},
};
export const channelContentConfig: ChannelContentConfig = {
models: contentModels,
sortingMethods: contentSortingMethods,
layoutMethods: contentLayoutMethods,
autoUpdateMethods: contentAutoUpdateMethods,
userSelectableLayouts: [Layout.grid, Layout.landscapeGrid, Layout.list],
};
export type ChannelContentModel = (Title | NewsArticle | Person | Channel) & {
channelable_id?: number;
channelable_order?: number;
};

View File

@@ -0,0 +1,24 @@
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
import {useImageSrc} from '@app/images/use-image-src';
import clsx from 'clsx';
import {ImageIcon} from '@common/icons/material/Image';
interface Props {
item: NormalizedModel;
}
export function ChannelContentItemImage({item}: Props) {
const src = useImageSrc(item.image, {size: 'sm'});
const imageClassName = clsx(
'aspect-square w-40 rounded object-cover',
!src ? 'flex items-center justify-center' : 'block',
);
return src ? (
<img className={imageClassName} src={src} alt="" />
) : (
<span className={imageClassName}>
<ImageIcon className="max-w-[60%] text-divider" size="text-6xl" />
</span>
);
}

View File

@@ -0,0 +1,140 @@
import {Trans} from '@common/i18n/trans';
import {Item} from '@common/ui/forms/listbox/item';
import {FormSelect} from '@common/ui/forms/select/select';
import React, {Fragment, useState} from 'react';
import {GENRE_MODEL} from '@app/titles/models/genre';
import {KEYWORD_MODEL} from '@app/titles/models/keyword';
import {PRODUCTION_COUNTRY_MODEL} from '@app/titles/models/production-country';
import {useValueLists} from '@common/http/value-lists';
import {message} from '@common/i18n/message';
import {useTrans} from '@common/i18n/use-trans';
import {useFormContext} from 'react-hook-form';
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
import {MOVIE_MODEL, SERIES_MODEL, TITLE_MODEL} from '@app/titles/models/title';
import {InfoDialogTrigger} from '@common/ui/overlays/dialog/info-dialog-trigger/info-dialog-trigger';
import clsx from 'clsx';
import {ChannelsDocsLink} from '@common/admin/channels/channels-docs-link';
const supportedModels = [TITLE_MODEL, MOVIE_MODEL, SERIES_MODEL];
const restrictions = {
[GENRE_MODEL]: message('Genre'),
[KEYWORD_MODEL]: message('Keyword'),
[PRODUCTION_COUNTRY_MODEL]: message('Production country'),
};
interface Props {
className?: string;
}
export function ChannelRestrictionField({className}: Props) {
const {setValue} = useFormContext<UpdateChannelPayload>();
const {watch} = useFormContext<UpdateChannelPayload>();
if (!supportedModels.includes(watch('config.contentModel'))) {
return null;
}
return (
<div className={clsx('items-end gap-14 md:flex', className)}>
<FormSelect
className="w-full flex-auto"
name="config.restriction"
selectionMode="single"
label={
<Fragment>
<Trans message="Filter titles by" />
<InfoTrigger />
</Fragment>
}
onSelectionChange={() => {
setValue('config.restrictionModelId', 'urlParam');
}}
>
<Item value={null}>
<Trans message="Don't filter titles" />
</Item>
{Object.entries(restrictions).map(([value, label]) => (
<Item key={value} value={value}>
<Trans {...label} />
</Item>
))}
</FormSelect>
<RestrictionModelField />
</div>
);
}
function RestrictionModelField() {
const {trans} = useTrans();
const [searchValue, setSearchValue] = useState('');
const {watch} = useFormContext<UpdateChannelPayload>();
const {data} = useValueLists(['genres', 'productionCountries'], {
type: watch('config.autoUpdateProvider'),
});
const selectedRestriction = watch(
'config.restriction',
) as keyof typeof restrictions;
const selectedKeywordId = watch('config.restrictionModelId');
const keywordQuery = useValueLists(['keywords'], {
searchQuery: searchValue,
selectedValue: selectedKeywordId,
type: watch('config.autoUpdateProvider'),
});
if (!selectedRestriction) return null;
const options = {
[GENRE_MODEL]: data?.genres,
[KEYWORD_MODEL]: keywordQuery.data?.keywords,
[PRODUCTION_COUNTRY_MODEL]: data?.productionCountries,
};
const restrictionLabel = restrictions[selectedRestriction];
// allow setting keyword to custom value, because there are too many keywords
// to put into autocomplete, ideally it would use async search from backend though
return (
<FormSelect
className="w-full flex-auto"
name="config.restrictionModelId"
selectionMode="single"
showSearchField
searchPlaceholder={trans(message('Search...'))}
isAsync={selectedRestriction === KEYWORD_MODEL}
isLoading={
selectedRestriction === KEYWORD_MODEL && keywordQuery.isLoading
}
inputValue={searchValue}
onInputValueChange={setSearchValue}
label={
<Trans
message=":restriction name"
values={{restriction: trans(restrictionLabel)}}
/>
}
>
<Item value="urlParam">
<Trans message="Dynamic (from url)" />
</Item>
{options[selectedRestriction]?.map(option => (
<Item key={option.value} value={option.value}>
<Trans message={option.name} />
</Item>
))}
</FormSelect>
);
}
function InfoTrigger() {
return (
<InfoDialogTrigger
body={
<Fragment>
<Trans message="Allows specifying additional condition channel content should be filtered on. " />
<ChannelsDocsLink className="mt-20" hash="filter-titles-by" />
</Fragment>
}
/>
);
}

View File

@@ -0,0 +1,26 @@
import React, {Fragment} from 'react';
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import {Trans} from '@common/i18n/trans';
import {useTrans} from '@common/i18n/use-trans';
import {message} from '@common/i18n/message';
export function ChannelSeoFields() {
const {trans} = useTrans();
return (
<Fragment>
<FormTextField
name="config.seoTitle"
label={<Trans message="SEO title" />}
className="mb-24"
placeholder={trans(message('Optional'))}
/>
<FormTextField
name="config.seoDescription"
label={<Trans message="SEO description" />}
inputElementType="textarea"
rows={6}
placeholder={trans(message('Optional'))}
/>
</Fragment>
);
}

View File

@@ -0,0 +1,98 @@
import React, {ReactElement} from 'react';
import {CreateChannelPageLayout} from '@common/admin/channels/channel-editor/create-channel-page-layout';
import {MOVIE_MODEL} from '@app/titles/models/title';
import {Trans} from '@common/i18n/trans';
import {ChannelNameField} from '@common/admin/channels/channel-editor/controls/channel-name-field';
import {FormSwitch} from '@common/ui/forms/toggle/switch';
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import {ContentTypeField} from '@common/admin/channels/channel-editor/controls/content-type-field';
import {channelContentConfig} from '@app/admin/channels/channel-content-config';
import {ContentModelField} from '@common/admin/channels/channel-editor/controls/content-model-field';
import {ChannelRestrictionField} from '@app/admin/channels/channel-restriction-field';
import {ContentOrderField} from '@common/admin/channels/channel-editor/controls/content-order-field';
import {ContentLayoutFields} from '@common/admin/channels/channel-editor/controls/content-layout-fields';
import {ChannelPaginationTypeField} from '@common/admin/channels/channel-editor/controls/channel-pagination-type-field';
import {ChannelAutoUpdateField} from '@app/admin/channels/channel-auto-update-field';
import {ChannelSeoFields} from '@app/admin/channels/channel-seo-fields';
import clsx from 'clsx';
import {Tabs} from '@common/ui/tabs/tabs';
import {TabList} from '@common/ui/tabs/tab-list';
import {Tab} from '@common/ui/tabs/tab';
import {TabPanel, TabPanels} from '@common/ui/tabs/tab-panels';
export function CreateChannelPage() {
return (
<CreateChannelPageLayout
defaultValues={{
contentModel: MOVIE_MODEL,
autoUpdateProvider: 'local',
layout: 'grid',
nestedLayout: 'carousel',
paginationType: 'infiniteScroll',
}}
>
<Tabs>
<TabList>
<Tab>
<Trans message="Settings" />
</Tab>
<Tab>
<Trans message="SEO" />
</Tab>
</TabList>
<TabPanels className="pt-24">
<TabPanel>
<ChannelNameField />
<FormSwitch
className="mt-24"
name="config.hideTitle"
description={
<Trans message="Whether title should be shown when displaying this channel on the site." />
}
>
<Trans message="Hide title" />
</FormSwitch>
<FormTextField
name="description"
label={<Trans message="Description" />}
inputElementType="textarea"
rows={2}
className="my-24"
/>
<ContentTypeField config={channelContentConfig} className="mb-24" />
<ChannelAutoUpdateField className="mb-24" />
<ContentModelField
config={channelContentConfig}
className="mb-24"
/>
<ChannelRestrictionField className="mb-24" />
<ContentOrderField config={channelContentConfig} />
<ContentLayoutFields
config={channelContentConfig}
className="my-24"
/>
<ChannelPaginationTypeField
config={channelContentConfig}
className="mb-24"
/>
</TabPanel>
<TabPanel>
<ChannelSeoFields />
</TabPanel>
</TabPanels>
</Tabs>
</CreateChannelPageLayout>
);
}
interface TitleProps {
children: ReactElement;
className?: string;
}
function Title({children, className}: TitleProps) {
return (
<h2 className={clsx('mb-20 mt-20 border-t pt-20 text-2xl', className)}>
{children}
</h2>
);
}

View File

@@ -0,0 +1,115 @@
import {EditChannelPageLayout} from '@common/admin/channels/channel-editor/edit-channel-page-layout';
import React, {Fragment} from 'react';
import {Accordion, AccordionItem} from '@common/ui/accordion/accordion';
import {Trans} from '@common/i18n/trans';
import {DescriptionIcon} from '@common/icons/material/Description';
import {ChannelNameField} from '@common/admin/channels/channel-editor/controls/channel-name-field';
import {FormSwitch} from '@common/ui/forms/toggle/switch';
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import {InfoDialogTrigger} from '@common/ui/overlays/dialog/info-dialog-trigger/info-dialog-trigger';
import {SettingsIcon} from '@common/icons/material/Settings';
import {ContentTypeField} from '@common/admin/channels/channel-editor/controls/content-type-field';
import {channelContentConfig} from '@app/admin/channels/channel-content-config';
import {ChannelAutoUpdateField} from '@app/admin/channels/channel-auto-update-field';
import {ContentModelField} from '@common/admin/channels/channel-editor/controls/content-model-field';
import {ChannelRestrictionField} from '@app/admin/channels/channel-restriction-field';
import {ContentOrderField} from '@common/admin/channels/channel-editor/controls/content-order-field';
import {DashboardIcon} from '@common/icons/material/Dashboard';
import {ContentLayoutFields} from '@common/admin/channels/channel-editor/controls/content-layout-fields';
import {ChannelPaginationTypeField} from '@common/admin/channels/channel-editor/controls/channel-pagination-type-field';
import {PublicIcon} from '@common/icons/material/Public';
import {ChannelSeoFields} from '@app/admin/channels/channel-seo-fields';
import {ChannelContentEditor} from '@common/admin/channels/channel-editor/channel-content-editor';
import {
ChannelContentSearchField,
ChannelContentSearchFieldProps,
} from '@common/admin/channels/channel-editor/channel-content-search-field';
import {ChannelContentItemImage} from '@app/admin/channels/channel-content-item-image';
export function EditChannelPage() {
return (
<EditChannelPageLayout>
<Fragment>
<Accordion variant="outline">
<AccordionItem
label={<Trans message="Title & description" />}
startIcon={<DescriptionIcon />}
>
<ChannelNameField />
<FormSwitch
className="mt-24"
name="config.hideTitle"
description={
<Trans message="Whether title should be shown when displaying this channel on the site." />
}
>
<Trans message="Hide title" />
</FormSwitch>
<FormTextField
name="description"
label={<Trans message="Description" />}
inputElementType="textarea"
rows={1}
className="mt-24"
/>
<FormTextField
name="config.adminDescription"
label={
<Fragment>
<Trans message="Internal description" />
<InfoDialogTrigger
body={
<Trans message="This describes the purpose of the channel and is only visible in admin area." />
}
/>
</Fragment>
}
inputElementType="textarea"
rows={1}
className="mt-24"
/>
</AccordionItem>
<AccordionItem
label={<Trans message="Content settings" />}
startIcon={<SettingsIcon />}
>
<ContentTypeField config={channelContentConfig} className="mb-24" />
<ChannelAutoUpdateField className="mb-24" />
<ContentModelField
config={channelContentConfig}
className="mb-24"
/>
<ChannelRestrictionField className="mb-24" />
<ContentOrderField config={channelContentConfig} />
</AccordionItem>
<AccordionItem
label={<Trans message="Layout" />}
startIcon={<DashboardIcon />}
>
<ContentLayoutFields
config={channelContentConfig}
className="mb-24"
/>
<ChannelPaginationTypeField config={channelContentConfig} />
</AccordionItem>
<AccordionItem
label={<Trans message="SEO" />}
startIcon={<PublicIcon />}
>
<ChannelSeoFields />
</AccordionItem>
</Accordion>
<ChannelContentEditor searchField={<SearchField />} />
</Fragment>
</EditChannelPageLayout>
);
}
function SearchField(props: ChannelContentSearchFieldProps) {
return (
<ChannelContentSearchField
{...props}
imgRenderer={item => <ChannelContentItemImage item={item} />}
/>
);
}