steelforesight/panel/public/app.js

436 lines
16 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: 'ویژه' },
];
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') },
});
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>
</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', () => {
if (btn.dataset.tab === 'articles') renderList();
else if (btn.dataset.tab === 'risks') renderRisksList();
});
});
$('#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) {
const a = article || {
title: '', category: '', type: '', author: '', authorRole: '', authorInitial: '',
publishDate: '', pages: 0, price: 0, isFree: true, 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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
}
function escapeHtml(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
const RISK_LEVELS = [
{ value: 'critical', label: 'بحرانی' },
{ value: 'high', label: 'بالا' },
{ value: 'medium', label: 'متوسط' },
{ value: 'low', label: 'پایین' },
{ value: 'opportunity', label: 'فرصت' },
];
async function renderRisksList() {
root.innerHTML = `
${topbarHtml('risks', '+ سیگنال جدید')}
<main class="page">
<h2>سیگنال‌های ریسک</h2>
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
</main>
`;
wireTabs();
$('#newBtn').addEventListener('click', () => renderRiskEditor(null));
try {
const items = await api('/api/risks?limit=200');
if (!items.length) {
$('#list').innerHTML = `<p class="muted">هنوز سیگنالی ثبت نشده است.</p>`;
return;
}
const SAFE_LEVELS = new Set(['critical', 'high', 'medium', 'low', 'opportunity']);
const labelOf = (lvl) => (RISK_LEVELS.find(r => r.value === lvl)?.label) || escapeHtml(lvl);
$('#list').innerHTML = items.map(r => `
<article class="row risk-row" data-id="${escapeAttr(r.id)}">
<div class="row-main">
<div class="row-meta">
<span class="pill level-${SAFE_LEVELS.has(r.level) ? r.level : 'low'}">${labelOf(r.level)}</span>
<strong>${escapeHtml(r.name)}</strong>
${r.date ? `<span class="muted">${escapeHtml(r.date)}</span>` : ''}
</div>
<p class="muted clamp-2">${escapeHtml(r.quote)}</p>
</div>
<div class="row-actions">
<button data-action="edit">ویرایش</button>
<button data-action="delete" class="danger">حذف</button>
</div>
</article>
`).join('');
$('#list').addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const id = btn.closest('.row').dataset.id;
if (btn.dataset.action === 'edit') {
const r = await api(`/api/risks/${id}`);
renderRiskEditor(r);
} else if (btn.dataset.action === 'delete') {
if (!confirm('این سیگنال حذف شود؟')) return;
await api(`/api/risks/${id}`, { method: 'DELETE' });
renderRisksList();
}
});
} catch (err) {
console.error(err);
$('#list').innerHTML = `<p class="error">خطا در بارگذاری سیگنال‌ها.</p>`;
}
}
function renderRiskEditor(risk) {
const r = risk || { quote: '', name: '', date: '', level: 'high', sortOrder: 0 };
const isEdit = !!risk;
root.innerHTML = `
<header class="topbar">
<div class="brand">${isEdit ? 'ویرایش سیگنال ریسک' : 'سیگنال ریسک جدید'}</div>
<div class="actions">
<button id="backBtn" class="ghost">← بازگشت</button>
</div>
</header>
<main class="page editor">
<form id="editorForm" class="grid">
<div class="col span-2">
<label>عنوان / منبع
<input name="name" value="${escapeAttr(r.name)}" required />
</label>
</div>
<label>سطح
<select name="level">
${RISK_LEVELS.map(l => `<option value="${l.value}" ${l.value===r.level?'selected':''}>${l.label}</option>`).join('')}
</select>
</label>
<label>تاریخ / افق (شمسی)
<input name="date" value="${escapeAttr(r.date)}" />
</label>
<label>ترتیب نمایش (عدد کوچک‌تر اول)
<input name="sortOrder" type="number" value="${r.sortOrder || 0}" />
</label>
<div class="col span-2">
<label>متن سیگنال
<textarea name="quote" rows="6" required>${escapeHtml(r.quote)}</textarea>
</label>
</div>
<div class="col span-2 row-end">
<button type="submit" class="primary">${isEdit ? 'ذخیره تغییرات' : 'ایجاد'}</button>
</div>
</form>
</main>
`;
$('#backBtn').addEventListener('click', () => renderRisksList());
$('#editorForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const payload = {
name: fd.get('name'),
quote: fd.get('quote'),
date: fd.get('date') || null,
level: fd.get('level'),
sortOrder: Number(fd.get('sortOrder') || 0),
};
try {
if (isEdit) await api(`/api/risks/${risk.id}`, { method: 'PUT', body: payload });
else await api('/api/risks', { method: 'POST', body: payload });
renderRisksList();
} catch (err) {
alert('ذخیره ناموفق بود: ' + err.message);
}
});
}
api('/api/auth/me').then(() => renderList()).catch(() => renderLogin());