@@ -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[];
|
||||
}
|
||||
351
common/resources/client/admin/channels/channel-editor/channel-content-editor.tsx
Executable file
351
common/resources/client/admin/channels/channel-editor/channel-content-editor.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
151
common/resources/client/admin/channels/channels-datatable-columns.tsx
Executable file
151
common/resources/client/admin/channels/channels-datatable-columns.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
120
common/resources/client/admin/channels/channels-datatable-page.tsx
Executable file
120
common/resources/client/admin/channels/channels-datatable-page.tsx
Executable 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" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
16
common/resources/client/admin/channels/channels-docs-link.tsx
Executable file
16
common/resources/client/admin/channels/channels-docs-link.tsx
Executable 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} />;
|
||||
}
|
||||
2
common/resources/client/admin/channels/playlist.svg
Executable file
2
common/resources/client/admin/channels/playlist.svg
Executable file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.5 KiB |
34
common/resources/client/admin/channels/requests/use-add-to-channel.ts
Executable file
34
common/resources/client/admin/channels/requests/use-add-to-channel.ts
Executable 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);
|
||||
}
|
||||
29
common/resources/client/admin/channels/requests/use-addable-content.ts
Executable file
29
common/resources/client/admin/channels/requests/use-addable-content.ts
Executable 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);
|
||||
}
|
||||
34
common/resources/client/admin/channels/requests/use-apply-channel-preset.ts
Executable file
34
common/resources/client/admin/channels/requests/use-apply-channel-preset.ts
Executable 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);
|
||||
}
|
||||
47
common/resources/client/admin/channels/requests/use-create-channel.ts
Executable file
47
common/resources/client/admin/channels/requests/use-create-channel.ts
Executable 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);
|
||||
}
|
||||
31
common/resources/client/admin/channels/requests/use-remove-from-channel.ts
Executable file
31
common/resources/client/admin/channels/requests/use-remove-from-channel.ts
Executable 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
45
common/resources/client/admin/channels/requests/use-update-channel.ts
Executable file
45
common/resources/client/admin/channels/requests/use-update-channel.ts
Executable 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);
|
||||
}
|
||||
Reference in New Issue
Block a user