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:
arsam
2026-09-18 23:23:56 +03:30
parent 14a7b196f2
commit 4bedb46a0e
+165
View File
@@ -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 };