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,42 @@
import {MessageDescriptor} from '@common/i18n/message-descriptor';
import {Channel} from '@common/channels/channel';
import {ReactElement} from 'react';
import {SvgIconProps} from '@common/icons/svg-icon';
export interface ChannelContentConfig {
models: Record<
string,
{
label: MessageDescriptor;
sortMethods: string[];
layoutMethods: string[];
autoUpdateMethods?: string[];
}
>;
sortingMethods: Record<
string,
{
label: MessageDescriptor;
contentTypes?: Channel['config']['contentType'][];
}
>;
layoutMethods: Record<
string,
{
label: MessageDescriptor;
icon?: ReactElement<SvgIconProps>;
}
>;
autoUpdateMethods: Record<
string,
{
label: MessageDescriptor;
provider?: string;
value?: {
label: MessageDescriptor;
inputType: 'text' | 'number';
};
}
>;
userSelectableLayouts: string[];
}

View File

@@ -0,0 +1,351 @@
import {useFormContext} from 'react-hook-form';
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
import {Trans} from '@common/i18n/trans';
import {Table} from '@common/ui/tables/table';
import {RowElementProps} from '@common/ui/tables/table-row';
import {useIsTouchDevice} from '@common/utils/hooks/is-touch-device';
import React, {
cloneElement,
ReactElement,
ReactNode,
useContext,
useRef,
useState,
} from 'react';
import {TableContext} from '@common/ui/tables/table-context';
import {DragPreviewRenderer} from '@common/ui/interactions/dnd/use-draggable';
import {
DropPosition,
useSortable,
} from '@common/ui/interactions/dnd/sortable/use-sortable';
import clsx from 'clsx';
import {mergeProps} from '@react-aria/utils';
import {ColumnConfig} from '@common/datatable/column-config';
import {DragHandleIcon} from '@common/icons/material/DragHandle';
import {NameWithAvatar} from '@common/datatable/column-templates/name-with-avatar';
import {IconButton} from '@common/ui/buttons/icon-button';
import {CloseIcon} from '@common/icons/material/Close';
import {DragPreview} from '@common/ui/interactions/dnd/drag-preview';
import {WarningIcon} from '@common/icons/material/Warning';
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
import playlist from '../playlist.svg';
import {SvgImage} from '@common/ui/images/svg-image/svg-image';
import {Link, useParams} from 'react-router-dom';
import {Button} from '@common/ui/buttons/button';
import {RefreshIcon} from '@common/icons/material/Refresh';
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
import {useUpdateChannelContent} from '@common/admin/channels/requests/use-update-channel-content';
import {ChannelContentSearchFieldProps} from '@common/admin/channels/channel-editor/channel-content-search-field';
import {useChannelContent} from '@common/channels/requests/use-channel-content';
import {PaginationControls} from '@common/ui/navigation/pagination-controls';
import {queryClient} from '@common/http/query-client';
import {PaginationResponse} from '@common/http/backend-response/pagination-response';
import {moveItemInNewArray} from '@common/utils/array/move-item-in-new-array';
import {useReorderChannelContent} from '@common/admin/channels/requests/use-reorder-channel-content';
import {useAddToChannel} from '@common/admin/channels/requests/use-add-to-channel';
import {useRemoveFromChannel} from '@common/admin/channels/requests/use-remove-from-channel';
import {ChannelContentItem} from '@common/channels/channel';
const columnConfig: ColumnConfig<NormalizedModel>[] = [
{
key: 'dragHandle',
width: 'w-42 flex-shrink-0',
header: () => <Trans message="Drag handle" />,
hideHeader: true,
body: () => (
<DragHandleIcon className="cursor-pointer text-muted hover:text" />
),
},
{
key: 'name',
header: () => <Trans message="Content item" />,
visibleInMode: 'all',
body: item => {
return (
<NameWithAvatar
image={item.image}
label={
item.model_type === 'channel' ? (
<Link
className="hover:underline"
to={`/admin/channels/${item.id}/edit`}
target="_blank"
>
{item.name}
</Link>
) : (
item.name
)
}
description={item.description}
/>
);
},
},
{
key: 'type',
header: () => <Trans message="Content type" />,
width: 'w-100 flex-shrink-0',
body: item => <span className="capitalize">{item.model_type}</span>,
},
{
key: 'actions',
header: () => <Trans message="Actions" />,
hideHeader: true,
align: 'end',
width: 'w-42 flex-shrink-0',
visibleInMode: 'all',
body: item => <RemoveItemColumn item={item} />,
},
];
interface Props {
searchField: ReactElement<ChannelContentSearchFieldProps>;
title?: ReactNode;
noResultsMessage?: ReactNode;
}
export function ChannelContentEditor({
searchField,
title,
noResultsMessage,
}: Props) {
const {watch, getValues} = useFormContext<UpdateChannelPayload>();
const channel = getValues();
const contentType = watch('config.contentType');
const addToChannel = useAddToChannel();
const query = useChannelContent<ChannelContentItem<NormalizedModel>>(
channel,
{loader: 'editChannelPage', paginate: 'simple'},
{paginate: true},
);
const pagination = query.data!;
// only show delete and drag buttons when channel content is managed manually
const filteredColumns = columnConfig.filter(col => {
return !(
contentType !== 'manual' &&
(col.key === 'actions' || col.key === 'dragHandle')
);
});
return (
<div className="mt-40">
<div className="mb-40">
<h2 className="mb-10 text-2xl">
{title || <Trans message="Channel content" />}
</h2>
<ContentNotEditableWarning />
<UpdateContentButton />
{contentType === 'manual'
? cloneElement<ChannelContentSearchFieldProps>(searchField, {
onResultSelected: result => {
addToChannel.mutate({
channelId: channel.id,
item: result,
});
},
})
: null}
</div>
<PaginationControls
pagination={query.data}
type="simple"
className="mb-24"
/>
<Table
className="mt-24"
columns={filteredColumns}
data={pagination?.data || []}
meta={query.queryKey}
renderRowAs={contentType === 'manual' ? ContentTableRow : undefined}
enableSelection={false}
hideHeaderRow
/>
<PaginationControls
pagination={query.data}
type="simple"
className="mt-24"
scrollToTop
/>
{!pagination.data?.length && contentType === 'manual'
? noResultsMessage || (
<IllustratedMessage
title={<Trans message="Channel is empty" />}
description={
<Trans message="No content is attached to this channel yet." />
}
image={<SvgImage src={playlist} />}
/>
)
: null}
</div>
);
}
function ContentTableRow({
item,
children,
className,
...domProps
}: RowElementProps<NormalizedModel>) {
const isTouchDevice = useIsTouchDevice();
const {data, meta} = useContext(TableContext);
const {getValues} = useFormContext<UpdateChannelPayload>();
const domRef = useRef<HTMLTableRowElement>(null);
const reorderContent = useReorderChannelContent();
const previewRef = useRef<DragPreviewRenderer>(null);
const [dropPosition, setDropPosition] = useState<DropPosition>(null);
const {sortableProps} = useSortable({
ref: domRef,
disabled: isTouchDevice ?? false,
item,
items: data,
type: 'channelContentItem',
preview: previewRef,
strategy: 'line',
onDropPositionChange: position => {
setDropPosition(position);
},
onSortEnd: (oldIndex, newIndex) => {
// do optimistic reorder
const newPagination = queryClient.setQueryData<
PaginationResponse<unknown>
>(meta, pagination => {
if (pagination) {
pagination = {
...pagination,
data: moveItemInNewArray(pagination.data, oldIndex, newIndex),
};
}
return pagination;
});
// reorder on backend
if (newPagination) {
reorderContent.mutate({
channelId: getValues('id'),
modelType: item.model_type,
ids: newPagination.data.map(item => (item as NormalizedModel).id),
});
}
},
});
return (
<div
className={clsx(
className,
dropPosition === 'before' && 'sort-preview-before',
dropPosition === 'after' && 'sort-preview-after',
)}
ref={domRef}
{...mergeProps(sortableProps, domProps)}
>
{children}
{!item.isPlaceholder && <RowDragPreview item={item} ref={previewRef} />}
</div>
);
}
interface RowDragPreviewProps {
item: NormalizedModel;
}
const RowDragPreview = React.forwardRef<
DragPreviewRenderer,
RowDragPreviewProps
>(({item}, ref) => {
return (
<DragPreview ref={ref}>
{() => (
<div className="rounded bg-chip p-8 text-base shadow">{item.name}</div>
)}
</DragPreview>
);
});
interface RemoveItemColumnProps {
item: NormalizedModel;
}
function RemoveItemColumn({item}: RemoveItemColumnProps) {
const removeFromChannel = useRemoveFromChannel();
const {getValues} = useFormContext<UpdateChannelPayload>();
return (
<IconButton
size="md"
className="text-muted"
disabled={removeFromChannel.isPending}
onClick={() => {
removeFromChannel.mutate({
channelId: getValues('id'),
item: item,
});
}}
>
<CloseIcon />
</IconButton>
);
}
function ContentNotEditableWarning() {
const {watch} = useFormContext<UpdateChannelPayload>();
const contentType = watch('config.contentType');
if (contentType === 'manual') {
return null;
}
return (
<div className="mb-20 mt-4 flex items-center gap-8">
<WarningIcon size="xs" />
<div className="text-xs text-muted">
{contentType === 'listAll' ? (
<Trans message="This channel is listing all available content of specified type, and can't be curated manually." />
) : null}
{contentType === 'autoUpdate' ? (
<Trans message="This channel content is set to update automatically and can't be curated manually." />
) : null}
</div>
</div>
);
}
function UpdateContentButton() {
const {slugOrId} = useParams();
const updateContent = useUpdateChannelContent(slugOrId!);
const {setValue, watch, getValues} = useFormContext<UpdateChannelPayload>();
if (watch('config.contentType') !== 'autoUpdate') {
return null;
}
return (
<Button
size="xs"
variant="outline"
color="primary"
startIcon={<RefreshIcon />}
onClick={() => {
updateContent.mutate(
{
channelConfig: (getValues as any)('config'),
},
{
onSuccess: response => {
if (response.channel.content) {
(setValue as any)('content', response.channel.content);
}
},
},
);
}}
disabled={
updateContent.isPending ||
!watch('config.autoUpdateMethod') ||
!watch('id')
}
>
<Trans message="Update content now" />
</Button>
);
}

