Files
clinicpro/assets/admin/pages/RepresentationBlogsPage.tsx
T
hamed 6876135a53 feat: add BlogBodySanitizer for HTML sanitization on article save
- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks.
- Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content.
- Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
2026-08-07 21:13:38 +03:30

88 lines
3.6 KiB
TypeScript

import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import type { Blog } from '../types';
import { formatDate } from '../lib/utils';
import PageHeader from '../components/ui/PageHeader';
import DataTable, { Column } from '../components/ui/DataTable';
import Pagination from '../components/ui/Pagination';
import ConfirmDialog from '../components/ui/ConfirmDialog';
const limit = 15;
const STATUS_LABEL: Record<string, string> = { draft: 'پیش‌نویس', published: 'منتشر شده', archived: 'آرشیو' };
export default function RepresentationBlogsPage() {
const navigate = useNavigate();
const qc = useQueryClient();
const [page, setPage] = useState(1);
const [deleteTarget, setDeleteTarget] = useState<Blog | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['rep-blogs', page],
queryFn: () => {
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
return api.get<PaginatedResponse<Blog>>(`/api/v1/representation/blogs?${params}`);
},
});
const items = data?.data ?? [];
const total = data?.meta?.totalRecords ?? 0;
const deleteMutation = useMutation({
mutationFn: (b: Blog) => api.delete<ApiResponse<null>>(`/api/v1/representation/blog/${b.uuid}`),
onSuccess: () => {
toast.success('مقاله حذف شد');
setDeleteTarget(null);
qc.invalidateQueries({ queryKey: ['rep-blogs'] });
},
onError: (err: Error) => toast.error(err.message),
});
const columns: Column<Blog>[] = [
{ key: 'title', header: 'عنوان', render: (b) => <span className="fw-600">{b.title}</span> },
{ key: 'status', header: 'وضعیت', render: (b) => <span className="badge gray"><span className="bdot" />{STATUS_LABEL[b.status] ?? b.status}</span> },
{ key: 'city', header: 'شهر', render: (b) => b.city?.name ?? '—' },
{ key: 'scheduled_at', header: 'زمان‌بندی', render: (b) => (b.scheduled_at ? formatDate(b.scheduled_at) : '—') },
{ key: 'created_at', header: 'تاریخ', render: (b) => formatDate(b.created_at) },
];
return (
<div className="fade-in">
<PageHeader
title="وبلاگ من"
description="مقالات مخصوص دامنه و برند شما."
action={<button className="cp-btn-primary" onClick={() => navigate('/admin/representation-blogs/new')}>مقالهٔ جدید</button>}
/>
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
<DataTable<Blog>
columns={columns}
data={items}
loading={isLoading}
emptyMessage="هنوز مقاله‌ای ننوشته‌اید"
actions={(b) => (
<div className="row-actions">
<button className="mini-btn" onClick={() => navigate(`/admin/representation-blogs/${b.uuid}/edit`)}>ویرایش</button>
<button className="mini-btn danger" onClick={() => setDeleteTarget(b)}>حذف</button>
</div>
)}
/>
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
</div>
<ConfirmDialog
open={!!deleteTarget}
title="حذف مقاله"
message={${deleteTarget?.title ?? ''}» حذف شود؟`}
danger
loading={deleteMutation.isPending}
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)}
onCancel={() => setDeleteTarget(null)}
/>
</div>
);
}