1
common/resources/client/admin/custom-pages/articles.svg
Executable file
1
common/resources/client/admin/custom-pages/articles.svg
Executable file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 15 KiB |
58
common/resources/client/admin/custom-pages/create-custom-page.tsx
Executable file
58
common/resources/client/admin/custom-pages/create-custom-page.tsx
Executable file
@@ -0,0 +1,58 @@
|
||||
import React, {Suspense} from 'react';
|
||||
import {FormProvider, useForm} from 'react-hook-form';
|
||||
import {FileUploadProvider} from '@common/uploads/uploader/file-upload-provider';
|
||||
import {ArticleEditorTitle} from '@common/article-editor/article-editor-title';
|
||||
import {ArticleEditorStickyHeader} from '@common/article-editor/article-editor-sticky-header';
|
||||
import {useNavigate} from '@common/utils/hooks/use-navigate';
|
||||
import {
|
||||
CreateCustomPagePayload,
|
||||
useCreateCustomPage,
|
||||
} from '@common/admin/custom-pages/requests/use-create-custom-page';
|
||||
import {FullPageLoader} from '@common/ui/progress/full-page-loader';
|
||||
|
||||
const ArticleBodyEditor = React.lazy(
|
||||
() => import('@common/article-editor/article-body-editor'),
|
||||
);
|
||||
|
||||
export function CreateCustomPage() {
|
||||
const navigate = useNavigate();
|
||||
const createPage = useCreateCustomPage();
|
||||
const form = useForm<CreateCustomPagePayload>();
|
||||
|
||||
const handleSave = (editorContent: string) => {
|
||||
createPage.mutate(
|
||||
{
|
||||
...form.getValues(),
|
||||
body: editorContent,
|
||||
},
|
||||
{
|
||||
onSuccess: () => navigate('../', {relative: 'path'}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Suspense fallback={<FullPageLoader />}>
|
||||
<ArticleBodyEditor>
|
||||
{(content, editor) => (
|
||||
<FileUploadProvider>
|
||||
<FormProvider {...form}>
|
||||
<ArticleEditorStickyHeader
|
||||
editor={editor}
|
||||
isLoading={createPage.isPending}
|
||||
onSave={handleSave}
|
||||
backLink="../"
|
||||
/>
|
||||
<div className="mx-20">
|
||||
<div className="prose dark:prose-invert mx-auto flex-auto">
|
||||
<ArticleEditorTitle />
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</FormProvider>
|
||||
</FileUploadProvider>
|
||||
)}
|
||||
</ArticleBodyEditor>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
45
common/resources/client/admin/custom-pages/custom-page-datable-page.tsx
Executable file
45
common/resources/client/admin/custom-pages/custom-page-datable-page.tsx
Executable file
@@ -0,0 +1,45 @@
|
||||
import React, {useContext, useMemo} from 'react';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {DataTablePage} from '../../datatable/page/data-table-page';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {DataTableEmptyStateMessage} from '../../datatable/page/data-table-emty-state-message';
|
||||
import articlesSvg from './articles.svg';
|
||||
import {DataTableAddItemButton} from '../../datatable/data-table-add-item-button';
|
||||
import {CustomPageDatatableFilters} from './custom-page-datatable-filters';
|
||||
import {DeleteSelectedItemsAction} from '../../datatable/page/delete-selected-items-action';
|
||||
import {CustomPageDatatableColumns} from '@common/admin/custom-pages/custom-page-datatable-columns';
|
||||
import {SiteConfigContext} from '@common/core/settings/site-config-context';
|
||||
|
||||
export function CustomPageDatablePage() {
|
||||
const config = useContext(SiteConfigContext);
|
||||
const filters = useMemo(() => {
|
||||
return CustomPageDatatableFilters(config);
|
||||
}, [config]);
|
||||
|
||||
return (
|
||||
<DataTablePage
|
||||
endpoint="custom-pages"
|
||||
title={<Trans message="Custom pages" />}
|
||||
filters={filters}
|
||||
columns={CustomPageDatatableColumns}
|
||||
queryParams={{with: 'user'}}
|
||||
actions={<Actions />}
|
||||
selectedActions={<DeleteSelectedItemsAction />}
|
||||
emptyStateMessage={
|
||||
<DataTableEmptyStateMessage
|
||||
image={articlesSvg}
|
||||
title={<Trans message="No pages have been created yet" />}
|
||||
filteringTitle={<Trans message="No matching pages" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Actions() {
|
||||
return (
|
||||
<DataTableAddItemButton elementType={Link} to="new">
|
||||
<Trans message="New page" />
|
||||
</DataTableAddItemButton>
|
||||
);
|
||||
}
|
||||
70
common/resources/client/admin/custom-pages/custom-page-datatable-columns.tsx
Executable file
70
common/resources/client/admin/custom-pages/custom-page-datatable-columns.tsx
Executable file
@@ -0,0 +1,70 @@
|
||||
import {ColumnConfig} from '@common/datatable/column-config';
|
||||
import {CustomPage} from '@common/admin/custom-pages/custom-page';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {LinkStyle} from '@common/ui/buttons/external-link';
|
||||
import {NameWithAvatar} from '@common/datatable/column-templates/name-with-avatar';
|
||||
import {FormattedDate} from '@common/i18n/formatted-date';
|
||||
import React from 'react';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {EditIcon} from '@common/icons/material/Edit';
|
||||
|
||||
export const CustomPageDatatableColumns: ColumnConfig<CustomPage>[] = [
|
||||
{
|
||||
key: 'slug',
|
||||
allowsSorting: true,
|
||||
width: 'flex-2 min-w-200',
|
||||
visibleInMode: 'all',
|
||||
header: () => <Trans message="Slug" />,
|
||||
body: page => (
|
||||
<Link target="_blank" to={`/pages/${page.slug}`} className={LinkStyle}>
|
||||
{page.slug}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'user_id',
|
||||
allowsSorting: true,
|
||||
width: 'flex-2 min-w-140',
|
||||
header: () => <Trans message="Owner" />,
|
||||
body: page =>
|
||||
page.user && (
|
||||
<NameWithAvatar
|
||||
image={page.user.avatar}
|
||||
label={page.user.display_name}
|
||||
description={page.user.email}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
maxWidth: 'max-w-100',
|
||||
header: () => <Trans message="Type" />,
|
||||
body: page => <Trans message={page.type} />,
|
||||
},
|
||||
{
|
||||
key: 'updated_at',
|
||||
allowsSorting: true,
|
||||
width: 'w-100',
|
||||
header: () => <Trans message="Last updated" />,
|
||||
body: page => <FormattedDate date={page.updated_at} />,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: () => <Trans message="Actions" />,
|
||||
hideHeader: true,
|
||||
align: 'end',
|
||||
width: 'w-84 flex-shrink-0',
|
||||
visibleInMode: 'all',
|
||||
body: page => (
|
||||
<IconButton
|
||||
size="md"
|
||||
className="text-muted"
|
||||
elementType={Link}
|
||||
to={`${page.id}/edit`}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
];
|
||||
58
common/resources/client/admin/custom-pages/custom-page-datatable-filters.tsx
Executable file
58
common/resources/client/admin/custom-pages/custom-page-datatable-filters.tsx
Executable file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
BackendFilter,
|
||||
FilterControlType,
|
||||
FilterOperator,
|
||||
} from '../../datatable/filters/backend-filter';
|
||||
import {message} from '../../i18n/message';
|
||||
import {USER_MODEL} from '../../auth/user';
|
||||
import {SiteConfigContextValue} from '@common/core/settings/site-config-context';
|
||||
import {
|
||||
createdAtFilter,
|
||||
updatedAtFilter,
|
||||
} from '@common/datatable/filters/timestamp-filters';
|
||||
|
||||
export const CustomPageDatatableFilters = (
|
||||
config: SiteConfigContextValue
|
||||
): BackendFilter[] => {
|
||||
const dynamicFilters: BackendFilter[] =
|
||||
config.customPages.types.length > 1
|
||||
? [
|
||||
{
|
||||
control: {
|
||||
type: FilterControlType.Select,
|
||||
defaultValue: 'default',
|
||||
options: config.customPages.types.map(type => ({
|
||||
value: type.type,
|
||||
label: type.label,
|
||||
key: type.type,
|
||||
})),
|
||||
},
|
||||
|
||||
key: 'type',
|
||||
label: message('Type'),
|
||||
description: message('Type of the page'),
|
||||
defaultOperator: FilterOperator.eq,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'user_id',
|
||||
label: message('User'),
|
||||
description: message('User page was created by'),
|
||||
defaultOperator: FilterOperator.eq,
|
||||
control: {
|
||||
type: FilterControlType.SelectModel,
|
||||
model: USER_MODEL,
|
||||
},
|
||||
},
|
||||
...dynamicFilters,
|
||||
createdAtFilter({
|
||||
description: message('Date page was created'),
|
||||
}),
|
||||
updatedAtFilter({
|
||||
description: message('Date page was last updated'),
|
||||
}),
|
||||
];
|
||||
};
|
||||
16
common/resources/client/admin/custom-pages/custom-page.ts
Executable file
16
common/resources/client/admin/custom-pages/custom-page.ts
Executable file
@@ -0,0 +1,16 @@
|
||||
import {User} from '../../auth/user';
|
||||
|
||||
export interface CustomPage {
|
||||
id: number;
|
||||
title?: string;
|
||||
body: string;
|
||||
slug: string;
|
||||
type: string;
|
||||
user?: User;
|
||||
user_id?: number;
|
||||
hide_nav: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
meta?: Record<string, unknown>;
|
||||
model_type: 'customPage';
|
||||
}
|
||||
84
common/resources/client/admin/custom-pages/edit-custom-page.tsx
Executable file
84
common/resources/client/admin/custom-pages/edit-custom-page.tsx
Executable file
@@ -0,0 +1,84 @@
|
||||
import {useCustomPage} from '@common/custom-page/use-custom-page';
|
||||
import React, {Fragment, Suspense} from 'react';
|
||||
import {PageMetaTags} from '@common/http/page-meta-tags';
|
||||
import {PageStatus} from '@common/http/page-status';
|
||||
import {CustomPage} from '@common/admin/custom-pages/custom-page';
|
||||
import {FormProvider, useForm} from 'react-hook-form';
|
||||
import {useUpdateCustomPage} from '@common/admin/custom-pages/requests/use-update-custom-page';
|
||||
import {FileUploadProvider} from '@common/uploads/uploader/file-upload-provider';
|
||||
import {ArticleEditorTitle} from '@common/article-editor/article-editor-title';
|
||||
import {ArticleEditorStickyHeader} from '@common/article-editor/article-editor-sticky-header';
|
||||
import {useNavigate} from '@common/utils/hooks/use-navigate';
|
||||
import {CreateCustomPagePayload} from '@common/admin/custom-pages/requests/use-create-custom-page';
|
||||
import {FullPageLoader} from '@common/ui/progress/full-page-loader';
|
||||
|
||||
const ArticleBodyEditor = React.lazy(
|
||||
() => import('@common/article-editor/article-body-editor'),
|
||||
);
|
||||
|
||||
export function EditCustomPage() {
|
||||
const query = useCustomPage();
|
||||
|
||||
return query.data ? (
|
||||
<Fragment>
|
||||
<PageMetaTags query={query} />
|
||||
<PageContent page={query.data.page} />
|
||||
</Fragment>
|
||||
) : (
|
||||
<div className="relative w-full h-full">
|
||||
<PageStatus query={query} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageContentProps {
|
||||
page: CustomPage;
|
||||
}
|
||||
function PageContent({page}: PageContentProps) {
|
||||
const navigate = useNavigate();
|
||||
const crupdatePage = useUpdateCustomPage();
|
||||
const form = useForm<CreateCustomPagePayload>({
|
||||
defaultValues: {
|
||||
title: page.title,
|
||||
slug: page.slug,
|
||||
body: page.body,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = (editorContent: string) => {
|
||||
crupdatePage.mutate(
|
||||
{
|
||||
...form.getValues(),
|
||||
body: editorContent,
|
||||
},
|
||||
{
|
||||
onSuccess: () => navigate('../..', {relative: 'path'}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Suspense fallback={<FullPageLoader />}>
|
||||
<ArticleBodyEditor initialContent={page.body}>
|
||||
{(content, editor) => (
|
||||
<FileUploadProvider>
|
||||
<FormProvider {...form}>
|
||||
<ArticleEditorStickyHeader
|
||||
editor={editor}
|
||||
backLink="../.."
|
||||
isLoading={crupdatePage.isPending}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
<div className="mx-20">
|
||||
<div className="prose dark:prose-invert mx-auto flex-auto">
|
||||
<ArticleEditorTitle />
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</FormProvider>
|
||||
</FileUploadProvider>
|
||||
)}
|
||||
</ArticleBodyEditor>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {CustomPage} from '../custom-page';
|
||||
import {apiClient, queryClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
import {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
page: CustomPage;
|
||||
}
|
||||
|
||||
export interface CreateCustomPagePayload {
|
||||
title?: string;
|
||||
body?: string;
|
||||
slug?: string;
|
||||
hide_nav?: boolean;
|
||||
}
|
||||
|
||||
export function useCreateCustomPage(endpoint?: string) {
|
||||
const finalEndpoint = endpoint || 'custom-pages';
|
||||
return useMutation({
|
||||
mutationFn: (payload: CreateCustomPagePayload) =>
|
||||
createPage(payload, finalEndpoint),
|
||||
onError: err => showHttpErrorToast(err),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({queryKey: ['custom-pages']});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: DatatableDataQueryKey(finalEndpoint),
|
||||
});
|
||||
toast(message('Page created'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createPage(
|
||||
payload: CreateCustomPagePayload,
|
||||
endpoint: string,
|
||||
): Promise<Response> {
|
||||
return apiClient.post(`${endpoint}`, payload).then(r => r.data);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {CustomPage} from '../custom-page';
|
||||
import {apiClient, queryClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
import {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {useParams} from 'react-router-dom';
|
||||
import {CreateCustomPagePayload} from '@common/admin/custom-pages/requests/use-create-custom-page';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
page: CustomPage;
|
||||
}
|
||||
|
||||
export function useUpdateCustomPage(endpoint?: string) {
|
||||
const {pageId} = useParams();
|
||||
const finalEndpoint = `${endpoint || 'custom-pages'}/${pageId}`;
|
||||
return useMutation({
|
||||
mutationFn: (payload: CreateCustomPagePayload) =>
|
||||
updatePage(payload, finalEndpoint),
|
||||
onError: err => showHttpErrorToast(err),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({queryKey: ['custom-pages']});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: DatatableDataQueryKey(finalEndpoint),
|
||||
});
|
||||
toast(message('Page updated'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function updatePage(
|
||||
payload: CreateCustomPagePayload,
|
||||
endpoint: string,
|
||||
): Promise<Response> {
|
||||
return apiClient.put(`${endpoint}`, payload).then(r => r.data);
|
||||
}
|
||||
Reference in New Issue
Block a user