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:
@@ -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, '<').replace(/>/g, '>');
|
||||||
|
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, '<').replace(/>/g, '>');
|
||||||
|
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} /> <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 };
|
||||||
Reference in New Issue
Block a user