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,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>
);
}

View 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>
);
}

View 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()},
);
}}
/>
);
}

View 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'),
}),
];

View 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>
);
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}