View File

@@ -0,0 +1,58 @@
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
import {useFormContext} from 'react-hook-form';
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
import {useTrans} from '@common/i18n/use-trans';
import React, {useState} from 'react';
import {useAddableContent} from '@common/admin/channels/requests/use-addable-content';
import {ComboBox} from '@common/ui/forms/combobox/combobox';
import {message} from '@common/i18n/message';
import {SearchIcon} from '@common/icons/material/Search';
import {Item} from '@common/ui/forms/listbox/item';
export interface ChannelContentSearchFieldProps {
onResultSelected?: (result: NormalizedModel) => void;
imgRenderer?: (result: NormalizedModel) => React.ReactNode;
}
export function ChannelContentSearchField({
onResultSelected,
imgRenderer,
}: ChannelContentSearchFieldProps) {
const {watch} = useFormContext<UpdateChannelPayload>();
const contentModel = watch('config.contentModel');
const {trans} = useTrans();
const [query, setQuery] = useState('');
const {isFetching, data} = useAddableContent({
query,
modelType: contentModel,
limit: 20,
});
return (
<ComboBox
isAsync
placeholder={trans(message('Search for content to add...'))}
isLoading={isFetching}
inputValue={query}
onInputValueChange={setQuery}
clearInputOnItemSelection
blurReferenceOnItemSelection
selectionMode="none"
openMenuOnFocus
floatingMaxHeight={670}
startAdornment={<SearchIcon />}
hideEndAdornment
>
{data?.results.map(result => (
<Item
key={result.id}
value={result.id}
onSelected={() => onResultSelected?.(result)}
startIcon={imgRenderer ? imgRenderer(result) : null}
description={result.description}
textLabel={result.name}
>
{result.name}
</Item>
))}
</ComboBox>
);
}

