steelforesight/panel/public/app.js

2791 lines
144 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const $ = (sel, root = document) => root.querySelector(sel);
const root = $('#app');
const TAB_GROUPS = [
{ label: 'محتوا', tabs: ['glance', 'radarFuture', 'factoryReports', 'media', 'events', 'banners'] },
{ label: 'کاربران', tabs: ['members', 'newsletter'] },
];
const TAB_LABELS = {
glance: 'در یک نگاه', radarFuture: 'رادار آینده', factoryReports: 'ماهنامه کارخانه', media: 'ویدیوکست و پادکست',
events: 'رویدادها', banners: 'بنرها',
members: 'کاربران سایت', newsletter: 'خبرنامه',
};
const CATEGORIES = [
'بازار جهانی', 'سیاست‌گذاری', 'انرژی و ESG', 'صادرات', 'تولید داخلی', 'ریسک و بحران',
'رادار آینده', 'اخبار صنعت', 'گزارش کارخانه',
];
const TYPES = [
{ value: 'quarterly', label: 'فصلی' },
{ value: 'flash', label: 'فلش' },
{ value: 'risk', label: 'هشدار ریسک' },
{ value: 'free', label: 'رایگان' },
{ value: 'special', label: 'ویژه' },
{ value: 'video', label: 'ویدیوکست' },
{ value: 'podcast', label: 'پادکست' },
{ value: 'news', label: 'خبر' },
{ value: 'radar', label: 'رادار آینده' },
];
const CONTENT_TYPES = [
{ key: 'report', label: 'گزارش تحلیلی', icon: '📊', desc: 'گزارش فصلی، فلش یا ویژه با تحلیل بازار', defaultType: 'quarterly', defaultCategory: '' },
{ key: 'news', label: 'خبر', icon: '📰', desc: 'اخبار صنعت فولاد', defaultType: 'news', defaultCategory: 'اخبار صنعت' },
{ key: 'video', label: 'ویدیوکست', icon: '🎬', desc: 'ویدیوی تحلیلی یا مصاحبه', defaultType: 'video', defaultCategory: '' },
{ key: 'podcast', label: 'پادکست', icon: '🎙️', desc: 'اپیزود پادکست صوتی', defaultType: 'podcast', defaultCategory: '' },
{ key: 'factory', label: 'گزارش کارخانه', icon: '🏭', desc: 'گزارش تولید و عملیات کارخانه', defaultType: 'factoryReport', defaultCategory: 'گزارش کارخانه' },
{ key: 'radarFuture', label: 'رادار آینده', icon: '🔭', desc: 'مقالهٔ بلوکی (متن/عکس) برای ۵ زیرمنوی رادار آینده', defaultType: 'radarfuture', defaultCategory: '' },
];
// the 5 رادار آینده sub-sections (block-article categories)
const RADAR_FUTURE_CATEGORIES = [
'پایداری و فولاد سبز',
'ژئوپلیتیک و اقتصاد جهانی',
'فناوری و نوآوری',
'بازار و زنجیره فولاد',
];
function inferContentType(a) {
if (!a) return 'report';
if (a.type === 'video') return 'video';
if (a.type === 'podcast') return 'podcast';
if (a.type === 'radarfuture') return 'radarFuture';
if (a.type === 'factoryReport' || a.category === 'گزارش کارخانه') return 'factory';
if (a.type === 'news') return 'news';
return 'report';
}
const EVENT_TYPES = [
{ value: 'conference', label: 'کنگره' },
{ value: 'exhibition', label: 'نمایشگاه' },
{ value: 'seminar', label: 'سمینار' },
{ value: 'international', label: 'جهانی' },
];
const RADAR_CATEGORIES = [
{ value: 'market', label: 'بازارهای جهانی و اقتصادِ کلان' },
{ value: 'tech', label: 'نوآوری و فناوری‌هایِ تحول‌آفرین' },
{ value: 'commodity', label: 'مواد معدنیِ حیاتی و زنجیره‌ی تامین' },
{ value: 'geo', label: 'ژئوپلیتیک و امنیتِ اقتصادی' },
{ value: 'energy', label: 'تجارت و کسب‌وکارِ بین‌الملل' },
];
const SCROLL_DIRS = [
{ value: 'up', label: 'از پایین' },
{ value: 'left', label: 'از چپ' },
{ value: 'right', label: 'از راست' },
];
const INTEGRATION_ICONS = [
{ value: 'exchange', label: 'صرافی' },
{ value: 'globe', label: 'جهانی' },
{ value: 'chart', label: 'نمودار' },
{ value: 'activity', label: 'فعالیت' },
{ value: 'database', label: 'پایگاه داده' },
{ value: 'newspaper', label: 'خبرگزاری' },
{ value: 'building', label: 'سازمان' },
{ value: 'trending', label: 'روند' },
{ value: 'coins', label: 'کالا' },
];
const TREND_TYPES = [
{ value: 'up', label: 'صعودی ↑' },
{ value: 'down', label: 'نزولی ↓' },
{ value: 'flat', label: 'ثابت →' },
];
const CHART_SERIES = [
{ value: 'history', label: 'روند قیمت' },
{ value: 'export', label: 'صادرات ماهانه' },
{ value: 'comparison', label: 'مقایسه جهانی' },
];
const VISION_ICONS = ['FlaskConical', 'Network', 'Leaf', 'Telescope', 'Sparkles'];
async function api(path, { method = 'GET', body, isForm = false } = {}) {
const headers = {};
if (body && !isForm) headers['Content-Type'] = 'application/json';
const res = await fetch(path, {
method,
credentials: 'include',
headers,
body: isForm ? body : (body ? JSON.stringify(body) : undefined),
});
if (res.status === 401) {
renderLogin('نیاز به ورود مجدد است');
throw new Error('unauthorized');
}
if (!res.ok) {
const text = await res.text();
throw new Error(text || res.statusText);
}
if (res.status === 204) return null;
return res.json();
}
function renderLogin(errorMsg = '') {
root.innerHTML = `
<div class="auth-wrap">
<form class="card auth-card" id="loginForm">
<h1>ورود به پنل مدیریت</h1>
<p class="muted">اندیشکده فولاد</p>
<label>نام کاربری
<input name="username" autocomplete="username" required />
</label>
<label>رمز عبور
<input name="password" type="password" autocomplete="current-password" required />
</label>
${errorMsg ? `<div class="error">${escapeHtml(errorMsg)}</div>` : ''}
<button type="submit">ورود</button>
</form>
</div>
`;
$('#loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
try {
const data = await api('/api/auth/login', {
method: 'POST',
body: { username: fd.get('username'), password: fd.get('password') },
});
renderRadarList();
} catch {
renderLogin('نام کاربری یا رمز عبور اشتباه است');
}
});
}
function topbarHtml(active, primaryLabel) {
const activeGroup = TAB_GROUPS.find(g => g.tabs.includes(active)) || TAB_GROUPS[0];
const groupBtns = TAB_GROUPS.map(g => `
<button class="group-btn${g === activeGroup ? ' group-active' : ''}" data-tab="${g.tabs[0]}">${g.label}</button>
`).join('');
const subTabs = activeGroup.tabs.map(t => `
<button data-tab="${t}" class="${active === t ? 'tab-active' : ''}">${TAB_LABELS[t]}</button>
`).join('');
return `
<header class="topbar">
<div class="topbar-row">
<div class="brand">پنل اندیشکده فولاد</div>
<nav class="groups">${groupBtns}</nav>
<div class="actions">
<button id="newBtn" class="primary">${primaryLabel}</button>
<button id="genBtn" class="ghost">+ تولید</button>
<button id="logoutBtn" class="ghost">خروج</button>
</div>
</div>
<nav class="tabs">${subTabs}</nav>
</header>
`;
}
function wireTabs() {
document.querySelectorAll('.tabs button, .groups button').forEach(btn => {
btn.addEventListener('click', () => {
const t = btn.dataset.tab;
if (t === 'glance') renderRadarList();
else if (t === 'radarFuture') renderRadarFutureList();
else if (t === 'factoryReports') renderFactoryReportsList();
else if (t === 'media') renderMediaList();
else if (t === 'events') renderEventsList();
else if (t === 'banners') renderBannersList();
else if (t === 'members') renderMembersList();
else if (t === 'newsletter') renderNewsletterList();
});
});
$('#logoutBtn').addEventListener('click', async () => {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {});
renderLogin();
});
$('#genBtn')?.addEventListener('click', () => openGeneratorDialog());
}
function openGeneratorDialog(defaultKind = 'glance') {
const existing = document.getElementById('generatorModal');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'generatorModal';
overlay.style.cssText = 'position:fixed;inset:0;z-index:1000;display:grid;place-items:center;';
overlay.innerHTML = `
<div class="modal-backdrop" style="position:absolute;inset:0;background:rgba(15,23,42,.55)"></div>
<div class="modal-card" style="position:relative;z-index:1;width:min(720px,calc(100vw - 32px));background:#fff;border:1px solid #dbe3ee;border-radius:12px;padding:18px;box-shadow:0 20px 50px rgba(15,23,42,.25)">
<div class="modal-head" style="display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:16px">
<strong>تولید محتوا</strong>
<button type="button" id="closeGen" class="ghost">بستن</button>
</div>
<div style="display:grid;gap:12px">
<label>نوع محتوا
<select id="genKind">
<option value="glance">در یک نگاه</option>
<option value="radarFuture">رادار آینده</option>
</select>
</label>
<label>دسته
<select id="genCategory"></select>
</label>
<label>منابع و لینک‌ها
<textarea id="genSources" rows="8" dir="ltr" placeholder="هر خط یک دامنه یا لینک:
mckinsey.com
worldsteel.org
عنوان منبع | https://example.com/report"></textarea>
</label>
<div style="display:flex;gap:8px;align-items:center;justify-content:space-between;flex-wrap:wrap">
<span class="muted" id="sourceStatus">در حال بارگذاری منابع...</span>
<button type="button" id="saveSources" class="ghost">ذخیره منابع</button>
</div>
<div class="muted" id="genHint">برای رادار آینده، انتخاب «همه» هر ۴ زیرسرفصل را draft می‌کند.</div>
<div id="genStatus" class="muted" style="min-height:24px"></div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button type="button" id="startGen" class="primary">شروع تولید</button>
</div>
</div>
</div>
`;
document.body.appendChild(overlay);
const kindEl = overlay.querySelector('#genKind');
const catEl = overlay.querySelector('#genCategory');
const statusEl = overlay.querySelector('#genStatus');
const hintEl = overlay.querySelector('#genHint');
const sourceEl = overlay.querySelector('#genSources');
const sourceStatusEl = overlay.querySelector('#sourceStatus');
const categories = {
glance: [{ value: 'market', label: 'بازارهای جهانی و اقتصاد کلان' }, { value: 'tech', label: 'نوآوری و فناوری‌های تحول‌آفرین' }, { value: 'commodity', label: 'مواد معدنی حیاتی و زنجیره تامین' }, { value: 'geo', label: 'ژئوپلیتیک و امنیت اقتصادی' }, { value: 'energy', label: 'تجارت و کسب‌وکار بین‌الملل' }],
radarFuture: [{ value: 'all', label: 'همه' }, { value: 'بازار و زنجیره فولاد', label: 'بازار و زنجیره فولاد' }, { value: 'پایداری و فولاد سبز', label: 'پایداری و فولاد سبز' }, { value: 'ژئوپلیتیک و اقتصاد جهانی', label: 'ژئوپلیتیک و اقتصاد جهانی' }, { value: 'فناوری و نوآوری', label: 'فناوری و نوآوری' }],
};
const fillCats = () => {
const list = categories[kindEl.value] || categories.glance;
catEl.innerHTML = list.map(c => `<option value="${escapeAttr(c.value)}">${escapeHtml(c.label)}</option>`).join('');
hintEl.textContent = kindEl.value === 'radarFuture' ? 'برای رادار آینده، انتخاب «همه» هر ۴ زیرسرفصل را draft می‌کند.' : 'در یک نگاه، چند آیتم کوتاه و فشرده ساخته می‌شود.';
};
kindEl.value = defaultKind;
fillCats();
kindEl.addEventListener('change', fillCats);
overlay.querySelector('#closeGen').addEventListener('click', () => overlay.remove());
overlay.querySelector('.modal-backdrop').addEventListener('click', () => overlay.remove());
const saveSources = async () => {
const text = sourceEl.value || '';
const data = await api('/api/admin/generator-sources', { method: 'PUT', body: { text } });
sourceStatusEl.textContent = `${data.count || 0} منبع ذخیره شد`;
return data;
};
api('/api/admin/generator-sources').then((data) => {
sourceEl.value = data.text || '';
sourceStatusEl.textContent = `${data.sources?.length || 0} منبع ذخیره شده`;
}).catch((err) => {
sourceStatusEl.textContent = `خطا در بارگذاری منابع: ${err.message}`;
});
overlay.querySelector('#saveSources').addEventListener('click', async () => {
try { await saveSources(); }
catch (err) { sourceStatusEl.textContent = `خطا در ذخیره منابع: ${err.message}`; }
});
overlay.querySelector('#startGen').addEventListener('click', async () => {
statusEl.textContent = 'در حال ذخیره منابع...';
try { await saveSources(); }
catch (err) { statusEl.textContent = `خطا در ذخیره منابع: ${err.message}`; return; }
statusEl.textContent = 'در حال شروع...';
const job = await api('/api/admin/generate', { method: 'POST', body: { kind: kindEl.value, category: catEl.value, sourcesText: sourceEl.value || '' } });
statusEl.textContent = `${job.message} | ${job.step} | ${job.progress}%`;
const timer = setInterval(async () => {
try {
const fresh = await api(`/api/admin/generate/jobs/${job.id}`);
statusEl.textContent = `${fresh.message} | ${fresh.step} | ${fresh.progress}%`;
if (fresh.status === 'done') {
clearInterval(timer);
statusEl.textContent = 'تمام شد';
setTimeout(() => {
overlay.remove();
if (kindEl.value === 'radarFuture') renderRadarFutureList();
else renderRadarList();
}, 700);
}
if (fresh.status === 'error') {
clearInterval(timer);
statusEl.textContent = `خطا: ${fresh.error || fresh.message}`;
}
} catch (err) {
clearInterval(timer);
statusEl.textContent = `خطا: ${err.message}`;
}
}, 1000);
});
}
async function renderList() {
root.innerHTML = `
${topbarHtml('articles', '+ مطلب جدید')}
<main class="page">
<h2>مطالب</h2>
<div id="cat-bar" class="cat-bar">
<button class="cat-btn active" data-cat="">همه</button>
${CATEGORIES.map(c => `<button class="cat-btn" data-cat="${escapeAttr(c)}">${escapeHtml(c)}</button>`).join('')}
</div>
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
</main>
`;
wireTabs();
$('#newBtn').addEventListener('click', () => renderEditor(null));
let allItems = [];
let activeCat = '';
const safeCoverUrl = (u) => /^\/uploads\/[a-zA-Z0-9._-]+$/.test(String(u || '')) ? u : '';
function renderItems() {
const filtered = activeCat ? allItems.filter(a => a.category === activeCat) : allItems;
if (!filtered.length) {
$('#list').innerHTML = `<p class="muted">مطلبی در این دسته‌بندی یافت نشد.</p>`;
return;
}
$('#list').innerHTML = filtered.map((a) => `
<article class="row" data-id="${escapeAttr(a.id)}">
<div class="row-cover" style="background-image:url('${escapeAttr(safeCoverUrl(a.coverImage))}')"></div>
<div class="row-main">
<div class="row-meta">
${a.featured ? '<span class="pill red">ویژه</span>' : ''}
${a.category ? `<span class="pill">${escapeHtml(a.category)}</span>` : ''}
${a.publishDate ? `<span class="muted">${escapeHtml(a.publishDate)}</span>` : ''}
</div>
<h3>${escapeHtml(a.title)}</h3>
<p class="muted clamp-2">${escapeHtml(a.summary)}</p>
</div>
<div class="row-actions">
<button data-action="edit">ویرایش</button>
<button data-action="delete" class="danger">حذف</button>
</div>
</article>
`).join('');
}
$('#cat-bar').addEventListener('click', (e) => {
const btn = e.target.closest('.cat-btn');
if (!btn) return;
activeCat = btn.dataset.cat;
document.querySelectorAll('.cat-btn').forEach(b => b.classList.toggle('active', b === btn));
renderItems();
});
$('#list').addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const row = btn.closest('.row');
const id = row.dataset.id;
if (btn.dataset.action === 'edit') {
const a = await api(`/api/articles/${id}`);
renderEditor(a);
} else if (btn.dataset.action === 'delete') {
if (!confirm('این مطلب حذف شود؟')) return;
await api(`/api/articles/${id}`, { method: 'DELETE' });
renderList();
}
});
try {
allItems = await api('/api/articles?limit=500');
if (!allItems.length) {
$('#list').innerHTML = `<p class="muted">هنوز مطلبی ثبت نشده است.</p>`;
return;
}
renderItems();
} catch (err) {
console.error(err);
$('#list').innerHTML = `<p class="error">خطا در بارگذاری مطالب.</p>`;
}
}
async function renderRadarFutureList() {
root.innerHTML = `
${topbarHtml('radarFuture', '+ رادار آینده جدید')}
<main class="page">
<h2>رادار آینده</h2>
<p class="muted" style="margin:-4px 0 12px">محتوای صفحات «رادار آینده» سایت (/radar/*) — مقالهٔ بلوکی به تفکیک ۵ زیرمنو.</p>
<div id="cat-bar" class="cat-bar">
<button class="cat-btn active" data-cat="">همه</button>
${RADAR_FUTURE_CATEGORIES.map(c => `<button class="cat-btn" data-cat="${escapeAttr(c)}">${escapeHtml(c)}</button>`).join('')}
</div>
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
</main>`;
wireTabs();
$('#newBtn').addEventListener('click', () => renderEditor(null, { contentType: 'radarFuture', type: 'radarfuture', category: '' }));
let allItems = [], activeCat = '';
function renderItems() {
const filtered = activeCat ? allItems.filter(a => a.category === activeCat) : allItems;
if (!filtered.length) { $('#list').innerHTML = `<p class="muted">مطلبی در این بخش یافت نشد.</p>`; return; }
$('#list').innerHTML = filtered.map(a => `
<article class="row" data-id="${escapeAttr(a.id)}">
<div class="row-cover" style="background-image:url('${escapeAttr(a.coverImage||'')}')"></div>
<div class="row-main">
<div class="row-meta">${a.category?`<span class="pill">${escapeHtml(a.category)}</span>`:''}${a.publishDate?`<span class="muted">${escapeHtml(a.publishDate)}</span>`:''}</div>
<h3>${escapeHtml(a.title)}</h3>
<p class="muted clamp-2">${escapeHtml(a.summary||'')}</p>
</div>
<div class="row-actions"><button data-action="edit">ویرایش</button><button data-action="delete" class="danger">حذف</button></div>
</article>`).join('');
}
$('#cat-bar').addEventListener('click', e => { const b = e.target.closest('.cat-btn'); if (!b) return; activeCat = b.dataset.cat; document.querySelectorAll('.cat-btn').forEach(x => x.classList.toggle('active', x === b)); renderItems(); });
$('#list').addEventListener('click', async e => {
const btn = e.target.closest('button[data-action]'); if (!btn) return;
const id = btn.closest('.row').dataset.id;
if (btn.dataset.action === 'edit') renderEditor(await api(`/api/articles/${id}`));
else if (btn.dataset.action === 'delete') { if (!confirm('این مطلب حذف شود؟')) return; await api(`/api/articles/${id}`, { method: 'DELETE' }); renderRadarFutureList(); }
});
try {
const all = await api('/api/articles?limit=500');
allItems = all.filter(a => a.type === 'radarfuture');
if (!allItems.length) { $('#list').innerHTML = `<p class="muted">هنوز مطلبی ثبت نشده — با «+ رادار آینده جدید» شروع کن.</p>`; return; }
renderItems();
} catch (err) { $('#list').innerHTML = `<p class="error">خطا در بارگذاری.</p>`; }
}
async function renderMediaList() {
root.innerHTML = `
${topbarHtml('media', '+ ویدیوکست جدید')}
<main class="page">
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap">
<h2>ویدیوکست و پادکست</h2>
<button id="newPod" class="primary">+ پادکست جدید</button>
</div>
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
</main>`;
wireTabs();
$('#newBtn').addEventListener('click', () => renderEditor(null, { contentType: 'video', type: 'video', category: '' }));
$('#newPod').addEventListener('click', () => renderEditor(null, { contentType: 'podcast', type: 'podcast', category: '' }));
$('#list').addEventListener('click', async e => {
const btn = e.target.closest('button[data-action]'); if (!btn) return;
const id = btn.closest('.row').dataset.id;
if (btn.dataset.action === 'edit') renderEditor(await api(`/api/articles/${id}`));
else if (btn.dataset.action === 'delete') { if (!confirm('این مورد حذف شود؟')) return; await api(`/api/articles/${id}`, { method: 'DELETE' }); renderMediaList(); }
});
try {
const all = await api('/api/articles?limit=500');
const items = all.filter(a => a.type === 'video' || a.type === 'podcast');
if (!items.length) { $('#list').innerHTML = `<p class="muted">هنوز موردی ثبت نشده.</p>`; return; }
$('#list').innerHTML = items.map(a => `
<article class="row" data-id="${escapeAttr(a.id)}">
<div class="row-cover" style="background-image:url('${escapeAttr(a.coverImage||'')}')"></div>
<div class="row-main">
<div class="row-meta"><span class="pill">${a.type==='video'?'ویدیوکست':'پادکست'}</span>${a.publishDate?`<span class="muted">${escapeHtml(a.publishDate)}</span>`:''}</div>
<h3>${escapeHtml(a.title)}</h3>
<p class="muted clamp-2">${escapeHtml(a.summary||'')}</p>
</div>
<div class="row-actions"><button data-action="edit">ویرایش</button><button data-action="delete" class="danger">حذف</button></div>
</article>`).join('');
} catch (err) { $('#list').innerHTML = `<p class="error">خطا در بارگذاری.</p>`; }
}
function renderTypeSelector() {
root.innerHTML = `
<header class="topbar">
<div class="topbar-row">
<div class="brand">مطلب جدید</div>
<nav class="groups"></nav>
<div class="actions">
<button id="backBtn" class="ghost">← بازگشت</button>
</div>
</div>
<div class="tabs"></div>
</header>
<main class="page">
<h2>چه نوع محتوایی می‌خواهید منتشر کنید؟</h2>
<div class="type-grid">
${CONTENT_TYPES.map(ct => `
<button class="type-card" data-ctype="${ct.key}">
<span class="type-icon">${ct.icon}</span>
<strong>${ct.label}</strong>
<span class="type-desc">${ct.desc}</span>
</button>
`).join('')}
</div>
</main>
`;
$('#backBtn').addEventListener('click', () => renderList());
document.querySelectorAll('.type-card').forEach(btn => {
btn.addEventListener('click', () => {
const ct = CONTENT_TYPES.find(c => c.key === btn.dataset.ctype);
if (ct.externalTab === 'radar') { renderRadarList(); return; }
renderEditor(null, { contentType: ct.key, type: ct.defaultType, category: ct.defaultCategory });
});
});
}
function renderEditor(article, draft = {}) {
if (!article && !draft.contentType) {
renderTypeSelector();
return;
}
const contentType = draft.contentType || inferContentType(article);
const ctInfo = CONTENT_TYPES.find(c => c.key === contentType) || CONTENT_TYPES[0];
const isEdit = !!article;
const a = article || {
title: '', category: draft.category || ctInfo.defaultCategory,
type: draft.type || ctInfo.defaultType,
author: '', authorRole: '', authorInitial: '',
publishDate: '', pages: 0, price: 0, isFree: true,
summary: '', body: '', coverImage: '', tags: [], featured: false,
};
// content blocks (interleaved text + chart/image) and citation sources
let blocks = (Array.isArray(a.blocks) && a.blocks.length)
? a.blocks.map(b => ({ ...b }))
: (a.body ? [{ type: 'text', value: a.body }] : []);
let sources = Array.isArray(a.sources) ? a.sources.map(s => ({ ...s })) : [];
const REPORT_TYPES = TYPES.filter(t => ['quarterly','flash','special','risk','free'].includes(t.value));
const isRadarFuture = contentType === 'radarFuture';
const hasCover = ['report','news','video','podcast','factory','radarFuture'].includes(contentType);
const hasCategory = ['report','news','radarFuture'].includes(contentType);
const hasAuthor = ['report','video','podcast'].includes(contentType);
const hasPages = ['report','factory'].includes(contentType);
const hasPrice = contentType === 'report';
const hasBody = ['report','news','factory','radarFuture'].includes(contentType);
const hasMediaLink = ['video','podcast'].includes(contentType);
const hasReportType = contentType === 'report';
const categoryOptions = isRadarFuture ? RADAR_FUTURE_CATEGORIES : CATEGORIES;
root.innerHTML = `
<header class="topbar">
<div class="topbar-row">
<div class="brand">${ctInfo.icon} ${isEdit ? 'ویرایش' : 'ایجاد'}${ctInfo.label}</div>
<nav class="groups"></nav>
<div class="actions">
<button id="backBtn" class="ghost">← بازگشت</button>
</div>
</div>
<div class="tabs"></div>
</header>
<main class="page editor">
<form id="editorForm" class="grid">
<div class="col span-2">
<label>عنوان
<input name="title" value="${escapeAttr(a.title)}" required placeholder="عنوان ${ctInfo.label}…" />
</label>
</div>
${hasCategory ? `
<label>${isRadarFuture ? 'بخش رادار آینده' : 'دسته‌بندی'}
<select name="category">
<option value="">انتخاب کنید…</option>
${categoryOptions.map(c => `<option value="${escapeAttr(c)}" ${c===a.category?'selected':''}>${escapeHtml(c)}</option>`).join('')}
</select>
</label>` : `<input type="hidden" name="category" value="${escapeAttr(a.category)}" />`}
${hasReportType ? `
<label>نوع گزارش
<select name="type">
${REPORT_TYPES.map(t => `<option value="${t.value}" ${t.value===a.type?'selected':''}>${t.label}</option>`).join('')}
</select>
</label>` : `<input type="hidden" name="type" value="${escapeAttr(a.type)}" />`}
<label>تاریخ انتشار (مثلاً «بهمن ۱۴۰۳»)
<input name="publishDate" value="${escapeAttr(a.publishDate)}" placeholder="بهمن ۱۴۰۳" />
</label>
${hasAuthor ? `
<label>نویسنده / مجری
<input name="author" value="${escapeAttr(a.author)}" />
</label>
<label>سمت
<input name="authorRole" value="${escapeAttr(a.authorRole)}" />
</label>
<label>حرف آواتار
<input name="authorInitial" maxlength="2" value="${escapeAttr(a.authorInitial)}" />
</label>` : `<input type="hidden" name="author" /><input type="hidden" name="authorRole" /><input type="hidden" name="authorInitial" />`}
${hasPages ? `
<label>تعداد صفحات
<input name="pages" type="number" min="0" value="${a.pages || 0}" />
</label>` : `<input type="hidden" name="pages" value="0" />`}
${hasPrice ? `
<label>قیمت (ریال)
<input name="price" type="number" min="0" value="${a.price || 0}" />
</label>
<label class="checkbox" style="align-self:flex-end;padding-bottom:10px">
<input type="checkbox" name="isFree" ${a.isFree?'checked':''} />
<span>رایگان</span>
</label>` : `<input type="hidden" name="price" value="0" /><input type="hidden" name="isFree" value="true" />`}
${hasCover ? `
<div class="col span-2">
<label>تصویر شاخص
<div class="cover-row">
<input type="file" id="coverFile" accept="image/*" />
<input type="hidden" name="coverImage" value="${escapeAttr(a.coverImage)}" />
<img id="coverPreview" alt="" src="${escapeAttr(a.coverImage)}" style="${a.coverImage?'':'display:none'}" />
</div>
</label>
</div>` : `<input type="hidden" name="coverImage" value="" />`}
<div class="col span-2">
<label>${contentType==='radar'?'توضیح سیگنال':'خلاصه'}
<textarea name="summary" rows="3">${escapeHtml(a.summary)}</textarea>
</label>
</div>
${hasBody ? `
<div class="col span-2">
<label style="display:block;margin-bottom:6px">متن کامل (متن + نمودار / تصویر، به ترتیب دلخواه)</label>
<div id="blocksEditor" class="blocks-editor"></div>
<div style="display:flex;gap:8px;margin-top:10px">
<button type="button" id="addTextBlock" class="ghost"> پاراگراف متن</button>
<button type="button" id="addImageBlock" class="ghost">🖼 تصویر / نمودار</button>
</div>
</div>
<div class="col span-2">
<label style="display:block;margin-bottom:6px">منابع و ارجاعات</label>
<div id="sourcesEditor"></div>
<button type="button" id="addSource" class="ghost" style="margin-top:8px"> افزودن منبع</button>
</div>` : ``}
${hasMediaLink ? `
<div class="col span-2">
<label>لینک ویدیو / فایل صوتی
<input name="body" id="mediaUrl" value="${escapeAttr(a.body)}" placeholder="https://… یا فایل آپلود کن" />
</label>
<div class="cover-row" style="margin-top:8px">
<input type="file" id="mediaFile" accept="video/*,audio/*" />
<span id="mediaStatus" class="muted" style="font-size:12px"></span>
</div>
</div>` : (hasBody ? `` : `<input type="hidden" name="body" value="" />`)}
<div class="col span-2">
<label>برچسب‌ها (با ویرگول جدا کنید)
<input name="tags" value="${escapeAttr((a.tags||[]).join('، '))}" placeholder="فولاد، بازار جهانی، …" />
</label>
</div>
<label class="checkbox">
<input type="checkbox" name="featured" ${a.featured?'checked':''} />
<span>مطلب ویژه (نمایش برجسته)</span>
</label>
<div class="col span-2 row-end">
<button type="submit" class="primary">${isEdit ? 'ذخیره تغییرات' : `انتشار ${ctInfo.label}`}</button>
${!isEdit ? `<button type="button" id="changeTypeBtn" class="ghost">← تغییر نوع محتوا</button>` : ''}
</div>
</form>
</main>
`;
const backToList = () => {
if (contentType === 'radarFuture') return renderRadarFutureList();
if (contentType === 'video' || contentType === 'podcast') return renderMediaList();
return renderRadarFutureList();
};
$('#backBtn').addEventListener('click', backToList);
document.getElementById('changeTypeBtn')?.addEventListener('click', () => renderTypeSelector());
const coverFile = document.getElementById('coverFile');
if (coverFile) {
coverFile.addEventListener('change', async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const fd = new FormData();
fd.append('file', file);
try {
const data = await api('/api/uploads', { method: 'POST', body: fd, isForm: true });
const url = data.url;
const input = $('input[name="coverImage"]');
if (input) input.value = url;
const prev = document.getElementById('coverPreview');
if (prev) { prev.src = url; prev.style.display = ''; }
} catch (err) { alert('آپلود ناموفق بود: ' + err.message); }
});
}
// video / podcast media file → object storage (fills the media URL field)
const mediaFile = document.getElementById('mediaFile');
if (mediaFile) {
mediaFile.addEventListener('change', async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const status = document.getElementById('mediaStatus');
if (status) status.textContent = 'در حال آپلود فایل… (بسته به حجم چند لحظه طول می‌کشد)';
const fd = new FormData();
fd.append('file', file);
try {
const { url } = await api('/api/uploads/raw', { method: 'POST', body: fd, isForm: true });
document.getElementById('mediaUrl').value = url;
if (status) status.textContent = '✓ آپلود شد و در آبجکت استوریج ذخیره شد';
} catch (err) {
if (status) status.textContent = '';
alert('آپلود فایل ناموفق بود: ' + err.message);
}
});
}
// ── content blocks (text + chart/image, reorderable) ──────────────────
function renderBlocks() {
const el = document.getElementById('blocksEditor');
if (!el) return;
if (!blocks.length) {
el.innerHTML = `<div class="muted" style="padding:10px 0">هنوز محتوایی اضافه نشده — یک پاراگراف یا تصویر/نمودار اضافه کنید.</div>`;
return;
}
el.innerHTML = blocks.map((b, i) => {
const ctrls = `<div style="position:absolute;top:6px;left:6px;display:flex;gap:4px">
<button type="button" data-act="up" data-i="${i}" class="ghost" style="padding:2px 8px" title="بالا">↑</button>
<button type="button" data-act="down" data-i="${i}" class="ghost" style="padding:2px 8px" title="پایین">↓</button>
<button type="button" data-act="del" data-i="${i}" class="ghost" style="padding:2px 8px" title="حذف">✕</button>
</div>`;
if (b.type === 'image') {
return `<div style="position:relative;border:1px solid #e2e8f0;border-radius:8px;padding:12px;margin-bottom:10px;background:#fafafa">
${ctrls}
<div style="display:flex;align-items:center;gap:12px;margin:6px 0 8px">
${b.url ? `<img src="${escapeAttr(b.url)}" style="max-width:220px;max-height:150px;border-radius:6px;border:1px solid #e2e8f0" />` : `<div style="width:140px;height:90px;border:1px dashed #cbd5e1;border-radius:6px;display:flex;align-items:center;justify-content:center;color:#94a3b8;font-size:12px">تصویر / نمودار</div>`}
<input type="file" accept="image/*" data-imgfile="${i}" />
</div>
<input type="text" data-cap="${i}" value="${escapeAttr(b.caption || '')}" placeholder="توضیح تصویر / نمودار (اختیاری)" style="width:100%" />
</div>`;
}
return `<div style="position:relative;border:1px solid #e2e8f0;border-radius:8px;padding:12px;margin-bottom:10px">
${ctrls}
<textarea data-txt="${i}" rows="5" placeholder="متن پاراگراف…" style="width:100%;margin-top:18px">${escapeHtml(b.value || '')}</textarea>
</div>`;
}).join('');
el.querySelectorAll('[data-txt]').forEach(t => t.addEventListener('input', e => { blocks[+e.target.dataset.txt].value = e.target.value; }));
el.querySelectorAll('[data-cap]').forEach(t => t.addEventListener('input', e => { blocks[+e.target.dataset.cap].caption = e.target.value; }));
el.querySelectorAll('[data-imgfile]').forEach(f => f.addEventListener('change', async e => {
const file = e.target.files?.[0]; if (!file) return;
const i = +e.target.dataset.imgfile;
const fd2 = new FormData(); fd2.append('file', file);
try { const { url } = await api('/api/uploads', { method: 'POST', body: fd2, isForm: true }); blocks[i].url = url; renderBlocks(); }
catch (err) { alert('آپلود ناموفق بود: ' + err.message); }
}));
el.querySelectorAll('button[data-act]').forEach(btn => btn.addEventListener('click', e => {
const i = +e.currentTarget.dataset.i, act = e.currentTarget.dataset.act;
if (act === 'del') blocks.splice(i, 1);
else if (act === 'up' && i > 0) { [blocks[i - 1], blocks[i]] = [blocks[i], blocks[i - 1]]; }
else if (act === 'down' && i < blocks.length - 1) { [blocks[i + 1], blocks[i]] = [blocks[i], blocks[i + 1]]; }
renderBlocks();
}));
}
document.getElementById('addTextBlock')?.addEventListener('click', () => { blocks.push({ type: 'text', value: '' }); renderBlocks(); });
document.getElementById('addImageBlock')?.addEventListener('click', () => { blocks.push({ type: 'image', url: '', caption: '' }); renderBlocks(); });
renderBlocks();
// ── sources / citations ───────────────────────────────────────────────
function renderSources() {
const el = document.getElementById('sourcesEditor');
if (!el) return;
el.innerHTML = sources.length ? sources.map((s, i) => `
<div style="display:flex;gap:8px;margin-bottom:6px">
<input type="text" data-stitle="${i}" value="${escapeAttr(s.title || '')}" placeholder="عنوان منبع" style="flex:1" />
<input type="text" data-surl="${i}" value="${escapeAttr(s.url || '')}" placeholder="https://… (اختیاری)" style="flex:1" dir="ltr" />
<button type="button" data-sdel="${i}" class="ghost" style="padding:2px 10px">✕</button>
</div>`).join('') : `<div class="muted" style="padding:6px 0">منبعی اضافه نشده.</div>`;
el.querySelectorAll('[data-stitle]').forEach(t => t.addEventListener('input', e => { sources[+e.target.dataset.stitle].title = e.target.value; }));
el.querySelectorAll('[data-surl]').forEach(t => t.addEventListener('input', e => { sources[+e.target.dataset.surl].url = e.target.value; }));
el.querySelectorAll('[data-sdel]').forEach(b => b.addEventListener('click', e => { sources.splice(+e.currentTarget.dataset.sdel, 1); renderSources(); }));
}
document.getElementById('addSource')?.addEventListener('click', () => { sources.push({ title: '', url: '' }); renderSources(); });
renderSources();
$('#editorForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const tagsRaw = String(fd.get('tags') || '').split(/[,،]/).map(s => s.trim()).filter(Boolean);
const isFreeVal = fd.get('isFree');
const payload = {
title: fd.get('title'),
category: fd.get('category') || null,
type: fd.get('type') || null,
author: fd.get('author') || null,
authorRole: fd.get('authorRole') || null,
authorInitial:fd.get('authorInitial') || null,
publishDate: fd.get('publishDate') || null,
pages: Number(fd.get('pages') || 0),
price: Number(fd.get('price') || 0),
isFree: isFreeVal === 'on' || isFreeVal === 'true',
featured: fd.get('featured') === 'on',
summary: fd.get('summary') || null,
body: hasBody
? blocks.filter(b => b.type === 'text' && b.value && b.value.trim()).map(b => b.value.trim()).join('\n\n')
: (fd.get('body') || null),
coverImage: fd.get('coverImage') || null,
blocks: hasBody ? blocks.filter(b => (b.type === 'text' && b.value && b.value.trim()) || (b.type === 'image' && b.url)) : [],
sources: sources.filter(s => (s.title && s.title.trim()) || (s.url && s.url.trim())),
tags: tagsRaw,
};
try {
if (isEdit) {
await api(`/api/articles/${article.id}`, { method: 'PUT', body: payload });
} else {
await api('/api/articles', { method: 'POST', body: payload });
}
backToList();
} catch (err) { alert('ذخیره ناموفق بود: ' + err.message); }
});
}
function escapeAttr(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
}
function escapeHtml(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
const RISK_LEVELS = [
{ value: 'critical', label: 'بحرانی' },
{ value: 'high', label: 'بالا' },
{ value: 'medium', label: 'متوسط' },
{ value: 'low', label: 'پایین' },
{ value: 'opportunity', label: 'فرصت' },
];
async function renderRisksList() {
root.innerHTML = `
${topbarHtml('risks', '+ سیگنال جدید')}
<main class="page">
<h2>سیگنال‌های ریسک</h2>
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
</main>
`;
wireTabs();
$('#newBtn').addEventListener('click', () => renderRiskEditor(null));
try {
const items = await api('/api/risks?limit=200');
if (!items.length) {
$('#list').innerHTML = `<p class="muted">هنوز سیگنالی ثبت نشده است.</p>`;
return;
}
const SAFE_LEVELS = new Set(['critical', 'high', 'medium', 'low', 'opportunity']);
const labelOf = (lvl) => (RISK_LEVELS.find(r => r.value === lvl)?.label) || escapeHtml(lvl);
$('#list').innerHTML = items.map(r => `
<article class="row risk-row" data-id="${escapeAttr(r.id)}">
<div class="row-main">
<div class="row-meta">
<span class="pill level-${SAFE_LEVELS.has(r.level) ? r.level : 'low'}">${labelOf(r.level)}</span>
<strong>${escapeHtml(r.name)}</strong>
${r.date ? `<span class="muted">${escapeHtml(r.date)}</span>` : ''}
</div>
<p class="muted clamp-2">${escapeHtml(r.quote)}</p>
</div>
<div class="row-actions">
<button data-action="edit">ویرایش</button>
<button data-action="delete" class="danger">حذف</button>
</div>
</article>
`).join('');
$('#list').addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const id = btn.closest('.row').dataset.id;
if (btn.dataset.action === 'edit') {
const r = await api(`/api/risks/${id}`);
renderRiskEditor(r);
} else if (btn.dataset.action === 'delete') {
if (!confirm('این سیگنال حذف شود؟')) return;
await api(`/api/risks/${id}`, { method: 'DELETE' });
renderRisksList();
}
});
} catch (err) {
console.error(err);
$('#list').innerHTML = `<p class="error">خطا در بارگذاری سیگنال‌ها.</p>`;
}
}
function renderRiskEditor(risk) {
const r = risk || { quote: '', name: '', date: '', level: 'high', sortOrder: 0 };
const isEdit = !!risk;
root.innerHTML = `
<header class="topbar">
<div class="brand">${isEdit ? 'ویرایش سیگنال ریسک' : 'سیگنال ریسک جدید'}</div>
<div class="actions">
<button id="backBtn" class="ghost">← بازگشت</button>
</div>
</header>
<main class="page editor">
<form id="editorForm" class="grid">
<div class="col span-2">
<label>عنوان / منبع
<input name="name" value="${escapeAttr(r.name)}" required />
</label>
</div>
<label>سطح
<select name="level">
${RISK_LEVELS.map(l => `<option value="${l.value}" ${l.value===r.level?'selected':''}>${l.label}</option>`).join('')}
</select>
</label>
<label>تاریخ / افق (شمسی)
<input name="date" value="${escapeAttr(r.date)}" />
</label>
<label>ترتیب نمایش (عدد کوچک‌تر اول)
<input name="sortOrder" type="number" value="${r.sortOrder || 0}" />
</label>
<div class="col span-2">
<label>متن سیگنال
<textarea name="quote" rows="6" required>${escapeHtml(r.quote)}</textarea>
</label>
</div>
<div class="col span-2 row-end">
<button type="submit" class="primary">${isEdit ? 'ذخیره تغییرات' : 'ایجاد'}</button>
</div>
</form>
</main>
`;
$('#backBtn').addEventListener('click', () => renderRisksList());
$('#editorForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const payload = {
name: fd.get('name'),
quote: fd.get('quote'),
date: fd.get('date') || null,
level: fd.get('level'),
sortOrder: Number(fd.get('sortOrder') || 0),
};
try {
if (isEdit) await api(`/api/risks/${risk.id}`, { method: 'PUT', body: payload });
else await api('/api/risks', { method: 'POST', body: payload });
renderRisksList();
} catch (err) {
alert('ذخیره ناموفق بود: ' + err.message);
}
});
}
// ---------- users ----------
async function renderUsersList() {
root.innerHTML = `
${topbarHtml('users', '+ کاربر جدید')}
<main class="page">
<h2>کاربران پنل</h2>
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
</main>
`;
wireTabs();
$('#newBtn').addEventListener('click', () => renderUserEditor(null));
try {
const items = await api('/api/users');
if (!items.length) {
$('#list').innerHTML = `<p class="muted">هنوز کاربری ثبت نشده است.</p>`;
return;
}
$('#list').innerHTML = items.map(u => `
<article class="row" data-id="${escapeAttr(u.id)}" data-username="${escapeAttr(u.username)}">
<div class="row-main">
<div class="row-meta">
<strong>${escapeHtml(u.username)}</strong>
${u.createdAt ? `<span class="muted">عضویت: ${escapeHtml(u.createdAt)}</span>` : ''}
</div>
</div>
<div class="row-actions">
<button data-action="edit">ویرایش</button>
<button data-action="delete" class="danger">حذف</button>
</div>
</article>
`).join('');
$('#list').addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const row = btn.closest('.row');
const id = row.dataset.id;
if (btn.dataset.action === 'edit') {
renderUserEditor({ id, username: row.dataset.username });
} else if (btn.dataset.action === 'delete') {
if (!confirm('این کاربر حذف شود؟')) return;
try {
await api(`/api/users/${id}`, { method: 'DELETE' });
renderUsersList();
} catch (err) {
alert('حذف ناموفق بود: ' + friendlyUserError(err.message));
}
}
});
} catch (err) {
console.error(err);
$('#list').innerHTML = `<p class="error">خطا در بارگذاری کاربران.</p>`;
}
}
function renderUserEditor(user) {
const u = user || { username: '' };
const isEdit = !!user;
root.innerHTML = `
<header class="topbar">
<div class="brand">${isEdit ? 'ویرایش کاربر' : 'کاربر جدید'}</div>
<div class="actions">
<button id="backBtn" class="ghost">← بازگشت</button>
</div>
</header>
<main class="page editor">
<form id="editorForm" class="grid">
<div class="col span-2">
<label>نام کاربری
<input name="username" value="${escapeAttr(u.username)}" autocomplete="off" required />
</label>
</div>
<div class="col span-2">
<label>رمز عبور ${isEdit ? '(برای تغییر پر کنید، خالی = بدون تغییر)' : '(حداقل ۸ کاراکتر)'}
<input name="password" type="password" autocomplete="new-password" ${isEdit ? '' : 'required'} />
</label>
</div>
<div class="col span-2 row-end">
<button type="submit" class="primary">${isEdit ? 'ذخیره تغییرات' : 'ایجاد کاربر'}</button>
</div>
</form>
</main>
`;
$('#backBtn').addEventListener('click', () => renderUsersList());
$('#editorForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const username = String(fd.get('username') || '').trim();
const password = String(fd.get('password') || '');
const payload = { username };
if (password) payload.password = password;
try {
if (isEdit) await api(`/api/users/${user.id}`, { method: 'PUT', body: payload });
else await api('/api/users', { method: 'POST', body: { username, password } });
renderUsersList();
} catch (err) {
alert('ذخیره ناموفق بود: ' + friendlyUserError(err.message));
}
});
}
function friendlyUserError(msg) {
const m = String(msg || '');
if (m.includes('username_taken')) return 'این نام کاربری قبلاً ثبت شده است.';
if (m.includes('username_too_short')) return 'نام کاربری باید حداقل ۳ کاراکتر باشد.';
if (m.includes('password_too_short')) return 'رمز عبور باید حداقل ۸ کاراکتر باشد.';
if (m.includes('cannot_delete_self')) return 'نمی‌توانید حساب خودتان را حذف کنید.';
if (m.includes('cannot_delete_last_user')) return 'آخرین کاربر را نمی‌توان حذف کرد.';
return m;
}
// ═══════════════════════════════════════════════
// SITE MEMBERS (phone/OTP users)
// ═══════════════════════════════════════════════
function fmtDateTime(ts) {
if (!ts) return '—';
// pg returns timestamptz as "2026-06-27 08:01:07.749392+00" — space separator,
// microseconds, and a colon-less "+00" offset that Date can't parse. Normalize.
const iso = String(ts).replace(' ', 'T')
.replace(/(\.\d{3})\d+/, '$1') // trim micro→milliseconds
.replace(/([+-]\d{2})(\d{2})?$/, (_, h, m) => `${h}:${m || '00'}`); // +00 → +00:00
const d = new Date(iso);
if (isNaN(d)) return escapeHtml(String(ts));
try {
return new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'medium', timeStyle: 'short', calendar: 'persian',
}).format(d);
} catch { return d.toISOString(); }
}
async function renderMembersList() {
root.innerHTML = `
${topbarHtml('members', '↻ بروزرسانی')}
<main class="page">
<h2>کاربران سایت</h2>
<p class="muted" style="margin:-4px 0 12px">کاربران ثبت‌نامی سایت (موبایل/کد یکبارمصرف). آخرین ورود، شماره تماس، مسدودسازی و حذف.</p>
<input id="memberSearch" placeholder="جستجو بر اساس نام، ایمیل یا شماره…" style="width:100%;max-width:420px;margin-bottom:14px;padding:9px 12px;border:1px solid #d6d6d6;border-radius:8px" />
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
</main>
`;
wireTabs();
$('#newBtn').addEventListener('click', () => renderMembersList());
let all = [];
function paint(items) {
if (!items.length) { $('#list').innerHTML = `<p class="muted">کاربری یافت نشد.</p>`; return; }
$('#list').innerHTML = items.map(m => `
<article class="row" data-id="${escapeAttr(m.id)}">
<div class="row-main">
<div class="row-meta">
<strong>${escapeHtml(m.name || 'بی‌نام')}</strong>
${m.isActive ? '' : '<span class="pill red">مسدود</span>'}
${m.phone ? `<span class="muted" style="direction:ltr">${escapeHtml(m.phone)}</span>` : '<span class="muted">بدون شماره</span>'}
</div>
<p class="muted clamp-2">آخرین ورود: ${fmtDateTime(m.lastLogin)} · عضویت: ${fmtDateTime(m.createdAt)}${m.purchases ? ` · ${m.purchases} خرید` : ''}</p>
</div>
<div class="row-actions">
<button data-action="view">جزئیات</button>
<button data-action="toggle">${m.isActive ? 'مسدود کردن' : 'رفع مسدودی'}</button>
<button data-action="delete" class="danger">حذف</button>
</div>
</article>
`).join('');
}
$('#list').addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const id = btn.closest('.row').dataset.id;
const m = all.find(x => String(x.id) === String(id));
if (btn.dataset.action === 'view') {
renderMemberDetail(id);
} else if (btn.dataset.action === 'toggle') {
try {
await api(`/api/members/${id}`, { method: 'PATCH', body: { isActive: !m.isActive } });
renderMembersList();
} catch (err) { alert('عملیات ناموفق بود: ' + err.message); }
} else if (btn.dataset.action === 'delete') {
if (!confirm('این کاربر برای همیشه حذف شود؟')) return;
try {
await api(`/api/members/${id}`, { method: 'DELETE' });
renderMembersList();
} catch (err) {
alert('حذف ناموفق بود' + (String(err.message).includes('forbidden') ? ' (فقط مالک پنل می‌تواند حذف کند)' : ': ' + err.message));
}
}
});
$('#memberSearch').addEventListener('input', (e) => {
const q = e.target.value.trim().toLowerCase();
paint(!q ? all : all.filter(m =>
(m.name || '').toLowerCase().includes(q) ||
(m.email || '').toLowerCase().includes(q) ||
(m.phone || '').includes(q)));
});
try {
all = await api('/api/members');
paint(all);
} catch (err) {
console.error(err);
$('#list').innerHTML = `<p class="error">خطا در بارگذاری کاربران.</p>`;
}
}
async function renderMemberDetail(id) {
root.innerHTML = `
<header class="topbar">
<div class="brand">جزئیات کاربر</div>
<div class="actions"><button id="backBtn" class="ghost">← بازگشت</button></div>
</header>
<main class="page"><div id="detail"><p class="muted">در حال بارگذاری…</p></div></main>
`;
$('#backBtn').addEventListener('click', () => renderMembersList());
try {
const m = await api(`/api/members/${id}`);
const field = (label, val, ltr) => `
<div style="display:flex;gap:8px;padding:10px 0;border-bottom:1px solid #eee">
<span class="muted" style="min-width:120px">${label}</span>
<strong${ltr ? ' style="direction:ltr"' : ''}>${val}</strong>
</div>`;
const buys = m.purchases?.length
? m.purchases.map(p => `<li>${escapeHtml(p.title || p.articleId)}${fmtDateTime(p.purchasedAt)}${p.amount ? ` (${p.amount.toLocaleString('fa-IR')} تومان)` : ''}</li>`).join('')
: '<li class="muted">خریدی ثبت نشده</li>';
$('#detail').innerHTML = `
<div class="card" style="max-width:560px;padding:20px 24px">
<h2 style="margin:0 0 6px">${escapeHtml(m.name || 'بی‌نام')} ${m.isActive ? '' : '<span class="pill red">مسدود</span>'}</h2>
${field('شماره موبایل:', m.phone ? escapeHtml(m.phone) : '—', true)}
${field('ایمیل:', m.email ? escapeHtml(m.email) : '—', true)}
${field('آخرین ورود:', fmtDateTime(m.lastLogin))}
${field('تاریخ عضویت:', fmtDateTime(m.createdAt))}
${field('وضعیت:', m.isActive ? 'فعال' : 'مسدود')}
<h3 style="margin:18px 0 6px">خریدها (${m.purchases?.length || 0})</h3>
<ul style="margin:0;padding-inline-start:18px">${buys}</ul>
<h3 style="margin:18px 0 6px">فعالیت اخیر</h3>
<div id="activity"><p class="muted">در حال بارگذاری…</p></div>
<div style="display:flex;gap:10px;margin-top:20px">
<button id="toggleBtn">${m.isActive ? 'مسدود کردن' : 'رفع مسدودی'}</button>
<button id="delBtn" class="danger">حذف کاربر</button>
</div>
</div>`;
$('#toggleBtn').addEventListener('click', async () => {
try { await api(`/api/members/${id}`, { method: 'PATCH', body: { isActive: !m.isActive } }); renderMemberDetail(id); }
catch (err) { alert('عملیات ناموفق بود: ' + err.message); }
});
$('#delBtn').addEventListener('click', async () => {
if (!confirm('این کاربر برای همیشه حذف شود؟')) return;
try { await api(`/api/members/${id}`, { method: 'DELETE' }); renderMembersList(); }
catch (err) { alert('حذف ناموفق بود: ' + err.message); }
});
// activity timeline (loaded separately so the card renders instantly)
try {
const acts = await api(`/api/members/${id}/activity`);
if (!acts.length) {
$('#activity').innerHTML = `<p class="muted">فعالیتی ثبت نشده.</p>`;
} else {
$('#activity').innerHTML = `<ul style="margin:0;padding-inline-start:18px;max-height:260px;overflow:auto">` +
acts.map(a => `<li style="padding:3px 0">
<span class="pill${a.kind === 'click' ? ' red' : ''}">${a.kind === 'click' ? 'کلیک' : 'بازدید'}</span>
${a.kind === 'click' && a.label ? `«${escapeHtml(a.label)}» در ` : ''}<span style="direction:ltr">${escapeHtml(a.path || '')}</span>
<span class="muted"> — ${fmtDateTime(a.at)}</span>
</li>`).join('') + `</ul>`;
}
} catch { $('#activity').innerHTML = `<p class="error">خطا در بارگذاری فعالیت.</p>`; }
} catch (err) {
$('#detail').innerHTML = `<p class="error">خطا در بارگذاری.</p>`;
}
}
// ═══════════════════════════════════════════════
// NEWSLETTER SUBSCRIBERS
// ═══════════════════════════════════════════════
async function renderNewsletterList() {
root.innerHTML = `
${topbarHtml('newsletter', '↻ بروزرسانی')}
<main class="page">
<h2>عضویت در خبرنامه</h2>
<p class="muted" style="margin:-4px 0 12px">ایمیل‌هایی که از فرم فوتر سایت ثبت شده‌اند.</p>
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
</main>
`;
wireTabs();
$('#newBtn').addEventListener('click', () => renderNewsletterList());
try {
const items = await api('/api/newsletter');
if (!items.length) { $('#list').innerHTML = `<p class="muted">هنوز ایمیلی ثبت نشده.</p>`; return; }
$('#list').innerHTML = `<p class="muted" style="margin-bottom:10px">${items.length.toLocaleString('fa-IR')} ایمیل</p>` +
items.map(s => `
<article class="row" data-id="${escapeAttr(s.id)}">
<div class="row-main">
<div class="row-meta">
<strong style="direction:ltr">${escapeHtml(s.email)}</strong>
<span class="muted">${fmtDateTime(s.createdAt)}</span>
</div>
</div>
<div class="row-actions"><button data-action="delete" class="danger">حذف</button></div>
</article>`).join('');
$('#list').addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action="delete"]');
if (!btn) return;
const id = btn.closest('.row').dataset.id;
if (!confirm('این ایمیل حذف شود؟')) return;
try { await api(`/api/newsletter/${id}`, { method: 'DELETE' }); renderNewsletterList(); }
catch (err) { alert('حذف ناموفق بود: ' + err.message); }
});
} catch (err) {
console.error(err);
$('#list').innerHTML = `<p class="error">خطا در بارگذاری.</p>`;
}
}
// ═══════════════════════════════════════════════
// EVENTS
// ═══════════════════════════════════════════════
async function renderEventsList() {
root.innerHTML = `
${topbarHtml('events', '+ رویداد جدید')}
<main class="page">
<h2>رویدادها</h2>
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
</main>`;
wireTabs();
$('#newBtn').addEventListener('click', () => renderEventEditor(null));
try {
const items = await api('/api/events?limit=200');
if (!items.length) { $('#list').innerHTML = `<p class="muted">هنوز رویدادی ثبت نشده.</p>`; return; }
$('#list').innerHTML = items.map(e => `
<article class="row" data-id="${escapeAttr(e.id)}">
<div class="row-main">
<div class="row-meta">
${e.type ? `<span class="pill">${escapeHtml(EVENT_TYPES.find(t=>t.value===e.type)?.label||e.type)}</span>` : ''}
${e.cityFa ? `<span class="muted">${escapeHtml(e.cityFa)}</span>` : ''}
${e.dateFa ? `<span class="muted">${escapeHtml(e.dateFa)}</span>` : ''}
<span class="pill ${e.registrationOpen?'green':'red'}">${e.registrationOpen?'ثبت‌نام باز':'بسته'}</span>
</div>
<h3>${escapeHtml(e.titleFa)}</h3>
<p class="muted clamp-2">${escapeHtml(e.descriptionFa||'')}</p>
</div>
<div class="row-actions">
<button data-action="edit">ویرایش</button>
<button data-action="delete" class="danger">حذف</button>
</div>
</article>`).join('');
$('#list').addEventListener('click', async ev => {
const btn = ev.target.closest('button[data-action]'); if (!btn) return;
const id = btn.closest('.row').dataset.id;
if (btn.dataset.action === 'edit') renderEventEditor(await api(`/api/events/${id}`));
else if (btn.dataset.action === 'delete') {
if (!confirm('این رویداد حذف شود؟')) return;
await api(`/api/events/${id}`, { method: 'DELETE' });
renderEventsList();
}
});
} catch (err) { $('#list').innerHTML = `<p class="error">خطا: ${escapeHtml(err.message)}</p>`; }
}
function renderEventEditor(event) {
const e = event || { titleFa:'', titleEn:'', dateFa:'', dateEn:'', monthFa:'', monthEn:'', year:'',
type:'', locationFa:'', locationEn:'', cityFa:'', cityEn:'', descriptionFa:'', descriptionEn:'',
registrationOpen:true, sortOrder:0 };
const isEdit = !!event;
root.innerHTML = `
<header class="topbar">
<div class="brand">${isEdit?'ویرایش رویداد':'رویداد جدید'}</div>
<div class="actions"><button id="backBtn" class="ghost">← بازگشت</button></div>
</header>
<main class="page editor">
<form id="editorForm" class="grid">
<label>عنوان (فارسی) <input name="titleFa" value="${escapeAttr(e.titleFa)}" required /></label>
<label>عنوان (انگلیسی) <input name="titleEn" value="${escapeAttr(e.titleEn||'')}" /></label>
<label>نوع
<select name="type">
<option value="">—</option>
${EVENT_TYPES.map(t=>`<option value="${t.value}" ${t.value===e.type?'selected':''}>${t.label}</option>`).join('')}
</select>
</label>
<label class="checkbox"><input type="checkbox" name="registrationOpen" ${e.registrationOpen?'checked':''}><span>ثبت‌نام باز است</span></label>
<label>تاریخ (فارسی) <input name="dateFa" value="${escapeAttr(e.dateFa||'')}" /></label>
<label>تاریخ (انگلیسی) <input name="dateEn" value="${escapeAttr(e.dateEn||'')}" /></label>
<label>ماه (فارسی) <input name="monthFa" value="${escapeAttr(e.monthFa||'')}" /></label>
<label>ماه (انگلیسی) <input name="monthEn" value="${escapeAttr(e.monthEn||'')}" /></label>
<label>سال <input name="year" value="${escapeAttr(e.year||'')}" /></label>
<label>ترتیب نمایش <input name="sortOrder" type="number" value="${e.sortOrder||0}" /></label>
<label>شهر (فارسی) <input name="cityFa" value="${escapeAttr(e.cityFa||'')}" /></label>
<label>شهر (انگلیسی) <input name="cityEn" value="${escapeAttr(e.cityEn||'')}" /></label>
<div class="col span-2"><label>مکان (فارسی) <input name="locationFa" value="${escapeAttr(e.locationFa||'')}" /></label></div>
<div class="col span-2"><label>مکان (انگلیسی) <input name="locationEn" value="${escapeAttr(e.locationEn||'')}" /></label></div>
<div class="col span-2"><label>توضیحات (فارسی) <textarea name="descriptionFa" rows="4">${escapeHtml(e.descriptionFa||'')}</textarea></label></div>
<div class="col span-2"><label>توضیحات (انگلیسی) <textarea name="descriptionEn" rows="4">${escapeHtml(e.descriptionEn||'')}</textarea></label></div>
<div class="col span-2 row-end"><button type="submit" class="primary">${isEdit?'ذخیره':'ایجاد'}</button></div>
</form>
</main>`;
$('#backBtn').addEventListener('click', () => renderEventsList());
$('#editorForm').addEventListener('submit', async ev => {
ev.preventDefault();
const fd = new FormData(ev.target);
const payload = {
titleFa: fd.get('titleFa'), titleEn: fd.get('titleEn')||null,
dateFa: fd.get('dateFa')||null, dateEn: fd.get('dateEn')||null,
monthFa: fd.get('monthFa')||null, monthEn: fd.get('monthEn')||null,
year: fd.get('year')||null, type: fd.get('type')||null,
locationFa: fd.get('locationFa')||null, locationEn: fd.get('locationEn')||null,
cityFa: fd.get('cityFa')||null, cityEn: fd.get('cityEn')||null,
descriptionFa: fd.get('descriptionFa')||null, descriptionEn: fd.get('descriptionEn')||null,
registrationOpen: fd.get('registrationOpen') === 'on',
sortOrder: Number(fd.get('sortOrder')||0),
};
try {
if (isEdit) await api(`/api/events/${event.id}`, { method:'PUT', body:payload });
else await api('/api/events', { method:'POST', body:payload });
renderEventsList();
} catch (err) { alert('ذخیره ناموفق: ' + err.message); }
});
}
// ═══════════════════════════════════════════════
// TEAM & EXPERTS (shared UI, isExpert flag)
// ═══════════════════════════════════════════════
async function renderTeamList(isExpert) {
const tab = isExpert ? 'experts' : 'team';
const label = isExpert ? 'خبرگان' : 'تیم';
root.innerHTML = `
${topbarHtml(tab, `+ ${isExpert?'خبره':'عضو'} جدید`)}
<main class="page">
<h2>${isExpert?'شبکه خبرگان':'اعضای تیم'}</h2>
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
</main>`;
wireTabs();
$('#newBtn').addEventListener('click', () => renderTeamEditor(null, isExpert));
try {
const items = await api(`/api/team?expert=${isExpert?1:0}`);
if (!items.length) { $('#list').innerHTML = `<p class="muted">هنوز ${label}ی ثبت نشده.</p>`; return; }
$('#list').innerHTML = items.map(m => `
<article class="row" data-id="${escapeAttr(m.id)}">
<div class="row-avatar">${escapeHtml(m.initial||m.nameFa?.charAt(0)||'؟')}</div>
<div class="row-main">
<div class="row-meta">
${m.roleFa ? `<span class="pill">${escapeHtml(m.roleFa)}</span>` : ''}
${m.expertRoomFa ? `<span class="muted">${escapeHtml(m.expertRoomFa)}</span>` : ''}
${m.reportCount ? `<span class="muted">${m.reportCount} گزارش</span>` : ''}
</div>
<h3>${escapeHtml(m.nameFa)}</h3>
<p class="muted clamp-2">${escapeHtml(m.bioFa||'')}</p>
</div>
<div class="row-actions">
<button data-action="edit">ویرایش</button>
<button data-action="delete" class="danger">حذف</button>
</div>
</article>`).join('');
$('#list').addEventListener('click', async ev => {
const btn = ev.target.closest('button[data-action]'); if (!btn) return;
const id = btn.closest('.row').dataset.id;
if (btn.dataset.action === 'edit') renderTeamEditor(await api(`/api/team/${id}`), isExpert);
else if (btn.dataset.action === 'delete') {
if (!confirm('این عضو حذف شود؟')) return;
await api(`/api/team/${id}`, { method:'DELETE' });
renderTeamList(isExpert);
}
});
} catch (err) { $('#list').innerHTML = `<p class="error">خطا: ${escapeHtml(err.message)}</p>`; }
}
function renderTeamEditor(member, isExpert) {
const m = member || { nameFa:'', nameEn:'', roleFa:'', roleEn:'', bioFa:'', bioEn:'',
expertise:[], email:'', initial:'', photo:'', reportCount:0,
expertRoomFa:'', expertRoomEn:'', telegram:'', linkedin:'', twitter:'', website:'', sortOrder:0 };
const isEdit = !!member;
const expertiseStr = Array.isArray(m.expertise) ? m.expertise.join('، ') : '';
root.innerHTML = `
<header class="topbar">
<div class="brand">${isEdit?'ویرایش':'جدید'}${isExpert?'خبره':'عضو تیم'}</div>
<div class="actions"><button id="backBtn" class="ghost">← بازگشت</button></div>
</header>
<main class="page editor">
<form id="editorForm" class="grid">
<label>نام (فارسی) <input name="nameFa" value="${escapeAttr(m.nameFa)}" required /></label>
<label>نام (انگلیسی) <input name="nameEn" value="${escapeAttr(m.nameEn||'')}" /></label>
<label>سمت (فارسی) <input name="roleFa" value="${escapeAttr(m.roleFa||'')}" /></label>
<label>سمت (انگلیسی) <input name="roleEn" value="${escapeAttr(m.roleEn||'')}" /></label>
<label>حرف آواتار <input name="initial" maxlength="2" value="${escapeAttr(m.initial||'')}" /></label>
<label>تعداد گزارش <input name="reportCount" type="number" min="0" value="${m.reportCount||0}" /></label>
<label>ایمیل <input name="email" type="email" value="${escapeAttr(m.email||'')}" /></label>
<label>ترتیب نمایش <input name="sortOrder" type="number" value="${m.sortOrder||0}" /></label>
<div class="col span-2"><label>تخصص‌ها (با ویرگول جدا کنید) <input name="expertise" value="${escapeAttr(expertiseStr)}" /></label></div>
${isExpert ? `
<label>اتاق فکر (فارسی) <input name="expertRoomFa" value="${escapeAttr(m.expertRoomFa||'')}" /></label>
<label>اتاق فکر (انگلیسی) <input name="expertRoomEn" value="${escapeAttr(m.expertRoomEn||'')}" /></label>
<label>تلگرام <input name="telegram" value="${escapeAttr(m.telegram||'')}" /></label>
<label>لینکدین <input name="linkedin" value="${escapeAttr(m.linkedin||'')}" /></label>
<label>توییتر <input name="twitter" value="${escapeAttr(m.twitter||'')}" /></label>
<label>وبسایت <input name="website" value="${escapeAttr(m.website||'')}" /></label>` : ''}
<div class="col span-2"><label>بیوگرافی (فارسی) <textarea name="bioFa" rows="4">${escapeHtml(m.bioFa||'')}</textarea></label></div>
<div class="col span-2"><label>بیوگرافی (انگلیسی) <textarea name="bioEn" rows="4">${escapeHtml(m.bioEn||'')}</textarea></label></div>
<div class="col span-2 row-end"><button type="submit" class="primary">${isEdit?'ذخیره':'ایجاد'}</button></div>
</form>
</main>`;
$('#backBtn').addEventListener('click', () => renderTeamList(isExpert));
$('#editorForm').addEventListener('submit', async ev => {
ev.preventDefault();
const fd = new FormData(ev.target);
const expertiseRaw = String(fd.get('expertise')||'').split(/[,،]/).map(s=>s.trim()).filter(Boolean);
const payload = {
nameFa: fd.get('nameFa'), nameEn: fd.get('nameEn')||null,
roleFa: fd.get('roleFa')||null, roleEn: fd.get('roleEn')||null,
bioFa: fd.get('bioFa')||null, bioEn: fd.get('bioEn')||null,
expertise: expertiseRaw, email: fd.get('email')||null,
initial: fd.get('initial')||null, reportCount: Number(fd.get('reportCount')||0),
isExpert: isExpert,
expertRoomFa: fd.get('expertRoomFa')||null, expertRoomEn: fd.get('expertRoomEn')||null,
telegram: fd.get('telegram')||null, linkedin: fd.get('linkedin')||null,
twitter: fd.get('twitter')||null, website: fd.get('website')||null,
sortOrder: Number(fd.get('sortOrder')||0),
};
try {
if (isEdit) await api(`/api/team/${member.id}`, { method:'PUT', body:payload });
else await api('/api/team', { method:'POST', body:payload });
renderTeamList(isExpert);
} catch (err) { alert('ذخیره ناموفق: ' + err.message); }
});
}
// ═══════════════════════════════════════════════
// PLANS
// ═══════════════════════════════════════════════
async function renderPlansList() {
root.innerHTML = `
${topbarHtml('plans', '+ پلان جدید')}
<main class="page">
<h2>پلان‌های اشتراک</h2>
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
</main>`;
wireTabs();
$('#newBtn').addEventListener('click', () => renderPlanEditor(null));
try {
const items = await api('/api/plans');
if (!items.length) { $('#list').innerHTML = `<p class="muted">هنوز پلانی ثبت نشده.</p>`; return; }
$('#list').innerHTML = items.map(p => `
<article class="row" data-id="${escapeAttr(p.id)}">
<div class="row-main">
<div class="row-meta">
${p.isFeatured ? '<span class="pill red">ویژه</span>' : ''}
${p.badgeFa ? `<span class="pill">${escapeHtml(p.badgeFa)}</span>` : ''}
<span class="muted">${Number(p.price).toLocaleString()} ریال / ${escapeHtml(p.periodFa||'')}</span>
</div>
<h3>${escapeHtml(p.nameFa)}</h3>
<p class="muted">${Array.isArray(p.features) ? p.features.length : 0} ویژگی</p>
</div>
<div class="row-actions">
<button data-action="edit">ویرایش</button>
<button data-action="delete" class="danger">حذف</button>
</div>
</article>`).join('');
$('#list').addEventListener('click', async ev => {
const btn = ev.target.closest('button[data-action]'); if (!btn) return;
const id = btn.closest('.row').dataset.id;
if (btn.dataset.action === 'edit') renderPlanEditor(await api(`/api/plans/${id}`));
else if (btn.dataset.action === 'delete') {
if (!confirm('این پلان حذف شود؟')) return;
await api(`/api/plans/${id}`, { method:'DELETE' });
renderPlansList();
}
});
} catch (err) { $('#list').innerHTML = `<p class="error">خطا: ${escapeHtml(err.message)}</p>`; }
}
function renderPlanEditor(plan) {
const p = plan || { nameFa:'', nameEn:'', price:0, periodFa:'', periodEn:'',
features:[], badgeFa:'', badgeEn:'', isFeatured:false, ctaFa:'', ctaEn:'', sortOrder:0 };
const isEdit = !!plan;
const featStr = Array.isArray(p.features)
? p.features.map(f => typeof f === 'object' ? f.fa||'' : f).join('\n')
: '';
root.innerHTML = `
<header class="topbar">
<div class="brand">${isEdit?'ویرایش پلان':'پلان جدید'}</div>
<div class="actions"><button id="backBtn" class="ghost">← بازگشت</button></div>
</header>
<main class="page editor">
<form id="editorForm" class="grid">
<label>نام (فارسی) <input name="nameFa" value="${escapeAttr(p.nameFa)}" required /></label>
<label>نام (انگلیسی) <input name="nameEn" value="${escapeAttr(p.nameEn||'')}" /></label>
<label>قیمت (ریال) <input name="price" type="number" min="0" value="${p.price||0}" /></label>
<label>دوره (فارسی) <input name="periodFa" value="${escapeAttr(p.periodFa||'')}" /></label>
<label>دوره (انگلیسی) <input name="periodEn" value="${escapeAttr(p.periodEn||'')}" /></label>
<label>برچسب (فارسی) <input name="badgeFa" value="${escapeAttr(p.badgeFa||'')}" /></label>
<label>برچسب (انگلیسی) <input name="badgeEn" value="${escapeAttr(p.badgeEn||'')}" /></label>
<label>دکمه CTA (فارسی) <input name="ctaFa" value="${escapeAttr(p.ctaFa||'')}" /></label>
<label>دکمه CTA (انگلیسی) <input name="ctaEn" value="${escapeAttr(p.ctaEn||'')}" /></label>
<label>ترتیب نمایش <input name="sortOrder" type="number" value="${p.sortOrder||0}" /></label>
<label class="checkbox"><input type="checkbox" name="isFeatured" ${p.isFeatured?'checked':''}><span>پلان ویژه (هایلایت)</span></label>
<div class="col span-2">
<label>ویژگی‌ها (هر ویژگی در یک خط — فارسی)
<textarea name="features" rows="8">${escapeHtml(featStr)}</textarea>
</label>
</div>
<div class="col span-2 row-end"><button type="submit" class="primary">${isEdit?'ذخیره':'ایجاد'}</button></div>
</form>
</main>`;
$('#backBtn').addEventListener('click', () => renderPlansList());
$('#editorForm').addEventListener('submit', async ev => {
ev.preventDefault();
const fd = new FormData(ev.target);
const featLines = String(fd.get('features')||'').split('\n').map(s=>s.trim()).filter(Boolean);
const payload = {
nameFa: fd.get('nameFa'), nameEn: fd.get('nameEn')||null,
price: Number(fd.get('price')||0),
periodFa: fd.get('periodFa')||null, periodEn: fd.get('periodEn')||null,
features: featLines,
badgeFa: fd.get('badgeFa')||null, badgeEn: fd.get('badgeEn')||null,
isFeatured: fd.get('isFeatured') === 'on',
ctaFa: fd.get('ctaFa')||null, ctaEn: fd.get('ctaEn')||null,
sortOrder: Number(fd.get('sortOrder')||0),
};
try {
if (isEdit) await api(`/api/plans/${plan.id}`, { method:'PUT', body:payload });
else await api('/api/plans', { method:'POST', body:payload });
renderPlansList();
} catch (err) { alert('ذخیره ناموفق: ' + err.message); }
});
}
// ═══════════════════════════════════════════════
// RADAR (رادار آینده)
// ═══════════════════════════════════════════════
// section pages on the site → which raw categories feed them
const RADAR_PAGE_SLUGS = [
{ slug: 'market', label: 'بازار و زنجیره فولاد', cats: ['market','commodity'] },
{ slug: 'sustainability', label: 'پایداری و فولاد سبز', cats: ['energy','commodity'] },
{ slug: 'geopolitics', label: 'ژئوپلیتیک و اقتصاد جهانی', cats: ['geo','market'] },
{ slug: 'technology', label: 'فناوری و نوآوری', cats: ['tech'] },
];
function sectionLabelsForCategory(cat) {
return RADAR_PAGE_SLUGS.filter(s => s.cats.includes(cat)).map(s => s.label);
}
async function renderRadarList() {
root.innerHTML = `
${topbarHtml('glance', '+ در یک نگاه جدید')}
<main class="page">
<h2>در یک نگاه</h2>
<p class="muted" style="margin:-4px 0 12px">محتوای صفحهٔ «در یک نگاه» سایت (/at-a-glance) — به تفکیک دسته.</p>
<div id="section-bar" class="cat-bar">
<button class="cat-btn active" data-cat="">همه</button>
${RADAR_CATEGORIES.map(c => `<button class="cat-btn" data-cat="${c.value}">${c.label}</button>`).join('')}
</div>
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
</main>`;
wireTabs();
$('#newBtn').addEventListener('click', () => renderRadarEditor(null));
let allItems = [];
let activeCat = '';
const safeImgUrl = (u) => /^(\/uploads\/[a-zA-Z0-9._-]+|\/[a-zA-Z0-9._\/-]+)$/.test(String(u || '')) ? u : '';
const catLabel = (v) => (RADAR_CATEGORIES.find(c => c.value === v) || {}).label || v;
function renderItems() {
const filtered = activeCat ? allItems.filter(r => r.category === activeCat) : allItems;
if (!filtered.length) { $('#list').innerHTML = `<p class="muted">مطلبی در این دسته یافت نشد.</p>`; return; }
$('#list').innerHTML = filtered.map(r => {
return `
<article class="row" data-id="${escapeAttr(r.id)}">
<div class="row-cover" style="background-image:url('${escapeAttr(safeImgUrl(r.img))}')"></div>
<div class="row-main">
<div class="row-meta">
${r.status === 'draft' ? `<span class="pill" style="background:#b45309;color:#fff">پیش‌نویس</span>` : ''}
${r.category ? `<span class="pill">${escapeHtml(catLabel(r.category))}</span>` : ''}
${r.dateFa ? `<span class="muted">${escapeHtml(r.dateFa)}</span>` : ''}
</div>
<h3>${escapeHtml(r.titleFa||'')}</h3>
<p class="muted clamp-2">${escapeHtml(r.excerptFa||'')}</p>
</div>
<div class="row-actions">
${r.status === 'draft' ? `<button data-action="publish" style="background:#15803d;color:#fff">انتشار</button>` : ''}
<button data-action="edit">ویرایش</button>
<button data-action="delete" class="danger">حذف</button>
</div>
</article>`;
}).join('');
}
$('#section-bar').addEventListener('click', e => {
const btn = e.target.closest('.cat-btn'); if (!btn) return;
activeCat = btn.dataset.cat;
document.querySelectorAll('#section-bar .cat-btn').forEach(b => b.classList.toggle('active', b === btn));
renderItems();
});
$('#list').addEventListener('click', async ev => {
const btn = ev.target.closest('button[data-action]'); if (!btn) return;
const id = btn.closest('.row').dataset.id;
if (btn.dataset.action === 'edit') renderRadarEditor(await api(`/api/radar/${id}`));
else if (btn.dataset.action === 'publish') {
await api(`/api/radar/${id}/publish`, { method: 'POST' });
renderRadarList();
}
else if (btn.dataset.action === 'delete') {
if (!confirm('این مطلب حذف شود؟')) return;
await api(`/api/radar/${id}`, { method: 'DELETE' });
renderRadarList();
}
});
try {
allItems = await api('/api/admin/radar?limit=500');
if (!allItems.length) { $('#list').innerHTML = `<p class="muted">هنوز مطلبی ثبت نشده.</p>`; return; }
renderItems();
} catch (err) { $('#list').innerHTML = `<p class="error">خطا: ${escapeHtml(err.message)}</p>`; }
}
function renderRadarEditor(item) {
const r = item || { category:'', img:'', authorName:'', authorAvatar:'',
titleFa:'', dateFa:'', excerptFa:'', bodyFa:'', analysisFa:'',
titleEn:'', dateEn:'', excerptEn:'', bodyEn:'', analysisEn:'', tags:[], source:'', sortOrder:0 };
const isEdit = !!item;
function sectionBadges(cat) {
const labels = sectionLabelsForCategory(cat);
if (!labels.length) return '<span class="muted" style="font-size:12px">—</span>';
return labels.map(l => `<span class="pill green">${escapeHtml(l)}</span>`).join(' ');
}
root.innerHTML = `
<header class="topbar">
<div class="topbar-row">
<div class="brand">📌 ${isEdit?'ویرایش':'مطلب جدید'} — در یک نگاه</div>
<nav class="groups"></nav>
<div class="actions"><button id="backBtn" class="ghost">← بازگشت</button></div>
</div>
<div class="tabs"></div>
</header>
<main class="page editor">
<form id="editorForm" class="grid">
<div class="col span-2">
<label>بخش سایت
<select name="category" id="catSelect">
<option value="">انتخاب کنید…</option>
${RADAR_CATEGORIES.map(c=>`<option value="${c.value}" ${c.value===r.category?'selected':''}>${c.label}</option>`).join('')}
</select>
</label>
<div id="sectionBadges" style="margin-top:8px;display:flex;gap:6px;flex-wrap:wrap;align-items:center">
<span class="muted" style="font-size:12px">نمایش در بخش‌های سایت:</span>
${sectionBadges(r.category)}
</div>
</div>
<div class="col span-2">
<label>تصویر شاخص
<div class="cover-row">
<input type="file" id="radarCoverFile" accept="image/*" />
<input type="hidden" name="img" value="${escapeAttr(r.img||'')}" />
<img id="radarCoverPreview" alt="" src="${escapeAttr(r.img||'')}" style="${r.img?'':'display:none'}" />
</div>
</label>
</div>
<div class="col span-2" style="border-top:1px solid var(--rule);padding-top:16px;margin-top:4px">
<strong style="font-size:13px;color:var(--ink-3)">فارسی</strong>
</div>
<div class="col span-2">
<label>عنوان (فارسی) <input name="titleFa" value="${escapeAttr(r.titleFa)}" required /></label>
</div>
<label>تاریخ (فارسی) <input name="dateFa" value="${escapeAttr(r.dateFa||'')}" placeholder="۱۴ خرداد ۱۴۰۴" /></label>
<label>نام نویسنده <input name="authorName" value="${escapeAttr(r.authorName||'')}" /></label>
<label>منبع <input name="source" value="${escapeAttr(r.source||'')}" placeholder="مثلاً Financial Times" /></label>
<div class="col span-2">
<label>هشتگ‌ها (با ویرگول جدا کنید) <input name="tags" value="${escapeAttr((r.tags||[]).join('، '))}" placeholder="فولاد سبز، هیدروژن، اروپا" /></label>
</div>
<div class="col span-2">
<label>چکیده (فارسی) <textarea name="excerptFa" rows="3">${escapeHtml(r.excerptFa||'')}</textarea></label>
</div>
<div class="col span-2">
<label>متن کامل (فارسی) <textarea name="bodyFa" rows="8">${escapeHtml(r.bodyFa||'')}</textarea></label>
</div>
<div class="col span-2">
<label>تحلیل استراتژیک (فارسی — برای اعضا) <textarea name="analysisFa" rows="4">${escapeHtml(r.analysisFa||'')}</textarea></label>
</div>
<div class="col span-2" style="border-top:1px solid var(--rule);padding-top:16px;margin-top:4px">
<strong style="font-size:13px;color:var(--ink-3)">English</strong>
</div>
<div class="col span-2">
<label>Title (English) <input name="titleEn" value="${escapeAttr(r.titleEn||'')}" /></label>
</div>
<label>Date (English) <input name="dateEn" value="${escapeAttr(r.dateEn||'')}" placeholder="June 4, 2025" /></label>
<div></div>
<div class="col span-2">
<label>Excerpt (English) <textarea name="excerptEn" rows="3">${escapeHtml(r.excerptEn||'')}</textarea></label>
</div>
<div class="col span-2">
<label>Body (English) <textarea name="bodyEn" rows="8">${escapeHtml(r.bodyEn||'')}</textarea></label>
</div>
<div class="col span-2">
<label>Strategic Analysis (English) <textarea name="analysisEn" rows="4">${escapeHtml(r.analysisEn||'')}</textarea></label>
</div>
<label>ترتیب نمایش <input name="sortOrder" type="number" value="${r.sortOrder||0}" /></label>
<div class="col span-2 row-end">
<button type="submit" class="primary">${isEdit?'ذخیره تغییرات':'انتشار مطلب رادار'}</button>
</div>
</form>
</main>`;
$('#backBtn').addEventListener('click', () => renderRadarList());
document.getElementById('catSelect').addEventListener('change', e => {
const cat = e.target.value;
const badgesEl = document.getElementById('sectionBadges');
badgesEl.innerHTML = `<span class="muted" style="font-size:12px">نمایش در بخش‌های سایت:</span> ${sectionBadges(cat)}`;
});
document.getElementById('radarCoverFile').addEventListener('change', async e => {
const file = e.target.files?.[0]; if (!file) return;
const fd = new FormData(); fd.append('file', file);
try {
const { url } = await api('/api/uploads', { method: 'POST', body: fd, isForm: true });
$('input[name="img"]').value = url;
const prev = document.getElementById('radarCoverPreview');
prev.src = url; prev.style.display = '';
} catch (err) { alert('آپلود ناموفق: ' + err.message); }
});
$('#editorForm').addEventListener('submit', async ev => {
ev.preventDefault();
const fd = new FormData(ev.target);
const payload = {
titleFa: fd.get('titleFa'), titleEn: fd.get('titleEn')||null,
category: fd.get('category')||null,
img: fd.get('img')||null,
authorName: fd.get('authorName')||null,
authorAvatar: r.authorAvatar || null,
dateFa: fd.get('dateFa')||null, dateEn: fd.get('dateEn')||null,
excerptFa: fd.get('excerptFa')||null, excerptEn: fd.get('excerptEn')||null,
bodyFa: fd.get('bodyFa')||null, bodyEn: fd.get('bodyEn')||null,
analysisFa: fd.get('analysisFa')||null, analysisEn: fd.get('analysisEn')||null,
source: fd.get('source')||null,
tags: String(fd.get('tags')||'').split(/[,،]/).map(s=>s.trim()).filter(Boolean),
sortOrder: Number(fd.get('sortOrder')||0),
};
try {
if (isEdit) await api(`/api/radar/${item.id}`, { method:'PUT', body:payload });
else await api('/api/radar', { method:'POST', body:payload });
renderRadarList();
} catch (err) { alert('ذخیره ناموفق: ' + err.message); }
});
}
async function renderRadarPagesList() {
root.innerHTML = `
${topbarHtml('radarPages', '')}
<main class="page">
<h2>صفحات رادار آینده</h2>
<p class="muted">۵ صفحه ثابت. محتوای هیرو (کارت ویژه) و بنر هر دسته را اینجا ویرایش کنید.</p>
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
</main>`;
wireTabs();
const newBtn = $('#newBtn'); if (newBtn) newBtn.style.display = 'none';
try {
const saved = await api('/api/radar-pages');
const bySlug = {}; saved.forEach(p => { bySlug[p.slug] = p; });
$('#list').innerHTML = RADAR_PAGE_SLUGS.map(s => {
const p = bySlug[s.slug];
return `
<article class="row" data-slug="${escapeAttr(s.slug)}">
<div class="row-main">
<div class="row-meta">
<span class="pill">${escapeHtml(s.slug)}</span>
${p ? '<span class="muted">ذخیره‌شده</span>' : '<span class="muted">پیش‌فرض (هنوز ذخیره نشده)</span>'}
</div>
<h3>${escapeHtml((p && p.labelFa) || s.label)}</h3>
<p class="muted clamp-2">${escapeHtml((p && p.bannerTitleFa) || '')}</p>
</div>
<div class="row-actions"><button data-action="edit">ویرایش</button></div>
</article>`;
}).join('');
$('#list').addEventListener('click', async ev => {
const btn = ev.target.closest('button[data-action]'); if (!btn) return;
const slug = btn.closest('.row').dataset.slug;
const def = RADAR_PAGE_SLUGS.find(x => x.slug === slug);
renderRadarPageEditor(bySlug[slug] || { slug, labelFa: def.label }, def);
});
} catch (err) { $('#list').innerHTML = `<p class="error">خطا: ${escapeHtml(err.message)}</p>`; }
}
function renderRadarPageEditor(p, def) {
const r = Object.assign({
slug: def.slug, sourceCategories: [], labelFa: def.label, labelEn: '',
latestHeadingFa: '', latestHeadingEn: '',
featuredTitleFa: '', featuredTitleEn: '', featuredDateFa: '', featuredDateEn: '', featuredImg: '',
bannerKickerFa: 'تحلیل ویژه', bannerKickerEn: 'Special Analysis',
bannerTitleFa: '', bannerTitleEn: '', bannerDescFa: '', bannerDescEn: '',
bannerBookImg: '', sortOrder: 0,
}, p || {});
const catChecked = (c) => (r.sourceCategories || []).includes(c) ? 'checked' : '';
root.innerHTML = `
<header class="topbar">
<div class="brand">ویرایش صفحه: ${escapeHtml(r.labelFa)}</div>
<div class="actions"><button id="backBtn" class="ghost">← بازگشت</button></div>
</header>
<main class="page editor">
<form id="editorForm" class="grid">
<label>عنوان دسته (فارسی) <input name="labelFa" value="${escapeAttr(r.labelFa)}" required /></label>
<label>عنوان دسته (انگلیسی) <input name="labelEn" value="${escapeAttr(r.labelEn||'')}" /></label>
<div class="col span-2"><label>دسته‌های منبع کارت‌ها
<div style="display:flex;gap:14px;flex-wrap:wrap;margin-top:6px">
${RADAR_CATEGORIES.map(c=>`<label style="display:inline-flex;gap:5px;align-items:center;font-weight:400"><input type="checkbox" name="srcCat" value="${c.value}" ${catChecked(c.value)} /> ${c.label}</label>`).join('')}
</div>
</label></div>
<label>تیتر تازه‌ترین‌ها (فارسی) <input name="latestHeadingFa" value="${escapeAttr(r.latestHeadingFa||'')}" /></label>
<label>تیتر تازه‌ترین‌ها (انگلیسی) <input name="latestHeadingEn" value="${escapeAttr(r.latestHeadingEn||'')}" /></label>
<div class="col span-2"><h3 style="margin:10px 0 0">کارت ویژه (هیرو)</h3></div>
<label>عنوان ویژه (فارسی) <input name="featuredTitleFa" value="${escapeAttr(r.featuredTitleFa||'')}" /></label>
<label>عنوان ویژه (انگلیسی) <input name="featuredTitleEn" value="${escapeAttr(r.featuredTitleEn||'')}" /></label>
<label>تاریخ ویژه (فارسی) <input name="featuredDateFa" value="${escapeAttr(r.featuredDateFa||'')}" /></label>
<label>تاریخ ویژه (انگلیسی) <input name="featuredDateEn" value="${escapeAttr(r.featuredDateEn||'')}" /></label>
<div class="col span-2"><label>تصویر ویژه (URL) <input name="featuredImg" value="${escapeAttr(r.featuredImg||'')}" /></label></div>
<div class="col span-2"><h3 style="margin:10px 0 0">بنر تحلیل ویژه</h3></div>
<label>برچسب بنر (فارسی) <input name="bannerKickerFa" value="${escapeAttr(r.bannerKickerFa||'')}" /></label>
<label>برچسب بنر (انگلیسی) <input name="bannerKickerEn" value="${escapeAttr(r.bannerKickerEn||'')}" /></label>
<label>عنوان بنر (فارسی) <input name="bannerTitleFa" value="${escapeAttr(r.bannerTitleFa||'')}" /></label>
<label>عنوان بنر (انگلیسی) <input name="bannerTitleEn" value="${escapeAttr(r.bannerTitleEn||'')}" /></label>
<div class="col span-2"><label>توضیح بنر (فارسی) <textarea name="bannerDescFa" rows="3">${escapeHtml(r.bannerDescFa||'')}</textarea></label></div>
<div class="col span-2"><label>توضیح بنر (انگلیسی) <textarea name="bannerDescEn" rows="3">${escapeHtml(r.bannerDescEn||'')}</textarea></label></div>
<div class="col span-2"><label>تصویر کتاب بنر (URL) <input name="bannerBookImg" value="${escapeAttr(r.bannerBookImg||'')}" /></label></div>
<label>ترتیب نمایش <input name="sortOrder" type="number" value="${r.sortOrder||0}" /></label>
<div class="col span-2 row-end"><button type="submit" class="primary">ذخیره</button></div>
</form>
</main>`;
$('#backBtn').addEventListener('click', () => renderRadarPagesList());
$('#editorForm').addEventListener('submit', async ev => {
ev.preventDefault();
const fd = new FormData(ev.target);
const payload = {
sourceCategories: fd.getAll('srcCat'),
labelFa: fd.get('labelFa'), labelEn: fd.get('labelEn')||null,
latestHeadingFa: fd.get('latestHeadingFa')||null, latestHeadingEn: fd.get('latestHeadingEn')||null,
featuredTitleFa: fd.get('featuredTitleFa')||null, featuredTitleEn: fd.get('featuredTitleEn')||null,
featuredDateFa: fd.get('featuredDateFa')||null, featuredDateEn: fd.get('featuredDateEn')||null,
featuredImg: fd.get('featuredImg')||null,
bannerKickerFa: fd.get('bannerKickerFa')||null, bannerKickerEn: fd.get('bannerKickerEn')||null,
bannerTitleFa: fd.get('bannerTitleFa')||null, bannerTitleEn: fd.get('bannerTitleEn')||null,
bannerDescFa: fd.get('bannerDescFa')||null, bannerDescEn: fd.get('bannerDescEn')||null,
bannerBookImg: fd.get('bannerBookImg')||null,
sortOrder: Number(fd.get('sortOrder')||0),
};
try {
await api(`/api/radar-pages/${r.slug}`, { method:'PUT', body:payload });
renderRadarPagesList();
} catch (err) { alert('ذخیره ناموفق: ' + err.message); }
});
}
// ═══════════════════════════════════════════════
// FACTORY REPORTS (گزارش کارخانه)
// ═══════════════════════════════════════════════
async function renderFactoryReportsList() {
root.innerHTML = `
${topbarHtml('factoryReports', '+ گزارش جدید')}
<main class="page">
<h2>گزارش کارخانه</h2>
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
</main>`;
wireTabs();
$('#newBtn').addEventListener('click', () => renderFactoryReportEditor(null));
try {
const items = await api('/api/factory-reports?limit=50');
if (!items.length) { $('#list').innerHTML = `<p class="muted">هنوز گزارشی ثبت نشده.</p>`; return; }
$('#list').innerHTML = items.map(r => `
<article class="row" data-id="${escapeAttr(r.id)}">
<div class="row-main">
<div class="row-meta">
${r.tagFa ? `<span class="pill">${escapeHtml(r.tagFa)}</span>` : ''}
${r.dateFa ? `<span class="muted">${escapeHtml(r.dateFa)}</span>` : ''}
<span class="muted">${escapeHtml(SCROLL_DIRS.find(d=>d.value===r.scrollDir)?.label||r.scrollDir||'')}</span>
</div>
<h3>${escapeHtml(r.titleFa)}</h3>
<p class="muted clamp-2">${escapeHtml(r.excerptFa||'')}</p>
</div>
<div class="row-actions">
<button data-action="edit">ویرایش</button>
<button data-action="delete" class="danger">حذف</button>
</div>
</article>`).join('');
$('#list').addEventListener('click', async ev => {
const btn = ev.target.closest('button[data-action]'); if (!btn) return;
const id = btn.closest('.row').dataset.id;
if (btn.dataset.action === 'edit') renderFactoryReportEditor(await api(`/api/factory-reports/${id}`));
else if (btn.dataset.action === 'delete') {
if (!confirm('این گزارش حذف شود؟')) return;
await api(`/api/factory-reports/${id}`, { method: 'DELETE' });
renderFactoryReportsList();
}
});
} catch (err) { $('#list').innerHTML = `<p class="error">خطا: ${escapeHtml(err.message)}</p>`; }
}
function renderFactoryReportEditor(report) {
const r = report || { img:'', tagFa:'', tagEn:'', titleFa:'', titleEn:'', dateFa:'', dateEn:'',
excerptFa:'', excerptEn:'', fileUrl:'', scrollDir:'up', sortOrder:0 };
const isEdit = !!report;
root.innerHTML = `
<header class="topbar">
<div class="brand">${isEdit?'ویرایش گزارش':'گزارش جدید'}</div>
<div class="actions"><button id="backBtn" class="ghost">← بازگشت</button></div>
</header>
<main class="page editor">
<form id="editorForm" class="grid">
<label>عنوان (فارسی) <input name="titleFa" value="${escapeAttr(r.titleFa)}" required /></label>
<label>عنوان (انگلیسی) <input name="titleEn" value="${escapeAttr(r.titleEn||'')}" /></label>
<label>برچسب (فارسی) <input name="tagFa" value="${escapeAttr(r.tagFa||'')}" /></label>
<label>برچسب (انگلیسی) <input name="tagEn" value="${escapeAttr(r.tagEn||'')}" /></label>
<label>تاریخ (فارسی) <input name="dateFa" value="${escapeAttr(r.dateFa||'')}" /></label>
<label>تاریخ (انگلیسی) <input name="dateEn" value="${escapeAttr(r.dateEn||'')}" /></label>
<label>جهت ورود
<select name="scrollDir">
${SCROLL_DIRS.map(d=>`<option value="${d.value}" ${d.value===r.scrollDir?'selected':''}>${d.label}</option>`).join('')}
</select>
</label>
<label>ترتیب نمایش <input name="sortOrder" type="number" value="${r.sortOrder||0}" /></label>
<div class="col span-2">
<label style="display:block;margin-bottom:6px">تصویر کاور</label>
<input type="file" id="factoryCoverFile" accept="image/*" style="display:block;margin-bottom:8px" />
<input type="text" name="img" value="${escapeAttr(r.img||'')}" placeholder="آدرس تصویر (یا از دکمه بالا آپلود کنید)" style="width:100%" />
<img id="factoryCoverPreview" alt="" src="${escapeAttr(r.img||'')}" style="${r.img?'':'display:none'};max-width:200px;max-height:130px;border-radius:6px;margin-top:8px" />
</div>
<div class="col span-2">
<label style="display:block;margin-bottom:6px">فایل ماهنامه (PDF) — مستقیم دانلود می‌شود</label>
<input type="file" id="factoryFile" accept="application/pdf" style="display:block;margin-bottom:8px" />
<input type="text" name="fileUrl" value="${escapeAttr(r.fileUrl||'')}" placeholder="آدرس فایل (یا از دکمه بالا آپلود کنید)" style="width:100%" />
<span id="factoryFileStatus" class="muted" style="display:block;font-size:12px;margin-top:6px"></span>
</div>
<div class="col span-2"><label>خلاصه (فارسی) <textarea name="excerptFa" rows="3">${escapeHtml(r.excerptFa||'')}</textarea></label></div>
<div class="col span-2"><label>خلاصه (انگلیسی) <textarea name="excerptEn" rows="3">${escapeHtml(r.excerptEn||'')}</textarea></label></div>
<div class="col span-2 row-end"><button type="submit" class="primary">${isEdit?'ذخیره':'ایجاد'}</button></div>
</form>
</main>`;
$('#backBtn').addEventListener('click', () => renderFactoryReportsList());
document.getElementById('factoryCoverFile').addEventListener('change', async e => {
const file = e.target.files?.[0]; if (!file) return;
const fd = new FormData(); fd.append('file', file);
try {
const { url } = await api('/api/uploads', { method:'POST', body:fd, isForm:true });
$('input[name="img"]').value = url;
const prev = document.getElementById('factoryCoverPreview');
if (prev) { prev.src = url; prev.style.display = ''; }
} catch (err) { alert('آپلود تصویر ناموفق: ' + err.message); }
});
document.getElementById('factoryFile').addEventListener('change', async e => {
const file = e.target.files?.[0]; if (!file) return;
const status = document.getElementById('factoryFileStatus');
if (status) status.textContent = 'در حال آپلود فایل…';
const fd = new FormData(); fd.append('file', file);
try {
const { url } = await api('/api/uploads/raw', { method:'POST', body:fd, isForm:true });
$('input[name="fileUrl"]').value = url;
if (status) status.textContent = '✓ آپلود شد';
} catch (err) { if (status) status.textContent = ''; alert('آپلود ناموفق: ' + err.message); }
});
$('#editorForm').addEventListener('submit', async ev => {
ev.preventDefault();
const fd = new FormData(ev.target);
const payload = {
img: fd.get('img')||null,
tagFa: fd.get('tagFa')||null, tagEn: fd.get('tagEn')||null,
titleFa: fd.get('titleFa'), titleEn: fd.get('titleEn')||null,
dateFa: fd.get('dateFa')||null, dateEn: fd.get('dateEn')||null,
excerptFa: fd.get('excerptFa')||null, excerptEn: fd.get('excerptEn')||null,
fileUrl: fd.get('fileUrl')||null,
scrollDir: fd.get('scrollDir')||'up',
sortOrder: Number(fd.get('sortOrder')||0),
};
try {
if (isEdit) await api(`/api/factory-reports/${report.id}`, { method:'PUT', body:payload });
else await api('/api/factory-reports', { method:'POST', body:payload });
renderFactoryReportsList();
} catch (err) { alert('ذخیره ناموفق: ' + err.message); }
});
}
// ═══════════════════════════════════════════════
// INTEGRATIONS (منابع داده)
// ═══════════════════════════════════════════════
async function renderIntegrationsList() {
root.innerHTML = `
${topbarHtml('integrations', '+ منبع جدید')}
<main class="page">
<h2>منابع داده</h2>
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
</main>`;
wireTabs();
$('#newBtn').addEventListener('click', () => renderIntegrationEditor(null));
try {
const items = await api('/api/integrations?limit=50');
if (!items.length) { $('#list').innerHTML = `<p class="muted">هنوز منبعی ثبت نشده.</p>`; return; }
$('#list').innerHTML = items.map(it => `
<article class="row" data-id="${escapeAttr(it.id)}">
<div class="row-main">
<div class="row-meta">
${it.icon ? `<span class="pill">${escapeHtml(INTEGRATION_ICONS.find(i=>i.value===it.icon)?.label||it.icon)}</span>` : ''}
<span class="muted">سلول ${it.gridCol}×${it.gridRow}</span>
${it.accent ? `<span class="pill" style="background:${escapeAttr(it.accent)}">&nbsp;</span>` : ''}
</div>
<h3>${escapeHtml(it.name)}</h3>
${it.logo ? `<p class="muted clamp-2">${escapeHtml(it.logo)}</p>` : ''}
</div>
<div class="row-actions">
<button data-action="edit">ویرایش</button>
<button data-action="delete" class="danger">حذف</button>
</div>
</article>`).join('');
$('#list').addEventListener('click', async ev => {
const btn = ev.target.closest('button[data-action]'); if (!btn) return;
const id = btn.closest('.row').dataset.id;
if (btn.dataset.action === 'edit') renderIntegrationEditor(await api(`/api/integrations/${id}`));
else if (btn.dataset.action === 'delete') {
if (!confirm('این منبع حذف شود؟')) return;
await api(`/api/integrations/${id}`, { method: 'DELETE' });
renderIntegrationsList();
}
});
} catch (err) { $('#list').innerHTML = `<p class="error">خطا: ${escapeHtml(err.message)}</p>`; }
}
function renderIntegrationEditor(integration) {
const it = integration || { name:'', icon:'globe', logo:'', accent:'', gridCol:1, gridRow:1, sortOrder:0 };
const isEdit = !!integration;
root.innerHTML = `
<header class="topbar">
<div class="brand">${isEdit?'ویرایش منبع':'منبع جدید'}</div>
<div class="actions"><button id="backBtn" class="ghost">← بازگشت</button></div>
</header>
<main class="page editor">
<form id="editorForm" class="grid">
<div class="col span-2"><label>نام <input name="name" value="${escapeAttr(it.name)}" required /></label></div>
<label>آیکن
<select name="icon">
${INTEGRATION_ICONS.map(i=>`<option value="${i.value}" ${i.value===it.icon?'selected':''}>${i.label}</option>`).join('')}
</select>
</label>
<label>رنگ تأکید <input name="accent" value="${escapeAttr(it.accent||'')}" placeholder="#CD9E53" /></label>
<label>ستون شبکه (۱-۶) <input name="gridCol" type="number" min="1" max="6" value="${it.gridCol||1}" /></label>
<label>ردیف شبکه (۱-۵) <input name="gridRow" type="number" min="1" max="5" value="${it.gridRow||1}" /></label>
<label>ترتیب نمایش <input name="sortOrder" type="number" value="${it.sortOrder||0}" /></label>
<div class="col span-2"><label>مسیر لوگو (اختیاری) <input name="logo" value="${escapeAttr(it.logo||'')}" placeholder="/logos/lme.svg" /></label></div>
<div class="col span-2 row-end"><button type="submit" class="primary">${isEdit?'ذخیره':'ایجاد'}</button></div>
</form>
</main>`;
$('#backBtn').addEventListener('click', () => renderIntegrationsList());
$('#editorForm').addEventListener('submit', async ev => {
ev.preventDefault();
const fd = new FormData(ev.target);
const payload = {
name: fd.get('name'),
icon: fd.get('icon')||'globe',
logo: fd.get('logo')||null,
accent: fd.get('accent')||null,
gridCol: Number(fd.get('gridCol')||1),
gridRow: Number(fd.get('gridRow')||1),
sortOrder: Number(fd.get('sortOrder')||0),
};
try {
if (isEdit) await api(`/api/integrations/${integration.id}`, { method:'PUT', body:payload });
else await api('/api/integrations', { method:'POST', body:payload });
renderIntegrationsList();
} catch (err) { alert('ذخیره ناموفق: ' + err.message); }
});
}
// ═══════════════════════════════════════════════
// INSTITUTE STATS (آمار)
// ═══════════════════════════════════════════════
async function renderStatsList() {
root.innerHTML = `
${topbarHtml('stats', '+ آمار جدید')}
<main class="page">
<h2>آمار اندیشکده</h2>
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
</main>`;
wireTabs();
$('#newBtn').addEventListener('click', () => renderStatEditor(null));
try {
const items = await api('/api/institute-stats');
if (!items.length) { $('#list').innerHTML = `<p class="muted">هنوز آماری ثبت نشده.</p>`; return; }
$('#list').innerHTML = items.map(s => `
<article class="row" data-id="${escapeAttr(s.id)}">
<div class="row-main">
<div class="row-meta">
<span class="pill">${s.value}${s.suffixFa ? escapeHtml(s.suffixFa) : ''}</span>
</div>
<h3>${escapeHtml(s.labelFa)}</h3>
${s.labelEn ? `<p class="muted clamp-2">${escapeHtml(s.labelEn)}</p>` : ''}
</div>
<div class="row-actions">
<button data-action="edit">ویرایش</button>
<button data-action="delete" class="danger">حذف</button>
</div>
</article>`).join('');
$('#list').addEventListener('click', async ev => {
const btn = ev.target.closest('button[data-action]'); if (!btn) return;
const id = btn.closest('.row').dataset.id;
if (btn.dataset.action === 'edit') renderStatEditor(await api(`/api/institute-stats/${id}`));
else if (btn.dataset.action === 'delete') {
if (!confirm('این آمار حذف شود؟')) return;
await api(`/api/institute-stats/${id}`, { method: 'DELETE' });
renderStatsList();
}
});
} catch (err) { $('#list').innerHTML = `<p class="error">خطا: ${escapeHtml(err.message)}</p>`; }
}
function renderStatEditor(stat) {
const s = stat || { id: '', labelFa: '', labelEn: '', value: 0, suffixFa: '', sortOrder: 0 };
const isEdit = !!stat;
root.innerHTML = `
<header class="topbar">
<div class="brand">${isEdit ? 'ویرایش آمار' : 'آمار جدید'}</div>
<div class="actions"><button id="backBtn" class="ghost">← بازگشت</button></div>
</header>
<main class="page editor">
<form id="editorForm" class="grid">
<label>شناسه (لاتین، بدون فاصله)
<input name="id" value="${escapeAttr(s.id || '')}" ${isEdit ? 'readonly' : ''} placeholder="total-reports" required />
</label>
<label>مقدار (عدد) <input name="value" type="number" value="${s.value || 0}" required /></label>
<label>برچسب (فارسی) <input name="labelFa" value="${escapeAttr(s.labelFa)}" required /></label>
<label>برچسب (انگلیسی) <input name="labelEn" value="${escapeAttr(s.labelEn || '')}" /></label>
<label>پسوند (فارسی، مثل +) <input name="suffixFa" value="${escapeAttr(s.suffixFa || '')}" /></label>
<label>ترتیب نمایش <input name="sortOrder" type="number" value="${s.sortOrder || 0}" /></label>
<div class="col span-2 row-end"><button type="submit" class="primary">${isEdit ? 'ذخیره' : 'ایجاد'}</button></div>
</form>
</main>`;
$('#backBtn').addEventListener('click', () => renderStatsList());
$('#editorForm').addEventListener('submit', async ev => {
ev.preventDefault();
const fd = new FormData(ev.target);
const payload = {
id: fd.get('id'),
labelFa: fd.get('labelFa'), labelEn: fd.get('labelEn') || null,
value: Number(fd.get('value') || 0),
suffixFa: fd.get('suffixFa') || null,
sortOrder: Number(fd.get('sortOrder') || 0),
};
try {
if (isEdit) await api(`/api/institute-stats/${stat.id}`, { method: 'PUT', body: payload });
else await api('/api/institute-stats', { method: 'POST', body: payload });
renderStatsList();
} catch (err) { alert('ذخیره ناموفق: ' + err.message); }
});
}
// ═══════════════════════════════════════════════
// MARKET PRICES (قیمت‌ها)
// ═══════════════════════════════════════════════
async function renderMarketPricesList() {
root.innerHTML = `
${topbarHtml('mprices', '+ قیمت جدید')}
<main class="page">
<h2>قیمت‌های بازار</h2>
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
</main>`;
wireTabs();
$('#newBtn').addEventListener('click', () => renderMarketPriceEditor(null));
try {
const items = await api('/api/market-prices');
if (!items.length) { $('#list').innerHTML = `<p class="muted">هنوز قیمتی ثبت نشده.</p>`; return; }
const trendLabel = (t) => (TREND_TYPES.find(x => x.value === t)?.label) || t;
$('#list').innerHTML = items.map(p => `
<article class="row" data-id="${escapeAttr(p.id)}">
<div class="row-main">
<div class="row-meta">
<span class="pill">${p.value} ${escapeHtml(p.unit || '')}</span>
<span class="muted">${escapeHtml(trendLabel(p.trend))} ${p.changePercent}%</span>
</div>
<h3>${escapeHtml(p.name)}</h3>
</div>
<div class="row-actions">
<button data-action="edit">ویرایش</button>
<button data-action="delete" class="danger">حذف</button>
</div>
</article>`).join('');
$('#list').addEventListener('click', async ev => {
const btn = ev.target.closest('button[data-action]'); if (!btn) return;
const id = btn.closest('.row').dataset.id;
if (btn.dataset.action === 'edit') renderMarketPriceEditor(await api(`/api/market-prices/${id}`));
else if (btn.dataset.action === 'delete') {
if (!confirm('این قیمت حذف شود؟')) return;
await api(`/api/market-prices/${id}`, { method: 'DELETE' });
renderMarketPricesList();
}
});
} catch (err) { $('#list').innerHTML = `<p class="error">خطا: ${escapeHtml(err.message)}</p>`; }
}
function renderMarketPriceEditor(price) {
const p = price || { id: '', name: '', value: 0, unit: '', change: 0, changePercent: 0, trend: 'flat', sortOrder: 0 };
const isEdit = !!price;
root.innerHTML = `
<header class="topbar">
<div class="brand">${isEdit ? 'ویرایش قیمت' : 'قیمت جدید'}</div>
<div class="actions"><button id="backBtn" class="ghost">← بازگشت</button></div>
</header>
<main class="page editor">
<form id="editorForm" class="grid">
<label>شناسه (لاتین) <input name="id" value="${escapeAttr(p.id || '')}" ${isEdit ? 'readonly' : ''} placeholder="steel-billet" required /></label>
<label>نام محصول (فارسی) <input name="name" value="${escapeAttr(p.name)}" required /></label>
<label>مقدار / قیمت <input name="value" type="number" step="any" value="${p.value || 0}" required /></label>
<label>واحد (مثل تومان/کیلو) <input name="unit" value="${escapeAttr(p.unit || '')}" /></label>
<label>تغییر (مقدار) <input name="change" type="number" step="any" value="${p.change || 0}" /></label>
<label>تغییر (درصد) <input name="changePercent" type="number" step="any" value="${p.changePercent || 0}" /></label>
<label>روند
<select name="trend">
${TREND_TYPES.map(t => `<option value="${t.value}" ${t.value === p.trend ? 'selected' : ''}>${t.label}</option>`).join('')}
</select>
</label>
<label>ترتیب نمایش <input name="sortOrder" type="number" value="${p.sortOrder || 0}" /></label>
<div class="col span-2 row-end"><button type="submit" class="primary">${isEdit ? 'ذخیره' : 'ایجاد'}</button></div>
</form>
</main>`;
$('#backBtn').addEventListener('click', () => renderMarketPricesList());
$('#editorForm').addEventListener('submit', async ev => {
ev.preventDefault();
const fd = new FormData(ev.target);
const payload = {
id: fd.get('id'),
name: fd.get('name'),
value: Number(fd.get('value') || 0),
unit: fd.get('unit') || null,
change: Number(fd.get('change') || 0),
changePercent: Number(fd.get('changePercent') || 0),
trend: fd.get('trend'),
sortOrder: Number(fd.get('sortOrder') || 0),
};
try {
if (isEdit) await api(`/api/market-prices/${price.id}`, { method: 'PUT', body: payload });
else await api('/api/market-prices', { method: 'POST', body: payload });
renderMarketPricesList();
} catch (err) { alert('ذخیره ناموفق: ' + err.message); }
});
}
// ═══════════════════════════════════════════════
// MARKET CHART POINTS (نمودارها)
// ═══════════════════════════════════════════════
async function renderChartPointsList() {
root.innerHTML = `
${topbarHtml('charts', '+ نقطه جدید')}
<main class="page">
<h2>نقاط نمودار بازار</h2>
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
</main>`;
wireTabs();
$('#newBtn').addEventListener('click', () => renderChartPointEditor(null));
try {
const items = await api('/api/market-chart-points?limit=500');
if (!items.length) { $('#list').innerHTML = `<p class="muted">هنوز نقطه‌ای ثبت نشده.</p>`; return; }
const seriesLabel = (s) => (CHART_SERIES.find(x => x.value === s)?.label) || s;
$('#list').innerHTML = items.map(c => `
<article class="row" data-id="${escapeAttr(c.id)}">
<div class="row-main">
<div class="row-meta">
<span class="pill">${escapeHtml(seriesLabel(c.series))}</span>
<span class="muted">${c.value}</span>
${c.meta ? `<span class="muted">${escapeHtml(c.meta)}</span>` : ''}
</div>
<h3>${escapeHtml(c.label)}</h3>
</div>
<div class="row-actions">
<button data-action="edit">ویرایش</button>
<button data-action="delete" class="danger">حذف</button>
</div>
</article>`).join('');
$('#list').addEventListener('click', async ev => {
const btn = ev.target.closest('button[data-action]'); if (!btn) return;
const id = btn.closest('.row').dataset.id;
if (btn.dataset.action === 'edit') renderChartPointEditor(await api(`/api/market-chart-points/${id}`));
else if (btn.dataset.action === 'delete') {
if (!confirm('این نقطه حذف شود؟')) return;
await api(`/api/market-chart-points/${id}`, { method: 'DELETE' });
renderChartPointsList();
}
});
} catch (err) { $('#list').innerHTML = `<p class="error">خطا: ${escapeHtml(err.message)}</p>`; }
}
function renderChartPointEditor(point) {
const c = point || { id: '', series: 'history', label: '', value: 0, meta: '', sortOrder: 0 };
const isEdit = !!point;
root.innerHTML = `
<header class="topbar">
<div class="brand">${isEdit ? 'ویرایش نقطه نمودار' : 'نقطه نمودار جدید'}</div>
<div class="actions"><button id="backBtn" class="ghost">← بازگشت</button></div>
</header>
<main class="page editor">
<form id="editorForm" class="grid">
<label>سری داده
<select name="series">
${CHART_SERIES.map(s => `<option value="${s.value}" ${s.value === c.series ? 'selected' : ''}>${s.label}</option>`).join('')}
</select>
</label>
<label>ترتیب نمایش <input name="sortOrder" type="number" value="${c.sortOrder || 0}" /></label>
<label>برچسب (ماه فارسی / نام کشور) <input name="label" value="${escapeAttr(c.label)}" required /></label>
<label>مقدار (قیمت / تناژ) <input name="value" type="number" step="any" value="${c.value || 0}" required /></label>
<div class="col span-2">
<label>متادیتا (فقط «مقایسه جهانی» — JSON: {"flag":"🇮🇷","currency":"IRR","unit":"تومان/کیلو"})
<input name="meta" value="${escapeAttr(c.meta || '')}" />
</label>
</div>
<div class="col span-2 row-end"><button type="submit" class="primary">${isEdit ? 'ذخیره' : 'ایجاد'}</button></div>
</form>
</main>`;
$('#backBtn').addEventListener('click', () => renderChartPointsList());
$('#editorForm').addEventListener('submit', async ev => {
ev.preventDefault();
const fd = new FormData(ev.target);
const payload = {
series: fd.get('series'),
label: fd.get('label'),
value: Number(fd.get('value') || 0),
meta: fd.get('meta') || null,
sortOrder: Number(fd.get('sortOrder') || 0),
};
try {
if (isEdit) await api(`/api/market-chart-points/${point.id}`, { method: 'PUT', body: payload });
else await api('/api/market-chart-points', { method: 'POST', body: payload });
renderChartPointsList();
} catch (err) { alert('ذخیره ناموفق: ' + err.message); }
});
}
// ═══════════════════════════════════════════════
// VISION ITEMS (چشم‌انداز — About)
// ═══════════════════════════════════════════════
async function renderVisionList() {
root.innerHTML = `
${topbarHtml('vision', '+ آیتم جدید')}
<main class="page">
<h2>چشم‌انداز</h2>
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
</main>`;
wireTabs();
$('#newBtn').addEventListener('click', () => renderVisionEditor(null));
try {
const items = await api('/api/vision-items?limit=200');
if (!items.length) { $('#list').innerHTML = `<p class="muted">هنوز آیتمی ثبت نشده.</p>`; return; }
$('#list').innerHTML = items.map(v => `
<article class="row" data-id="${escapeAttr(v.id)}">
<div class="row-main">
<div class="row-meta">
${v.icon ? `<span class="pill">${escapeHtml(v.icon)}</span>` : ''}
${v.titleEn ? `<span class="muted">${escapeHtml(v.titleEn)}</span>` : ''}
</div>
<h3>${escapeHtml(v.titleFa)}</h3>
<p class="muted clamp-2">${escapeHtml(v.descFa||'')}</p>
</div>
<div class="row-actions">
<button data-action="edit">ویرایش</button>
<button data-action="delete" class="danger">حذف</button>
</div>
</article>`).join('');
$('#list').addEventListener('click', async ev => {
const btn = ev.target.closest('button[data-action]'); if (!btn) return;
const id = btn.closest('.row').dataset.id;
if (btn.dataset.action === 'edit') renderVisionEditor(await api(`/api/vision-items/${id}`));
else if (btn.dataset.action === 'delete') {
if (!confirm('این آیتم حذف شود؟')) return;
await api(`/api/vision-items/${id}`, { method: 'DELETE' });
renderVisionList();
}
});
} catch (err) { $('#list').innerHTML = `<p class="error">خطا: ${escapeHtml(err.message)}</p>`; }
}
function renderVisionEditor(item) {
const v = item || { icon:'', titleFa:'', titleEn:'', descFa:'', descEn:'', sortOrder:0 };
const isEdit = !!item;
root.innerHTML = `
<header class="topbar">
<div class="brand">${isEdit?'ویرایش آیتم':'آیتم جدید'}</div>
<div class="actions"><button id="backBtn" class="ghost">← بازگشت</button></div>
</header>
<main class="page editor">
<form id="editorForm" class="grid">
<label>عنوان (فارسی) <input name="titleFa" value="${escapeAttr(v.titleFa)}" required /></label>
<label>عنوان (انگلیسی) <input name="titleEn" value="${escapeAttr(v.titleEn||'')}" /></label>
<label>آیکون
<select name="icon">
<option value="">—</option>
${VISION_ICONS.map(ic=>`<option value="${ic}" ${ic===v.icon?'selected':''}>${ic}</option>`).join('')}
</select>
</label>
<label>ترتیب نمایش <input name="sortOrder" type="number" value="${v.sortOrder||0}" /></label>
<div class="col span-2"><label>توضیحات (فارسی) <textarea name="descFa" rows="3">${escapeHtml(v.descFa||'')}</textarea></label></div>
<div class="col span-2"><label>توضیحات (انگلیسی) <textarea name="descEn" rows="3">${escapeHtml(v.descEn||'')}</textarea></label></div>
<div class="col span-2 row-end"><button type="submit" class="primary">${isEdit?'ذخیره':'ایجاد'}</button></div>
</form>
</main>`;
$('#backBtn').addEventListener('click', () => renderVisionList());
$('#editorForm').addEventListener('submit', async ev => {
ev.preventDefault();
const fd = new FormData(ev.target);
const payload = {
icon: fd.get('icon')||null,
titleFa: fd.get('titleFa'), titleEn: fd.get('titleEn')||null,
descFa: fd.get('descFa')||null, descEn: fd.get('descEn')||null,
sortOrder: Number(fd.get('sortOrder')||0),
};
try {
if (isEdit) await api(`/api/vision-items/${item.id}`, { method:'PUT', body:payload });
else await api('/api/vision-items', { method:'POST', body:payload });
renderVisionList();
} catch (err) { alert('ذخیره ناموفق: ' + err.message); }
});
}
// ═══════════════════════════════════════════════
// ADVISORY BOARD (شورای مشورتی — About)
// ═══════════════════════════════════════════════
async function renderAdvisoryList() {
root.innerHTML = `
${topbarHtml('advisory', '+ عضو جدید')}
<main class="page">
<h2>شورای مشورتی</h2>
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
</main>`;
wireTabs();
$('#newBtn').addEventListener('click', () => renderAdvisoryEditor(null));
try {
const items = await api('/api/advisory-board?limit=200');
if (!items.length) { $('#list').innerHTML = `<p class="muted">هنوز عضوی ثبت نشده.</p>`; return; }
$('#list').innerHTML = items.map(m => `
<article class="row" data-id="${escapeAttr(m.id)}">
<div class="row-avatar">${escapeHtml(m.nameFa?.charAt(0)||'؟')}</div>
<div class="row-main">
<div class="row-meta">
${m.roleFa ? `<span class="pill">${escapeHtml(m.roleFa)}</span>` : ''}
${m.nameEn ? `<span class="muted">${escapeHtml(m.nameEn)}</span>` : ''}
</div>
<h3>${escapeHtml(m.nameFa)}</h3>
</div>
<div class="row-actions">
<button data-action="edit">ویرایش</button>
<button data-action="delete" class="danger">حذف</button>
</div>
</article>`).join('');
$('#list').addEventListener('click', async ev => {
const btn = ev.target.closest('button[data-action]'); if (!btn) return;
const id = btn.closest('.row').dataset.id;
if (btn.dataset.action === 'edit') renderAdvisoryEditor(await api(`/api/advisory-board/${id}`));
else if (btn.dataset.action === 'delete') {
if (!confirm('این عضو حذف شود؟')) return;
await api(`/api/advisory-board/${id}`, { method: 'DELETE' });
renderAdvisoryList();
}
});
} catch (err) { $('#list').innerHTML = `<p class="error">خطا: ${escapeHtml(err.message)}</p>`; }
}
function renderAdvisoryEditor(member) {
const m = member || { nameFa:'', nameEn:'', roleFa:'', roleEn:'', sortOrder:0 };
const isEdit = !!member;
root.innerHTML = `
<header class="topbar">
<div class="brand">${isEdit?'ویرایش عضو':'عضو جدید'}</div>
<div class="actions"><button id="backBtn" class="ghost">← بازگشت</button></div>
</header>
<main class="page editor">
<form id="editorForm" class="grid">
<label>نام (فارسی) <input name="nameFa" value="${escapeAttr(m.nameFa)}" required /></label>
<label>نام (انگلیسی) <input name="nameEn" value="${escapeAttr(m.nameEn||'')}" /></label>
<label>سمت (فارسی) <input name="roleFa" value="${escapeAttr(m.roleFa||'')}" /></label>
<label>سمت (انگلیسی) <input name="roleEn" value="${escapeAttr(m.roleEn||'')}" /></label>
<label>ترتیب نمایش <input name="sortOrder" type="number" value="${m.sortOrder||0}" /></label>
<div class="col span-2 row-end"><button type="submit" class="primary">${isEdit?'ذخیره':'ایجاد'}</button></div>
</form>
</main>`;
$('#backBtn').addEventListener('click', () => renderAdvisoryList());
$('#editorForm').addEventListener('submit', async ev => {
ev.preventDefault();
const fd = new FormData(ev.target);
const payload = {
nameFa: fd.get('nameFa'), nameEn: fd.get('nameEn')||null,
roleFa: fd.get('roleFa')||null, roleEn: fd.get('roleEn')||null,
sortOrder: Number(fd.get('sortOrder')||0),
};
try {
if (isEdit) await api(`/api/advisory-board/${member.id}`, { method:'PUT', body:payload });
else await api('/api/advisory-board', { method:'POST', body:payload });
renderAdvisoryList();
} catch (err) { alert('ذخیره ناموفق: ' + err.message); }
});
}
// ═══════════════════════════════════════════════
// BANNERS
// ═══════════════════════════════════════════════
const POSITIONS = [
{ value: 'featured', label: 'صفحه اصلی (زیر رویدادها)' },
{ value: 'glance', label: 'صفحه در یک نگاه (پایین)' },
{ value: 'media-split', label: 'صفحه رسانه (بین ویدیو و پادکست)' },
{ value: 'radar-sustainability', label: 'صفحه رادار: پایداری و فولاد سبز' },
{ value: 'radar-geopolitics', label: 'صفحه رادار: ژئوپلیتیک و اقتصاد جهانی' },
{ value: 'radar-technology', label: 'صفحه رادار: فناوری و نوآوری' },
{ value: 'radar-market', label: 'صفحه رادار: بازار و زنجیره فولاد' },
];
async function renderBannersList() {
root.innerHTML = `
${topbarHtml('banners', '+ بنر جدید')}
<main class="page">
<h2>بنرها</h2>
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
</main>`;
wireTabs();
$('#newBtn').addEventListener('click', () => renderBannerEditor(null));
try {
const items = await api('/api/banners');
if (!items.length) { $('#list').innerHTML = `<p class="muted">هنوز بنری ثبت نشده.</p>`; return; }
$('#list').innerHTML = items.map(b => `
<article class="row" data-id="${escapeAttr(b.id)}">
${b.image ? `<div class="row-cover" style="background-image:url('${escapeAttr(b.image)}')"></div>` : ''}
<div class="row-main">
<div class="row-meta">
${b.active ? '<span class="pill green">فعال</span>' : '<span class="pill">غیرفعال</span>'}
<span class="pill">${escapeHtml(POSITIONS.find(p=>p.value===b.position)?.label || b.position)}</span>
</div>
<h3>${escapeHtml(b.titleFa)}</h3>
<p class="muted clamp-2">${escapeHtml(b.subtitleFa||'')}</p>
</div>
<div class="row-actions">
<button data-action="edit">ویرایش</button>
<button data-action="delete" class="danger">حذف</button>
</div>
</article>`).join('');
$('#list').addEventListener('click', async ev => {
const btn = ev.target.closest('button[data-action]'); if (!btn) return;
const id = btn.closest('.row').dataset.id;
if (btn.dataset.action === 'edit') renderBannerEditor(await api(`/api/banners/${id}`));
else if (btn.dataset.action === 'delete') {
if (!confirm('این بنر حذف شود؟')) return;
await api(`/api/banners/${id}`, { method: 'DELETE' });
renderBannersList();
}
});
} catch (err) { $('#list').innerHTML = `<p class="error">خطا: ${escapeHtml(err.message)}</p>`; }
}
function renderBannerEditor(banner) {
const b = banner || { overlineFa:'', overlineEn:'', titleFa:'', titleEn:'', subtitleFa:'', subtitleEn:'',
ctaLabelFa:'', ctaLabelEn:'', ctaUrl:'', image:'', position:'featured', active:true, sortOrder:0 };
const isEdit = !!banner;
root.innerHTML = `
<header class="topbar">
<div class="brand">${isEdit ? 'ویرایش بنر' : 'بنر جدید'}</div>
<div class="actions"><button id="backBtn" class="ghost">← بازگشت</button></div>
</header>
<main class="page editor">
<form id="editorForm" class="grid">
<div class="col span-2"><label>برچسب بالای عنوان (فارسی) — مثلاً «ماهنامه شماره ۱۲ منتشر شد…» <input name="overlineFa" value="${escapeAttr(b.overlineFa||'')}" /></label></div>
<div class="col span-2"><label>برچسب بالای عنوان (انگلیسی) <input name="overlineEn" value="${escapeAttr(b.overlineEn||'')}" /></label></div>
<label>عنوان (فارسی) <input name="titleFa" value="${escapeAttr(b.titleFa)}" required /></label>
<label>عنوان (انگلیسی) <input name="titleEn" value="${escapeAttr(b.titleEn||'')}" /></label>
<div class="col span-2"><label>زیرعنوان (فارسی) <input name="subtitleFa" value="${escapeAttr(b.subtitleFa||'')}" /></label></div>
<div class="col span-2"><label>زیرعنوان (انگلیسی) <input name="subtitleEn" value="${escapeAttr(b.subtitleEn||'')}" /></label></div>
<label>متن دکمه (فارسی) <input name="ctaLabelFa" value="${escapeAttr(b.ctaLabelFa||'')}" /></label>
<label>متن دکمه (انگلیسی) <input name="ctaLabelEn" value="${escapeAttr(b.ctaLabelEn||'')}" /></label>
<div class="col span-2">
<label>لینک دکمه <input id="ctaUrl" name="ctaUrl" value="${escapeAttr(b.ctaUrl||'')}" /></label>
<label style="margin-top:8px">آپلود فایل PDF گزارش (لینک دکمه را پر می‌کند)
<input type="file" id="pdfFile" accept="application/pdf" />
</label>
</div>
<label>موقعیت
<select name="position">
${POSITIONS.map(p=>`<option value="${p.value}" ${p.value===b.position?'selected':''}>${p.label}</option>`).join('')}
</select>
</label>
<label>ترتیب نمایش <input name="sortOrder" type="number" value="${b.sortOrder||0}" /></label>
<label class="checkbox col span-2"><input type="checkbox" name="active" ${b.active?'checked':''}><span>فعال (نمایش داده شود)</span></label>
<div class="col span-2">
<label>تصویر بنر
<input id="imageUrl" name="imageUrl" placeholder="/uploads/... یا آپلود کنید" value="${escapeAttr(b.image||'')}" />
</label>
<label style="margin-top:8px">آپلود تصویر
<input type="file" id="imageFile" accept="image/*" />
</label>
${b.image ? `<img src="${escapeAttr(b.image)}" style="margin-top:8px;max-height:160px;border-radius:8px;object-fit:cover" />` : ''}
</div>
<div class="col span-2 row-end"><button type="submit" class="primary">${isEdit ? 'ذخیره' : 'ایجاد'}</button></div>
</form>
</main>`;
$('#backBtn').addEventListener('click', () => renderBannersList());
$('#pdfFile').addEventListener('change', async ev => {
const file = ev.target.files[0]; if (!file) return;
const fd = new FormData(); fd.append('file', file);
try {
const res = await api('/api/uploads/raw', { method:'POST', body:fd, isForm:true });
$('#ctaUrl').value = res.url;
} catch (err) { alert('آپلود PDF ناموفق: ' + err.message); }
});
$('#imageFile').addEventListener('change', async ev => {
const file = ev.target.files[0]; if (!file) return;
const fd = new FormData(); fd.append('file', file);
try {
const res = await api('/api/uploads', { method:'POST', body:fd, isForm:true });
$('#imageUrl').value = res.url;
} catch (err) { alert('آپلود ناموفق: ' + err.message); }
});
$('#editorForm').addEventListener('submit', async ev => {
ev.preventDefault();
const fd = new FormData(ev.target);
const payload = {
overlineFa: fd.get('overlineFa') || null,
overlineEn: fd.get('overlineEn') || null,
titleFa: fd.get('titleFa'),
titleEn: fd.get('titleEn') || null,
subtitleFa: fd.get('subtitleFa') || null,
subtitleEn: fd.get('subtitleEn') || null,
ctaLabelFa: fd.get('ctaLabelFa') || null,
ctaLabelEn: fd.get('ctaLabelEn') || null,
ctaUrl: fd.get('ctaUrl') || null,
image: fd.get('imageUrl') || null,
position: fd.get('position') || 'featured',
active: fd.get('active') === 'on',
sortOrder: Number(fd.get('sortOrder') || 0),
};
try {
if (isEdit) await api(`/api/banners/${banner.id}`, { method:'PUT', body:payload });
else await api('/api/banners', { method:'POST', body:payload });
renderBannersList();
} catch (err) { alert('ذخیره ناموفق: ' + err.message); }
});
}
// ─────────────────────────────────────────────
function renderImport() {
root.innerHTML = `
${topbarHtml('import', '')}
<div class="page-body" style="max-width:820px;margin:2rem auto;padding:0 1rem">
<h2 style="margin-bottom:.25rem">⬇️ ایمپورت از Gemini</h2>
<p style="color:#888;font-size:.875rem;margin-bottom:1.5rem">
خروجی JSON که Gemini داد رو اینجا paste کن و دکمه ایمپورت رو بزن.
گزارش توی <strong>مطالب</strong> و آیتم‌های رادار توی <strong>رادار</strong> ذخیره می‌شن.
</p>
<div id="import-status"></div>
<textarea id="import-json" rows="22" style="
width:100%;box-sizing:border-box;font-family:monospace;font-size:.8rem;
border:1.5px solid #ddd;border-radius:8px;padding:1rem;resize:vertical;
direction:ltr;background:#fafafa;outline:none;
" placeholder='{ "report": { "id": "...", "title": "...", ... }, "radar_items": [...], "fa_summary_long": "..." }'></textarea>
<div style="display:flex;gap:.75rem;margin-top:1rem;align-items:center">
<button id="import-btn" class="primary" style="padding:.7rem 2rem;font-size:1rem">ایمپورت</button>
<button id="import-clear" class="ghost">پاک کردن</button>
<span id="import-count" style="color:#aaa;font-size:.82rem"></span>
</div>
<div id="import-result" style="margin-top:1.5rem"></div>
</div>
`;
setupTopbar();
const jsonTA = document.getElementById('import-json');
const statusEl = document.getElementById('import-status');
const resultEl = document.getElementById('import-result');
const countEl = document.getElementById('import-count');
jsonTA.addEventListener('input', () => {
try {
const d = JSON.parse(jsonTA.value);
const ra = d.radar_items?.length || 0;
countEl.textContent = `✓ JSON معتبر — گزارش: ${d.report?.title ? '1' : '0'} | رادار: ${ra}`;
countEl.style.color = '#22c55e';
} catch {
countEl.textContent = 'JSON نامعتبر';
countEl.style.color = '#ef4444';
}
});
document.getElementById('import-clear').onclick = () => {
jsonTA.value = ''; countEl.textContent = ''; resultEl.innerHTML = '';
};
document.getElementById('import-btn').onclick = async () => {
let data;
try { data = JSON.parse(jsonTA.value); }
catch { statusEl.innerHTML = '<div class="alert alert-error">JSON نامعتبر است</div>'; return; }
const btn = document.getElementById('import-btn');
btn.disabled = true; btn.textContent = 'در حال ایمپورت...';
statusEl.innerHTML = '';
try {
const result = await api('/api/admin/import', { method:'POST', body: data });
resultEl.innerHTML = `
<div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:10px;padding:1.25rem 1.5rem">
<div style="font-weight:700;color:#166534;font-size:1rem;margin-bottom:.75rem">✓ ایمپورت موفق</div>
<div style="display:flex;flex-direction:column;gap:.4rem;font-size:.875rem;color:#166534">
<div>📄 گزارش: <code style="background:#dcfce7;padding:2px 6px;border-radius:4px">${result.article}</code></div>
${result.radar.length ? `<div>🔭 آیتم‌های رادار: ${result.radar.length} مورد اضافه شد</div>` : ''}
</div>
<div style="margin-top:1rem;display:flex;gap:.5rem">
<a href="#" onclick="document.querySelector('[data-tab=articles]').click();return false"
style="font-size:.8rem;color:#16a34a;text-decoration:underline">مشاهده در مطالب ←</a>
${result.radar.length ? `<a href="#" onclick="document.querySelector('[data-tab=radar]').click();return false"
style="font-size:.8rem;color:#16a34a;text-decoration:underline">مشاهده در رادار ←</a>` : ''}
</div>
</div>
`;
jsonTA.value = ''; countEl.textContent = '';
} catch (err) {
statusEl.innerHTML = `<div class="alert alert-error">خطا: ${err.message}</div>`;
} finally {
btn.disabled = false; btn.textContent = 'ایمپورت';
}
};
}
// ─────────────────────────────────────────────
api('/api/auth/me').then(() => renderRadarList()).catch(() => renderLogin());