Import source: app/src/admin/ArticleList
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useNav, useToast, CategoryBadge, StatusBadge, ConfirmModal, EmptyState, inputStyle } from '../../teknav-ui.jsx';
|
||||
import { adminApi } from '../lib/admin-api.js';
|
||||
import triangleAlertIcon from '../assets/icons/triangle-alert.svg';
|
||||
import { PanelState, actionBtn } from './Shared.jsx';
|
||||
|
||||
function ArticlesManagement({ role, onEdit }) {
|
||||
const { navigate } = useNav();
|
||||
const toast = useToast();
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [freshnessFilter, setFreshnessFilter] = useState('');
|
||||
const [confirm, setConfirm] = useState(null);
|
||||
const [articles, setArticles] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
const refresh = () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
return adminApi.listArticles({ limit: 100 })
|
||||
.then(res => setArticles(res.items ?? []))
|
||||
.catch(() => { setArticles([]); setError(true); })
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
useEffect(() => { refresh(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (role !== 'admin' && role !== 'editor') return undefined;
|
||||
const es = new EventSource('/api/v1/admin/stream', { withCredentials: true });
|
||||
es.addEventListener('view_update', event => {
|
||||
const data = JSON.parse(event.data);
|
||||
setArticles(list => list.map(article => article.id === data.articleId ? { ...article, views: data.views } : article));
|
||||
});
|
||||
return () => es.close();
|
||||
}, [role]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = articles;
|
||||
if (role === 'reviewer') list = articles.filter(a => a.status === 'در انتظار بررسی');
|
||||
if (search) { const q = search.toLowerCase(); list = list.filter(a => a.title.toLowerCase().includes(q) || a.authorName?.toLowerCase().includes(q)); }
|
||||
if (statusFilter) list = list.filter(a => a.status === statusFilter);
|
||||
if (freshnessFilter) list = list.filter(a => a.contentFreshnessStatus === freshnessFilter);
|
||||
return list;
|
||||
}, [articles, search, statusFilter, freshnessFilter, role]);
|
||||
|
||||
const changeStatus = async (id, newStatus) => {
|
||||
try {
|
||||
if (newStatus === 'منتشرشده') await adminApi.publishArticle(id);
|
||||
else await adminApi.updateArticle(id, { status: newStatus });
|
||||
toast('وضعیت مقاله تغییر کرد');
|
||||
refresh();
|
||||
} catch (err) {
|
||||
if (err?.body?.error === 'quality_check_failed') {
|
||||
toast(`انتشار متوقف شد: ${err.body.issues?.join('، ')}`, 'error');
|
||||
} else {
|
||||
toast('خطا در تغییر وضعیت مقاله', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deleteArt = async (id) => {
|
||||
try {
|
||||
await adminApi.deleteArticle(id);
|
||||
toast('مقاله حذف شد');
|
||||
refresh();
|
||||
} catch {
|
||||
toast('خطا در حذف مقاله', 'error');
|
||||
}
|
||||
setConfirm(null);
|
||||
};
|
||||
|
||||
const statusOptions = ['منتشرشده', 'پیشنویس', 'در انتظار بررسی', 'نیازمند اصلاح', 'زمانبندیشده'];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{confirm && <ConfirmModal msg={`آیا از حذف این مقاله اطمینان دارید؟`} onConfirm={() => deleteArt(confirm)} onCancel={() => setConfirm(null)} />}
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 20, flexWrap: 'wrap' }}>
|
||||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="جستجوی مقاله..." style={{ ...inputStyle, maxWidth: 260 }} dir="rtl" />
|
||||
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} style={{ ...inputStyle, maxWidth: 180 }} dir="rtl">
|
||||
<option value="">همه وضعیتها</option>
|
||||
{statusOptions.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<select value={freshnessFilter} onChange={e => setFreshnessFilter(e.target.value)} style={{ ...inputStyle, maxWidth: 180 }} dir="rtl">
|
||||
<option value="">همه تازگیها</option>
|
||||
<option value="current">بهروز</option>
|
||||
<option value="needs_update">نیاز به بازنگری</option>
|
||||
<option value="scheduled_refresh">بازنگری برنامهریزیشده</option>
|
||||
<option value="archived">آرشیو</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, overflow: 'hidden' }}>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'Vazirmatn,sans-serif', fontSize: 13, direction: 'rtl' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#F4EFE6', borderBottom: '1px solid #E4DDD2' }}>
|
||||
{['عنوان', 'نویسنده', 'دسته', 'وضعیت', 'بازدید', 'تاریخ', 'اقدام'].map(h => (
|
||||
<th key={h} style={{ padding: '12px 16px', textAlign: 'right', color: '#5F6B6D', fontWeight: 600, whiteSpace: 'nowrap' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(showAll ? filtered : filtered.slice(0, PAGE_SIZE)).map((a, i) => (
|
||||
<tr key={a.id} style={{ borderBottom: '1px solid #E4DDD2', background: i % 2 === 0 ? '#fff' : '#FDFBF7' }}>
|
||||
<td style={{ padding: '12px 16px', maxWidth: 280 }}>
|
||||
<div style={{ fontWeight: 600, color: '#263238', cursor: 'pointer' }} onClick={() => navigate('/article/' + a.slug)}>{a.title.slice(0, 50)}{a.title.length > 50 ? '…' : ''}</div>
|
||||
<div style={{ fontSize: 11, color: '#5F6B6D', marginTop: 2 }}>{a.readTime} دقیقه · {a.type}</div>
|
||||
{a.status === 'نیازمند اصلاح' && a.reviewNote && (
|
||||
<div style={{ marginTop: 6, padding: '5px 9px', background: 'rgba(200,149,28,0.10)', border: '1px solid rgba(200,149,28,0.28)', borderRadius: 6, fontSize: 11, color: '#7A5A00', lineHeight: 1.7 }}>
|
||||
<span className="tek-icon" style={{ ['--i']: `url("${triangleAlertIcon}")` }} aria-hidden="true" /> {a.reviewNote}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', whiteSpace: 'nowrap', color: '#5F6B6D' }}>{a.authorName}</td>
|
||||
<td style={{ padding: '12px 16px' }}><CategoryBadge name={a.categoryName} small /></td>
|
||||
<td style={{ padding: '12px 16px' }}><StatusBadge status={a.status} /></td>
|
||||
<td style={{ padding: '12px 16px', whiteSpace: 'nowrap', fontSize: 12, color: '#5F6B6D' }}>{(a.views ?? 0).toLocaleString('fa-IR')}</td>
|
||||
<td style={{ padding: '12px 16px', whiteSpace: 'nowrap', fontSize: 12, color: '#5F6B6D' }}>{a.date}</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{(role === 'admin' || role === 'editor') && a.status === 'در انتظار بررسی' && (
|
||||
<>
|
||||
<button onClick={() => changeStatus(a.id, 'منتشرشده')} style={{ ...actionBtn, background: '#2F8F6B18', color: '#2F8F6B' }}>تأیید</button>
|
||||
<button onClick={() => changeStatus(a.id, 'نیازمند اصلاح')} style={{ ...actionBtn, background: '#C94C4C18', color: '#C94C4C' }}>رد</button>
|
||||
</>
|
||||
)}
|
||||
{role === 'writer' && a.status === 'پیشنویس' && (
|
||||
<button onClick={() => changeStatus(a.id, 'در انتظار بررسی')} style={{ ...actionBtn, background: '#D49A2A18', color: '#D49A2A' }}>ارسال</button>
|
||||
)}
|
||||
{role === 'reviewer' && (
|
||||
<button onClick={() => changeStatus(a.id, 'منتشرشده')} style={{ ...actionBtn, background: '#2F8F6B18', color: '#2F8F6B' }}>تأیید</button>
|
||||
)}
|
||||
<button onClick={() => onEdit(a)} style={{ ...actionBtn, background: '#0F6B7318', color: '#0F6B73' }}>ویرایش</button>
|
||||
{role === 'admin' && (
|
||||
<button onClick={() => setConfirm(a.id)} style={{ ...actionBtn, background: '#C94C4C18', color: '#C94C4C' }}>حذف</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<PanelState loading={loading} error={error} empty={!loading && !error && articles.length === 0} emptyTitle="هنوز مقالهای از API دریافت نشده است" />
|
||||
{!loading && !error && articles.length > 0 && filtered.length === 0 && <EmptyState title="موردی مطابق فیلترها یافت نشد" />}
|
||||
{!loading && !error && filtered.length > PAGE_SIZE && (
|
||||
<div style={{ padding: '14px 16px', textAlign: 'center', borderTop: '1px solid #E4DDD2' }}>
|
||||
<button
|
||||
onClick={() => setShowAll(s => !s)}
|
||||
style={{ background: 'none', border: '1px solid #E4DDD2', borderRadius: 8, padding: '7px 20px', cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif', fontSize: 12, color: '#5F6B6D' }}
|
||||
>
|
||||
{showAll
|
||||
? `نمایش کمتر ▴`
|
||||
: `نمایش ${(filtered.length - PAGE_SIZE).toLocaleString('fa-IR')} مورد دیگر ▾`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { ArticlesManagement };
|
||||
Reference in New Issue
Block a user