View File

@@ -0,0 +1,49 @@
import {TabList} from '@common/ui/tabs/tab-list';
import {Tab} from '@common/ui/tabs/tab';
import {Trans} from '@common/i18n/trans';
import {TabPanel, TabPanels} from '@common/ui/tabs/tab-panels';
import {Tabs} from '@common/ui/tabs/tabs';
import React, {Fragment, ReactNode} from 'react';
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
interface Props {
children: ReactNode;
}
export function ChannelEditorTabs({children}: Props) {
return (
<Tabs isLazy>
<TabList>
<Tab>
<Trans message="Details" />
</Tab>
<Tab>
<Trans message="SEO" />
</Tab>
</TabList>
<TabPanels className="pt-20">
<TabPanel>{children}</TabPanel>
<TabPanel>
<SeoFields />
</TabPanel>
</TabPanels>
</Tabs>
);
}
function SeoFields() {
return (
<Fragment>
<FormTextField
name="config.seoTitle"
label={<Trans message="SEO title" />}
className="mb-24"
/>
<FormTextField
name="config.seoDescription"
label={<Trans message="SEO description" />}
inputElementType="textarea"
rows={6}
/>
</Fragment>
);
}

View File

@@ -0,0 +1,57 @@
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import {Trans} from '@common/i18n/trans';
import React, {Fragment} from 'react';
import {useFormContext} from 'react-hook-form';
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
import {SlugEditor} from '@common/ui/slug-editor';
import {useTrans} from '@common/i18n/use-trans';
import {message} from '@common/i18n/message';
import clsx from 'clsx';
interface Props {
className?: string;
autoFocus?: boolean;
}
export function ChannelNameField({className, autoFocus}: Props) {
return (
<Fragment>
<FormTextField
name="name"
label={<Trans message="Title" />}
required
autoFocus={autoFocus}
className={clsx('mb-10', className)}
/>
<FormSlugField />
</Fragment>
);
}
function FormSlugField() {
const {watch, setValue} = useFormContext<UpdateChannelPayload>();
const value = watch('slug');
const name = watch('name');
const disableSlugEditing = watch('config.lockSlug');
const restriction = watch('config.restriction');
const restrictionId = watch('config.restrictionModelId');
const {trans} = useTrans();
return (
<SlugEditor
hideButton={disableSlugEditing}
placeholder={name}
suffix={
restriction && restrictionId === 'urlParam'
? trans(message(':restriction_name', {values: {restriction}}))
: undefined
}
className="text-sm"
pattern="[A-Za-z0-9_-]+"
minLength={3}
maxLength={20}
value={value}
onChange={newSlug => {
setValue('slug', newSlug);
}}
/>
);
}

View File

@@ -0,0 +1,28 @@
import {FormSelect, Option} from '@common/ui/forms/select/select';
import {Trans} from '@common/i18n/trans';
import {ChannelContentConfig} from '@common/admin/channels/channel-editor/channel-content-config';
interface Props {
config: ChannelContentConfig;
className?: string;
}
export function ChannelPaginationTypeField({className}: Props) {
return (
<FormSelect
className={className}
selectionMode="single"
name="config.paginationType"
label={<Trans message="Pagination type" />}
>
<Option value="infiniteScroll">
<Trans message="Infinite scroll" />
</Option>
<Option value="lengthAware">
<Trans message="List of page buttons" />
</Option>
<Option value="simple">
<Trans message="Next/previous page buttons only" />
</Option>
</FormSelect>
);
}

View File

