2227 lines
114 KiB
JavaScript
2227 lines
114 KiB
JavaScript
const $ = (sel, root = document) => root.querySelector(sel);
|
||
const root = $('#app');
|
||
|
||
|
||
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 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">${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') },
|
||
});
|
||
if (data.token) sessionStorage.setItem('sf_token', data.token);
|
||
renderList();
|
||
} catch {
|
||
renderLogin('نام کاربری یا رمز عبور اشتباه است');
|
||
}
|
||
});
|
||
}
|
||
|
||
function topbarHtml(active, primaryLabel) {
|
||
return `
|
||
<header class="topbar">
|
||
<div class="brand">پنل اندیشکده فولاد</div>
|
||
<nav class="tabs">
|
||
<button data-tab="articles" class="${active==='articles'?'tab-active':''}">مطالب</button>
|
||
<button data-tab="risks" class="${active==='risks' ?'tab-active':''}">ریسکها</button>
|
||
<button data-tab="events" class="${active==='events' ?'tab-active':''}">رویدادها</button>
|
||
<button data-tab="radar" class="${active==='radar' ?'tab-active':''}">رادار</button>
|
||
<button data-tab="radarPages" class="${active==='radarPages'?'tab-active':''}">صفحات رادار</button>
|
||
<button data-tab="factoryReports" class="${active==='factoryReports'?'tab-active':''}">گزارش کارخانه</button>
|
||
<button data-tab="integrations" class="${active==='integrations'?'tab-active':''}">منابع داده</button>
|
||
<button data-tab="stats" class="${active==='stats' ?'tab-active':''}">آمار</button>
|
||
<button data-tab="mprices" class="${active==='mprices' ?'tab-active':''}">قیمتها</button>
|
||
<button data-tab="charts" class="${active==='charts' ?'tab-active':''}">نمودارها</button>
|
||
<button data-tab="vision" class="${active==='vision' ?'tab-active':''}">چشمانداز</button>
|
||
<button data-tab="advisory" class="${active==='advisory'?'tab-active':''}">شورای مشورتی</button>
|
||
<button data-tab="team" class="${active==='team' ?'tab-active':''}">تیم</button>
|
||
<button data-tab="experts" class="${active==='experts' ?'tab-active':''}">خبرگان</button>
|
||
<button data-tab="plans" class="${active==='plans' ?'tab-active':''}">پلانها</button>
|
||
<button data-tab="banners" class="${active==='banners' ?'tab-active':''}">بنرها</button>
|
||
<button data-tab="pdfAnalyze" class="${active==='pdfAnalyze'?'tab-active':''}">📄 آنالیز PDF</button>
|
||
<button data-tab="import" class="${active==='import' ?'tab-active':''}">⬇️ ایمپورت</button>
|
||
<button data-tab="users" class="${active==='users' ?'tab-active':''}">کاربران</button>
|
||
</nav>
|
||
<div class="actions">
|
||
<button id="newBtn" class="primary">${primaryLabel}</button>
|
||
<button id="logoutBtn" class="ghost">خروج</button>
|
||
</div>
|
||
</header>
|
||
`;
|
||
}
|
||
function wireTabs() {
|
||
document.querySelectorAll('.tabs button').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const t = btn.dataset.tab;
|
||
if (t === 'articles') renderList();
|
||
else if (t === 'risks') renderRisksList();
|
||
else if (t === 'events') renderEventsList();
|
||
else if (t === 'radar') renderRadarList();
|
||
else if (t === 'radarPages') renderRadarPagesList();
|
||
else if (t === 'factoryReports') renderFactoryReportsList();
|
||
else if (t === 'integrations') renderIntegrationsList();
|
||
else if (t === 'stats') renderStatsList();
|
||
else if (t === 'mprices') renderMarketPricesList();
|
||
else if (t === 'charts') renderChartPointsList();
|
||
else if (t === 'vision') renderVisionList();
|
||
else if (t === 'advisory') renderAdvisoryList();
|
||
else if (t === 'team') renderTeamList(false);
|
||
else if (t === 'experts') renderTeamList(true);
|
||
else if (t === 'plans') renderPlansList();
|
||
else if (t === 'banners') renderBannersList();
|
||
else if (t === 'pdfAnalyze') renderPdfAnalyze();
|
||
else if (t === 'import') renderImport();
|
||
else if (t === 'users') renderUsersList();
|
||
});
|
||
});
|
||
$('#logoutBtn').addEventListener('click', async () => {
|
||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {});
|
||
renderLogin();
|
||
});
|
||
}
|
||
|
||
async function renderList() {
|
||
root.innerHTML = `
|
||
${topbarHtml('articles', '+ مطلب جدید')}
|
||
<main class="page">
|
||
<h2>مطالب</h2>
|
||
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
|
||
</main>
|
||
`;
|
||
wireTabs();
|
||
$('#newBtn').addEventListener('click', () => renderEditor(null));
|
||
|
||
try {
|
||
const items = await api('/api/articles?limit=200');
|
||
if (!items.length) {
|
||
$('#list').innerHTML = `<p class="muted">هنوز مطلبی ثبت نشده است.</p>`;
|
||
return;
|
||
}
|
||
const safeCoverUrl = (u) => /^\/uploads\/[a-zA-Z0-9._-]+$/.test(String(u || '')) ? u : '';
|
||
$('#list').innerHTML = items.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('');
|
||
|
||
$('#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();
|
||
}
|
||
});
|
||
} catch (err) {
|
||
console.error(err);
|
||
$('#list').innerHTML = `<p class="error">خطا در بارگذاری مطالب.</p>`;
|
||
}
|
||
}
|
||
|
||
function renderEditor(article, draft = {}) {
|
||
const a = article || {
|
||
title: draft.title || '', category: draft.category || '', type: draft.type || '',
|
||
author: '', authorRole: '', authorInitial: '',
|
||
publishDate: '', pages: 0, price: 0, isFree: true,
|
||
summary: draft.summary || '', body: '',
|
||
coverImage: '', tags: [], featured: false,
|
||
};
|
||
const isEdit = !!article;
|
||
|
||
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="title" value="${escapeAttr(a.title)}" required />
|
||
</label>
|
||
</div>
|
||
|
||
<label>دستهبندی
|
||
<select name="category">
|
||
<option value="">—</option>
|
||
${CATEGORIES.map(c => `<option value="${c}" ${c===a.category?'selected':''}>${c}</option>`).join('')}
|
||
</select>
|
||
</label>
|
||
|
||
<label>نوع
|
||
<select name="type">
|
||
<option value="">—</option>
|
||
${TYPES.map(t => `<option value="${t.value}" ${t.value===a.type?'selected':''}>${t.label}</option>`).join('')}
|
||
</select>
|
||
</label>
|
||
|
||
<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>
|
||
<label>تاریخ انتشار (شمسی، مثلاً «بهمن ۱۴۰۳»)
|
||
<input name="publishDate" value="${escapeAttr(a.publishDate)}" />
|
||
</label>
|
||
<label>تعداد صفحات
|
||
<input name="pages" type="number" min="0" value="${a.pages || 0}" />
|
||
</label>
|
||
<label>قیمت (ریال) — اگر رایگان است صفر
|
||
<input name="price" type="number" min="0" value="${a.price || 0}" />
|
||
</label>
|
||
|
||
<label class="checkbox">
|
||
<input type="checkbox" name="isFree" ${a.isFree?'checked':''} />
|
||
<span>رایگان است</span>
|
||
</label>
|
||
<label class="checkbox">
|
||
<input type="checkbox" name="featured" ${a.featured?'checked':''} />
|
||
<span>بهعنوان مطلب ویژه نمایش داده شود</span>
|
||
</label>
|
||
|
||
<div class="col span-2">
|
||
<label>برچسبها (با ویرگول فارسی یا انگلیسی جدا کنید)
|
||
<input name="tags" value="${escapeAttr((a.tags || []).join('، '))}" />
|
||
</label>
|
||
</div>
|
||
|
||
<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)}" />
|
||
</div>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="col span-2">
|
||
<label>خلاصه
|
||
<textarea name="summary" rows="3">${escapeHtml(a.summary)}</textarea>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="col span-2">
|
||
<label>متن کامل مطلب
|
||
<textarea name="body" rows="14">${escapeHtml(a.body)}</textarea>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="col span-2 row-end">
|
||
<button type="submit" class="primary">${isEdit ? 'ذخیره تغییرات' : 'ایجاد و انتشار'}</button>
|
||
</div>
|
||
</form>
|
||
</main>
|
||
`;
|
||
|
||
$('#backBtn').addEventListener('click', () => renderList());
|
||
|
||
$('#coverFile').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="coverImage"]').value = url;
|
||
$('#coverPreview').src = url;
|
||
} catch (err) {
|
||
alert('آپلود ناموفق بود: ' + err.message);
|
||
}
|
||
});
|
||
|
||
$('#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 payload = {
|
||
title: fd.get('title'),
|
||
category: fd.get('category') || null,
|
||
type: fd.get('type') || null,
|
||
author: fd.get('author') || null,
|
||
authorRole: fd.get('authorRole') || null,
|
||
authorInitial: fd.get('authorInitial') || null,
|
||
publishDate: fd.get('publishDate') || null,
|
||
pages: Number(fd.get('pages') || 0),
|
||
price: Number(fd.get('price') || 0),
|
||
isFree: fd.get('isFree') === 'on',
|
||
featured: fd.get('featured') === 'on',
|
||
summary: fd.get('summary') || null,
|
||
body: fd.get('body') || null,
|
||
coverImage: fd.get('coverImage') || null,
|
||
tags: tagsRaw,
|
||
};
|
||
try {
|
||
if (isEdit) {
|
||
await api(`/api/articles/${article.id}`, { method: 'PUT', body: payload });
|
||
} else {
|
||
await api('/api/articles', { method: 'POST', body: payload });
|
||
}
|
||
renderList();
|
||
} 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;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════
|
||
// 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 (رادار آینده)
|
||
// ═══════════════════════════════════════════════
|
||
async function renderRadarList() {
|
||
root.innerHTML = `
|
||
${topbarHtml('radar', '+ سیگنال جدید')}
|
||
<main class="page">
|
||
<h2>رادار آینده</h2>
|
||
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
|
||
</main>`;
|
||
wireTabs();
|
||
$('#newBtn').addEventListener('click', () => renderRadarEditor(null));
|
||
try {
|
||
const items = await api('/api/radar?limit=200');
|
||
if (!items.length) { $('#list').innerHTML = `<p class="muted">هنوز سیگنالی ثبت نشده.</p>`; return; }
|
||
const safeImgUrl = (u) => /^(\/uploads\/[a-zA-Z0-9._-]+|\/[a-zA-Z0-9._\/-]+)$/.test(String(u || '')) ? u : '';
|
||
$('#list').innerHTML = items.map(r => `
|
||
<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.category ? `<span class="pill">${escapeHtml(RADAR_CATEGORIES.find(c=>c.value===r.category)?.label||r.category)}</span>` : ''}
|
||
${r.authorName ? `<span class="muted">${escapeHtml(r.authorName)}</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">
|
||
<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') renderRadarEditor(await api(`/api/radar/${id}`));
|
||
else if (btn.dataset.action === 'delete') {
|
||
if (!confirm('این سیگنال حذف شود؟')) return;
|
||
await api(`/api/radar/${id}`, { method: 'DELETE' });
|
||
renderRadarList();
|
||
}
|
||
});
|
||
} catch (err) { $('#list').innerHTML = `<p class="error">خطا: ${escapeHtml(err.message)}</p>`; }
|
||
}
|
||
|
||
function renderRadarEditor(item) {
|
||
const r = item || { category:'', img:'', authorName:'', authorAvatar:'',
|
||
titleFa:'', dateFa:'', excerptFa:'', titleEn:'', dateEn:'', excerptEn:'', 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(r.titleFa)}" required /></label>
|
||
<label>عنوان (انگلیسی) <input name="titleEn" value="${escapeAttr(r.titleEn||'')}" /></label>
|
||
<label>دستهبندی
|
||
<select name="category">
|
||
<option value="">—</option>
|
||
${RADAR_CATEGORIES.map(c=>`<option value="${c.value}" ${c.value===r.category?'selected':''}>${c.label}</option>`).join('')}
|
||
</select>
|
||
</label>
|
||
<label>ترتیب نمایش <input name="sortOrder" type="number" value="${r.sortOrder||0}" /></label>
|
||
<label>نام نویسنده <input name="authorName" value="${escapeAttr(r.authorName||'')}" /></label>
|
||
<label>آواتار نویسنده (URL) <input name="authorAvatar" value="${escapeAttr(r.authorAvatar||'')}" /></label>
|
||
<div class="col span-2"><label>تصویر (URL) <input name="img" value="${escapeAttr(r.img||'')}" /></label></div>
|
||
<label>تاریخ (فارسی) <input name="dateFa" value="${escapeAttr(r.dateFa||'')}" /></label>
|
||
<label>تاریخ (انگلیسی) <input name="dateEn" value="${escapeAttr(r.dateEn||'')}" /></label>
|
||
<div class="col span-2"><label>چکیده (فارسی) <textarea name="excerptFa" rows="4">${escapeHtml(r.excerptFa||'')}</textarea></label></div>
|
||
<div class="col span-2"><label>چکیده (انگلیسی) <textarea name="excerptEn" rows="4">${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', () => renderRadarList());
|
||
$('#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: fd.get('authorAvatar')||null,
|
||
dateFa: fd.get('dateFa')||null, dateEn: fd.get('dateEn')||null,
|
||
excerptFa: fd.get('excerptFa')||null, excerptEn: fd.get('excerptEn')||null,
|
||
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); }
|
||
});
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════
|
||
// RADAR PAGES (صفحات رادار — hero + banner per category)
|
||
// ═══════════════════════════════════════════════
|
||
const RADAR_PAGE_SLUGS = [
|
||
{ slug: 'foresight', label: 'آیندهپژوهی و رصد هوشمند' },
|
||
{ slug: 'market', label: 'بازار و زنجیره فولاد' },
|
||
{ slug: 'sustainability', label: 'پایداری و فولاد سبز' },
|
||
{ slug: 'geopolitics', label: 'ژئوپلیتیک و اقتصاد جهانی' },
|
||
{ slug: 'technology', label: 'فناوری و نوآوری' },
|
||
];
|
||
|
||
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:'', 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>مسیر تصویر <input name="img" value="${escapeAttr(r.img||'')}" placeholder="/news/..." /></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="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());
|
||
$('#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,
|
||
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: 'hero', label: 'هیرو (بالای صفحه اصلی)' },
|
||
{ value: 'announcement', label: 'اطلاعرسانی (نوار بالا)' },
|
||
{ value: 'promo', 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 name="ctaUrl" value="${escapeAttr(b.ctaUrl||'')}" /></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());
|
||
$('#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') || 'hero',
|
||
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 res = await apiFetch('/api/admin/import', { method:'POST', body: JSON.stringify(data) });
|
||
if (!res.ok) { const e = await res.json(); throw new Error(e.error || res.status); }
|
||
const result = await res.json();
|
||
|
||
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 = 'ایمپورت';
|
||
}
|
||
};
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
function renderPdfAnalyze() {
|
||
root.innerHTML = `
|
||
${topbarHtml('pdfAnalyze', '')}
|
||
<main class="page">
|
||
<h2>آنالیز و دستهبندی PDF</h2>
|
||
<p class="muted" style="margin-bottom:24px">فایل PDF گزارش را آپلود کنید — سیستم متن را استخراج کرده و پیشنهاد بخش میدهد.</p>
|
||
|
||
<div class="card" style="max-width:640px;padding:28px 32px">
|
||
<form id="pdfForm">
|
||
<label style="display:block;margin-bottom:16px">
|
||
<span style="font-weight:700;display:block;margin-bottom:8px">انتخاب فایل PDF</span>
|
||
<input type="file" name="pdf" accept="application/pdf" required style="width:100%" />
|
||
</label>
|
||
<button type="submit" class="primary" style="width:100%">📄 استخراج و آنالیز</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div id="pdfResult" style="margin-top:28px;max-width:760px"></div>
|
||
</main>
|
||
`;
|
||
wireTabs();
|
||
$('#newBtn') && ($('#newBtn').style.display = 'none');
|
||
|
||
$('#pdfForm').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const file = e.target.pdf.files[0];
|
||
if (!file) return;
|
||
const result = $('#pdfResult');
|
||
result.innerHTML = `<p class="muted">در حال پردازش PDF… (${(file.size/1024).toFixed(0)} KB)</p>`;
|
||
try {
|
||
const base64 = await new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(reader.result.split(',')[1]);
|
||
reader.onerror = reject;
|
||
reader.readAsDataURL(file);
|
||
});
|
||
const _token = sessionStorage.getItem('sf_token') || '';
|
||
const _res = await fetch('/api/pdf-analyze', {
|
||
method: 'POST', credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json', ...(_token ? { 'Authorization': 'Bearer ' + _token } : {}) },
|
||
body: JSON.stringify({ base64 }),
|
||
});
|
||
if (!_res.ok) {
|
||
const errText = await _res.text();
|
||
result.innerHTML = `<div class="error">خطای سرور ${_res.status}: <pre style="font-size:11px;white-space:pre-wrap">${escapeHtml(errText)}</pre></div>`;
|
||
return;
|
||
}
|
||
const data = await _res.json();
|
||
const SECTION_LABELS = {
|
||
market: 'بازار و زنجیره فولاد', technology: 'فناوری صنعتی',
|
||
geopolitics: 'ژئواکونومی', sustainability: 'پایداری محیطی', foresight: 'آیندهنگاری',
|
||
};
|
||
|
||
if (data.is_garbled) {
|
||
// PDF has non-standard font encoding — offer manual text input
|
||
result.innerHTML = `
|
||
<div class="card" style="padding:24px 28px;margin-bottom:16px;border-right:4px solid #e67e22">
|
||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">
|
||
<span style="font-size:22px">⚠️</span>
|
||
<strong style="font-size:15px;color:#032340">متن PDF قابل استخراج نیست</strong>
|
||
</div>
|
||
<p style="margin:0;font-size:13px;color:#555;line-height:1.9">
|
||
این PDF از رمزگذاری فونت غیراستاندارد استفاده میکند (رایج در نشریات فارسی).
|
||
متن بهدرستی خوانده نمیشود و نیاز به OCR دارد.<br>
|
||
میتوانید متن رو دستی paste کنید تا بخش پیشنهادی و خلاصه ساخته بشه.
|
||
</p>
|
||
<p class="muted" style="margin:10px 0 0;font-size:12px">${data.page_count} صفحه · ${(data.text_length/1000).toFixed(1)}k کاراکتر (ناخوانا)</p>
|
||
</div>
|
||
|
||
<div class="card" style="padding:20px 28px;margin-bottom:16px">
|
||
<h3 style="margin:0 0 10px;font-size:15px">عنوان گزارش</h3>
|
||
<input id="manualTitle" type="text" placeholder="عنوان را وارد کنید..." style="width:100%;padding:10px 14px;border:1px solid #ddd;border-radius:8px;font-family:inherit;font-size:14px;direction:rtl;box-sizing:border-box">
|
||
|
||
<h3 style="margin:18px 0 10px;font-size:15px">متن / خلاصه (paste کنید)</h3>
|
||
<textarea id="manualText" rows="8" placeholder="متن یا خلاصه گزارش را اینجا paste کنید..." style="width:100%;padding:10px 14px;border:1px solid #ddd;border-radius:8px;font-family:inherit;font-size:13px;direction:rtl;resize:vertical;box-sizing:border-box"></textarea>
|
||
|
||
<h3 style="margin:18px 0 10px;font-size:15px">بخش مناسب</h3>
|
||
<select id="manualCategory" style="width:100%;padding:10px 14px;border:1px solid #ddd;border-radius:8px;font-family:inherit;font-size:14px;direction:rtl">
|
||
${Object.entries(SECTION_LABELS).map(([k,v]) => `<option value="${k}">${v}</option>`).join('')}
|
||
</select>
|
||
|
||
<div style="margin-top:18px;display:flex;gap:10px;flex-wrap:wrap">
|
||
<button id="generateContentBtn" style="background:#032340;color:#fff;border:none;padding:10px 18px;border-radius:8px;font-family:inherit;font-size:14px;cursor:pointer;font-weight:700">✨ تولید محتوا با هوش مصنوعی</button>
|
||
<button id="createArticleBtn" class="primary">+ ایجاد مقاله مستقیم</button>
|
||
</div>
|
||
<div id="generateResult" style="margin-top:16px"></div>
|
||
</div>
|
||
`;
|
||
|
||
async function runGenerate(textSrc, titleSrc, catKey) {
|
||
const gr = $('#generateResult');
|
||
gr.innerHTML = '<p class="muted" style="font-size:13px">در حال تولید محتوا... ⏳</p>';
|
||
const _token = sessionStorage.getItem('sf_token') || '';
|
||
try {
|
||
const r = await fetch('/api/pdf-generate', {
|
||
method: 'POST', credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json', ...(_token ? { 'Authorization': 'Bearer ' + _token } : {}) },
|
||
body: JSON.stringify({ text: textSrc, category: SECTION_LABELS[catKey] || catKey }),
|
||
});
|
||
const d = await r.json();
|
||
if (!r.ok) { gr.innerHTML = `<div class="error">${escapeHtml(d.error || 'خطا')}</div>`; return; }
|
||
const generated = d.generated || '';
|
||
gr.innerHTML = `
|
||
<div class="card" style="padding:16px 20px;background:#f9f6f2;border-radius:10px;margin-bottom:12px">
|
||
<h4 style="margin:0 0 10px;font-size:14px;color:#032340">محتوای تولیدشده</h4>
|
||
<pre id="generatedText" style="white-space:pre-wrap;font-size:13px;line-height:2;color:#333;direction:rtl;text-align:right;font-family:inherit;margin:0">${escapeHtml(generated)}</pre>
|
||
</div>
|
||
<button id="useGeneratedBtn" class="primary">+ ایجاد مقاله از این محتوا</button>
|
||
`;
|
||
$('#useGeneratedBtn').addEventListener('click', () => {
|
||
const lines = generated.split('\n').filter(l => l.trim());
|
||
const autoTitle = titleSrc || lines[0]?.replace(/^#+\s*|\*\*/g,'').slice(0,100) || '';
|
||
window._pdfDraft = { title: autoTitle, summary: generated.slice(0, 600), category: SECTION_LABELS[catKey] || catKey, type: 'quarterly' };
|
||
renderEditor(null, window._pdfDraft);
|
||
});
|
||
} catch(e) { gr.innerHTML = `<div class="error">${escapeHtml(e.message)}</div>`; }
|
||
}
|
||
|
||
$('#generateContentBtn').addEventListener('click', () => {
|
||
const t = $('#manualText').value.trim();
|
||
if (!t) { alert('لطفاً ابتدا متن را paste کنید'); return; }
|
||
runGenerate(t, $('#manualTitle').value.trim(), $('#manualCategory').value);
|
||
});
|
||
|
||
$('#createArticleBtn').addEventListener('click', () => {
|
||
const manualTitle = $('#manualTitle').value.trim();
|
||
const manualText = $('#manualText').value.trim();
|
||
const catKey = $('#manualCategory').value;
|
||
window._pdfDraft = {
|
||
title: manualTitle,
|
||
summary: manualText.slice(0, 300),
|
||
category: SECTION_LABELS[catKey] || catKey,
|
||
type: 'quarterly',
|
||
};
|
||
renderEditor(null, window._pdfDraft);
|
||
});
|
||
return;
|
||
}
|
||
|
||
const c = data.classification;
|
||
const allRows = Object.entries(c.allScores)
|
||
.sort((a,b) => b[1].score - a[1].score)
|
||
.map(([cat, d]) => `
|
||
<tr style="opacity:${cat===c.category?1:0.55}">
|
||
<td>${cat === c.category ? '✅' : ''} ${SECTION_LABELS[cat] || cat}</td>
|
||
<td style="direction:ltr;text-align:left">
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<div style="background:#CD9E53;height:8px;border-radius:4px;width:${Math.round((d.score/(c.allScores[c.category]?.score||1))*100)}%"></div>
|
||
<span style="font-size:12px;color:#666">${d.score}</span>
|
||
</div>
|
||
</td>
|
||
</tr>`).join('');
|
||
|
||
result.innerHTML = `
|
||
<div class="card" style="padding:24px 28px;margin-bottom:16px">
|
||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:20px;flex-wrap:wrap">
|
||
<span style="background:#CD9E53;color:#032340;font-weight:800;padding:6px 14px;border-radius:8px;font-size:14px">${escapeHtml(c.label)}</span>
|
||
<span style="background:#f0f0f0;padding:6px 14px;border-radius:8px;font-size:13px">اطمینان: ${c.confidence}٪</span>
|
||
<span class="muted" style="font-size:12px">${data.page_count} صفحه · ${(data.text_length/1000).toFixed(1)}k کاراکتر</span>
|
||
</div>
|
||
|
||
<h3 style="margin:0 0 6px;font-size:16px">عنوان پیشنهادی</h3>
|
||
<p style="margin:0 0 18px;background:#f9f6f2;padding:10px 14px;border-radius:8px;font-size:14px">${escapeHtml(data.title)}</p>
|
||
|
||
<h3 style="margin:0 0 6px;font-size:16px">کلیدواژههای شناساییشده</h3>
|
||
<div style="margin-bottom:18px;display:flex;flex-wrap:wrap;gap:6px">
|
||
${c.keywords.map(kw => `<span style="background:#032340;color:#fff;padding:4px 10px;border-radius:6px;font-size:12px">${escapeHtml(kw)}</span>`).join('')}
|
||
</div>
|
||
|
||
<h3 style="margin:0 0 10px;font-size:16px">امتیاز بخشها</h3>
|
||
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||
<tbody>${allRows}</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="card" style="padding:20px 28px;margin-bottom:16px">
|
||
<h3 style="margin:0 0 10px;font-size:15px">متن استخراجشده (۸۰۰ کاراکتر اول)</h3>
|
||
<pre style="white-space:pre-wrap;font-size:12px;line-height:1.8;color:#444;max-height:220px;overflow-y:auto;background:#f9f6f2;padding:12px;border-radius:8px;direction:rtl;text-align:right">${escapeHtml(data.excerpt)}</pre>
|
||
</div>
|
||
|
||
<div class="card" style="padding:20px 28px">
|
||
<h3 style="margin:0 0 14px;font-size:15px">ایجاد مقاله از این PDF</h3>
|
||
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:12px">
|
||
<button id="generateContentBtn" style="background:#032340;color:#fff;border:none;padding:10px 18px;border-radius:8px;font-family:inherit;font-size:14px;cursor:pointer;font-weight:700">✨ تولید محتوا با هوش مصنوعی</button>
|
||
<button id="createArticleBtn" class="primary">+ ایجاد مقاله مستقیم</button>
|
||
</div>
|
||
<div id="generateResult"></div>
|
||
</div>
|
||
`;
|
||
|
||
$('#createArticleBtn').addEventListener('click', () => {
|
||
window._pdfDraft = {
|
||
title: data.title,
|
||
summary: data.excerpt.slice(0, 300),
|
||
category: c.label,
|
||
type: 'quarterly',
|
||
};
|
||
renderEditor(null, window._pdfDraft);
|
||
});
|
||
|
||
$('#generateContentBtn').addEventListener('click', async () => {
|
||
const gr = $('#generateResult');
|
||
gr.innerHTML = '<p class="muted" style="font-size:13px">در حال تولید محتوا... ⏳</p>';
|
||
const _token = sessionStorage.getItem('sf_token') || '';
|
||
try {
|
||
const r = await fetch('/api/pdf-generate', {
|
||
method: 'POST', credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json', ...(_token ? { 'Authorization': 'Bearer ' + _token } : {}) },
|
||
body: JSON.stringify({ text: data.excerpt + '\n\n' + (data.text || '').slice(0, 5000), category: c.label }),
|
||
});
|
||
const d = await r.json();
|
||
if (!r.ok) { gr.innerHTML = `<div class="error">${escapeHtml(d.error || 'خطا')}</div>`; return; }
|
||
const generated = d.generated || '';
|
||
gr.innerHTML = `
|
||
<div class="card" style="padding:16px 20px;background:#f9f6f2;border-radius:10px;margin-bottom:12px">
|
||
<h4 style="margin:0 0 10px;font-size:14px;color:#032340">محتوای تولیدشده</h4>
|
||
<pre style="white-space:pre-wrap;font-size:13px;line-height:2;color:#333;direction:rtl;text-align:right;font-family:inherit;margin:0">${escapeHtml(generated)}</pre>
|
||
</div>
|
||
<button id="useGeneratedBtn" class="primary">+ ایجاد مقاله از این محتوا</button>
|
||
`;
|
||
$('#useGeneratedBtn').addEventListener('click', () => {
|
||
const lines = generated.split('\n').filter(l => l.trim());
|
||
const autoTitle = data.title || lines[0]?.replace(/^#+\s*|\*\*/g,'').slice(0,100) || '';
|
||
window._pdfDraft = { title: autoTitle, summary: generated.slice(0, 600), category: c.label, type: 'quarterly' };
|
||
renderEditor(null, window._pdfDraft);
|
||
});
|
||
} catch(e) { gr.innerHTML = `<div class="error">${escapeHtml(e.message)}</div>`; }
|
||
});
|
||
|
||
} catch (err) {
|
||
result.innerHTML = `<div class="error">خطا: ${escapeHtml(err.message)}</div>`;
|
||
}
|
||
});
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
api('/api/auth/me').then(() => renderList()).catch(() => renderLogin());
|