Import source: app/src/admin/Dashboard
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,468 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useAuth, useNav, useToast, RoleBadge, EmptyState, pillBtn, inputStyle } from '../../teknav-ui.jsx';
|
||||||
|
import { adminApi } from '../lib/admin-api.js';
|
||||||
|
import { iconNode } from '../lib/icon.jsx';
|
||||||
|
import triangleAlertIcon from '../assets/icons/triangle-alert.svg';
|
||||||
|
import { SIDEBAR_ITEMS, BRAND, PanelState, actionBtn, ADMIN_ROLE_NAMES } from './Shared.jsx';
|
||||||
|
import { ArticleEditor } from './ArticleEditor.jsx';
|
||||||
|
import { ArticlesManagement } from './ArticleList.jsx';
|
||||||
|
import { UserManagement, CategoryManagement, TagsManagement, AuthorsManagement } from './UserManagement.jsx';
|
||||||
|
import { MediaLibrary } from './MediaLibrary.jsx';
|
||||||
|
import { CommentsModeration } from './CommentModeration.jsx';
|
||||||
|
import { Phase9AnalyticsPanel, SeoAuditPanel, JobsPanel, ClientErrorsPanel } from './AnalyticsPanel.jsx';
|
||||||
|
import { ReviewsPanel } from './ReviewPanel.jsx';
|
||||||
|
import { NewsletterPanel } from './NewsletterPanel.jsx';
|
||||||
|
import { SeriesManagementPanel } from './SeriesPanel.jsx';
|
||||||
|
|
||||||
|
function AdminSidebar({ activeTab, onTab, user, open, onClose }) {
|
||||||
|
const items = SIDEBAR_ITEMS[user?.role] || SIDEBAR_ITEMS.writer;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{open && <div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 299, display: 'none' }} className="admin-overlay" />}
|
||||||
|
<aside className={`admin-sidebar${open ? ' open' : ''}`} style={{ width: 256, background: `linear-gradient(180deg,${BRAND.primaryDark} 0%,${BRAND.primary} 52%,#0A3136 100%)`, minHeight: '100%', padding: '22px 0', display: 'flex', flexDirection: 'column', flexShrink: 0, boxShadow: '-14px 0 40px rgba(15,42,46,0.16)' }}>
|
||||||
|
<div style={{ padding: '0 22px 22px', borderBottom: '1px solid rgba(250,247,240,0.14)' }}>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 900, color: '#fff', marginBottom: 4, fontFamily: "'PelakFA','Vazirmatn',sans-serif" }}>تکناو</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'rgba(255,255,255,0.72)', letterSpacing: '0.02em' }}>پنل تولید محتوا</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '18px 12px', flex: 1 }}>
|
||||||
|
{items.map(item => (
|
||||||
|
<button key={item.id} onClick={() => onTab(item.id)} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12, width: '100%', textAlign: 'right',
|
||||||
|
padding: '12px 14px', marginBottom: 6, background: activeTab === item.id ? 'rgba(250,247,240,0.96)' : 'transparent',
|
||||||
|
border: '1px solid', borderColor: activeTab === item.id ? 'rgba(250,247,240,0.95)' : 'transparent',
|
||||||
|
borderRadius: 12,
|
||||||
|
color: activeTab === item.id ? BRAND.primary : 'rgba(255,255,255,0.74)', fontSize: 13,
|
||||||
|
fontFamily: 'Vazirmatn,sans-serif', fontWeight: activeTab === item.id ? 800 : 600, cursor: 'pointer', transition: 'all 0.18s',
|
||||||
|
boxShadow: activeTab === item.id ? '0 12px 28px rgba(0,0,0,0.18)' : 'none',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 15, width: 26, height: 26, borderRadius: 8, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', background: activeTab === item.id ? `${BRAND.primary}14` : 'rgba(255,255,255,0.10)' }}>{iconNode(item.icon)}</span>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '16px 20px', borderTop: '1px solid rgba(250,247,240,0.14)' }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'rgba(255,255,255,0.72)', marginBottom: 6 }}>{user?.name}</div>
|
||||||
|
<RoleBadge role={user?.role} />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardStats({ role }) {
|
||||||
|
const [statsData, setStatsData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(false);
|
||||||
|
adminApi.getDashboard()
|
||||||
|
.then(res => { if (!cancelled) setStatsData(res); })
|
||||||
|
.catch(() => { if (!cancelled) { setStatsData(null); setError(true); } })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
if (loading || error || !statsData || statsData.totalArticles === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 28 }}>
|
||||||
|
<PanelState loading={loading} error={error} empty={!loading && !error && (!statsData || statsData.totalArticles === 0)} emptyTitle="هنوز دادهای برای آمار پنل ثبت نشده است" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const published = statsData.published ?? 0;
|
||||||
|
const pending = statsData.pending ?? 0;
|
||||||
|
const drafts = statsData.drafts ?? 0;
|
||||||
|
const totalViews = statsData.totalViews ?? 0;
|
||||||
|
|
||||||
|
const adminStats = [
|
||||||
|
{ label: 'مقاله منتشرشده', value: published, color: '#2F8F6B', icon: '◉' },
|
||||||
|
{ label: 'در انتظار بررسی', value: pending, color: '#D08A22', icon: '◷' },
|
||||||
|
{ label: 'پیشنویس', value: drafts, color: '#5F6B6D', icon: '◐' },
|
||||||
|
{ label: 'مجموع بازدید', value: totalViews.toLocaleString('fa'), color: '#0F6B73', icon: '◈' },
|
||||||
|
{ label: 'کاربران', value: statsData.totalUsers ?? 0, color: '#3A7D5E', icon: '◐' },
|
||||||
|
{ label: 'رسانهها', value: statsData.totalMedia ?? 0, color: '#C46A4D', icon: '▣' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const editorStats = [
|
||||||
|
{ label: 'در انتظار تأیید', value: pending, color: '#D08A22', icon: '◷' },
|
||||||
|
{ label: 'منتشرشده', value: published, color: '#2F8F6B', icon: '◉' },
|
||||||
|
{ label: 'نیازمند اصلاح', value: statsData.needsRevision ?? 0, color: '#C94C4C', icon: triangleAlertIcon },
|
||||||
|
];
|
||||||
|
|
||||||
|
const writerStats = [
|
||||||
|
{ label: 'مقاله منتشرشده', value: published, color: '#2F8F6B', icon: '◉' },
|
||||||
|
{ label: 'پیشنویس', value: drafts, color: '#5F6B6D', icon: '◐' },
|
||||||
|
{ label: 'در انتظار بررسی', value: pending, color: '#D08A22', icon: '◷' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const stats = role === 'admin' ? adminStats : role === 'editor' ? editorStats : writerStats;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(180px,1fr))', gap: 16, marginBottom: 28 }}>
|
||||||
|
{stats.map(s => (
|
||||||
|
<div key={s.label} style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: '20px 20px', borderTop: `3px solid ${s.color}` }}>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 900, color: s.color, marginBottom: 4 }}>{s.value}</div>
|
||||||
|
<div style={{ fontSize: 13, color: '#5F6B6D' }}>{s.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LiveAdminFeed() {
|
||||||
|
const [liveData, setLiveData] = useState({ count: 0, guests: 0, users: 0, commentsPerMin: 0 });
|
||||||
|
const [events, setEvents] = useState([]);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const es = new EventSource('/api/v1/admin/stream', { withCredentials: true });
|
||||||
|
es.onopen = () => setConnected(true);
|
||||||
|
es.onerror = () => setConnected(false);
|
||||||
|
es.addEventListener('visitor_update', event => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(event.data);
|
||||||
|
setLiveData({
|
||||||
|
count: parsed.count || 0,
|
||||||
|
guests: parsed.guests || 0,
|
||||||
|
users: parsed.users || 0,
|
||||||
|
commentsPerMin: parsed.commentsPerMin || 0
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
es.addEventListener('activity', event => setEvents(list => [JSON.parse(event.data), ...list].slice(0, 50)));
|
||||||
|
return () => es.close();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 18, marginBottom: 20, direction: 'rtl' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', flexWrap: 'wrap', marginBottom: 16 }}>
|
||||||
|
<strong style={{ fontSize: 14, color: BRAND.ink }}>وضعیت زنده</strong>
|
||||||
|
<span style={{ fontSize: 12, color: connected ? '#2F8F6B' : '#C94C4C', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#2F8F6B' : '#C94C4C', animation: connected ? 'pulseRing 2s infinite' : 'none' }} />
|
||||||
|
{connected ? 'متصل' : 'قطع'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))', gap: 12, marginBottom: 16 }}>
|
||||||
|
<div style={{ background: '#FAF7F0', padding: 12, borderRadius: 8, textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 900, color: BRAND.primary }}>{liveData.count.toLocaleString('fa-IR')}</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#5F6B6D' }}>بازدید کل</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: '#FAF7F0', padding: 12, borderRadius: 8, textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 900, color: '#2F8F6B' }}>{liveData.users.toLocaleString('fa-IR')}</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#5F6B6D' }}>کاربر لاگینشده</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: '#FAF7F0', padding: 12, borderRadius: 8, textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 900, color: '#D49A2A' }}>{liveData.guests.toLocaleString('fa-IR')}</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#5F6B6D' }}>مهمان</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: '#FAF7F0', padding: 12, borderRadius: 8, textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 900, color: '#C46A4D' }}>{liveData.commentsPerMin.toLocaleString('fa-IR')}</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#5F6B6D' }}>نظر بر دقیقه</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: BRAND.ink, marginBottom: 8, borderTop: '1px solid #E4DDD2', paddingTop: 16 }}>آخرین رخدادها</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{events.length === 0 ? <div style={{ fontSize: 12, color: '#5F6B6D' }}>هنوز رخدادی ثبت نشده...</div> : null}
|
||||||
|
{events.slice(0, 6).map((event, idx) => (
|
||||||
|
<div key={`${event.ts}-${idx}`} style={{ fontSize: 11, color: '#5F6B6D', background: '#F4EFE6', padding: '6px 10px', borderRadius: 6 }}>
|
||||||
|
<span style={{ fontWeight: 600, color: BRAND.ink }}>{event.actor}</span>: {event.action || event.type} روی {event.target}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActivityLog() {
|
||||||
|
const [log, setLog] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(false);
|
||||||
|
adminApi.listActivity({ limit: 50 })
|
||||||
|
.then(items => { if (!cancelled) { setLog(Array.isArray(items) ? items : []); setLoading(false); } })
|
||||||
|
.catch(() => { if (!cancelled) { setLog([]); setError(true); setLoading(false); } });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
const typeColors = { publish: '#2F8F6B', submit: '#D49A2A', draft: '#5F6B6D', approve: '#2F8F6B', reject: '#C94C4C', user: '#0F6B73', tag: '#C76D4A', category: '#D49A2A', edit: '#0F6B73', role: '#C76D4A', delete: '#C94C4C', author: '#C76D4A', upload: '#2F8F6B', media: '#C46A4D', schedule: '#D49A2A' };
|
||||||
|
if (loading) return <div style={{ color: '#5F6B6D', fontSize: 13, padding: '20px 0', direction: 'rtl' }}>در حال بارگذاری...</div>;
|
||||||
|
if (error) return <EmptyState title="اتصال به API فعالیتها برقرار نشد" subtitle="فعالیت نمونه نمایش داده نمیشود." />;
|
||||||
|
if (log.length === 0) return <div style={{ color: '#5F6B6D', fontSize: 13, padding: '20px 0', direction: 'rtl' }}>هنوز فعالیتی ثبت نشده است.</div>;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
|
||||||
|
<a href={adminApi.activityExportUrl({ limit: 5000 })} style={{ ...pillBtn, background: '#0F6B7318', color: '#0F6B73', textDecoration: 'none', fontSize: 12 }}>خروجی CSV</a>
|
||||||
|
</div>
|
||||||
|
{log.slice(0, 20).map(item => (
|
||||||
|
<div key={item.id} style={{ display: 'flex', gap: 14, padding: '14px 0', borderBottom: '1px solid #E4DDD2', direction: 'rtl', alignItems: 'flex-start' }}>
|
||||||
|
<div style={{ width: 8, height: 8, borderRadius: '50%', background: typeColors[item.type] || '#5F6B6D', marginTop: 6, flexShrink: 0 }} />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<span style={{ fontWeight: 600, color: '#263238', fontSize: 13 }}>{typeof item.user === 'string' ? item.user : (item.user?.name ?? 'کاربر')}</span>
|
||||||
|
<span style={{ color: '#5F6B6D', fontSize: 13 }}> — {item.action}: </span>
|
||||||
|
<span style={{ color: '#0F6B73', fontSize: 13 }}>{item.target}</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, color: '#5F6B6D', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||||
|
{item.time || (item.createdAt ? new Date(item.createdAt).toLocaleDateString('fa-IR') : '')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardCategoryStats() {
|
||||||
|
const [cats, setCats] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(false);
|
||||||
|
adminApi.listCategories()
|
||||||
|
.then(items => { if (!cancelled) setCats(items ?? []); })
|
||||||
|
.catch(() => { if (!cancelled) { setCats([]); setError(true); } })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading || error || cats.length === 0) {
|
||||||
|
return <PanelState loading={loading} error={error} empty={!loading && !error && cats.length === 0} emptyTitle="هنوز آمار دستهبندی از API دریافت نشده است" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cats.map(c => (
|
||||||
|
<div key={c.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 0', borderBottom: '1px solid #E4DDD2' }}>
|
||||||
|
<span style={{ fontSize: 13, color: '#263238' }}>{c.name}</span>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<div style={{ width: Math.max((c.articleCount || 0) * 2, 20), height: 6, background: c.color || BRAND.primary, borderRadius: 3, opacity: 0.7 }} />
|
||||||
|
<span style={{ fontSize: 12, color: '#5F6B6D' }}>{c.articleCount ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardTopArticles() {
|
||||||
|
const { navigate } = useNav();
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(false);
|
||||||
|
adminApi.getDashboard()
|
||||||
|
.then(res => { if (!cancelled) setItems(res?.topArticles ?? []); })
|
||||||
|
.catch(() => { if (!cancelled) { setItems([]); setError(true); } })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading || error || items.length === 0) {
|
||||||
|
return <PanelState loading={loading} error={error} empty={!loading && !error && items.length === 0} emptyTitle="هنوز مقالهای برای رتبهبندی بازدید وجود ندارد" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.map(article => (
|
||||||
|
<button key={article.id} onClick={() => navigate('/article/' + article.slug)} style={{ width: '100%', border: 'none', background: 'transparent', borderBottom: '1px solid #E4DDD2', padding: '10px 0', textAlign: 'right', cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif' }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: '#263238', lineHeight: 1.6 }}>{article.title}</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#5F6B6D', marginTop: 2 }}>{(article.views ?? 0).toLocaleString('fa-IR')} بازدید · {article.status}</div>
|
||||||
|
</button>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleMetricsPanel({ role }) {
|
||||||
|
const [metrics, setMetrics] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setError(false);
|
||||||
|
return adminApi.getPanelMetrics()
|
||||||
|
.then(setMetrics)
|
||||||
|
.catch(() => { setMetrics(null); setError(true); })
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
load();
|
||||||
|
const timer = setInterval(() => { if (!cancelled) load(); }, 15000);
|
||||||
|
let es;
|
||||||
|
if (role === 'admin' || role === 'editor') {
|
||||||
|
es = new EventSource('/api/v1/admin/stream', { withCredentials: true });
|
||||||
|
['activity', 'view_update', 'visitor_update'].forEach((eventName) => {
|
||||||
|
es.addEventListener(eventName, () => load());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return () => { cancelled = true; clearInterval(timer); es?.close(); };
|
||||||
|
}, [load, role]);
|
||||||
|
|
||||||
|
if (loading || error || !metrics || metrics.statuses?.totalArticles === 0) {
|
||||||
|
return <PanelState loading={loading} error={error} empty={!loading && !error} emptyTitle="هنوز دادهای برای آمار پنل ثبت نشده است" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventTotal = (metrics.realtime?.eventsByType ?? []).reduce((sum, item) => sum + Number(item.count ?? 0), 0);
|
||||||
|
const sets = {
|
||||||
|
admin: [
|
||||||
|
['مقاله منتشرشده', metrics.statuses?.published, '#2F8F6B'],
|
||||||
|
['در انتظار بررسی', metrics.statuses?.pending, '#D49A2A'],
|
||||||
|
['نیازمند اصلاح', metrics.statuses?.needsRevision, '#C94C4C'],
|
||||||
|
['بازدید زنده', metrics.engagement?.views, '#0F6B73'],
|
||||||
|
['تعاملها', (metrics.engagement?.comments ?? 0) + (metrics.engagement?.saved ?? 0), '#3A7D5E'],
|
||||||
|
['ایراد کیفیت', metrics.quality?.recentWithIssues, '#C46A4D'],
|
||||||
|
],
|
||||||
|
editor: [
|
||||||
|
['در انتظار تایید', metrics.statuses?.pending, '#D49A2A'],
|
||||||
|
['منتشرشده', metrics.statuses?.published, '#2F8F6B'],
|
||||||
|
['نیازمند اصلاح', metrics.statuses?.needsRevision, '#C94C4C'],
|
||||||
|
['مشترک خبرنامه', metrics.growth?.newsletterSubscribers, '#0F6B73'],
|
||||||
|
['رویدادهای زنده', eventTotal, '#3A7D5E'],
|
||||||
|
],
|
||||||
|
writer: [
|
||||||
|
['منتشرشده', metrics.statuses?.published, '#2F8F6B'],
|
||||||
|
['پیشنویس', metrics.statuses?.drafts, '#5F6B6D'],
|
||||||
|
['در انتظار بررسی', metrics.statuses?.pending, '#D49A2A'],
|
||||||
|
['بازدید نوشتهها', metrics.engagement?.views, '#0F6B73'],
|
||||||
|
['دنبالکننده', metrics.engagement?.writerFollows, '#3A7D5E'],
|
||||||
|
],
|
||||||
|
reviewer: [
|
||||||
|
['در صف بررسی', metrics.statuses?.pending, '#D49A2A'],
|
||||||
|
['تاییدشده', metrics.workflow?.reviewsApproved, '#2F8F6B'],
|
||||||
|
['نیازمند اصلاح', metrics.statuses?.needsRevision, '#C94C4C'],
|
||||||
|
['ایراد کیفیت اخیر', metrics.quality?.recentWithIssues, '#0F6B73'],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const stats = sets[role] ?? sets.writer;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gap: 14, marginBottom: 28 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(170px,1fr))', gap: 14 }}>
|
||||||
|
{stats.map(([label, value, color]) => (
|
||||||
|
<div key={label} style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: '18px 20px', borderTop: `3px solid ${color}` }}>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 900, color, marginBottom: 4 }}>{Number(value ?? 0).toLocaleString('fa-IR')}</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#5F6B6D' }}>{label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 16, direction: 'rtl' }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 800, color: BRAND.ink, marginBottom: 10 }}>پوشش زنده پنل</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
{(metrics.realtime?.eventsByType ?? []).slice(0, 8).map((item) => (
|
||||||
|
<span key={item.type} style={{ background: '#F4EFE6', border: '1px solid #E4DDD2', borderRadius: 999, padding: '5px 10px', fontSize: 12, color: BRAND.ink }}>{item.type}: {Number(item.count ?? 0).toLocaleString('fa-IR')}</span>
|
||||||
|
))}
|
||||||
|
<span style={{ background: '#F4EFE6', border: '1px solid #E4DDD2', borderRadius: 999, padding: '5px 10px', fontSize: 12, color: BRAND.ink }}>اعلان خواندهنشده: {Number(metrics.realtime?.unreadNotifications ?? 0).toLocaleString('fa-IR')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminPanel() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const { navigate } = useNav();
|
||||||
|
const [tab, setTab] = useState('dashboard');
|
||||||
|
const [editingArticle, setEditingArticle] = useState(null);
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { if (!user) navigate('/login'); }, [user]);
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const sectionTitle = (t) => <h2 style={{ fontSize: 22, fontWeight: 900, color: BRAND.ink, margin: '0 0 20px', direction: 'rtl', fontFamily: "'PelakFA','Vazirmatn',sans-serif" }}>{t}</h2>;
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (tab === 'create' || editingArticle) return (
|
||||||
|
<ArticleEditor existing={editingArticle} role={user.role} onSave={() => { setEditingArticle(null); setTab('articles'); }} />
|
||||||
|
);
|
||||||
|
if (tab === 'articles') return (
|
||||||
|
<>
|
||||||
|
{sectionTitle(user.role === 'reviewer' ? 'مقالههای در انتظار بررسی' : 'مدیریت مقالهها')}
|
||||||
|
<ArticlesManagement role={user.role} onEdit={(a) => { setEditingArticle(a); setTab('edit'); }} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
if (tab === 'users' && user.role === 'admin') return <>{sectionTitle('مدیریت کاربران')}<UserManagement /></>;
|
||||||
|
if (tab === 'authors' && (user.role === 'admin' || user.role === 'editor')) return <>{sectionTitle('مدیریت نویسندگان')}<AuthorsManagement /></>;
|
||||||
|
if (tab === 'media' && (user.role === 'admin' || user.role === 'editor')) return <>{sectionTitle('کتابخانه رسانه')}<MediaLibrary role={user.role} /></>;
|
||||||
|
if (tab === 'comments' && (user.role === 'admin' || user.role === 'editor')) return <>{sectionTitle('مدیریت نظرها')}<CommentsModeration /></>;
|
||||||
|
if (tab === 'analytics') return <>{sectionTitle('تحلیل محتوا')}<Phase9AnalyticsPanel /></>;
|
||||||
|
if (tab === 'reviews') return <>{sectionTitle('گردش بررسی مقالهها')}<ReviewsPanel /></>;
|
||||||
|
if (tab === 'newsletter' && (user.role === 'admin' || user.role === 'editor')) return <>{sectionTitle('خبرنامه')}<NewsletterPanel /></>;
|
||||||
|
if (tab === 'series' && (user.role === 'admin' || user.role === 'editor')) return <>{sectionTitle('سری مقالهها')}<SeriesManagementPanel /></>;
|
||||||
|
if (tab === 'categories') return <>{sectionTitle('مدیریت دستهبندیها')}<CategoryManagement /></>;
|
||||||
|
if (tab === 'tags' && user.role === 'admin') return <>{sectionTitle('مدیریت برچسبها')}<TagsManagement /></>;
|
||||||
|
if (tab === 'seo-audit' && user.role === 'admin') return <>{sectionTitle('ممیزی SEO')}<SeoAuditPanel /></>;
|
||||||
|
if (tab === 'jobs' && (user.role === 'admin' || user.role === 'editor')) return <>{sectionTitle('آگهیهای شغلی')}<JobsPanel /></>;
|
||||||
|
if (tab === 'client-errors' && user.role === 'admin') return <>{sectionTitle('خطاهای کلاینت')}<ClientErrorsPanel /></>;
|
||||||
|
if (tab === 'activity' && user.role === 'admin') return <>{sectionTitle('گزارش فعالیت')}<ActivityLog /></>;
|
||||||
|
return (
|
||||||
|
<div style={{ direction: 'rtl' }}>
|
||||||
|
<h2 style={{ fontSize: 22, fontWeight: 800, color: '#263238', margin: '0 0 6px' }}>
|
||||||
|
خوش آمدید، {user.name}
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: 14, color: '#5F6B6D', margin: '0 0 28px' }}>نقش شما: <RoleBadge role={user.role} /></p>
|
||||||
|
{(user.role === 'admin' || user.role === 'editor') && <LiveAdminFeed />}
|
||||||
|
<RoleMetricsPanel role={user.role} />
|
||||||
|
{user.role === 'admin' && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 }} className="dash-grid">
|
||||||
|
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 24 }}>
|
||||||
|
<h3 style={{ fontSize: 15, fontWeight: 700, color: '#263238', margin: '0 0 16px' }}>آخرین فعالیتها</h3>
|
||||||
|
<ActivityLog />
|
||||||
|
</div>
|
||||||
|
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 24 }}>
|
||||||
|
<h3 style={{ fontSize: 15, fontWeight: 700, color: '#263238', margin: '0 0 16px' }}>آمار دستهبندیها</h3>
|
||||||
|
<DashboardCategoryStats />
|
||||||
|
</div>
|
||||||
|
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 24 }}>
|
||||||
|
<h3 style={{ fontSize: 15, fontWeight: 700, color: '#263238', margin: '0 0 16px' }}>پربازدیدترین مقالهها</h3>
|
||||||
|
<DashboardTopArticles />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', display: 'flex', background: 'linear-gradient(135deg,#FAF7F0 0%,#F3EEE4 100%)', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
|
||||||
|
<AdminSidebar activeTab={tab} onTab={(t) => { setTab(t); setSidebarOpen(false); }} user={user} open={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||||
|
<div style={{ background: 'rgba(250,247,240,0.86)', backdropFilter: 'blur(14px)', borderBottom: '1px solid #E4DDD2', padding: '0 28px', minHeight: 64, display: 'flex', alignItems: 'center', justifyContent: 'space-between', direction: 'rtl', gap: 16, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<button className="admin-mobile-toggle" onClick={() => setSidebarOpen(o => !o)} style={{ background: 'none', border: '1px solid #E4DDD2', borderRadius: 8, padding: '6px 10px', cursor: 'pointer', fontSize: 18, color: BRAND.ink }}>☰</button>
|
||||||
|
<div>
|
||||||
|
<span className="admin-topbar-title" style={{ fontSize: 14, fontWeight: 800, color: BRAND.ink }}>پنل مدیریت تکناو</span>
|
||||||
|
<div style={{ fontSize: 11, color: '#5F6B6D', marginTop: 2 }}>محیط تولید، بازبینی و انتشار مقاله</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
<button onClick={() => setTab('create')} style={{ ...pillBtn, background: BRAND.primary, color: '#fff', fontSize: 12 }}>مقاله جدید</button>
|
||||||
|
<button onClick={() => navigate('/')} style={{ ...pillBtn, background: `${BRAND.primary}12`, color: BRAND.primary, fontSize: 12 }}>بازگشت به سایت</button>
|
||||||
|
<button onClick={() => { logout(); navigate('/'); }} style={{ ...pillBtn, background: '#C94C4C18', color: '#C94C4C', fontSize: 12 }}>خروج</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, padding: '30px clamp(18px,3vw,38px)', overflowY: 'auto' }}>
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<style>{`
|
||||||
|
.dash-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
@media(max-width:768px){
|
||||||
|
.dash-grid { grid-template-columns: 1fr !important; }
|
||||||
|
}
|
||||||
|
@media(max-width:640px){
|
||||||
|
.admin-sidebar { display: none !important; }
|
||||||
|
.admin-sidebar.open { display: flex !important; position: fixed; top: 0; right: 0; bottom: 0; z-index: 300; width: 280px !important; overflow-y: auto; }
|
||||||
|
.admin-overlay { display: block !important; }
|
||||||
|
.admin-mobile-toggle { display: flex !important; }
|
||||||
|
.admin-topbar-title { font-size: 12px !important; }
|
||||||
|
}
|
||||||
|
.admin-mobile-toggle { display: none; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AdminPanel, DashboardStats, LiveAdminFeed, ActivityLog, DashboardCategoryStats, DashboardTopArticles, RoleMetricsPanel, AdminSidebar };
|
||||||
Reference in New Issue
Block a user