@@ -0,0 +1,79 @@
import {useFormContext} from 'react-hook-form';
import {FormSelect, Option} from '@common/ui/forms/select/select';
import {Trans} from '@common/i18n/trans';
import {InfoDialogTrigger} from '@common/ui/overlays/dialog/info-dialog-trigger/info-dialog-trigger';
import {Fragment, ReactNode} from 'react';
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
import {ChannelContentConfig} from '@common/admin/channels/channel-editor/channel-content-config';
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import clsx from 'clsx';
import {ChannelsDocsLink} from '@common/admin/channels/channels-docs-link';
interface Props {
children?: ReactNode;
config: ChannelContentConfig;
className?: string;
}
export function ContentAutoUpdateField({children, config, className}: Props) {
const {watch, setValue} = useFormContext<UpdateChannelPayload>();
const modelConfig = config.models[watch('config.contentModel')];
const selectedMethodConfig =
config.autoUpdateMethods[watch('config.autoUpdateMethod')!];
if (
watch('config.contentType') !== 'autoUpdate' ||
!modelConfig.autoUpdateMethods?.length
) {
return null;
}
return (
<div className={clsx('items-end gap-14 md:flex', className)}>
<FormSelect
required
className="flex-auto"
selectionMode="single"
name="config.autoUpdateMethod"
onSelectionChange={value => {
if (config.autoUpdateMethods[value].provider) {
setValue(
'config.autoUpdateProvider',
config.autoUpdateMethods[value].provider,
);
}
}}
label={
<Fragment>
<Trans message="Auto update method" />
<InfoDialogTrigger
body={
<Fragment>
<div className="mb-20">
<Trans message="This option will automatically update channel content every 24 hours using the specified method." />
</div>
<ChannelsDocsLink hash="automatically-update-content-with-specified-method" />
</Fragment>
}
/>
</Fragment>
}
>
{modelConfig.autoUpdateMethods.map(method => (
<Option value={method} key={method}>
<Trans {...config.autoUpdateMethods[method].label} />
</Option>
))}
</FormSelect>
{selectedMethodConfig?.value ? (
<FormTextField
name="config.autoUpdateValue"
required
className="flex-auto"
label={<Trans {...selectedMethodConfig?.value.label} />}
type={selectedMethodConfig?.value.inputType}
/>
) : null}
{children}
</div>
);
}

View File

@@ -0,0 +1,60 @@
import {useFormContext} from 'react-hook-form';
import {FormSelect, Option} from '@common/ui/forms/select/select';
import {Trans} from '@common/i18n/trans';
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
import {ReactNode} from 'react';
import {ChannelContentConfig} from '@common/admin/channels/channel-editor/channel-content-config';
import clsx from 'clsx';
interface Props {
config: ChannelContentConfig;
className?: string;
}
export function ContentLayoutFields({config, className}: Props) {
return (
<div className={clsx('items-end gap-14 md:flex', className)}>
<LayoutField
config={config}
name="config.layout"
label={<Trans message="Layout" />}
/>
<LayoutField
config={config}
name="config.nestedLayout"
label={<Trans message="Layout when nested" />}
/>
</div>
);
}
interface LayoutFieldProps extends Props {
name: string;
label: ReactNode;
}
function LayoutField({config, name, label}: LayoutFieldProps) {
const {watch} = useFormContext<UpdateChannelPayload>();
const contentModel = watch('config.contentModel');
const modelConfig = config.models[contentModel];
if (!modelConfig.layoutMethods?.length) {
return null;
}
return (
<FormSelect
className="w-full flex-auto"
selectionMode="single"
name={name}
label={label}
>
{modelConfig.layoutMethods.map(method => {
const label = config.layoutMethods[method].label;
return (
<Option key={method} value={method}>
<Trans {...label} />
</Option>
);
})}
</FormSelect>
);
}

View File

@@ -0,0 +1,46 @@
import {useFormContext} from 'react-hook-form';
import {FormSelect, Option} from '@common/ui/forms/select/select';
import {Trans} from '@common/i18n/trans';
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
import React from 'react';
import {ChannelContentConfig} from '@common/admin/channels/channel-editor/channel-content-config';
interface Props {
config: ChannelContentConfig;
className?: string;
exclude?: string[];
}
export function ContentModelField({config, className, exclude}: Props) {
const {setValue, getValues} = useFormContext<UpdateChannelPayload>();
return (
<FormSelect
className={className}
selectionMode="single"
name="config.contentModel"
label={<Trans message="Type of content" />}
onSelectionChange={newValue => {
const modelConfig = config.models[newValue];
if (
getValues('config.contentType') === 'autoUpdate' &&
!modelConfig.autoUpdateMethods?.length
) {
(setValue as any)('config.contentType', 'manual');
}
setValue('config.autoUpdateMethod', modelConfig.autoUpdateMethods?.[0]);
setValue(
'config.contentOrder',
modelConfig.sortMethods[0] || 'channelables.order:asc',
);
setValue('config.layout', modelConfig.layoutMethods[0]);
}}
>
{Object.entries(config.models)
.filter(([model]) => !exclude?.includes(model))
.map(([model, {label}]) => (
<Option value={model} key={model}>
<Trans {...label} />
</Option>
))}
</FormSelect>
);
}

View File

