Import source: app/src/admin/ArticleEditor

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:54 +03:30
parent fd84842f8a
commit 14a7b196f2
+742
View File
@@ -0,0 +1,742 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useToast, TypeBadge, CategoryBadge, pillBtn, inputStyle } from '../../teknav-ui.jsx';
import { LazyDiagram } from '../components/lazy-diagram.jsx';
import { adminApi } from '../lib/admin-api.js';
import { revisionsApi, presenceApi } from '../lib/engagement-api.js';
import { iconNode } from '../lib/icon.jsx';
import linkIcon from '../assets/icons/link.svg';
import imageIcon from '../assets/icons/image.svg';
import squarePenIcon from '../assets/icons/square-pen.svg';
import pencilIcon from '../assets/icons/pencil.svg';
import lockIcon from '../assets/icons/lock.svg';
import starIcon from '../assets/icons/star.svg';
import triangleAlertIcon from '../assets/icons/triangle-alert.svg';
import { BRAND, DIAGRAM_CHOICES, PanelState, actionBtn } from './Shared.jsx';
function ArtifactStudio({ selectedDiagram, onSelectDiagram, onInsert }) {
const [mode, setMode] = useState('library');
const [codeType, setCodeType] = useState('mermaid');
const [code, setCode] = useState('');
const [uploadState, setUploadState] = useState({ busy: false, message: '' });
const insertCodeArtifact = () => {
if (!code.trim()) return;
const safeCode = code.replace(/</g, '&lt;').replace(/>/g, '&gt;');
onInsert?.(`\n<div class="insight-box"><div class="insight-title">دیاگرام کدنویسی‌شده (${codeType})</div><pre dir="ltr">${safeCode}</pre></div>\n`);
};
const fileToBase64 = (file) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result).split(',')[1] || '');
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
const insertUploadedFile = (file, url) => {
const safeName = file.name.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const isImage = file.type.startsWith('image/');
onInsert?.(isImage
? `\n<figure><img src="${url}" alt="${safeName}" /><figcaption>${safeName}</figcaption></figure>\n`
: `\n<p><a href="${url}" target="_blank" rel="noopener">${safeName}</a></p>\n`);
};
const handleFileUpload = async (file) => {
if (!file) return;
if (file.size > 12 * 1024 * 1024) {
setUploadState({ busy: false, message: 'حداکثر حجم فایل برای بارگذاری مستقیم ۱۲ مگابایت است.' });
return;
}
setUploadState({ busy: true, message: 'در حال بارگذاری فایل...' });
try {
const dataBase64 = await fileToBase64(file);
const result = await adminApi.upload({ filename: file.name, mimeType: file.type || 'application/octet-stream', dataBase64 });
insertUploadedFile(file, result.url);
setUploadState({ busy: false, message: 'فایل روی سرور محلی ذخیره و به مقاله اضافه شد.' });
} catch {
setUploadState({ busy: false, message: 'اتصال upload در دسترس نبود؛ فایل درج نشد و داده نمونه یا محلی نمایش داده نمی‌شود.' });
}
};
return (
<section style={{ background: '#fff', border: `1px solid ${BRAND.line}`, borderRadius: 16, overflow: 'hidden', boxShadow: '0 14px 40px rgba(15,42,46,0.06)' }}>
<div style={{ padding: 18, background: `linear-gradient(135deg,${BRAND.primary} 0%,${BRAND.primaryDark} 100%)`, color: '#fff', direction: 'rtl' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 11, opacity: 0.76, marginBottom: 4 }}>Artifact Writing</div>
<h3 style={{ margin: 0, fontSize: 18, fontWeight: 900 }}>کتابخانه دیاگرام و ابزار کدنویسی</h3>
</div>
<div style={{ display: 'flex', background: 'rgba(255,255,255,0.12)', border: '1px solid rgba(255,255,255,0.18)', borderRadius: 10, padding: 3 }}>
{[['library', 'قالب‌ها'], ['code', 'کد اختصاصی']].map(([id, label]) => (
<button key={id} onClick={() => setMode(id)} style={{ border: 0, borderRadius: 8, padding: '7px 13px', cursor: 'pointer', background: mode === id ? '#fff' : 'transparent', color: mode === id ? BRAND.primary : '#fff', fontWeight: 800, fontSize: 12 }}>{label}</button>
))}
</div>
</div>
</div>
<div style={{ padding: '12px 18px', borderBottom: `1px solid ${BRAND.line}`, background: '#F8FBFA', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 12, color: '#5F6B6D', lineHeight: 1.7 }}>بارگذاری فایل از همین سرور انجام می‌شود و به CDN خارجی وابسته نیست.</div>
<label style={{ ...pillBtn, background: uploadState.busy ? '#5F6B6D' : BRAND.primary, color: '#fff', cursor: uploadState.busy ? 'wait' : 'pointer', margin: 0 }}>
{uploadState.busy ? 'در حال بارگذاری...' : 'بارگذاری فایل'}
<input type="file" disabled={uploadState.busy} onChange={e => handleFileUpload(e.target.files?.[0])} style={{ display: 'none' }} />
</label>
{uploadState.message && <div style={{ flexBasis: '100%', fontSize: 11, color: uploadState.message.includes('نبود') || uploadState.message.includes('حداکثر') ? '#C46A4D' : BRAND.primary }}>{uploadState.message}</div>}
</div>
{mode === 'library' ? (
<div style={{ padding: 18 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1.1fr 0.9fr', gap: 16 }} className="artifact-grid">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(150px,1fr))', gap: 10, maxHeight: 360, overflowY: 'auto', paddingLeft: 4 }}>
{DIAGRAM_CHOICES.map(t => (
<button key={t.id} onClick={() => onSelectDiagram(t.id)} title={t.label} style={{
textAlign: 'right', border: `1px solid ${selectedDiagram === t.id ? BRAND.primary : BRAND.line}`,
background: selectedDiagram === t.id ? `${BRAND.primary}10` : '#fff', borderRadius: 12, padding: 12, cursor: 'pointer',
boxShadow: selectedDiagram === t.id ? '0 8px 24px rgba(15,107,115,0.12)' : 'none',
}}>
<div style={{ height: 56, borderRadius: 10, marginBottom: 9, overflow: 'hidden', background: '#F4F0EA', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ width: 400, flexShrink: 0, transform: 'scale(0.33)', transformOrigin: 'center', pointerEvents: 'none' }}>
<LazyDiagram type={t.id} compact />
</div>
</div>
<div style={{ fontSize: 12, fontWeight: 800, color: BRAND.ink, lineHeight: 1.5 }}>{t.label}</div>
</button>
))}
</div>
<div style={{ background: BRAND.panel, border: `1px solid ${BRAND.line}`, borderRadius: 14, padding: 14 }}>
<div style={{ fontSize: 12, fontWeight: 800, color: BRAND.primary, marginBottom: 10 }}>پیش‌نمایش زنده</div>
<div style={{ background: '#fff', borderRadius: 12, padding: 10, minHeight: 180, display: 'flex', alignItems: 'center' }}>
<LazyDiagram type={selectedDiagram || DIAGRAM_CHOICES[0].id} compact />
</div>
<p style={{ margin: '12px 0 0', fontSize: 11, lineHeight: 1.8, color: '#5F6B6D' }}>فقط دیاگرام‌های واقعی پشتیبانی‌شده در کد نمایش داده می‌شوند؛ قالب نمونه یا محتوای آزمایشی درج نمی‌شود.</p>
</div>
</div>
</div>
) : (
<div style={{ padding: 18, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }} className="artifact-grid">
<div>
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
{['mermaid', 'plantuml', 'd2'].map(t => (
<button key={t} onClick={() => setCodeType(t)} style={{ ...actionBtn, background: codeType === t ? `${BRAND.primary}18` : BRAND.panel, color: codeType === t ? BRAND.primary : '#5F6B6D', padding: '7px 12px' }}>{t}</button>
))}
</div>
<textarea value={code} onChange={e => setCode(e.target.value)} rows={13} spellCheck={false} style={{ ...inputStyle, direction: 'ltr', textAlign: 'left', fontFamily: 'Consolas, monospace', lineHeight: 1.6, resize: 'vertical' }} />
<button onClick={insertCodeArtifact} style={{ ...pillBtn, background: BRAND.primary, color: '#fff', marginTop: 10 }}>درج به عنوان Artifact قابل ویرایش</button>
</div>
<div style={{ background: '#101A1C', color: '#DDEBE9', borderRadius: 14, padding: 16, minHeight: 290, direction: 'ltr', overflow: 'hidden', border: '1px solid rgba(15,107,115,0.45)', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12, color: '#8CCFD2', fontSize: 11 }}>
<span>code preview</span><span style={{ opacity: 0.7 }}>{codeType}</span>
</div>
{code.trim() ? (
<pre style={{ whiteSpace: 'pre-wrap', margin: 0, fontSize: 12, lineHeight: 1.7, flex: 1, overflowY: 'auto' }}>{code}</pre>
) : (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#4A6A6C', fontSize: 12, textAlign: 'center', lineHeight: 1.8 }}>
کد {codeType} را در کادر چپ بنویسید.<br />
<span style={{ opacity: 0.6, fontSize: 10 }}>render در مرورگر پس از درج انجام می‌شود.</span>
</div>
)}
</div>
</div>
)}
</section>
);
}
function HtmlEditor({ value, onChange }) {
const ref = useRef(null);
const toast = useToast();
const [webp, setWebp] = useState(false);
const [imgUploading, setImgUploading] = useState(false);
const fileRef = useRef(null);
const exec = useCallback((tag, attrs = '') => {
const el = ref.current;
if (!el) return;
const start = el.selectionStart;
const end = el.selectionEnd;
const sel = value.slice(start, end) || 'متن';
let insertion = '';
if (tag === 'hr') insertion = '\n<hr />\n';
else if (tag === 'a') insertion = `<a href="URL">${sel}</a>`;
else if (tag === 'img') insertion = `<img src="${attrs}" alt="" style="max-width:100%;border-radius:8px;margin:12px 0" />`;
else if (['h2','h3','blockquote','pre'].includes(tag)) insertion = `<${tag}>${sel}</${tag}>`;
else if (tag === 'ul') insertion = `<ul>\n <li>${sel}</li>\n</ul>`;
else if (tag === 'ol') insertion = `<ol>\n <li>${sel}</li>\n</ol>`;
else insertion = `<${tag}>${sel}</${tag}>`;
const next = value.slice(0, start) + insertion + value.slice(end);
onChange(next);
setTimeout(() => { el.focus(); el.selectionStart = el.selectionEnd = start + insertion.length; }, 0);
}, [value, onChange]);
const handleKeyDown = useCallback((e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === 'b') { e.preventDefault(); exec('strong'); }
else if (e.key === 'i') { e.preventDefault(); exec('em'); }
}
}, [exec]);
const handleImageUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setImgUploading(true);
try {
const base64 = await new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(r.result.split(',')[1]);
r.onerror = rej;
r.readAsDataURL(file);
});
const res = await adminApi.upload({ filename: file.name, mimeType: file.type, dataBase64: base64, webp });
if (res?.url) { exec('img', res.url); toast('تصویر بارگذاری شد'); }
else toast('خطا در بارگذاری تصویر', 'error');
} catch { toast('خطا در بارگذاری تصویر', 'error'); }
finally { setImgUploading(false); e.target.value = ''; }
};
const [hoveredBtn, setHoveredBtn] = useState(null);
const TB = ({ id, label, onClick, title, bold, mono }) => {
const isHovered = hoveredBtn === id;
return (
<button
type="button"
title={title || label}
onClick={onClick}
onMouseEnter={() => setHoveredBtn(id)}
onMouseLeave={() => setHoveredBtn(null)}
style={{
padding: '4px 9px', border: '1px solid', borderRadius: 5, cursor: 'pointer',
fontFamily: mono ? 'monospace' : 'Vazirmatn,sans-serif',
fontSize: mono ? 11 : 12,
fontWeight: bold ? 700 : 400,
background: isHovered ? '#EFE9E0' : '#fff',
color: isHovered ? '#C46A4D' : '#263238',
borderColor: isHovered ? '#C8951C' : '#D4C9B8',
transition: 'background 0.12s, color 0.12s, border-color 0.12s',
lineHeight: 1,
userSelect: 'none',
}}
>{label}</button>
);
};
const TBSep = () => (
<div style={{ width: 1, alignSelf: 'stretch', background: '#D4C9B8', margin: '2px 3px' }} />
);
const wordCount = value.replace(/<[^>]*>/g, ' ').trim().split(/\s+/).filter(Boolean).length;
const charCount = value.replace(/<[^>]*>/g, '').length;
return (
<div>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 4, padding: '7px 10px', background: '#F9F7F4', border: '1px solid #E4DDD2', borderRadius: '8px 8px 0 0', direction: 'ltr' }}>
<TB id="bold" label="B" title="Bold (Ctrl+B)" onClick={() => exec('strong')} bold />
<TB id="italic" label="I" title="Italic (Ctrl+I)" onClick={() => exec('em')} />
<TBSep />
<TB id="h2" label="H2" title="Heading 2" onClick={() => exec('h2')} />
<TB id="h3" label="H3" title="Heading 3" onClick={() => exec('h3')} />
<TBSep />
<TB id="link" label={<span className="tek-icon" style={{ ['--i']: `url("${linkIcon}")` }} aria-hidden="true" />} title="Link" onClick={() => exec('a')} />
<TB id="bq" label="«»" title="Blockquote" onClick={() => exec('blockquote')} />
<TB id="code" label="</>" title="Inline code" onClick={() => exec('code')} mono />
<TB id="pre" label="{ }" title="Code block" onClick={() => exec('pre')} mono />
<TBSep />
<TB id="ul" label="≡" title="Bullet list" onClick={() => exec('ul')} />
<TB id="ol" label="1." title="Numbered list" onClick={() => exec('ol')} />
<TB id="hr" label="—" title="Horizontal rule" onClick={() => exec('hr')} />
<TBSep />
<label
title="بارگذاری تصویر"
onMouseEnter={() => setHoveredBtn('img')}
onMouseLeave={() => setHoveredBtn(null)}
style={{
padding: '4px 9px', border: '1px solid', borderRadius: 5, cursor: 'pointer',
fontSize: 12,
background: hoveredBtn === 'img' ? '#EFE9E0' : '#fff',
color: hoveredBtn === 'img' ? '#C46A4D' : '#263238',
borderColor: hoveredBtn === 'img' ? '#C8951C' : '#D4C9B8',
transition: 'background 0.12s, color 0.12s, border-color 0.12s',
display: 'flex', alignItems: 'center', gap: 4, lineHeight: 1, userSelect: 'none',
}}
>
<span className="tek-icon" style={{ ['--i']: `url("${imageIcon}")` }} aria-hidden="true" /> {imgUploading ? '...' : 'تصویر'}
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleImageUpload} />
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#5F6B6D', cursor: 'pointer', marginInlineStart: 2 }}>
<input type="checkbox" checked={webp} onChange={e => setWebp(e.target.checked)} />
WebP
</label>
</div>
<textarea
ref={ref}
value={value}
onChange={e => onChange(e.target.value)}
onKeyDown={handleKeyDown}
rows={18}
style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.8, direction: 'rtl', borderTop: 'none', borderBottom: 'none', borderRadius: 0, fontFamily: 'monospace', fontSize: 13 }}
dir="rtl"
placeholder="<p>محتوای مقاله را اینجا بنویسید...</p>"
/>
<div style={{ display: 'flex', gap: 16, padding: '5px 12px', background: '#F9F7F4', border: '1px solid #E4DDD2', borderTop: 'none', borderRadius: '0 0 8px 8px', fontSize: 11, color: '#8A9A9B', direction: 'ltr' }}>
<span>{wordCount.toLocaleString('fa-IR')} کلمه</span>
<span>{charCount.toLocaleString('fa-IR')} کاراکتر</span>
</div>
</div>
);
}
function toDatetimeLocal(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const pad = (n) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function RevisionDrawer({ articleId, canRestore, onClose, onRestored }) {
const toast = useToast();
const [page, setPage] = useState(1);
const [data, setData] = useState(null);
const [selected, setSelected] = useState(null);
const [content, setContent] = useState(null);
const [restoring, setRestoring] = useState(false);
useEffect(() => {
setData(null);
revisionsApi.list(articleId, page).then(setData).catch(() => setData({ items: [], total: 0, pages: 1 }));
}, [articleId, page]);
const openRevision = async (rev) => {
setSelected(rev);
setContent(null);
try {
const d = await revisionsApi.get(articleId, rev.id);
setContent(d.revision.content);
} catch {
setContent('بارگذاری محتوا ناموفق بود');
}
};
const restore = async () => {
if (!selected) return;
setRestoring(true);
try {
await revisionsApi.restore(articleId, selected.id);
toast('نسخه قبلی بازیابی شد');
onRestored?.();
} catch {
toast('بازیابی ناموفق بود', 'error');
} finally {
setRestoring(false);
}
};
const fmtDate = (iso) => new Date(iso).toLocaleString('fa-IR');
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 700, display: 'flex', alignItems: 'stretch', justifyContent: 'flex-end' }}>
<div onClick={onClose} style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.45)' }} />
<div style={{ position: 'relative', width: '100%', maxWidth: 520, background: '#FAF7F0', display: 'flex', flexDirection: 'column', direction: 'rtl', boxShadow: '-8px 0 40px rgba(0,0,0,0.18)' }}>
<div style={{ padding: '20px 24px', borderBottom: '1px solid #E4DDD2', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ margin: 0, fontSize: 18, fontWeight: 900, color: '#263238' }}>تاریخچه نسخه‌ها</h3>
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 20, cursor: 'pointer', color: '#5F6B6D' }}>✕</button>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px' }}>
{!data ? (
<div style={{ color: '#5F6B6D', fontSize: 13 }}>در حال بارگذاری…</div>
) : data.items.length === 0 ? (
<div style={{ color: '#5F6B6D', fontSize: 13 }}>هنوز نسخه‌ای ذخیره نشده است.</div>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{data.items.map((rev) => (
<button
key={rev.id}
onClick={() => openRevision(rev)}
style={{ display: 'block', width: '100%', textAlign: 'right', background: selected?.id === rev.id ? '#F0F9F4' : '#fff', border: `1px solid ${selected?.id === rev.id ? '#3A7D5E' : '#E4DDD2'}`, borderRadius: 10, padding: '10px 14px', cursor: 'pointer' }}
>
<div style={{ fontSize: 13, fontWeight: 700, color: '#263238', marginBottom: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{rev.title}</div>
<div style={{ fontSize: 11, color: '#5F6B6D' }}>{fmtDate(rev.createdAt)} · {rev.savedByName ?? 'ناشناس'} · {rev.status}</div>
</button>
))}
</div>
)}
{data && data.pages > 1 && (
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', marginTop: 16 }}>
{page > 1 && <button onClick={() => setPage(p => p - 1)} style={{ ...pillBtn, fontSize: 12 }}>قبلی</button>}
<span style={{ fontSize: 12, color: '#5F6B6D', alignSelf: 'center' }}>{page} / {data.pages}</span>
{page < data.pages && <button onClick={() => setPage(p => p + 1)} style={{ ...pillBtn, fontSize: 12 }}>بعدی</button>}
</div>
)}
{selected && (
<div style={{ marginTop: 20, background: '#fff', border: '1px solid #E4DDD2', borderRadius: 10, padding: 16 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#263238', marginBottom: 10 }}>پیش‌نمایش محتوا — {selected.title}</div>
{content === null ? (
<div style={{ fontSize: 12, color: '#5F6B6D' }}>در حال بارگذاری محتوا…</div>
) : (
<div style={{ fontSize: 13, color: '#263238', lineHeight: 1.8, maxHeight: 300, overflowY: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }} dangerouslySetInnerHTML={{ __html: content }} />
)}
</div>
)}
</div>
{canRestore && selected && (
<div style={{ padding: '16px 24px', borderTop: '1px solid #E4DDD2' }}>
<button
onClick={restore}
disabled={restoring}
style={{ ...pillBtn, width: '100%', background: '#3A7D5E', color: '#fff', padding: '12px 0', fontSize: 14, opacity: restoring ? 0.6 : 1 }}
>
{restoring ? '…' : `بازیابی این نسخه`}
</button>
</div>
)}
</div>
</div>
);
}
function ArticleEditor({ existing, onSave, role = 'writer' }) {
const toast = useToast();
const [cats, setCats] = useState([]);
const [authors, setAuthors] = useState([]);
const [metaLoading, setMetaLoading] = useState(true);
const [metaError, setMetaError] = useState(false);
const [form, setForm] = useState({
title: '', subtitle: '', summary: '', category: '', categoryName: '',
authorId: '', authorName: '', readTime: 7,
type: 'تحلیل عمیق', status: 'پیش‌نویس', tags: '',
diagram: 'neural', content: '', slug: '', ...existing,
scheduledAt: toDatetimeLocal(existing?.scheduledAt),
});
const [preview, setPreview] = useState(false);
const [errors, setErrors] = useState({});
const [savedForm, setSavedForm] = useState(() => JSON.stringify(form));
const isDirty = JSON.stringify(form) !== savedForm;
useEffect(() => {
let cancelled = false;
setMetaLoading(true);
setMetaError(false);
Promise.all([adminApi.listCategories(), adminApi.listAuthors()])
.then(([categoryItems, authorItems]) => {
if (cancelled) return;
const nextCats = categoryItems ?? [];
const nextAuthors = authorItems ?? [];
setCats(nextCats);
setAuthors(nextAuthors);
setForm(f => ({
...f,
category: f.category || nextCats[0]?.slug || nextCats[0]?.id || '',
categoryName: f.categoryName || nextCats[0]?.name || '',
authorId: f.authorId || nextAuthors[0]?.id || '',
authorName: f.authorName || nextAuthors[0]?.name || '',
}));
})
.catch(() => { if (!cancelled) { setCats([]); setAuthors([]); setMetaError(true); } })
.finally(() => { if (!cancelled) setMetaLoading(false); });
return () => { cancelled = true; };
}, []);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const insertContent = (snippet) => setForm(f => ({ ...f, content: `${f.content || ''}${snippet}` }));
const [seoOpen, setSeoOpen] = useState(false);
const [revisionDrawerOpen, setRevisionDrawerOpen] = useState(false);
const [editors, setEditors] = useState([]);
const isPublished = existing?.status === 'منتشرشده';
const canPublishDirectly = role === 'admin' || role === 'editor';
useEffect(() => {
if (!existing?.id) return;
presenceApi.heartbeat(existing.id).catch(() => {});
presenceApi.getEditors(existing.id).then((d) => setEditors(d.editors ?? [])).catch(() => {});
const beat = setInterval(() => {
presenceApi.heartbeat(existing.id).catch(() => {});
presenceApi.getEditors(existing.id).then((d) => setEditors(d.editors ?? [])).catch(() => {});
}, 30_000);
return () => clearInterval(beat);
}, [existing?.id]);
useEffect(() => {
const handler = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
if (isPublished && canPublishDirectly) {
handleSaveRef.current?.('منتشرشده');
} else {
handleSaveRef.current?.('پیش‌نویس');
}
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [isPublished, canPublishDirectly]);
const handleSaveRef = useRef(null);
const validate = (nextStatus = form.status) => {
const e = {};
if (!form.title.trim()) e.title = 'عنوان الزامی است';
if (!form.summary.trim()) e.summary = 'خلاصه الزامی است';
if (!form.content.trim()) e.content = 'محتوا الزامی است';
if (!form.category) e.category = 'دسته‌بندی واقعی از API الزامی است';
if (!form.authorId) e.authorId = 'نویسنده واقعی از API الزامی است';
if (nextStatus === 'زمان‌بندی‌شده' && !form.scheduledAt) e.scheduledAt = 'زمان انتشار برای زمان‌بندی الزامی است';
setErrors(e);
return Object.keys(e).length === 0;
};
const handleSave = (status) => {
const nextForm = { ...form, status };
if (status === 'زمان‌بندی‌شده' && !nextForm.scheduledAt) {
setErrors(e => ({ ...e, scheduledAt: 'زمان انتشار برای زمان‌بندی الزامی است' }));
toast('برای زمان‌بندی، تاریخ و ساعت انتشار را وارد کنید', 'error');
return;
}
if (!validate(status)) { toast('لطفاً فیلدهای الزامی را پر کنید', 'error'); return; }
const cat = cats.find(c => c.id === form.category || c.slug === form.category);
const author = authors.find(a => a.id === form.authorId);
const data = {
...nextForm,
scheduledAt: nextForm.scheduledAt || null,
categoryName: cat?.name || form.categoryName,
authorName: author?.name || form.authorName,
slug: form.slug || form.title.replace(/\s+/g, '-').replace(/[^\w\u0600-\u06FF-]/g, ''),
tags: typeof form.tags === 'string' ? form.tags.split(',').map(t => t.trim()).filter(Boolean) : form.tags,
keywords: typeof form.keywords === 'string' ? form.keywords.split(',').map(t => t.trim()).filter(Boolean) : (form.keywords ?? []),
};
const save = existing?.id ? adminApi.updateArticle(existing.id, data) : adminApi.createArticle(data);
save
.then(() => {
setSavedForm(JSON.stringify(form));
const msg =
status === 'منتشرشده' ? 'مقاله منتشرشده بروزرسانی شد' :
status === 'پیش‌نویس' ? 'پیش‌نویس ذخیره شد' :
status === 'زمان‌بندی‌شده' ? 'مقاله زمان‌بندی شد' :
'مقاله برای بررسی ارسال شد';
toast(msg);
onSave?.();
})
.catch((err) => {
if (err?.body?.error === 'quality_check_failed') {
toast(`انتشار متوقف شد: ${err.body.issues?.join('، ')}`, 'error');
} else {
toast('خطا در ذخیره مقاله', 'error');
}
});
};
handleSaveRef.current = handleSave;
const field = (label, key, type = 'text', options = null) => (
<div style={{ marginBottom: 18 }}>
<label style={{ fontSize: 13, fontWeight: 600, color: '#5F6B6D', display: 'block', marginBottom: 6 }}>{label}</label>
{options ? (
<select value={form[key]} onChange={e => set(key, e.target.value)} style={{ ...inputStyle }} dir="rtl">
{options.map(o => <option key={o.v} value={o.v}>{o.l}</option>)}
</select>
) : type === 'textarea' ? (
<textarea value={form[key]} onChange={e => set(key, e.target.value)} rows={key === 'content' ? 14 : 3}
style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.8 }} dir="rtl" />
) : (
<input type={type} value={form[key]} onChange={e => set(key, type === 'number' ? +e.target.value : e.target.value)} style={{ ...inputStyle }} dir="rtl" />
)}
{errors[key] && <div style={{ fontSize: 12, color: '#C94C4C', marginTop: 4 }}>{errors[key]}</div>}
</div>
);
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: existing?.reviewNote ? 12 : 24, gap: 16, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 11, color: BRAND.primary, fontWeight: 800, marginBottom: 4 }}>جریان تولید: نوشتن → Artifact → بازبینی → انتشار</div>
<h2 style={{ fontSize: 24, fontWeight: 900, color: BRAND.ink, margin: 0, fontFamily: "'PelakFA','Vazirmatn',sans-serif" }}>
{existing ? (isPublished ? 'ویرایش مقاله منتشرشده' : 'ویرایش مقاله') : 'مقاله جدید'}
</h2>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
{editors.filter(e => e.userId !== undefined).length > 1 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, background: '#FFF8F0', border: '1px solid #F5C97A', borderRadius: 20, padding: '4px 10px', fontSize: 11, color: '#7A5A00' }}>
{editors.slice(0, 4).map((ed, i) => (
<span key={i} title={ed.name || ed.userId} style={{ width: 22, height: 22, borderRadius: '50%', background: '#E07A3A', color: '#fff', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 9, fontWeight: 800, marginLeft: -6, border: '2px solid #fff' }}>
{(ed.name || '؟')[0]}
</span>
))}
<span style={{ marginRight: 8 }}>{editors.length} ویرایشگر آنلاین</span>
</div>
)}
{existing?.id && (
<button onClick={() => setRevisionDrawerOpen(true)} style={{ ...pillBtn, background: '#F4EFE6', color: '#263238', border: '1px solid #E4DDD2', fontSize: 12 }}>تاریخچه</button>
)}
<button onClick={() => setPreview(p => !p)} style={{ ...pillBtn, background: preview ? BRAND.primary : '#F4EFE6', color: preview ? '#fff' : '#263238', border: `1px solid ${preview ? BRAND.primary : BRAND.line}` }}>{preview ? 'ویرایش' : 'پیش‌نمایش'}</button>
</div>
</div>
{revisionDrawerOpen && existing?.id && (
<RevisionDrawer articleId={existing.id} canRestore={canPublishDirectly} onClose={() => setRevisionDrawerOpen(false)} onRestored={() => { setRevisionDrawerOpen(false); onSave?.(); }} />
)}
{existing?.reviewNote && (
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start', background: 'linear-gradient(135deg,#FFF8F0,#FFF3E0)', border: '1px solid rgba(200,149,28,0.35)', borderRadius: 10, padding: '12px 16px', marginBottom: 20 }}>
<span style={{ fontSize: 18, flexShrink: 0 }}><span className="tek-icon" style={{ ['--i']: `url("${triangleAlertIcon}")` }} aria-hidden="true" /></span>
<div>
<div style={{ fontSize: 12, fontWeight: 800, color: '#7A5A00', marginBottom: 3 }}>بازخورد ویراستار</div>
<div style={{ fontSize: 13, color: '#5A4000', lineHeight: 1.8 }}>{existing.reviewNote}</div>
</div>
</div>
)}
{isPublished && !canPublishDirectly && (
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start', background: 'linear-gradient(135deg,#EFF6FF,#E0EEFF)', border: '1px solid rgba(59,130,246,0.30)', borderRadius: 10, padding: '12px 16px', marginBottom: 20 }}>
<span style={{ fontSize: 18, flexShrink: 0 }}><span className="tek-icon" style={{ ['--i']: `url("${squarePenIcon}")` }} aria-hidden="true" /></span>
<div>
<div style={{ fontSize: 12, fontWeight: 800, color: '#1E3A6E', marginBottom: 3 }}>ویرایش مقاله منتشرشده</div>
<div style={{ fontSize: 13, color: '#1E3A6E', lineHeight: 1.8 }}>تغییرات شما ابتدا پیش‌نویس ذخیره می‌شود و پس از ارسال برای بررسی، سردبیر آن را بررسی کرده و منتشر خواهد کرد.</div>
</div>
</div>
)}
{isPublished && canPublishDirectly && (
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start', background: 'linear-gradient(135deg,#F0FDF4,#DCFCE7)', border: '1px solid rgba(34,197,94,0.30)', borderRadius: 10, padding: '12px 16px', marginBottom: 20 }}>
<span style={{ fontSize: 18, flexShrink: 0 }}><span className="tek-icon" style={{ ['--i']: `url("${pencilIcon}")` }} aria-hidden="true" /></span>
<div>
<div style={{ fontSize: 12, fontWeight: 800, color: '#14532D', marginBottom: 3 }}>ویرایش مستقیم مقاله منتشرشده</div>
<div style={{ fontSize: 13, color: '#14532D', lineHeight: 1.8 }}>این مقاله هم‌اکنون منتشر است. تغییرات با کلیک «بروزرسانی و انتشار» بلافاصله در سایت اعمال می‌شود. Ctrl+S نیز همین کار را انجام می‌دهد.</div>
</div>
</div>
)}
<PanelState loading={metaLoading} error={metaError} empty={!metaLoading && !metaError && (cats.length === 0 || authors.length === 0)} emptyTitle="برای ایجاد مقاله، دسته‌بندی و نویسنده واقعی باید از API دریافت شود" />
{!metaLoading && !metaError && cats.length > 0 && authors.length > 0 && (
<>
{preview ? (
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 32, direction: 'rtl' }}>
<TypeBadge type={form.type} />&nbsp;<CategoryBadge name={cats.find(c => c.id === form.category || c.slug === form.category)?.name || form.categoryName || 'بدون دسته‌بندی'} color={cats.find(c => c.id === form.category || c.slug === form.category)?.color} />
<h1 style={{ fontSize: 28, fontWeight: 900, color: '#263238', margin: '16px 0 8px', lineHeight: 1.4 }}>{form.title || 'عنوان مقاله'}</h1>
{form.subtitle && <p style={{ fontSize: 18, color: '#5F6B6D', margin: '0 0 8px', lineHeight: 1.6, fontWeight: 500 }}>{form.subtitle}</p>}
<p style={{ fontSize: 16, color: '#5F6B6D', margin: '0 0 24px', lineHeight: 1.7 }}>{form.summary}</p>
{form.diagram && (
<div style={{ background: '#F4F0EA', borderRadius: 12, padding: '20px', marginBottom: 28, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<LazyDiagram type={form.diagram} />
</div>
)}
<div style={{ fontSize: 16, lineHeight: 2, color: '#263238' }} dangerouslySetInnerHTML={{ __html: form.content }} />
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: '0.9fr 1.1fr', gap: 24, alignItems: 'start' }} className="editor-grid">
<div style={{ background: '#fff', border: `1px solid ${BRAND.line}`, borderRadius: 16, padding: 22, boxShadow: '0 10px 34px rgba(15,42,46,0.05)' }}>
<div style={{ fontSize: 11, fontWeight: 800, color: BRAND.primary, letterSpacing: '0.08em', marginBottom: 14, paddingBottom: 8, borderBottom: `1px solid ${BRAND.line}` }}>
اطلاعات پایه
</div>
{field('عنوان', 'title')}
{field('زیرعنوان', 'subtitle')}
{field('خلاصه', 'summary', 'textarea')}
{field('دسته‌بندی', 'category', 'text', cats.map(c => ({ v: c.slug || c.id, l: c.name })))}
{field('نویسنده', 'authorId', 'text', authors.map(a => ({ v: a.id, l: a.name })))}
{field('نوع محتوا', 'type', 'text', ['تحلیل عمیق', 'راهنمای فنی', 'داده‌نما', 'پژوهش', 'پرونده', 'خبر فوری'].map(t => ({ v: t, l: t })))}
{field('زمان مطالعه (دقیقه)', 'readTime', 'number')}
{field('برچسب‌ها (با کاما جدا کنید)', 'tags')}
<div style={{ fontSize: 11, fontWeight: 800, color: BRAND.primary, letterSpacing: '0.08em', margin: '20px 0 14px', paddingBottom: 8, borderBottom: `1px solid ${BRAND.line}` }}>
نشانه‌گذاری
</div>
{[
{ key: 'premiumOnly', label: 'فقط اعضای ویژه', icon: lockIcon },
{ key: 'sponsored', label: 'محتوای حمایت‌شده', icon: starIcon },
{ key: 'featured', label: 'مقاله ویژه', icon: '★' },
].map(({ key, label, icon }) => (
<div key={key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, color: '#263238', cursor: 'pointer' }}>
<span style={{ fontSize: 14 }}>{iconNode(icon)}</span>
<span>{label}</span>
</label>
<button
type="button"
onClick={() => set(key, !form[key])}
style={{
width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
background: form[key] ? BRAND.primary : '#D0CCC5',
position: 'relative', transition: 'background 0.2s', flexShrink: 0,
}}
aria-pressed={!!form[key]}
>
<span style={{
position: 'absolute', top: 3, width: 18, height: 18, borderRadius: '50%', background: '#fff',
transition: 'inset-inline-start 0.2s',
insetInlineStart: form[key] ? 23 : 3,
}} />
</button>
</div>
))}
<div style={{ fontSize: 11, fontWeight: 800, color: BRAND.primary, letterSpacing: '0.08em', margin: '20px 0 14px', paddingBottom: 8, borderBottom: `1px solid ${BRAND.line}` }}>
زمان‌بندی
</div>
{field('زمان انتشار زمان‌بندی‌شده', 'scheduledAt', 'datetime-local')}
<button
type="button"
onClick={() => setSeoOpen(o => !o)}
style={{ width: '100%', background: 'none', border: 'none', cursor: 'pointer', padding: '10px 0 8px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontFamily: 'Vazirmatn,sans-serif', marginBottom: 2 }}
>
<span style={{ fontSize: 11, fontWeight: 800, color: BRAND.primary, letterSpacing: '0.08em' }}>سئو پیشرفته</span>
<span style={{ fontSize: 11, color: BRAND.muted, borderBottom: `1px solid ${BRAND.line}`, width: '75%', display: 'inline-block' }} />
<span style={{ fontSize: 12, color: BRAND.primary, marginRight: 6 }}>{seoOpen ? '▴' : '▾'}</span>
</button>
{seoOpen && (
<div style={{ paddingTop: 4 }}>
{field('آدرس مقاله (slug)', 'slug')}
{field('متا دسکریپشن', 'metaDescription', 'textarea')}
{field('کلیدواژه‌ها (با کاما)', 'keywords')}
{field('عنوان Open Graph', 'ogTitle')}
{field('توضیح Open Graph', 'ogDescription', 'textarea')}
{field('تصویر Open Graph', 'ogImage')}
{field('مسیر canonical', 'canonicalPath')}
</div>
)}
</div>
<div>
<div style={{ marginBottom: 18 }}>
<label style={{ fontSize: 13, fontWeight: 600, color: '#5F6B6D', display: 'block', marginBottom: 6 }}>محتوای مقاله</label>
<HtmlEditor value={form.content} onChange={v => set('content', v)} />
{errors.content && <div style={{ fontSize: 12, color: '#C94C4C', marginTop: 4 }}>{errors.content}</div>}
</div>
<ArtifactStudio
selectedDiagram={form.diagram}
onSelectDiagram={(diagram) => set('diagram', diagram)}
onInsert={insertContent}
/>
</div>
</div>
)}
<div style={{ display: 'flex', gap: 10, marginTop: 24, flexWrap: 'wrap', alignItems: 'center' }}>
{isPublished && canPublishDirectly ? (
<>
<button onClick={() => handleSave('منتشرشده')} style={{ ...pillBtn, background: '#2F8F6B', color: '#fff', padding: '10px 24px' }}>بروزرسانی و انتشار</button>
<button onClick={() => handleSave('پیش‌نویس')} style={{ ...pillBtn, background: '#F4EFE6', color: '#263238', border: '1px solid #E4DDD2', padding: '10px 24px' }}>تبدیل به پیش‌نویس</button>
<button onClick={() => handleSave('زمان‌بندی‌شده')} style={{ ...pillBtn, background: '#D49A2A', color: '#fff', padding: '10px 24px' }}>زمان‌بندی مجدد</button>
</>
) : isPublished ? (
<>
<button onClick={() => handleSave('پیش‌نویس')} style={{ ...pillBtn, background: '#F4EFE6', color: '#263238', border: '1px solid #E4DDD2', padding: '10px 24px' }}>ذخیره پیش‌نویس</button>
<button onClick={() => handleSave('در انتظار بررسی')} style={{ ...pillBtn, background: '#0F6B73', color: '#fff', padding: '10px 24px' }}>ارسال برای بررسی مجدد</button>
</>
) : (
<>
<button onClick={() => handleSave('پیش‌نویس')} style={{ ...pillBtn, background: '#F4EFE6', color: '#263238', border: '1px solid #E4DDD2', padding: '10px 24px' }}>ذخیره پیش‌نویس</button>
<button onClick={() => handleSave('در انتظار بررسی')} style={{ ...pillBtn, background: '#0F6B73', color: '#fff', padding: '10px 24px' }}>ارسال برای بررسی</button>
<button onClick={() => handleSave('زمان‌بندی‌شده')} style={{ ...pillBtn, background: '#D49A2A', color: '#fff', padding: '10px 24px' }}>زمان‌بندی انتشار</button>
</>
)}
{onSave && <button onClick={onSave} style={{ ...pillBtn, background: '#F4EFE6', color: '#5F6B6D', padding: '10px 24px' }}>انصراف</button>}
{isDirty && (
<span style={{ fontSize: 11, color: '#C94C4C', fontWeight: 700, marginInlineStart: 4 }}>
● ذخیره نشده · Ctrl+S
</span>
)}
</div>
</>
)}
<style>{`
.editor-grid { grid-template-columns: 0.9fr 1.1fr; }
.artifact-grid { grid-template-columns: 1.1fr 0.9fr; }
.artifact-filters { grid-template-columns: 1fr 170px; }
@media(max-width:980px){
.editor-grid, .artifact-grid, .artifact-filters { grid-template-columns: 1fr !important; }
}
`}</style>
</div>
);
}
export { ArticleEditor, ArtifactStudio, HtmlEditor, RevisionDrawer, toDatetimeLocal };