66
resources/client/admin/news/create-news-article-page.tsx
Executable file
66
resources/client/admin/news/create-news-article-page.tsx
Executable file
@@ -0,0 +1,66 @@
|
||||
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 {FullPageLoader} from '@common/ui/progress/full-page-loader';
|
||||
import {
|
||||
CreateNewsArticlePayload,
|
||||
useCreatNewsArticle,
|
||||
} from '@app/admin/news/requests/use-create-news-article';
|
||||
import {FormImageSelector} from '@common/ui/images/image-selector';
|
||||
|
||||
const ArticleBodyEditor = React.lazy(
|
||||
() => import('@common/article-editor/article-body-editor'),
|
||||
);
|
||||
|
||||
export function CreateNewsArticlePage() {
|
||||
const navigate = useNavigate();
|
||||
const createArticle = useCreatNewsArticle();
|
||||
const form = useForm<CreateNewsArticlePayload>({});
|
||||
|
||||
const handleSave = (editorContent: string) => {
|
||||
createArticle.mutate(
|
||||
{
|
||||
...form.getValues(),
|
||||
body: editorContent,
|
||||
},
|
||||
{
|
||||
onSuccess: () => navigate('..', {relative: 'path'}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Suspense fallback={<FullPageLoader />}>
|
||||
<ArticleBodyEditor>
|
||||
{(content, editor) => (
|
||||
<FileUploadProvider>
|
||||
<FormProvider {...form}>
|
||||
<ArticleEditorStickyHeader
|
||||
editor={editor}
|
||||
backLink=".."
|
||||
isLoading={createArticle.isPending}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
<div className="mx-20">
|
||||
<FormImageSelector
|
||||
className="mx-auto mb-32 max-w-[655px]"
|
||||
showEditButtonOnHover
|
||||
variant="square"
|
||||
name="image"
|
||||
diskPrefix="news_images"
|
||||
/>
|
||||
<div className="prose mx-auto flex-auto dark:prose-invert">
|
||||
<ArticleEditorTitle />
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</FormProvider>
|
||||
</FileUploadProvider>
|
||||
)}
|
||||
</ArticleBodyEditor>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
94
resources/client/admin/news/edit-news-article-page.tsx
Executable file
94
resources/client/admin/news/edit-news-article-page.tsx
Executable file
@@ -0,0 +1,94 @@
|
||||
import React, {Fragment, Suspense} from 'react';
|
||||
import {PageMetaTags} from '@common/http/page-meta-tags';
|
||||
import {PageStatus} from '@common/http/page-status';
|
||||
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 {FullPageLoader} from '@common/ui/progress/full-page-loader';
|
||||
import {useNewsArticle} from '@app/admin/news/requests/use-news-article';
|
||||
import {NewsArticle} from '@app/titles/models/news-article';
|
||||
import {useUpdateNewsArticle} from '@app/admin/news/requests/use-update-news-article';
|
||||
import {CreateNewsArticlePayload} from '@app/admin/news/requests/use-create-news-article';
|
||||
import {FormImageSelector} from '@common/ui/images/image-selector';
|
||||
|
||||
const ArticleBodyEditor = React.lazy(
|
||||
() => import('@common/article-editor/article-body-editor'),
|
||||
);
|
||||
|
||||
export function EditNewsArticlePage() {
|
||||
const query = useNewsArticle('newsArticlePage');
|
||||
|
||||
return query.data ? (
|
||||
<Fragment>
|
||||
<PageMetaTags query={query} />
|
||||
<PageContent article={query.data.article} />
|
||||
</Fragment>
|
||||
) : (
|
||||
<div className="relative h-full w-full">
|
||||
<PageStatus query={query} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageContentProps {
|
||||
article: NewsArticle;
|
||||
}
|
||||
function PageContent({article}: PageContentProps) {
|
||||
const navigate = useNavigate();
|
||||
const updateArticle = useUpdateNewsArticle();
|
||||
const form = useForm<CreateNewsArticlePayload>({
|
||||
defaultValues: {
|
||||
title: article.title,
|
||||
slug: article.slug,
|
||||
body: article.body,
|
||||
image: article.image,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = (editorContent: string) => {
|
||||
updateArticle.mutate(
|
||||
{
|
||||
...form.getValues(),
|
||||
body: editorContent,
|
||||
},
|
||||
{
|
||||
onSuccess: () => navigate('../..', {relative: 'path'}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Suspense fallback={<FullPageLoader />}>
|
||||
<ArticleBodyEditor initialContent={article.body}>
|
||||
{(content, editor) => (
|
||||
<FileUploadProvider>
|
||||
<FormProvider {...form}>
|
||||
<ArticleEditorStickyHeader
|
||||
editor={editor}
|
||||
backLink="../.."
|
||||
slugPrefix="news"
|
||||
isLoading={updateArticle.isPending}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
<div className="mx-20">
|
||||
<FormImageSelector
|
||||
className="mx-auto mb-32 max-w-[655px]"
|
||||
showEditButtonOnHover
|
||||
variant="square"
|
||||
name="image"
|
||||
diskPrefix="news_images"
|
||||
/>
|
||||
<div className="prose mx-auto flex-auto dark:prose-invert">
|
||||
<ArticleEditorTitle />
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</FormProvider>
|
||||
</FileUploadProvider>
|
||||
)}
|
||||
</ArticleBodyEditor>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
117
resources/client/admin/news/news-datatable-columns.tsx
Executable file
117
resources/client/admin/news/news-datatable-columns.tsx
Executable file
@@ -0,0 +1,117 @@
|
||||
import {ColumnConfig} from '@common/datatable/column-config';
|
||||
import {NewsArticle} from '@app/titles/models/news-article';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {FormattedDate} from '@common/i18n/formatted-date';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {EditIcon} from '@common/icons/material/Edit';
|
||||
import {useContext} from 'react';
|
||||
import {TableContext} from '@common/ui/tables/table-context';
|
||||
import clsx from 'clsx';
|
||||
import {useDeleteNewsArticle} from '@app/admin/news/requests/use-delete-news-article';
|
||||
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
|
||||
import {DeleteIcon} from '@common/icons/material/Delete';
|
||||
import {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';
|
||||
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
|
||||
import {NewsArticleLink} from '@app/news/news-article-link';
|
||||
import {NewsArticleImage} from '@app/news/news-article-image';
|
||||
|
||||
export const newsDatatableColumns: ColumnConfig<NewsArticle>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
width: 'flex-3 min-w-200',
|
||||
visibleInMode: 'all',
|
||||
header: () => <Trans message="Title" />,
|
||||
body: article => <ArticleColumn article={article} />,
|
||||
},
|
||||
{
|
||||
key: 'updatedAt',
|
||||
allowsSorting: true,
|
||||
width: 'w-96',
|
||||
header: () => <Trans message="Last updated" />,
|
||||
body: article => (
|
||||
<time>
|
||||
<FormattedDate date={article.updated_at} />
|
||||
</time>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: () => <Trans message="Actions" />,
|
||||
width: 'w-84 flex-shrink-0',
|
||||
hideHeader: true,
|
||||
align: 'end',
|
||||
visibleInMode: 'all',
|
||||
body: article => (
|
||||
<div className="text-muted">
|
||||
<Link to={`${article.id}/edit`}>
|
||||
<Tooltip label={<Trans message="Edit article" />}>
|
||||
<IconButton size="md">
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Link>
|
||||
<DialogTrigger type="modal">
|
||||
<Tooltip label={<Trans message="Delete article" />}>
|
||||
<IconButton>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<DeleteArticleDialog article={article} />
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
interface ArticleColumnProps {
|
||||
article: NewsArticle;
|
||||
}
|
||||
function ArticleColumn({article}: ArticleColumnProps) {
|
||||
const {isCollapsedMode} = useContext(TableContext);
|
||||
return (
|
||||
<div className="flex gap-14">
|
||||
<NewsArticleImage article={article} size="w-52 h-52" lazy={false} />
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className={clsx(
|
||||
isCollapsedMode
|
||||
? 'whitespace-normal'
|
||||
: 'font-medium whitespace-nowrap overflow-hidden overflow-ellipsis',
|
||||
)}
|
||||
>
|
||||
<NewsArticleLink article={article} target="_blank" />
|
||||
</div>
|
||||
{!isCollapsedMode && (
|
||||
<p className="text-muted mt-4 text-xs max-w-680 whitespace-normal">
|
||||
{article.body}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeleteArticleDialogProps {
|
||||
article: NewsArticle;
|
||||
}
|
||||
export function DeleteArticleDialog({article}: DeleteArticleDialogProps) {
|
||||
const deleteArticle = useDeleteNewsArticle();
|
||||
const {close} = useDialogContext();
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
isDanger
|
||||
isLoading={deleteArticle.isPending}
|
||||
title={<Trans message="Delete article" />}
|
||||
body={<Trans message="Are you sure you want to delete this article?" />}
|
||||
confirm={<Trans message="Delete" />}
|
||||
onConfirm={() => {
|
||||
deleteArticle.mutate(
|
||||
{articleId: article.id},
|
||||
{onSuccess: () => close()},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
15
resources/client/admin/news/news-datatable-filters.ts
Executable file
15
resources/client/admin/news/news-datatable-filters.ts
Executable file
@@ -0,0 +1,15 @@
|
||||
import {BackendFilter} from '@common/datatable/filters/backend-filter';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {
|
||||
createdAtFilter,
|
||||
updatedAtFilter,
|
||||
} from '@common/datatable/filters/timestamp-filters';
|
||||
|
||||
export const NewsDatatableFilters: BackendFilter[] = [
|
||||
createdAtFilter({
|
||||
description: message('Date article was created'),
|
||||
}),
|
||||
updatedAtFilter({
|
||||
description: message('Date article was last updated'),
|
||||
}),
|
||||
];
|
||||
62
resources/client/admin/news/news-datatable-page.tsx
Executable file
62
resources/client/admin/news/news-datatable-page.tsx
Executable file
@@ -0,0 +1,62 @@
|
||||
import {Fragment} from 'react';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {DataTablePage} from '@common/datatable/page/data-table-page';
|
||||
import {NewsDatatableFilters} from '@app/admin/news/news-datatable-filters';
|
||||
import {DataTableAddItemButton} from '@common/datatable/data-table-add-item-button';
|
||||
import {DataTableEmptyStateMessage} from '@common/datatable/page/data-table-emty-state-message';
|
||||
import {DeleteSelectedItemsAction} from '@common/datatable/page/delete-selected-items-action';
|
||||
import onlineArticlesImg from '@app/admin/news/online-articles.svg';
|
||||
import {newsDatatableColumns} from '@app/admin/news/news-datatable-columns';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {PublishIcon} from '@common/icons/material/Publish';
|
||||
import {useImportNewsArticles} from '@app/admin/news/requests/use-import-news-articles';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
|
||||
export function NewsDatatablePage() {
|
||||
return (
|
||||
<DataTablePage
|
||||
endpoint="news"
|
||||
title={<Trans message="News articles" />}
|
||||
filters={NewsDatatableFilters}
|
||||
columns={newsDatatableColumns}
|
||||
queryParams={{
|
||||
stripHtml: 'true',
|
||||
truncateBody: 200,
|
||||
}}
|
||||
actions={<Actions />}
|
||||
selectedActions={<DeleteSelectedItemsAction />}
|
||||
enableSelection={false}
|
||||
cellHeight="h-80"
|
||||
emptyStateMessage={
|
||||
<DataTableEmptyStateMessage
|
||||
image={onlineArticlesImg}
|
||||
title={<Trans message="No articles have been created yet" />}
|
||||
filteringTitle={<Trans message="No matching articles" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Actions() {
|
||||
const importArticles = useImportNewsArticles();
|
||||
return (
|
||||
<Fragment>
|
||||
<Tooltip label={<Trans message="Import news articles" />}>
|
||||
<IconButton
|
||||
variant="outline"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={() => importArticles.mutate()}
|
||||
disabled={importArticles.isPending}
|
||||
>
|
||||
<PublishIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<DataTableAddItemButton elementType={Link} to="add">
|
||||
<Trans message="Add news article" />
|
||||
</DataTableAddItemButton>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
1
resources/client/admin/news/online-articles.svg
Executable file
1
resources/client/admin/news/online-articles.svg
Executable file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 14 KiB |
33
resources/client/admin/news/requests/use-create-news-article.ts
Executable file
33
resources/client/admin/news/requests/use-create-news-article.ts
Executable file
@@ -0,0 +1,33 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {apiClient, queryClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {NewsArticle} from '@app/titles/models/news-article';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
article: NewsArticle;
|
||||
}
|
||||
|
||||
export interface CreateNewsArticlePayload {
|
||||
title?: string;
|
||||
body?: string;
|
||||
slug?: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
export function useCreatNewsArticle() {
|
||||
return useMutation({
|
||||
mutationFn: (payload: CreateNewsArticlePayload) => createArticle(payload),
|
||||
onError: err => showHttpErrorToast(err),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({queryKey: ['news']});
|
||||
toast(message('Article created'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createArticle(payload: CreateNewsArticlePayload): Promise<Response> {
|
||||
return apiClient.post(`news`, payload).then(r => r.data);
|
||||
}
|
||||
30
resources/client/admin/news/requests/use-delete-news-article.ts
Executable file
30
resources/client/admin/news/requests/use-delete-news-article.ts
Executable file
@@ -0,0 +1,30 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {apiClient, queryClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
interface Payload {
|
||||
articleId: number;
|
||||
}
|
||||
|
||||
export function useDeleteNewsArticle() {
|
||||
return useMutation({
|
||||
mutationFn: (payload: Payload) => deleteArticle(payload),
|
||||
onError: err => showHttpErrorToast(err),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: DatatableDataQueryKey('news'),
|
||||
});
|
||||
toast(message('Article deleted'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function deleteArticle(payload: Payload): Promise<Response> {
|
||||
return apiClient.delete(`news/${payload.articleId}`).then(r => r.data);
|
||||
}
|
||||
23
resources/client/admin/news/requests/use-import-news-articles.ts
Executable file
23
resources/client/admin/news/requests/use-import-news-articles.ts
Executable file
@@ -0,0 +1,23 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {apiClient, queryClient} from '@common/http/query-client';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
export function useImportNewsArticles() {
|
||||
return useMutation({
|
||||
mutationFn: () => importArticles(),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({queryKey: ['news']});
|
||||
toast(message('Imported news articles'));
|
||||
},
|
||||
onError: r => showHttpErrorToast(r),
|
||||
});
|
||||
}
|
||||
|
||||
function importArticles(): Promise<Response> {
|
||||
return apiClient.post(`news/import-from-remote-provider`).then(r => r.data);
|
||||
}
|
||||
32
resources/client/admin/news/requests/use-news-article.ts
Executable file
32
resources/client/admin/news/requests/use-news-article.ts
Executable file
@@ -0,0 +1,32 @@
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {NewsArticle} from '@app/titles/models/news-article';
|
||||
import {useParams} from 'react-router-dom';
|
||||
import {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';
|
||||
|
||||
export interface GetNewsArticleResponse extends BackendResponse {
|
||||
article: NewsArticle;
|
||||
related: NewsArticle[];
|
||||
}
|
||||
|
||||
export function useNewsArticle(loader: 'newsArticlePage') {
|
||||
const {articleId} = useParams();
|
||||
return useQuery<GetNewsArticleResponse>({
|
||||
queryKey: ['news-articles', `${articleId}`],
|
||||
queryFn: () => fetchNewsArticle(articleId!),
|
||||
initialData: () => {
|
||||
const data = getBootstrapData().loaders?.[loader];
|
||||
if (data?.article?.id == articleId) {
|
||||
return data;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function fetchNewsArticle(articleId: string) {
|
||||
return apiClient
|
||||
.get<GetNewsArticleResponse>(`news/${articleId}`)
|
||||
.then(response => response.data);
|
||||
}
|
||||
33
resources/client/admin/news/requests/use-update-news-article.ts
Executable file
33
resources/client/admin/news/requests/use-update-news-article.ts
Executable file
@@ -0,0 +1,33 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {apiClient, queryClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {NewsArticle} from '@app/titles/models/news-article';
|
||||
import {CreateNewsArticlePayload} from '@app/admin/news/requests/use-create-news-article';
|
||||
import {useParams} from 'react-router-dom';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
article: NewsArticle;
|
||||
}
|
||||
|
||||
export function useUpdateNewsArticle() {
|
||||
const {articleId} = useParams();
|
||||
return useMutation({
|
||||
mutationFn: (payload: CreateNewsArticlePayload) =>
|
||||
updateArticle(articleId!, payload),
|
||||
onError: err => showHttpErrorToast(err),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({queryKey: ['news']});
|
||||
toast(message('Article updated'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function updateArticle(
|
||||
articleId: string,
|
||||
payload: CreateNewsArticlePayload,
|
||||
): Promise<Response> {
|
||||
return apiClient.put(`news/${articleId}`, payload).then(r => r.data);
|
||||
}
|
||||
Reference in New Issue
Block a user