@@ -0,0 +1,39 @@
import {useFormContext} from 'react-hook-form';
import {FormSelect, Option} from '@common/ui/forms/select/select';
import {Trans} from '@common/i18n/trans';
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
import {ChannelContentConfig} from '@common/admin/channels/channel-editor/channel-content-config';
interface Props {
config: ChannelContentConfig;
className?: string;
}
export function ContentOrderField({config, className}: Props) {
const {watch} = useFormContext<UpdateChannelPayload>();
const contentType = watch('config.contentType');
const modelConfig = config.models[watch('config.contentModel')];
const sortMethods = [...modelConfig.sortMethods, 'channelables.order:asc'];
return (
<FormSelect
className={className}
selectionMode="single"
name="config.contentOrder"
label={<Trans message="How to order content" />}
>
{sortMethods.map(method => {
const sortConfig = config.sortingMethods[method];
if (
!sortConfig.contentTypes ||
sortConfig.contentTypes.includes(contentType)
) {
return (
<Option value={method} key={method}>
<Trans {...sortConfig.label} />
</Option>
);
}
})}
</FormSelect>
);
}

View File

@@ -0,0 +1,54 @@
import {useFormContext} from 'react-hook-form';
import {FormSelect, Option} from '@common/ui/forms/select/select';
import {Trans} from '@common/i18n/trans';
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
import {ChannelContentConfig} from '@common/admin/channels/channel-editor/channel-content-config';
interface Props {
config: ChannelContentConfig;
className?: string;
}
export function ContentTypeField({config, className}: Props) {
const {setValue} = useFormContext<UpdateChannelPayload>();
return (
<FormSelect
className={className}
selectionMode="single"
name="config.contentType"
label={<Trans message="Content" />}
onSelectionChange={newValue => {
// if content type is "auto update" select first model that
// can be auto updated, otherwise select first available model
let model = Object.entries(config.models)[0];
if (newValue === 'autoUpdate') {
const newModel = Object.entries(config.models).find(
([, modelConfig]) => modelConfig.autoUpdateMethods?.length,
);
if (newModel) {
model = newModel;
}
}
const [modelName, modelConfig] = model;
setValue('config.contentModel', modelName);
setValue('config.restrictionModelId', undefined);
setValue(
'config.autoUpdateMethod',
newValue === 'autoUpdate' ? modelConfig.autoUpdateMethods?.[0] : '',
);
setValue('config.contentOrder', modelConfig.sortMethods[0]);
(setValue as any)('config.restriction', null);
}}
>
<Option value="listAll">
<Trans message="List all content of specified type" />
</Option>
<Option value="manual">
<Trans message="Manage content manually" />
</Option>
<Option value="autoUpdate">
<Trans message="Automatically update content with specified method" />
</Option>
</FormSelect>
);
}

View File

@@ -0,0 +1,39 @@
import {useForm} from 'react-hook-form';
import React, {ReactNode} from 'react';
import {CrupdateResourceLayout} from '@common/admin/crupdate-resource-layout';
import {Trans} from '@common/i18n/trans';
import {EMPTY_PAGINATION_RESPONSE} from '@common/http/backend-response/pagination-response';
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
import {useCreateChannel} from '@common/admin/channels/requests/use-create-channel';
interface Props {
defaultValues?: Partial<UpdateChannelPayload['config']>;
children: ReactNode;
}
export function CreateChannelPageLayout({defaultValues, children}: Props) {
const form = useForm<UpdateChannelPayload>({
defaultValues: {
content: EMPTY_PAGINATION_RESPONSE.pagination,
config: {
contentType: 'listAll',
contentOrder: 'created_at:desc',
nestedLayout: 'carousel',
...defaultValues,
},
},
});
const createChannel = useCreateChannel(form);
return (
<CrupdateResourceLayout
form={form}
onSubmit={values => {
createChannel.mutate(values);
}}
title={<Trans message="Add new channel" />}
isLoading={createChannel.isPending}
>
{children}
</CrupdateResourceLayout>
);
}

View File

@@ -0,0 +1,51 @@
import {useForm} from 'react-hook-form';
import React, {ReactNode} from 'react';
import {CrupdateResourceLayout} from '@common/admin/crupdate-resource-layout';
import {Trans} from '@common/i18n/trans';
import {PageStatus} from '@common/http/page-status';
import {useChannel} from '@common/channels/requests/use-channel';
import {Channel} from '@common/channels/channel';
import {
UpdateChannelPayload,
useUpdateChannel,
} from '@common/admin/channels/requests/use-update-channel';
interface Props {
children: ReactNode;
}
export function EditChannelPageLayout({children}: Props) {
const query = useChannel(undefined, 'editChannelPage');
if (query.data) {
return <PageContent channel={query.data.channel}>{children}</PageContent>;
}
return <PageStatus query={query} loaderIsScreen={false} />;
}
interface PageContentProps {
channel: Channel;
children: ReactNode;
}
function PageContent({channel, children}: PageContentProps) {
const form = useForm<UpdateChannelPayload>({
// @ts-ignore
defaultValues: {
...channel,
},
});
const updateChannel = useUpdateChannel(form);
return (
<CrupdateResourceLayout
form={form}
onSubmit={values => {
updateChannel.mutate(values);
}}
title={
<Trans message="Edit “:name“ channel" values={{name: channel.name}} />
}
isLoading={updateChannel.isPending}
>
{children}
</CrupdateResourceLayout>
);
}

View File

