Import source: app/src/admin/WriterWorkspace
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,657 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback, Fragment } from 'react';
|
||||||
|
import { useAuth, useNav, useToast, StatusBadge, EmptyState, inputStyle, pillBtn } from '../../teknav-ui.jsx';
|
||||||
|
import { adminApi } from '../lib/admin-api.js';
|
||||||
|
import { engagementApi } from '../lib/engagement-api.js';
|
||||||
|
import triangleAlertIcon from '../assets/icons/triangle-alert.svg';
|
||||||
|
import { PanelState, actionBtn } from './Shared.jsx';
|
||||||
|
import { ArticleEditor } from './ArticleEditor.jsx';
|
||||||
|
import { ArticlesManagement } from './ArticleList.jsx';
|
||||||
|
|
||||||
|
const WW = {
|
||||||
|
terra: '#C46A4D', terraLight: '#FAF0EB', terraBorder: 'rgba(196,106,77,0.22)',
|
||||||
|
gold: '#C8951C', goldLight: '#FBF6E8',
|
||||||
|
ink: '#1A2E30', muted: '#7B8C8E', faint: '#9AA5A6',
|
||||||
|
cream: '#FAF7F0', border: '#E4DDD2',
|
||||||
|
success: '#3A7D5E', successLight: '#EFF8F4',
|
||||||
|
purple: '#7B5EA7', purpleLight: '#F5F0FB',
|
||||||
|
};
|
||||||
|
const wwPrimaryBtn = {
|
||||||
|
padding: '10px 22px', borderRadius: 10, border: 'none',
|
||||||
|
background: WW.terra, color: '#fff', fontSize: 13, fontWeight: 700,
|
||||||
|
cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif',
|
||||||
|
boxShadow: '0 3px 12px rgba(196,106,77,0.25)',
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
};
|
||||||
|
const wwGhostBtn = {
|
||||||
|
padding: '9px 16px', borderRadius: 10, border: `1px solid ${WW.border}`,
|
||||||
|
background: '#fff', color: WW.ink, fontSize: 13, fontWeight: 600,
|
||||||
|
cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif',
|
||||||
|
};
|
||||||
|
const wwDangerBtn = {
|
||||||
|
padding: '9px 16px', borderRadius: 10, border: 'none',
|
||||||
|
background: 'rgba(201,76,76,0.08)', color: '#C94C4C',
|
||||||
|
fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif',
|
||||||
|
};
|
||||||
|
const wwLinkBtn = {
|
||||||
|
background: WW.terraLight, border: 'none', cursor: 'pointer',
|
||||||
|
color: WW.terra, fontSize: 11, fontWeight: 700,
|
||||||
|
fontFamily: 'Vazirmatn,sans-serif', padding: '3px 10px', borderRadius: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const WRITING_TIPS = [
|
||||||
|
{ icon: '✦', tip: 'با یک سوال باز شروع کنید. بهترین مقالهها خواننده را وارد گفتگو میکنند.' },
|
||||||
|
{ icon: '◈', tip: 'عنوان را آخر بنویسید. وقتی محتوا کامل است، عنوان دقیقتر انتخاب میشود.' },
|
||||||
|
{ icon: '◉', tip: 'یک مفهوم پیچیده را با یک مثال واقعی توضیح دهید تا خواننده آن را به خاطر بسپارد.' },
|
||||||
|
{ icon: '◇', tip: 'پاراگراف اول تعیینکننده است. اگر خواننده را نگه ندارد، ادامه را نمیخواند.' },
|
||||||
|
{ icon: '◎', tip: 'از اعداد استفاده کنید. "۳ روش" قابل اعتمادتر از "چند روش" به نظر میرسد.' },
|
||||||
|
{ icon: '✦', tip: 'مقاله را با صدای بلند بخوانید. اگر گیر کردید، جمله نیاز به بازنویسی دارد.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const WEEK_DAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'];
|
||||||
|
|
||||||
|
function WritingTipCard() {
|
||||||
|
const [idx, setIdx] = useState(() => Math.floor(Math.random() * WRITING_TIPS.length));
|
||||||
|
const [fading, setFading] = useState(false);
|
||||||
|
const timerRef = useRef(null);
|
||||||
|
|
||||||
|
const advance = useCallback((step = 1) => {
|
||||||
|
setFading(true);
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
setTimeout(() => {
|
||||||
|
setIdx(i => (i + step + WRITING_TIPS.length) % WRITING_TIPS.length);
|
||||||
|
setFading(false);
|
||||||
|
timerRef.current = setInterval(autoAdvance, 7000);
|
||||||
|
}, 350);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function autoAdvance() { advance(1); }
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
timerRef.current = setInterval(autoAdvance, 7000);
|
||||||
|
return () => clearInterval(timerRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { icon, tip } = WRITING_TIPS[idx];
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(135deg, #FAF0EB 0%, #FBF6E8 100%)',
|
||||||
|
border: `1px solid ${WW.terraBorder}`, borderRadius: 12,
|
||||||
|
padding: '14px 16px', display: 'flex', gap: 12, alignItems: 'flex-start',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, borderRadius: 10, flexShrink: 0,
|
||||||
|
background: 'linear-gradient(135deg, rgba(196,106,77,0.14), rgba(200,149,28,0.1))',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 16, color: WW.terra,
|
||||||
|
}}>{icon}</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
|
||||||
|
<div style={{ fontSize: 10, fontWeight: 700, color: WW.terra, letterSpacing: '0.1em' }}>
|
||||||
|
{'نکته نویسندگی'}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: 10, color: WW.faint }}>
|
||||||
|
{idx + 1}/{WRITING_TIPS.length}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="نکته قبلی"
|
||||||
|
onClick={() => advance(-1)}
|
||||||
|
style={{ ...wwLinkBtn, padding: '2px 7px', fontSize: 13, lineHeight: 1 }}
|
||||||
|
>‹</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="نکته بعدی"
|
||||||
|
onClick={() => advance(1)}
|
||||||
|
style={{ ...wwLinkBtn, padding: '2px 7px', fontSize: 13, lineHeight: 1 }}
|
||||||
|
>›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style={{
|
||||||
|
margin: 0, fontSize: 12.5, color: WW.ink, lineHeight: 1.85,
|
||||||
|
opacity: fading ? 0 : 1,
|
||||||
|
transform: fading ? 'translateY(4px)' : 'translateY(0)',
|
||||||
|
transition: 'opacity 0.35s, transform 0.35s',
|
||||||
|
}}>{tip}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WeeklyChart({ data }) {
|
||||||
|
const values = (data && data.length === 7) ? data : [2, 5, 3, 8, 4, 6, 1];
|
||||||
|
const max = Math.max(...values, 1);
|
||||||
|
const today = new Date().getDay();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 5, height: 48, marginBottom: 6 }}>
|
||||||
|
{values.map((v, i) => (
|
||||||
|
<div key={i} style={{ flex: 1, height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'flex-end' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '100%', borderRadius: '3px 3px 0 0',
|
||||||
|
height: `${Math.max((v / max) * 100, 8)}%`,
|
||||||
|
background: i === today
|
||||||
|
? `linear-gradient(to top, ${WW.terra}, rgba(196,106,77,0.45))`
|
||||||
|
: 'linear-gradient(to top, #E4DDD2, #EDE9E2)',
|
||||||
|
transition: 'height 0.5s ease',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 5 }}>
|
||||||
|
{WEEK_DAYS.map((d, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
flex: 1, textAlign: 'center', fontSize: 9,
|
||||||
|
color: i === today ? WW.terra : WW.faint,
|
||||||
|
fontWeight: i === today ? 700 : 400,
|
||||||
|
}}>{d}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WriterAnalyticsPanel() {
|
||||||
|
const [articles, setArticles] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [expandedSlug, setExpandedSlug] = useState(null);
|
||||||
|
const [sparklineData, setSparklineData] = useState({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
engagementApi.writerAnalyticsArticles()
|
||||||
|
.then(res => { if (!cancelled) setArticles(res.items ?? []); })
|
||||||
|
.catch(() => { if (!cancelled) setError(true); })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (articles.length === 0) return;
|
||||||
|
let cancelled = false;
|
||||||
|
articles.forEach(a => {
|
||||||
|
engagementApi.writerAnalyticsArticle(a.slug)
|
||||||
|
.then(res => {
|
||||||
|
if (!cancelled) setSparklineData(prev => ({ ...prev, [a.slug]: res.viewsByDay.map(d => d.views) }));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [articles]);
|
||||||
|
|
||||||
|
const toggleSparkline = (slug) => {
|
||||||
|
setExpandedSlug(prev => prev === slug ? null : slug);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div style={{ color: WW.muted, fontSize: 13, padding: '20px 0', direction: 'rtl' }}>در حال بارگذاری تحلیلها...</div>;
|
||||||
|
if (error) return <EmptyState title="دریافت اطلاعات تحلیلی با خطا مواجه شد" />;
|
||||||
|
if (articles.length === 0) return <EmptyState title="مقالهای برای تحلیل وجود ندارد" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ background: '#fff', border: `1px solid ${WW.border}`, borderRadius: 12, padding: 24, direction: 'rtl' }}>
|
||||||
|
<h3 style={{ margin: '0 0 24px', fontSize: 16, color: WW.ink, fontWeight: 900 }}>عملکرد نوشتههای من</h3>
|
||||||
|
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'Vazirmatn,sans-serif', fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '2px solid #E4DDD2', color: WW.ink }}>
|
||||||
|
<th style={{ padding: '12px 8px', textAlign: 'right' }}>عنوان مقاله</th>
|
||||||
|
<th style={{ padding: '12px 8px', textAlign: 'center' }}>بازدید</th>
|
||||||
|
<th style={{ padding: '12px 8px', textAlign: 'center' }}>واکنش</th>
|
||||||
|
<th style={{ padding: '12px 8px', textAlign: 'center' }}>نظر</th>
|
||||||
|
<th style={{ padding: '12px 8px', textAlign: 'center' }}>روند اخیر</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{articles.map((a, i) => {
|
||||||
|
const spark = sparklineData[a.slug];
|
||||||
|
const max = spark ? Math.max(...spark, 1) : 1;
|
||||||
|
return (
|
||||||
|
<Fragment key={a.slug}>
|
||||||
|
<tr style={{ background: i % 2 === 0 ? '#fff' : '#FDFBF7', borderBottom: '1px solid #E4DDD2', cursor: 'pointer' }} onClick={() => toggleSparkline(a.slug)}>
|
||||||
|
<td style={{ padding: '14px 8px', fontWeight: 600, color: WW.ink }}>{a.title}</td>
|
||||||
|
<td style={{ padding: '14px 8px', textAlign: 'center', color: WW.muted }}>{Number(a.views || 0).toLocaleString('fa-IR')}</td>
|
||||||
|
<td style={{ padding: '14px 8px', textAlign: 'center', color: WW.muted }}>{Number(a.reactions || 0).toLocaleString('fa-IR')}</td>
|
||||||
|
<td style={{ padding: '14px 8px', textAlign: 'center', color: WW.muted }}>{Number(a.comments || 0).toLocaleString('fa-IR')}</td>
|
||||||
|
<td style={{ padding: '8px 8px', textAlign: 'center' }}>
|
||||||
|
{(() => {
|
||||||
|
const s = sparklineData[a.slug];
|
||||||
|
if (!s) return <span style={{ fontSize: 10, color: WW.faint }}>...</span>;
|
||||||
|
if (s.length < 2) return <span style={{ fontSize: 10, color: WW.faint }}>—</span>;
|
||||||
|
const W = 80, H = 20;
|
||||||
|
const mx = Math.max(...s, 1);
|
||||||
|
const pts = s.map((v, si) => [
|
||||||
|
(si / (s.length - 1)) * W,
|
||||||
|
H - 2 - (v / mx) * (H - 4),
|
||||||
|
]);
|
||||||
|
const d = pts.map(([x, y], si) => `${si === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`).join(' ');
|
||||||
|
const last = pts[pts.length - 1];
|
||||||
|
return (
|
||||||
|
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} style={{ display: 'inline-block', direction: 'ltr', verticalAlign: 'middle' }}>
|
||||||
|
<path d={d} fill="none" stroke={WW.terra} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<circle cx={last[0]} cy={last[1]} r="2.5" fill={WW.terra} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<div style={{ fontSize: 9, color: WW.faint, marginTop: 2 }}>{expandedSlug === a.slug ? '▴' : '▾'}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{expandedSlug === a.slug && (
|
||||||
|
<tr style={{ background: '#FAF7F0', borderBottom: '2px solid #E4DDD2' }}>
|
||||||
|
<td colSpan="5" style={{ padding: '20px 24px' }}>
|
||||||
|
{spark ? (
|
||||||
|
spark.length < 2 ? (
|
||||||
|
<div style={{ fontSize: 12, color: WW.muted, textAlign: 'center' }}>داده کافی برای رسم نمودار ۳۰ روزه وجود ندارد.</div>
|
||||||
|
) : (() => {
|
||||||
|
const W = 560, H = 80, pad = 4;
|
||||||
|
const n = spark.length;
|
||||||
|
const pts = spark.map((v, si) => [
|
||||||
|
(si / (n - 1)) * (W - pad * 2) + pad,
|
||||||
|
H - pad - (v / max) * (H - pad * 2 - 4),
|
||||||
|
]);
|
||||||
|
const line = pts.map(([x, y], si) => `${si === 0 ? 'M' : 'L'} ${x.toFixed(1)},${y.toFixed(1)}`).join(' ');
|
||||||
|
const area = `${line} L ${pts[pts.length-1][0].toFixed(1)},${H} L ${pts[0][0].toFixed(1)},${H} Z`;
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<svg width="100%" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" style={{ display: 'block', height: H, direction: 'ltr' }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`spfill-${a.slug}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={WW.terra} stopOpacity="0.22" />
|
||||||
|
<stop offset="100%" stopColor={WW.terra} stopOpacity="0.01" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d={area} fill={`url(#spfill-${a.slug})`} />
|
||||||
|
<path d={line} fill="none" stroke={WW.terra} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
{pts[pts.length - 1] && (
|
||||||
|
<circle cx={pts[pts.length-1][0]} cy={pts[pts.length-1][1]} r="3.5" fill={WW.terra} />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 10, color: WW.faint, direction: 'ltr' }}>
|
||||||
|
<span>۳۰ روز پیش</span>
|
||||||
|
<span>امروز</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 12, color: WW.muted, textAlign: 'center' }}>در حال بارگذاری روند...</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr style={{ borderTop: '2px solid #E4DDD2', background: '#FAF7F0', fontWeight: 700 }}>
|
||||||
|
<td style={{ padding: '12px 8px', color: WW.ink, fontSize: 12 }}>جمع کل</td>
|
||||||
|
<td style={{ padding: '12px 8px', textAlign: 'center', color: WW.terra, fontSize: 12 }}>
|
||||||
|
{articles.reduce((s, a) => s + (a.views || 0), 0).toLocaleString('fa-IR')}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 8px', textAlign: 'center', color: WW.terra, fontSize: 12 }}>
|
||||||
|
{articles.reduce((s, a) => s + (a.reactions || 0), 0).toLocaleString('fa-IR')}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 8px', textAlign: 'center', color: WW.terra, fontSize: 12 }}>
|
||||||
|
{articles.reduce((s, a) => s + (a.comments || 0), 0).toLocaleString('fa-IR')}
|
||||||
|
</td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WriterWorkspace() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const { navigate } = useNav();
|
||||||
|
const [tab, setTab] = useState('overview');
|
||||||
|
const [editingArticle, setEditingArticle] = useState(null);
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setError(false);
|
||||||
|
return adminApi.getWriterDashboard()
|
||||||
|
.then(setData)
|
||||||
|
.catch(() => { setData(null); setError(true); })
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { if (!user) navigate('/login'); }, [user, navigate]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
load();
|
||||||
|
const timer = setInterval(() => { if (!cancelled) load(); }, 20000);
|
||||||
|
return () => { cancelled = true; clearInterval(timer); };
|
||||||
|
}, [load, user]);
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const stats = data?.stats ?? {};
|
||||||
|
const openEditor = (article = null) => { setEditingArticle(article); setTab('write'); };
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'overview', label: 'آمار من', icon: '◈' },
|
||||||
|
{ id: 'analytics', label: 'تحلیلها', icon: '▤' },
|
||||||
|
{ id: 'write', label: editingArticle ? 'ویرایش' : 'نوشتن', icon: '✦' },
|
||||||
|
{ id: 'articles', label: 'نوشتهها', icon: '◉' },
|
||||||
|
{ id: 'comments', label: 'نظرها', icon: '◎' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{ label: 'بازدید', value: stats.views, color: WW.terra, bg: WW.terraLight, icon: '◉' },
|
||||||
|
{ label: 'دنبالکننده', value: stats.followers, color: WW.success, bg: WW.successLight, icon: '◎' },
|
||||||
|
{ label: 'نظر', value: stats.comments, color: WW.gold, bg: WW.goldLight, icon: '◇' },
|
||||||
|
{ label: 'در انتظار بررسی', value: stats.pending, color: WW.purple, bg: WW.purpleLight, icon: '◈' },
|
||||||
|
{ label: 'پیشنویس', value: stats.drafts, color: WW.muted, bg: '#F2F4F4', icon: '◻' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const renderOverview = () => (
|
||||||
|
<div style={{ display: 'grid', gap: 20 }}>
|
||||||
|
<PanelState loading={loading} error={error}
|
||||||
|
empty={!loading && !error && !data}
|
||||||
|
emptyTitle="هنوز دادهای برای داشبورد ثبت نشده است" />
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
{(stats.needsRevision > 0) && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 14,
|
||||||
|
background: 'linear-gradient(135deg, #FFF8F0, #FFF3E0)',
|
||||||
|
border: '1px solid rgba(200,149,28,0.35)',
|
||||||
|
borderRadius: 12, padding: '14px 18px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 38, height: 38, borderRadius: 10, flexShrink: 0,
|
||||||
|
background: WW.goldLight, border: '1px solid rgba(200,149,28,0.25)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 20,
|
||||||
|
}}><span className="tek-icon" style={{ ['--i']: `url("${triangleAlertIcon}")` }} aria-hidden="true" /></div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 800, color: '#7A5A00', marginBottom: 3 }}>
|
||||||
|
{Number(stats.needsRevision).toLocaleString('fa-IR')} {'مقاله نیاز به بازبینی دارد'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#A07A20' }}>
|
||||||
|
{'ویراستار بازخوردی ثبت کرده است. برای مشاهده توضیحات به بخش «نوشتهها» بروید.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setTab('articles')} style={{
|
||||||
|
...wwLinkBtn, background: WW.gold, color: '#fff',
|
||||||
|
padding: '6px 14px', fontSize: 12, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{'مشاهده →'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<WritingTipCard />
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(138px,1fr))', gap: 12 }}>
|
||||||
|
{statCards.map(({ label, value, color, bg, icon }) => (
|
||||||
|
<div key={label} style={{
|
||||||
|
background: '#fff', border: `1px solid ${WW.border}`,
|
||||||
|
borderRadius: 12, padding: '18px 16px',
|
||||||
|
position: 'relative', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 0, right: 0, width: 4, height: '100%',
|
||||||
|
background: color, borderRadius: '0 12px 12px 0',
|
||||||
|
}} />
|
||||||
|
<div style={{
|
||||||
|
width: 34, height: 34, borderRadius: 9, background: bg,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 15, color, marginBottom: 12,
|
||||||
|
}}>{icon}</div>
|
||||||
|
<div style={{ fontSize: 26, fontWeight: 900, color, lineHeight: 1 }}>
|
||||||
|
{Number(value ?? 0).toLocaleString('fa-IR')}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: WW.muted, marginTop: 5, fontWeight: 600 }}>{label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1.15fr 0.85fr', gap: 16 }} className="writer-work-grid">
|
||||||
|
<section style={{ background: '#fff', border: `1px solid ${WW.border}`, borderRadius: 12, padding: '20px 20px 8px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: 14, color: WW.ink, fontWeight: 800 }}>
|
||||||
|
{'آخرین نوشتهها'}
|
||||||
|
</h3>
|
||||||
|
<button onClick={() => setTab('articles')} style={wwLinkBtn}>
|
||||||
|
{'همه →'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{(data.recentArticles ?? []).length === 0
|
||||||
|
? <EmptyState title="هنوز نوشتهای ندارید" />
|
||||||
|
: data.recentArticles.map(article => (
|
||||||
|
<button key={article.id} onClick={() => openEditor(article)} style={{
|
||||||
|
width: '100%', background: 'transparent', border: 0,
|
||||||
|
borderBottom: `1px solid ${WW.border}`, padding: '13px 0',
|
||||||
|
textAlign: 'right', cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'flex-start' }}>
|
||||||
|
<span style={{ fontWeight: 700, color: WW.ink, fontSize: 13, lineHeight: 1.6 }}>{article.title}</span>
|
||||||
|
<StatusBadge status={article.status} />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: WW.faint, marginTop: 5 }}>
|
||||||
|
{Number(article.views ?? 0).toLocaleString('fa-IR')} {'بازدید ·'} {article.date}
|
||||||
|
</div>
|
||||||
|
{article.status === 'نیازمند اصلاح' && article.reviewNote && (
|
||||||
|
<div style={{ marginTop: 7, 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, textAlign: 'right' }}>
|
||||||
|
<span className="tek-icon" style={{ ['--i']: `url("${triangleAlertIcon}")` }} aria-hidden="true" /> {article.reviewNote}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ background: '#fff', border: `1px solid ${WW.border}`, borderRadius: 12, padding: '20px 20px 8px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: 14, color: WW.ink, fontWeight: 800 }}>
|
||||||
|
{'نظرهای تازه'}
|
||||||
|
</h3>
|
||||||
|
<button onClick={() => setTab('comments')} style={wwLinkBtn}>
|
||||||
|
{'همه →'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{(data.recentComments ?? []).length === 0
|
||||||
|
? <EmptyState title="هنوز نظری ثبت نشده است" />
|
||||||
|
: data.recentComments.slice(0, 5).map(comment => (
|
||||||
|
<div key={comment.id} style={{ borderBottom: `1px solid ${WW.border}`, padding: '11px 0' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginBottom: 4 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: WW.terra }}>{comment.author?.name}</div>
|
||||||
|
<div style={{ fontSize: 10, color: WW.faint }}>{comment.time}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: WW.ink, lineHeight: 1.8 }}>{comment.body}</div>
|
||||||
|
<div style={{ fontSize: 10, color: WW.muted, marginTop: 4 }}>{comment.article?.title}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }} className="writer-work-grid">
|
||||||
|
<div style={{ background: '#fff', border: `1px solid ${WW.border}`, borderRadius: 12, padding: '20px' }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 800, color: WW.ink, marginBottom: 14 }}>
|
||||||
|
{'فعالیت هفتگی'}
|
||||||
|
</div>
|
||||||
|
<WeeklyChart data={(data.viewsByDay ?? []).slice(-7).map(d => d.views ?? 0)} />
|
||||||
|
<div style={{ fontSize: 11, color: WW.muted, marginTop: 10, textAlign: 'center' }}>
|
||||||
|
{'بازدید روزانه در این هفته'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ background: '#fff', border: `1px solid ${WW.border}`, borderRadius: 12, padding: '20px' }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 800, color: WW.ink, marginBottom: 6 }}>
|
||||||
|
{'هدف ماهانه'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: WW.muted, marginBottom: 14 }}>
|
||||||
|
{'این ماه: '}{Number(stats.published ?? 0).toLocaleString('fa-IR')}
|
||||||
|
{' از ۴ مقاله'}
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 8, background: WW.terraLight, borderRadius: 4, overflow: 'hidden', marginBottom: 12 }}>
|
||||||
|
<div style={{
|
||||||
|
height: '100%', borderRadius: 4,
|
||||||
|
background: `linear-gradient(to left, ${WW.gold}, ${WW.terra})`,
|
||||||
|
width: `${Math.min(((stats.published ?? 0) / 4) * 100, 100)}%`,
|
||||||
|
transition: 'width 1s ease',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
{[1, 2, 3, 4].map(n => (
|
||||||
|
<div key={n} style={{
|
||||||
|
flex: 1, height: 28, borderRadius: 8,
|
||||||
|
border: `1.5px solid ${n <= (stats.published ?? 0) ? WW.terra : WW.border}`,
|
||||||
|
background: n <= (stats.published ?? 0) ? WW.terraLight : 'transparent',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 13, color: n <= (stats.published ?? 0) ? WW.terra : WW.faint,
|
||||||
|
fontWeight: 700, transition: 'all 0.3s',
|
||||||
|
}}>{n <= (stats.published ?? 0) ? '✔' : n.toLocaleString('fa-IR')}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: WW.faint, marginTop: 10 }}>
|
||||||
|
{'هر ماه ۴ مقاله بنویسید'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', background: WW.cream, direction: 'rtl', fontFamily: 'Vazirmatn,sans-serif' }}>
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(160deg, #F2EBE3 0%, #FAF7F0 55%)',
|
||||||
|
borderBottom: `1px solid ${WW.border}`,
|
||||||
|
padding: '24px clamp(16px,3vw,44px) 0',
|
||||||
|
}}>
|
||||||
|
<div style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap', marginBottom: 22 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
fontSize: 11, color: WW.terra, fontWeight: 700,
|
||||||
|
background: WW.terraLight, padding: '4px 12px', borderRadius: 20,
|
||||||
|
border: `1px solid ${WW.terraBorder}`, marginBottom: 10, letterSpacing: '0.05em',
|
||||||
|
}}>
|
||||||
|
{'✦ استودیوی نویسنده · تکناو'}
|
||||||
|
</div>
|
||||||
|
<h1 style={{
|
||||||
|
margin: 0, fontSize: 28, fontWeight: 900, color: WW.ink,
|
||||||
|
fontFamily: "'PelakFA','Vazirmatn',sans-serif", lineHeight: 1.2,
|
||||||
|
}}>
|
||||||
|
{(() => {
|
||||||
|
const h = new Date().getHours();
|
||||||
|
if (h < 6) return 'شب بخیر، ';
|
||||||
|
if (h < 12) return 'صبح بخیر، ';
|
||||||
|
if (h < 17) return 'ظهر بخیر، ';
|
||||||
|
if (h < 21) return 'عصر بخیر، ';
|
||||||
|
return 'شب بخیر، ';
|
||||||
|
})()}{user.name}
|
||||||
|
</h1>
|
||||||
|
<p style={{ margin: '6px 0 0', fontSize: 13, color: WW.muted }}>
|
||||||
|
{(() => {
|
||||||
|
const h = new Date().getHours();
|
||||||
|
if (h < 6) return 'ساعت کار است یا الهام گرفتن؟';
|
||||||
|
if (h < 12) return 'صبح بهترین وقت نوشتن است.';
|
||||||
|
if (h < 17) return 'امروز چه مینویسید؟';
|
||||||
|
return 'شبهای خلاق را به یاد داشته باشید.';
|
||||||
|
})()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', paddingTop: 4 }}>
|
||||||
|
<button onClick={() => openEditor(null)} style={wwPrimaryBtn}>
|
||||||
|
{'✦ مقاله جدید'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => navigate('/admin')} style={wwGhostBtn}>
|
||||||
|
{'پنل کامل'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => navigate('/')} style={wwGhostBtn}>
|
||||||
|
{'سایت'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { logout(); navigate('/'); }} style={wwDangerBtn}>
|
||||||
|
{'خروج'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 0, overflowX: 'auto' }}>
|
||||||
|
{tabs.map(({ id, label, icon }) => (
|
||||||
|
<button key={id} onClick={() => setTab(id)} style={{
|
||||||
|
padding: '11px 22px', border: 'none', cursor: 'pointer',
|
||||||
|
fontFamily: 'Vazirmatn,sans-serif', fontSize: 13, fontWeight: 700,
|
||||||
|
background: 'transparent', whiteSpace: 'nowrap',
|
||||||
|
color: tab === id ? WW.terra : WW.muted,
|
||||||
|
borderBottom: tab === id ? `2.5px solid ${WW.terra}` : '2.5px solid transparent',
|
||||||
|
transition: 'color 0.18s, border-color 0.18s',
|
||||||
|
}}>
|
||||||
|
<span style={{ marginLeft: 5, fontSize: 10, opacity: 0.7 }}>{icon}</span>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ maxWidth: 1280, margin: '0 auto', padding: '28px clamp(16px,3vw,44px)' }}>
|
||||||
|
{tab === 'write' ? (
|
||||||
|
<ArticleEditor existing={editingArticle} role="writer" onSave={() => { setEditingArticle(null); setTab('overview'); load(); }} />
|
||||||
|
) : tab === 'articles' ? (
|
||||||
|
<ArticlesManagement role="writer" onEdit={openEditor} />
|
||||||
|
) : tab === 'analytics' ? (
|
||||||
|
<WriterAnalyticsPanel />
|
||||||
|
) : tab === 'comments' ? (
|
||||||
|
<section style={{ background: '#fff', border: `1px solid ${WW.border}`, borderRadius: 12, padding: 24, direction: 'rtl' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 18 }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: 15, color: WW.ink, fontWeight: 800 }}>
|
||||||
|
{'نظرها روی نوشتههای من'}
|
||||||
|
</h3>
|
||||||
|
{(data?.recentComments ?? []).length > 0 && (
|
||||||
|
<span style={{ fontSize: 11, color: WW.faint }}>
|
||||||
|
{(data.recentComments.length).toLocaleString('fa-IR')} {'نظر اخیر'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<PanelState loading={loading} error={error}
|
||||||
|
empty={!loading && !error && (data?.recentComments ?? []).length === 0}
|
||||||
|
emptyTitle="هنوز نظری ثبت نشده است" />
|
||||||
|
{(data?.recentComments ?? []).map(comment => (
|
||||||
|
<div key={comment.id} style={{ borderBottom: `1px solid ${WW.border}`, padding: '16px 0' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 6 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<strong style={{ color: WW.ink, fontSize: 13 }}>{comment.author?.name}</strong>
|
||||||
|
<span style={{ fontSize: 10, color: WW.faint, background: '#F2F4F4', padding: '2px 7px', borderRadius: 10 }}>
|
||||||
|
{comment.article?.title?.slice(0, 28)}{comment.article?.title?.length > 28 ? '…' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, color: WW.faint }}>{comment.time}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: '0 0 10px', color: WW.ink, lineHeight: 2, fontSize: 13 }}>{comment.body}</p>
|
||||||
|
<button onClick={() => navigate(`/article/${comment.article?.slug}`)} style={{ ...wwLinkBtn, fontSize: 11 }}>
|
||||||
|
{'رفتن به مقاله و مشاهده همه نظرها ←'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(data?.recentComments ?? []).length >= 8 && (
|
||||||
|
<div style={{ padding: '14px 0 4px', fontSize: 11, color: WW.faint, textAlign: 'center' }}>
|
||||||
|
فقط ۸ نظر اخیر نمایش داده میشود. برای مشاهده همه نظرها روی مقاله مربوطه بروید.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
) : renderOverview()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@media(max-width:900px){
|
||||||
|
.writer-work-grid, .writer-editor-meta { grid-template-columns: 1fr !important; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { WriterWorkspace };
|
||||||
Reference in New Issue
Block a user