2799 lines
144 KiB
JavaScript
2799 lines
144 KiB
JavaScript
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: '', episodeNumber: '', 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>
|
||
|
||
${contentType === 'podcast' ? `
|
||
<label>شماره اپیزود
|
||
<input name="episodeNumber" value="${escapeAttr(a.episodeNumber || '')}" placeholder="مثلاً ۷۲" />
|
||
</label>` : `<input type="hidden" name="episodeNumber" value="" />`}
|
||
|
||
${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,
|
||
episodeNumber:fd.get('episodeNumber') || 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, '&').replace(/"/g, '"').replace(/</g, '<');
|
||
}
|
||
function escapeHtml(s) {
|
||
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
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:'', sourceUrl:'', 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="مثلاً Reuters" /></label>
|
||
<label>لینک منبع <input name="sourceUrl" value="${escapeAttr(r.sourceUrl||'')}" placeholder="https://www.reuters.com/..." /></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,
|
||
sourceUrl: fd.get('sourceUrl')||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)}"> </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());
|