@@ -0,0 +1,151 @@
import {ColumnConfig} from '@common/datatable/column-config';
import {Trans} from '@common/i18n/trans';
import {FormattedDate} from '@common/i18n/formatted-date';
import {Link} from 'react-router-dom';
import {IconButton} from '@common/ui/buttons/icon-button';
import {EditIcon} from '@common/icons/material/Edit';
import React from 'react';
import {Channel} from '@common/channels/channel';
import {Chip} from '@common/ui/forms/input-field/chip-field/chip';
import {Tooltip} from '@common/ui/tooltip/tooltip';
import {useSettings} from '@common/core/settings/use-settings';
import {HomeIcon} from '@common/icons/material/Home';
export const ChannelsDatatableColumns: ColumnConfig<Channel>[] = [
{
key: 'name',
allowsSorting: true,
width: 'flex-3',
visibleInMode: 'all',
header: () => <Trans message="Name" />,
body: channel => {
return (
<div>
<div className="overflow-hidden overflow-ellipsis whitespace-nowrap font-medium">
<ChannelName channel={channel} />
</div>
{channel.config.adminDescription && (
<p className="max-w-680 whitespace-normal text-xs text-muted">
{channel.config.adminDescription}
</p>
)}
</div>
);
},
},
{
key: 'content',
allowsSorting: false,
header: () => <Trans message="Content" />,
body: channel => <ContentType channel={channel} />,
},
{
key: 'content_type',
allowsSorting: false,
header: () => <Trans message="Content type" />,
body: channel => (
<span className="capitalize">
{channel.config.contentModel ? (
<Trans message={channel.config.contentModel} />
) : undefined}
</span>
),
},
{
key: 'internal',
allowsSorting: true,
maxWidth: 'max-w-100',
hideHeader: true,
header: () => <Trans message="Internal" />,
body: channel => <InternalColumn channel={channel} />,
},
{
key: 'updated_at',
allowsSorting: true,
maxWidth: 'max-w-100',
header: () => <Trans message="Last updated" />,
body: channel =>
channel.updated_at ? <FormattedDate date={channel.updated_at} /> : '',
},
{
key: 'actions',
header: () => <Trans message="Actions" />,
hideHeader: true,
visibleInMode: 'all',
align: 'end',
width: 'w-42 flex-shrink-0',
body: channel => (
<Link to={`${channel.id}/edit`} className="text-muted">
<IconButton size="md">
<EditIcon />
</IconButton>
</Link>
),
},
];
interface ContentTypeProps {
channel: Channel;
}
function ContentType({channel}: ContentTypeProps) {
switch (channel.config.contentType) {
case 'listAll':
return <Trans message="List all" />;
case 'manual':
return <Trans message="Managed manually" />;
case 'autoUpdate':
return <Trans message="Updated automatically" />;
}
}
interface ChannelNameProps {
channel: Channel;
}
function ChannelName({channel}: ChannelNameProps) {
// link will not work without specific genre name in channel url
if (
channel.config.restriction &&
channel.config.restrictionModelId === 'urlParam'
) {
return channel.name;
}
return (
<a
className="outline-none hover:underline focus-visible:underline"
href={`channel/${channel.slug}`}
target="_blank"
rel="noreferrer"
>
{channel.name}
</a>
);
}
function InternalColumn({channel}: ChannelNameProps) {
const {homepage} = useSettings();
const internalLabel = channel.internal ? (
<Tooltip
label={
<Trans message="This channel is required for some site functionality to work properly and can't be deleted." />
}
>
<div>
<Chip className="w-max" size="xs" radius="rounded-panel">
<Trans message="Internal" />
</Chip>
</div>
</Tooltip>
) : (
''
);
const isHomepage =
homepage?.type === 'channels' && `${homepage.value}` === `${channel.id}`;
return (
<div className="flex items-center gap-6">
{internalLabel}
{isHomepage ? <HomeIcon className="text-muted" size="sm" /> : null}
</div>
);
}

View File

@@ -0,0 +1,120 @@
import React, {Fragment} from 'react';
import {Trans} from '@common/i18n/trans';
import {DataTableEmptyStateMessage} from '@common/datatable/page/data-table-emty-state-message';
import playlist from './playlist.svg';
import {DataTableAddItemButton} from '@common/datatable/data-table-add-item-button';
import {InfoDialogTrigger} from '@common/ui/overlays/dialog/info-dialog-trigger/info-dialog-trigger';
import {Link} from 'react-router-dom';
import {ChannelsDatatableColumns} from '@common/admin/channels/channels-datatable-columns';
import {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';
import {useApplyChannelPreset} from '@common/admin/channels/requests/use-apply-channel-preset';
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
import {DataTablePage} from '@common/datatable/page/data-table-page';
import {DeleteSelectedItemsAction} from '@common/datatable/page/delete-selected-items-action';
import {useDataTable} from '@common/datatable/page/data-table-context';
import {Channel} from '@common/channels/channel';
import {Menu, MenuTrigger} from '@common/ui/navigation/menu/menu-trigger';
import {Button} from '@common/ui/buttons/button';
import {Item} from '@common/ui/forms/listbox/item';
import {KeyboardArrowDownIcon} from '@common/icons/material/KeyboardArrowDown';
import {openDialog} from '@common/ui/overlays/store/dialog-store';
import {ChannelsDocsLink} from '@common/admin/channels/channels-docs-link';
interface ChannelPresetConfig {
preset: string;
name: string;
description: string;
}
export function ChannelsDatatablePage() {
return (
<DataTablePage
endpoint="channel"
title={<Trans message="Channels" />}
headerContent={<InfoTrigger />}
headerItemsAlign="items-center"
queryParams={{type: 'channel'}}
columns={ChannelsDatatableColumns}
actions={<Actions />}
selectedActions={<DeleteSelectedItemsAction />}
cellHeight="h-52"
emptyStateMessage={
<DataTableEmptyStateMessage
image={playlist}
title={<Trans message="No channels have been created yet" />}
filteringTitle={<Trans message="No matching channels" />}
/>
}
/>
);
}
function InfoTrigger() {
return (
<InfoDialogTrigger
body={
<Fragment>
<Trans message="Channels are used to create pages that show various content on the site." />
<ChannelsDocsLink className="mt-14" />
</Fragment>
}
/>
);
}
function Actions() {
const {query} = useDataTable<Channel, {presets: ChannelPresetConfig[]}>();
return (
<Fragment>
<MenuTrigger
onItemSelected={preset => openDialog(ApplyPresetDialog, {preset})}
>
<Button
variant="outline"
color="primary"
size="sm"
endIcon={<KeyboardArrowDownIcon />}
disabled={!query.data?.presets.length}
>
<Trans message="Apply preset" />
</Button>
<Menu>
{query.data?.presets.map(preset => (
<Item
key={preset.preset}
value={preset.preset}
description={<Trans message={preset.description} />}
>
<Trans message={preset.name} />
</Item>
))}
</Menu>
</MenuTrigger>
<DataTableAddItemButton elementType={Link} to="new">
<Trans message="Add new channel" />
</DataTableAddItemButton>
</Fragment>
);
}
interface ApplyPresetDialogProps {
preset: string;
}
function ApplyPresetDialog({preset}: ApplyPresetDialogProps) {
const {close} = useDialogContext();
const resetChannels = useApplyChannelPreset();
return (
<ConfirmationDialog
isLoading={resetChannels.isPending}
onConfirm={() => {
resetChannels.mutate({preset}, {onSuccess: () => close()});
}}
isDanger
title={<Trans message="Apply preset" />}
body={
<Trans message="Are you sure you want to apply this channel preset? This will delete all current channels and leave only channels from the selected preset." />
}
confirm={<Trans message="Apply" />}
/>
);
}

View File

@@ -0,0 +1,16 @@
import {LearnMoreLink} from '@common/admin/settings/learn-more-link';
import {useContext} from 'react';
import {SiteConfigContext} from '@common/core/settings/site-config-context';
interface Props {
className?: string;
hash?: string;
}
export function ChannelsDocsLink({className, hash}: Props) {
const {admin} = useContext(SiteConfigContext);
if (!admin?.channelsDocsLink) return null;
const link = hash
? `${admin.channelsDocsLink}#${hash}`
: admin.channelsDocsLink;
return <LearnMoreLink link={link} className={className} />;
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@@ -0,0 +1,34 @@
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {useMutation} from '@tanstack/react-query';
import {apiClient, queryClient} from '@common/http/query-client';
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
import {channelQueryKey} from '@common/channels/requests/use-channel';
interface Response extends BackendResponse {}
interface Payload {
channelId: number | string;
item: NormalizedModel;
}
export function useAddToChannel() {
return useMutation({
mutationFn: (payload: Payload) => addToChannel(payload),
onSuccess: async (_, payload) => {
await queryClient.invalidateQueries({
queryKey: channelQueryKey(payload.channelId),
});
},
onError: r => showHttpErrorToast(r),
});
}
function addToChannel({channelId, item}: Payload): Promise<Response> {
return apiClient
.post(`channel/${channelId}/add`, {
itemId: item.id,
itemType: item.model_type,
})
.then(r => r.data);
}

View File

@@ -0,0 +1,29 @@
import {keepPreviousData, useQuery} from '@tanstack/react-query';
import {apiClient} from '@common/http/query-client';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
export interface SearchResponse extends BackendResponse {
results: NormalizedModel[];
}
interface SearchParams {
query?: string;
limit?: number;
modelType: string;
}
export function useAddableContent(params: SearchParams) {
return useQuery({
queryKey: ['search', params],
queryFn: () => search(params),
//enabled: !!params.query,
placeholderData: params.query ? keepPreviousData : undefined,
});
}
function search(params: SearchParams) {
return apiClient
.get<SearchResponse>(`channel/search-for-addable-content`, {params})
.then(response => response.data);
}

View File

@@ -0,0 +1,34 @@
import {useMutation} from '@tanstack/react-query';
import {useTrans} from '@common/i18n/use-trans';
import {toast} from '@common/ui/toast/toast';
import {message} from '@common/i18n/message';
import {apiClient, queryClient} from '@common/http/query-client';
import {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
interface Response extends BackendResponse {}
interface Payload {
preset: string;
}
export function useApplyChannelPreset() {
const {trans} = useTrans();
return useMutation({
mutationFn: (payload: Payload) => resetChannels(payload),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: DatatableDataQueryKey('channel'),
});
toast(trans(message('Channel preset applied')));
},
onError: err => showHttpErrorToast(err),
});
}
function resetChannels(payload: Payload) {
return apiClient
.post<Response>('channel/apply-preset', payload)
.then(r => r.data);
}

View File

@@ -0,0 +1,47 @@
import {useMutation, useQueryClient} from '@tanstack/react-query';
import {UseFormReturn} from 'react-hook-form';
import {apiClient} from '@common/http/query-client';
import {toast} from '@common/ui/toast/toast';
import {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';
import {useTrans} from '@common/i18n/use-trans';
import {onFormQueryError} from '@common/errors/on-form-query-error';
import {message} from '@common/i18n/message';
import {useNavigate} from '@common/utils/hooks/use-navigate';
import {PaginationResponse} from '@common/http/backend-response/pagination-response';
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {Channel} from '@common/channels/channel';
const endpoint = 'channel';
interface Response extends BackendResponse {
channel: Channel;
}
export interface CreateChannelPayload
extends Omit<Channel, 'content' | 'items'> {
content: PaginationResponse<NormalizedModel>;
}
export function useCreateChannel(form: UseFormReturn<CreateChannelPayload>) {
const {trans} = useTrans();
const navigate = useNavigate();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: CreateChannelPayload) => createChannel(payload),
onSuccess: async response => {
await queryClient.invalidateQueries({
queryKey: DatatableDataQueryKey(endpoint),
});
toast(trans(message('Channel created')));
navigate(`/admin/channels/${response.channel.id}/edit`, {
replace: true,
});
},
onError: err => onFormQueryError(err, form),
});
}
function createChannel(payload: CreateChannelPayload) {
return apiClient.post<Response>(endpoint, payload).then(r => r.data);
}

View File

@@ -0,0 +1,31 @@
import {useMutation} from '@tanstack/react-query';
import {apiClient, queryClient} from '@common/http/query-client';
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
import {channelQueryKey} from '@common/channels/requests/use-channel';
interface Payload {
channelId: number | string;
item: NormalizedModel;
}
export function useRemoveFromChannel() {
return useMutation({
mutationFn: (payload: Payload) => removeFromChannel(payload),
onSuccess: async (_, payload) => {
await queryClient.invalidateQueries({
queryKey: channelQueryKey(payload.channelId),
});
},
onError: r => showHttpErrorToast(r),
});
}
function removeFromChannel({channelId, item}: Payload) {
return apiClient
.post(`channel/${channelId}/remove`, {
itemId: item.id,
itemType: item.model_type,
})
.then(r => r.data);
}

View File

@@ -0,0 +1,32 @@
import {useMutation} from '@tanstack/react-query';
import {apiClient} from '@common/http/query-client';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
import {Channel} from '@common/channels/channel';
interface Response extends BackendResponse {
channel: Channel<NormalizedModel>;
}
interface Payload {
channelId: number | string;
modelType: string;
ids: (number | string)[];
}
export function useReorderChannelContent() {
return useMutation({
mutationFn: (payload: Payload) => reorderContent(payload),
onError: err => showHttpErrorToast(err),
});
}
function reorderContent({channelId, ids, modelType}: Payload) {
return apiClient
.post<Response>(`channel/${channelId}/reorder-content`, {
modelType,
ids,
})
.then(r => r.data);
}

View File

@@ -0,0 +1,41 @@
import {useMutation} from '@tanstack/react-query';
import {useTrans} from '@common/i18n/use-trans';
import {toast} from '@common/ui/toast/toast';
import {message} from '@common/i18n/message';
import {apiClient, queryClient} from '@common/http/query-client';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
import {Channel, ChannelConfig} from '@common/channels/channel';
import {channelQueryKey} from '@common/channels/requests/use-channel';
interface Response extends BackendResponse {
channel: Channel<NormalizedModel>;
}
interface Payload {
channelConfig?: Partial<ChannelConfig>;
}
export function useUpdateChannelContent(channelId: number | string) {
const {trans} = useTrans();
return useMutation({
mutationFn: (payload: Payload) => updateChannel(channelId, payload),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: channelQueryKey(channelId),
});
toast(trans(message('Channel content updated')));
},
onError: err => showHttpErrorToast(err),
});
}
function updateChannel(channelId: number | string, payload: Payload) {
return apiClient
.post<Response>(`channel/${channelId}/update-content`, {
...payload,
normalizeContent: true,
})
.then(r => r.data);
}

View File

@@ -0,0 +1,45 @@
import {useMutation} from '@tanstack/react-query';
import {useTrans} from '@common/i18n/use-trans';
import {useNavigate} from '@common/utils/hooks/use-navigate';
import {toast} from '@common/ui/toast/toast';
import {message} from '@common/i18n/message';
import {apiClient, queryClient} from '@common/http/query-client';
import {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';
import {onFormQueryError} from '@common/errors/on-form-query-error';
import {UseFormReturn} from 'react-hook-form';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {Channel} from '@common/channels/channel';
import {CreateChannelPayload} from '@common/admin/channels/requests/use-create-channel';
interface Response extends BackendResponse {
channel: Channel;
}
export interface UpdateChannelPayload extends CreateChannelPayload {
id: number;
}
const Endpoint = (id: number) => `channel/${id}`;
export function useUpdateChannel(form: UseFormReturn<UpdateChannelPayload>) {
const {trans} = useTrans();
const navigate = useNavigate();
return useMutation({
mutationFn: (payload: UpdateChannelPayload) => updateChannel(payload),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: DatatableDataQueryKey('channel'),
});
toast(trans(message('Channel updated')));
navigate('/admin/channels');
},
onError: err => onFormQueryError(err, form),
});
}
function updateChannel({
id,
...payload
}: UpdateChannelPayload): Promise<Response> {
return apiClient.put(Endpoint(id), payload).then(r => r.data);
}