feat: enhance RTL support and add service worker for PWA
- Updated CSS to improve touch actions and text size adjustment for RTL layout. - Modified ticker animation for RTL direction. - Implemented service worker registration for production builds to enable offline capabilities. - Enhanced Archive page to utilize search parameters and dynamically generate categories. - Improved AtAGlance component with new modal design and updated content structure. - Added keyword animation feature to the AuthPage for a more engaging login experience. - Created a new SearchResults component to aggregate and display search results from various content sources. - Introduced a minimal service worker to cache responses and support offline functionality.
This commit is contained in:
parent
dea0105899
commit
b1ce43bc7b
|
|
@ -7,7 +7,11 @@
|
||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="theme-color" content="#032340" />
|
||||||
<meta name="description" content="اندیشکده فولاد آینده — مرکز مستقل تحلیل راهبردی و سیاستگذاری صنعت فولاد" />
|
<meta name="description" content="اندیشکده فولاد آینده — مرکز مستقل تحلیل راهبردی و سیاستگذاری صنعت فولاد" />
|
||||||
|
|
||||||
<!-- Vazir font preloads (critical weights) -->
|
<!-- Vazir font preloads (critical weights) -->
|
||||||
|
|
|
||||||
23
panel/db.js
23
panel/db.js
|
|
@ -417,10 +417,33 @@ for (const sql of [
|
||||||
'ALTER TABLE articles ADD COLUMN IF NOT EXISTS sources TEXT',
|
'ALTER TABLE articles ADD COLUMN IF NOT EXISTS sources TEXT',
|
||||||
'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS tags TEXT',
|
'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS tags TEXT',
|
||||||
'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS source TEXT',
|
'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS source TEXT',
|
||||||
|
'ALTER TABLE members ADD COLUMN IF NOT EXISTS last_login TIMESTAMPTZ',
|
||||||
]) {
|
]) {
|
||||||
await pool.query(sql);
|
await pool.query(sql);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// per-member activity log (page views + element clicks) — logged-in users only
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS member_activity (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL, -- 'view' | 'click'
|
||||||
|
path TEXT,
|
||||||
|
label TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_activity_member ON member_activity(member_id, created_at DESC);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// newsletter subscribers (footer signup) — email is unique
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS subscribers (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
// promote the founding admin (lowest id) to owner if none exists yet
|
// promote the founding admin (lowest id) to owner if none exists yet
|
||||||
const { rows: ownerRows } = await pool.query("SELECT 1 FROM users WHERE role = 'owner' LIMIT 1");
|
const { rows: ownerRows } = await pool.query("SELECT 1 FROM users WHERE role = 'owner' LIMIT 1");
|
||||||
if (ownerRows.length === 0) {
|
if (ownerRows.length === 0) {
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@ const root = $('#app');
|
||||||
|
|
||||||
const TAB_GROUPS = [
|
const TAB_GROUPS = [
|
||||||
{ label: 'محتوا', tabs: ['glance', 'radarFuture', 'media', 'events', 'banners'] },
|
{ label: 'محتوا', tabs: ['glance', 'radarFuture', 'media', 'events', 'banners'] },
|
||||||
|
{ label: 'کاربران', tabs: ['members', 'newsletter'] },
|
||||||
];
|
];
|
||||||
const TAB_LABELS = {
|
const TAB_LABELS = {
|
||||||
glance: 'در یک نگاه', radarFuture: 'رادار آینده', media: 'ویدیوکست و پادکست',
|
glance: 'در یک نگاه', radarFuture: 'رادار آینده', media: 'ویدیوکست و پادکست',
|
||||||
events: 'رویدادها', banners: 'بنرها',
|
events: 'رویدادها', banners: 'بنرها',
|
||||||
|
members: 'کاربران سایت', newsletter: 'خبرنامه',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -177,7 +179,7 @@ function topbarHtml(active, primaryLabel) {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
function wireTabs() {
|
function wireTabs() {
|
||||||
document.querySelectorAll('.tabs button').forEach(btn => {
|
document.querySelectorAll('.tabs button, .groups button').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const t = btn.dataset.tab;
|
const t = btn.dataset.tab;
|
||||||
if (t === 'glance') renderRadarList();
|
if (t === 'glance') renderRadarList();
|
||||||
|
|
@ -185,6 +187,8 @@ function wireTabs() {
|
||||||
else if (t === 'media') renderMediaList();
|
else if (t === 'media') renderMediaList();
|
||||||
else if (t === 'events') renderEventsList();
|
else if (t === 'events') renderEventsList();
|
||||||
else if (t === 'banners') renderBannersList();
|
else if (t === 'banners') renderBannersList();
|
||||||
|
else if (t === 'members') renderMembersList();
|
||||||
|
else if (t === 'newsletter') renderNewsletterList();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
$('#logoutBtn').addEventListener('click', async () => {
|
$('#logoutBtn').addEventListener('click', async () => {
|
||||||
|
|
@ -950,6 +954,208 @@ function friendlyUserError(msg) {
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
// SITE MEMBERS (phone/OTP users)
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
function fmtDateTime(ts) {
|
||||||
|
if (!ts) return '—';
|
||||||
|
// pg returns timestamptz as "2026-06-27 08:01:07.749392+00" — space separator,
|
||||||
|
// microseconds, and a colon-less "+00" offset that Date can't parse. Normalize.
|
||||||
|
const iso = String(ts).replace(' ', 'T')
|
||||||
|
.replace(/(\.\d{3})\d+/, '$1') // trim micro→milliseconds
|
||||||
|
.replace(/([+-]\d{2})(\d{2})?$/, (_, h, m) => `${h}:${m || '00'}`); // +00 → +00:00
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d)) return escapeHtml(String(ts));
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('fa-IR', {
|
||||||
|
dateStyle: 'medium', timeStyle: 'short', calendar: 'persian',
|
||||||
|
}).format(d);
|
||||||
|
} catch { return d.toISOString(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderMembersList() {
|
||||||
|
root.innerHTML = `
|
||||||
|
${topbarHtml('members', '↻ بروزرسانی')}
|
||||||
|
<main class="page">
|
||||||
|
<h2>کاربران سایت</h2>
|
||||||
|
<p class="muted" style="margin:-4px 0 12px">کاربران ثبتنامی سایت (موبایل/کد یکبارمصرف). آخرین ورود، شماره تماس، مسدودسازی و حذف.</p>
|
||||||
|
<input id="memberSearch" placeholder="جستجو بر اساس نام، ایمیل یا شماره…" style="width:100%;max-width:420px;margin-bottom:14px;padding:9px 12px;border:1px solid #d6d6d6;border-radius:8px" />
|
||||||
|
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
|
||||||
|
</main>
|
||||||
|
`;
|
||||||
|
wireTabs();
|
||||||
|
$('#newBtn').addEventListener('click', () => renderMembersList());
|
||||||
|
|
||||||
|
let all = [];
|
||||||
|
function paint(items) {
|
||||||
|
if (!items.length) { $('#list').innerHTML = `<p class="muted">کاربری یافت نشد.</p>`; return; }
|
||||||
|
$('#list').innerHTML = items.map(m => `
|
||||||
|
<article class="row" data-id="${escapeAttr(m.id)}">
|
||||||
|
<div class="row-main">
|
||||||
|
<div class="row-meta">
|
||||||
|
<strong>${escapeHtml(m.name || 'بینام')}</strong>
|
||||||
|
${m.isActive ? '' : '<span class="pill red">مسدود</span>'}
|
||||||
|
${m.phone ? `<span class="muted" style="direction:ltr">${escapeHtml(m.phone)}</span>` : '<span class="muted">بدون شماره</span>'}
|
||||||
|
</div>
|
||||||
|
<p class="muted clamp-2">آخرین ورود: ${fmtDateTime(m.lastLogin)} · عضویت: ${fmtDateTime(m.createdAt)}${m.purchases ? ` · ${m.purchases} خرید` : ''}</p>
|
||||||
|
</div>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button data-action="view">جزئیات</button>
|
||||||
|
<button data-action="toggle">${m.isActive ? 'مسدود کردن' : 'رفع مسدودی'}</button>
|
||||||
|
<button data-action="delete" class="danger">حذف</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#list').addEventListener('click', async (e) => {
|
||||||
|
const btn = e.target.closest('button[data-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
const id = btn.closest('.row').dataset.id;
|
||||||
|
const m = all.find(x => String(x.id) === String(id));
|
||||||
|
if (btn.dataset.action === 'view') {
|
||||||
|
renderMemberDetail(id);
|
||||||
|
} else if (btn.dataset.action === 'toggle') {
|
||||||
|
try {
|
||||||
|
await api(`/api/members/${id}`, { method: 'PATCH', body: { isActive: !m.isActive } });
|
||||||
|
renderMembersList();
|
||||||
|
} catch (err) { alert('عملیات ناموفق بود: ' + err.message); }
|
||||||
|
} else if (btn.dataset.action === 'delete') {
|
||||||
|
if (!confirm('این کاربر برای همیشه حذف شود؟')) return;
|
||||||
|
try {
|
||||||
|
await api(`/api/members/${id}`, { method: 'DELETE' });
|
||||||
|
renderMembersList();
|
||||||
|
} catch (err) {
|
||||||
|
alert('حذف ناموفق بود' + (String(err.message).includes('forbidden') ? ' (فقط مالک پنل میتواند حذف کند)' : ': ' + err.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#memberSearch').addEventListener('input', (e) => {
|
||||||
|
const q = e.target.value.trim().toLowerCase();
|
||||||
|
paint(!q ? all : all.filter(m =>
|
||||||
|
(m.name || '').toLowerCase().includes(q) ||
|
||||||
|
(m.email || '').toLowerCase().includes(q) ||
|
||||||
|
(m.phone || '').includes(q)));
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
all = await api('/api/members');
|
||||||
|
paint(all);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
$('#list').innerHTML = `<p class="error">خطا در بارگذاری کاربران.</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderMemberDetail(id) {
|
||||||
|
root.innerHTML = `
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">جزئیات کاربر</div>
|
||||||
|
<div class="actions"><button id="backBtn" class="ghost">← بازگشت</button></div>
|
||||||
|
</header>
|
||||||
|
<main class="page"><div id="detail"><p class="muted">در حال بارگذاری…</p></div></main>
|
||||||
|
`;
|
||||||
|
$('#backBtn').addEventListener('click', () => renderMembersList());
|
||||||
|
try {
|
||||||
|
const m = await api(`/api/members/${id}`);
|
||||||
|
const field = (label, val, ltr) => `
|
||||||
|
<div style="display:flex;gap:8px;padding:10px 0;border-bottom:1px solid #eee">
|
||||||
|
<span class="muted" style="min-width:120px">${label}</span>
|
||||||
|
<strong${ltr ? ' style="direction:ltr"' : ''}>${val}</strong>
|
||||||
|
</div>`;
|
||||||
|
const buys = m.purchases?.length
|
||||||
|
? m.purchases.map(p => `<li>${escapeHtml(p.title || p.articleId)} — ${fmtDateTime(p.purchasedAt)}${p.amount ? ` (${p.amount.toLocaleString('fa-IR')} تومان)` : ''}</li>`).join('')
|
||||||
|
: '<li class="muted">خریدی ثبت نشده</li>';
|
||||||
|
$('#detail').innerHTML = `
|
||||||
|
<div class="card" style="max-width:560px;padding:20px 24px">
|
||||||
|
<h2 style="margin:0 0 6px">${escapeHtml(m.name || 'بینام')} ${m.isActive ? '' : '<span class="pill red">مسدود</span>'}</h2>
|
||||||
|
${field('شماره موبایل:', m.phone ? escapeHtml(m.phone) : '—', true)}
|
||||||
|
${field('ایمیل:', m.email ? escapeHtml(m.email) : '—', true)}
|
||||||
|
${field('آخرین ورود:', fmtDateTime(m.lastLogin))}
|
||||||
|
${field('تاریخ عضویت:', fmtDateTime(m.createdAt))}
|
||||||
|
${field('وضعیت:', m.isActive ? 'فعال' : 'مسدود')}
|
||||||
|
<h3 style="margin:18px 0 6px">خریدها (${m.purchases?.length || 0})</h3>
|
||||||
|
<ul style="margin:0;padding-inline-start:18px">${buys}</ul>
|
||||||
|
<h3 style="margin:18px 0 6px">فعالیت اخیر</h3>
|
||||||
|
<div id="activity"><p class="muted">در حال بارگذاری…</p></div>
|
||||||
|
<div style="display:flex;gap:10px;margin-top:20px">
|
||||||
|
<button id="toggleBtn">${m.isActive ? 'مسدود کردن' : 'رفع مسدودی'}</button>
|
||||||
|
<button id="delBtn" class="danger">حذف کاربر</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
$('#toggleBtn').addEventListener('click', async () => {
|
||||||
|
try { await api(`/api/members/${id}`, { method: 'PATCH', body: { isActive: !m.isActive } }); renderMemberDetail(id); }
|
||||||
|
catch (err) { alert('عملیات ناموفق بود: ' + err.message); }
|
||||||
|
});
|
||||||
|
$('#delBtn').addEventListener('click', async () => {
|
||||||
|
if (!confirm('این کاربر برای همیشه حذف شود؟')) return;
|
||||||
|
try { await api(`/api/members/${id}`, { method: 'DELETE' }); renderMembersList(); }
|
||||||
|
catch (err) { alert('حذف ناموفق بود: ' + err.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// activity timeline (loaded separately so the card renders instantly)
|
||||||
|
try {
|
||||||
|
const acts = await api(`/api/members/${id}/activity`);
|
||||||
|
if (!acts.length) {
|
||||||
|
$('#activity').innerHTML = `<p class="muted">فعالیتی ثبت نشده.</p>`;
|
||||||
|
} else {
|
||||||
|
$('#activity').innerHTML = `<ul style="margin:0;padding-inline-start:18px;max-height:260px;overflow:auto">` +
|
||||||
|
acts.map(a => `<li style="padding:3px 0">
|
||||||
|
<span class="pill${a.kind === 'click' ? ' red' : ''}">${a.kind === 'click' ? 'کلیک' : 'بازدید'}</span>
|
||||||
|
${a.kind === 'click' && a.label ? `«${escapeHtml(a.label)}» در ` : ''}<span style="direction:ltr">${escapeHtml(a.path || '')}</span>
|
||||||
|
<span class="muted"> — ${fmtDateTime(a.at)}</span>
|
||||||
|
</li>`).join('') + `</ul>`;
|
||||||
|
}
|
||||||
|
} catch { $('#activity').innerHTML = `<p class="error">خطا در بارگذاری فعالیت.</p>`; }
|
||||||
|
} catch (err) {
|
||||||
|
$('#detail').innerHTML = `<p class="error">خطا در بارگذاری.</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
// NEWSLETTER SUBSCRIBERS
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
async function renderNewsletterList() {
|
||||||
|
root.innerHTML = `
|
||||||
|
${topbarHtml('newsletter', '↻ بروزرسانی')}
|
||||||
|
<main class="page">
|
||||||
|
<h2>عضویت در خبرنامه</h2>
|
||||||
|
<p class="muted" style="margin:-4px 0 12px">ایمیلهایی که از فرم فوتر سایت ثبت شدهاند.</p>
|
||||||
|
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
|
||||||
|
</main>
|
||||||
|
`;
|
||||||
|
wireTabs();
|
||||||
|
$('#newBtn').addEventListener('click', () => renderNewsletterList());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = await api('/api/newsletter');
|
||||||
|
if (!items.length) { $('#list').innerHTML = `<p class="muted">هنوز ایمیلی ثبت نشده.</p>`; return; }
|
||||||
|
$('#list').innerHTML = `<p class="muted" style="margin-bottom:10px">${items.length.toLocaleString('fa-IR')} ایمیل</p>` +
|
||||||
|
items.map(s => `
|
||||||
|
<article class="row" data-id="${escapeAttr(s.id)}">
|
||||||
|
<div class="row-main">
|
||||||
|
<div class="row-meta">
|
||||||
|
<strong style="direction:ltr">${escapeHtml(s.email)}</strong>
|
||||||
|
<span class="muted">${fmtDateTime(s.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row-actions"><button data-action="delete" class="danger">حذف</button></div>
|
||||||
|
</article>`).join('');
|
||||||
|
$('#list').addEventListener('click', async (e) => {
|
||||||
|
const btn = e.target.closest('button[data-action="delete"]');
|
||||||
|
if (!btn) return;
|
||||||
|
const id = btn.closest('.row').dataset.id;
|
||||||
|
if (!confirm('این ایمیل حذف شود؟')) return;
|
||||||
|
try { await api(`/api/newsletter/${id}`, { method: 'DELETE' }); renderNewsletterList(); }
|
||||||
|
catch (err) { alert('حذف ناموفق بود: ' + err.message); }
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
$('#list').innerHTML = `<p class="error">خطا در بارگذاری.</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════
|
// ═══════════════════════════════════════════════
|
||||||
// EVENTS
|
// EVENTS
|
||||||
// ═══════════════════════════════════════════════
|
// ═══════════════════════════════════════════════
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,37 @@
|
||||||
|
/* ════════════════════════════════════════════════════════════
|
||||||
|
اندیشکده فولاد — Admin Panel UI (navy #032340 / gold #CD9E53)
|
||||||
|
Redesign: CSS-only. Markup/classes from app.js are unchanged.
|
||||||
|
════════════════════════════════════════════════════════════ */
|
||||||
:root {
|
:root {
|
||||||
--ink: #1a1712;
|
--navy: #032340;
|
||||||
--ink-3: #4a443c;
|
--navy-600: #0a3a5e;
|
||||||
--ink-5: #8a8278;
|
--gold: #cd9e53;
|
||||||
--paper: #fbfaf7;
|
--gold-700: #b8893d;
|
||||||
--rule: #e8e3d8;
|
--gold-soft: #f7ecd7;
|
||||||
--red: #b91c1c;
|
|
||||||
--green: #15803d;
|
--paper: #f4f1ea;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #faf8f3;
|
||||||
|
|
||||||
|
--ink: #1c2128;
|
||||||
|
--ink-2: #4b5563;
|
||||||
|
--ink-3: #6b7280;
|
||||||
|
--ink-5: #9aa1ac;
|
||||||
|
|
||||||
|
--rule: #e7e2d6;
|
||||||
|
--rule-soft: #f0ece2;
|
||||||
|
|
||||||
|
--red: #dc2626;
|
||||||
|
--red-soft: #fef2f2;
|
||||||
|
--green: #16a34a;
|
||||||
|
|
||||||
|
--r-sm: 8px;
|
||||||
|
--r-md: 12px;
|
||||||
|
--r-lg: 16px;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(3,35,64,.06), 0 1px 3px rgba(3,35,64,.05);
|
||||||
|
--shadow-md: 0 6px 18px -6px rgba(3,35,64,.18);
|
||||||
|
--shadow-lg: 0 18px 44px -20px rgba(3,35,64,.32);
|
||||||
|
--font: 'Vazirmatn', 'Vazir', 'Segoe UI', Tahoma, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
@ -13,77 +39,111 @@
|
||||||
html, body {
|
html, body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-family: Tahoma, 'Segoe UI', Vazir, sans-serif;
|
font-family: var(--font);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background: var(--paper);
|
background:
|
||||||
|
radial-gradient(1200px 480px at 100% -10%, rgba(205,158,83,.10), transparent 60%),
|
||||||
|
radial-gradient(1000px 460px at -10% 0%, rgba(3,35,64,.06), transparent 55%),
|
||||||
|
var(--paper);
|
||||||
|
background-attachment: fixed;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.65;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::selection { background: var(--gold-soft); color: var(--navy); }
|
||||||
|
|
||||||
|
/* nicer scrollbars */
|
||||||
|
* { scrollbar-width: thin; scrollbar-color: var(--rule) transparent; }
|
||||||
|
*::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
*::-webkit-scrollbar-thumb { background: var(--rule); border-radius: 8px; border: 2px solid transparent; background-clip: content-box; }
|
||||||
|
*::-webkit-scrollbar-thumb:hover { background: var(--ink-5); background-clip: content-box; }
|
||||||
|
|
||||||
|
/* ── Buttons ───────────────────────────────────────────────── */
|
||||||
button {
|
button {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 8px 16px;
|
padding: 9px 18px;
|
||||||
border: 1px solid var(--ink);
|
border: 1px solid var(--rule);
|
||||||
background: transparent;
|
background: var(--surface);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 0;
|
border-radius: var(--r-sm);
|
||||||
transition: background .15s;
|
transition: background .16s ease, color .16s ease, border-color .16s ease, box-shadow .16s ease, transform .06s ease;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
button:hover { background: var(--ink); color: var(--paper); }
|
button:hover { border-color: var(--navy); background: var(--surface-2); }
|
||||||
button.primary { background: var(--ink); color: var(--paper); }
|
button:active { transform: translateY(1px); }
|
||||||
button.primary:hover { background: #000; }
|
|
||||||
button.ghost { border-color: var(--rule); color: var(--ink-3); }
|
|
||||||
button.danger { border-color: var(--red); color: var(--red); }
|
|
||||||
button.danger:hover { background: var(--red); color: white; }
|
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: linear-gradient(180deg, var(--navy-600), var(--navy));
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--navy);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
button.primary:hover { filter: brightness(1.12); border-color: var(--navy); background: linear-gradient(180deg, var(--navy-600), var(--navy)); }
|
||||||
|
|
||||||
|
button.ghost { background: transparent; border-color: var(--rule); color: var(--ink-2); }
|
||||||
|
button.ghost:hover { background: var(--surface-2); color: var(--ink); }
|
||||||
|
|
||||||
|
button.danger { border-color: #f1b4b4; color: var(--red); background: var(--surface); }
|
||||||
|
button.danger:hover { background: var(--red); color: #fff; border-color: var(--red); }
|
||||||
|
|
||||||
|
/* ── Inputs ────────────────────────────────────────────────── */
|
||||||
input, textarea, select {
|
input, textarea, select {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 13px;
|
font-size: 13.5px;
|
||||||
padding: 8px 10px;
|
padding: 10px 13px;
|
||||||
border: 1px solid var(--rule);
|
border: 1px solid var(--rule);
|
||||||
background: white;
|
background: var(--surface);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 0;
|
border-radius: var(--r-sm);
|
||||||
|
transition: border-color .16s, box-shadow .16s;
|
||||||
}
|
}
|
||||||
|
input::placeholder, textarea::placeholder { color: var(--ink-5); }
|
||||||
input:focus, textarea:focus, select:focus {
|
input:focus, textarea:focus, select:focus {
|
||||||
outline: 2px solid var(--ink);
|
outline: none;
|
||||||
outline-offset: -2px;
|
border-color: var(--gold);
|
||||||
|
box-shadow: 0 0 0 3px rgba(205,158,83,.22);
|
||||||
}
|
}
|
||||||
|
textarea { resize: vertical; min-height: 90px; line-height: 1.7; }
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--ink-3);
|
color: var(--ink-2);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
|
letter-spacing: .1px;
|
||||||
}
|
}
|
||||||
label > input, label > textarea, label > select {
|
label > input, label > textarea, label > select { margin-top: 7px; font-weight: 400; }
|
||||||
margin-top: 6px;
|
|
||||||
font-weight: 400;
|
/* ── Utilities ─────────────────────────────────────────────── */
|
||||||
|
.muted { color: var(--ink-3); font-size: 12px; }
|
||||||
|
.error, .alert-error {
|
||||||
|
color: #991b1b;
|
||||||
|
background: var(--red-soft);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid #f3c7c7;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
.alert {
|
||||||
textarea { resize: vertical; min-height: 80px; }
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
.muted { color: var(--ink-5); font-size: 12px; }
|
background: var(--gold-soft);
|
||||||
.error {
|
border: 1px solid #ecd8b3;
|
||||||
color: var(--red);
|
color: var(--gold-700);
|
||||||
background: #fef2f2;
|
font-size: 12.5px;
|
||||||
padding: 8px 12px;
|
font-weight: 600;
|
||||||
border-right: 3px solid var(--red);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clamp-2 {
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
.clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
|
||||||
|
/* ── Auth screen ───────────────────────────────────────────── */
|
||||||
.auth-wrap {
|
.auth-wrap {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -91,56 +151,103 @@ textarea { resize: vertical; min-height: 80px; }
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
.card {
|
.card, .auth-card {
|
||||||
background: white;
|
background: var(--surface);
|
||||||
border: 1px solid var(--rule);
|
border: 1px solid var(--rule);
|
||||||
padding: 32px;
|
padding: 36px 34px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 380px;
|
max-width: 400px;
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.card h1 { font-size: 18px; margin: 0 0 4px; }
|
.auth-card::before {
|
||||||
.card p { margin: 0 0 24px; }
|
content: '';
|
||||||
|
position: absolute; inset: 0 0 auto 0; height: 5px;
|
||||||
|
background: linear-gradient(90deg, var(--navy), var(--gold));
|
||||||
|
}
|
||||||
|
.card h1 { font-size: 20px; font-weight: 900; margin: 6px 0 4px; color: var(--navy); letter-spacing: -.3px; }
|
||||||
|
.card p { margin: 0 0 22px; color: var(--ink-3); }
|
||||||
.card label { margin-top: 14px; }
|
.card label { margin-top: 14px; }
|
||||||
.card button { width: 100%; margin-top: 20px; }
|
.card button { width: 100%; margin-top: 22px; padding: 12px; font-size: 14px; }
|
||||||
|
|
||||||
|
/* ── Top bar ───────────────────────────────────────────────── */
|
||||||
.topbar {
|
.topbar {
|
||||||
background: white;
|
|
||||||
border-bottom: 1px solid var(--rule);
|
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 20;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
.topbar-row {
|
.topbar-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px 32px;
|
gap: 16px;
|
||||||
border-bottom: 1px solid var(--rule);
|
padding: 14px 28px;
|
||||||
|
background: linear-gradient(120deg, var(--navy) 0%, var(--navy-600) 100%);
|
||||||
|
color: #fff;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.topbar .brand { font-weight: 900; font-size: 15px; letter-spacing: -.3px; }
|
.topbar .brand {
|
||||||
.topbar .actions { display: flex; gap: 10px; }
|
font-weight: 900;
|
||||||
|
font-size: 16px;
|
||||||
.groups {
|
letter-spacing: -.3px;
|
||||||
|
color: #fff;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
.topbar .brand::before {
|
||||||
|
content: '';
|
||||||
|
width: 9px; height: 9px; border-radius: 50%;
|
||||||
|
background: var(--gold);
|
||||||
|
box-shadow: 0 0 0 4px rgba(205,158,83,.22);
|
||||||
|
}
|
||||||
|
.topbar .actions { display: flex; gap: 10px; }
|
||||||
|
/* buttons living on the navy bar */
|
||||||
|
.topbar-row .actions button { box-shadow: none; }
|
||||||
|
.topbar-row .actions button.primary {
|
||||||
|
background: linear-gradient(180deg, #e0b269, var(--gold));
|
||||||
|
color: var(--navy);
|
||||||
|
border-color: transparent;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.topbar-row .actions button.primary:hover { filter: brightness(1.06); }
|
||||||
|
.topbar-row .actions button.ghost {
|
||||||
|
background: rgba(255,255,255,.08);
|
||||||
|
border-color: rgba(255,255,255,.28);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.topbar-row .actions button.ghost:hover { background: rgba(255,255,255,.18); }
|
||||||
|
|
||||||
|
/* group switcher (on navy) */
|
||||||
|
.groups { display: flex; gap: 6px; }
|
||||||
.groups .group-btn {
|
.groups .group-btn {
|
||||||
border: 1px solid var(--rule);
|
border: 1px solid rgba(255,255,255,.22);
|
||||||
background: transparent;
|
background: rgba(255,255,255,.06);
|
||||||
color: var(--ink-3);
|
color: rgba(255,255,255,.82);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 6px 16px;
|
padding: 7px 18px;
|
||||||
border-radius: 20px;
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.groups .group-btn:hover { background: rgba(255,255,255,.16); color: #fff; }
|
||||||
|
.groups .group-btn.group-active {
|
||||||
|
background: var(--gold);
|
||||||
|
color: var(--navy);
|
||||||
|
border-color: var(--gold);
|
||||||
|
box-shadow: 0 2px 10px -2px rgba(205,158,83,.6);
|
||||||
}
|
}
|
||||||
.groups .group-btn:hover { background: var(--rule); color: var(--ink); }
|
|
||||||
.groups .group-btn.group-active { background: var(--ink); color: var(--paper); border-color: var(--ink); }
|
|
||||||
|
|
||||||
|
/* sub-tabs (white strip) */
|
||||||
.tabs {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0;
|
gap: 2px;
|
||||||
padding: 0 32px;
|
padding: 0 28px;
|
||||||
background: white;
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
.tabs button {
|
.tabs button {
|
||||||
border: none;
|
border: none;
|
||||||
|
|
@ -148,47 +255,49 @@ textarea { resize: vertical; min-height: 80px; }
|
||||||
color: var(--ink-3);
|
color: var(--ink-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 10px 16px;
|
padding: 13px 16px 11px;
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2.5px solid transparent;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.tabs button:hover { background: transparent; color: var(--ink); }
|
.tabs button:hover { background: transparent; color: var(--navy); border-bottom-color: var(--rule); }
|
||||||
.tabs button.tab-active { color: var(--ink); border-bottom-color: var(--red); }
|
.tabs button.tab-active { color: var(--navy); border-bottom-color: var(--gold); }
|
||||||
|
|
||||||
.risk-row { grid-template-columns: 1fr auto; }
|
/* ── Page shell ────────────────────────────────────────────── */
|
||||||
.risk-row .row-meta strong { font-size: 13px; }
|
.page, .page-body { max-width: 1120px; margin: 0 auto; padding: 34px 28px 64px; }
|
||||||
|
.page h2 {
|
||||||
.pill.level-critical { background: #7f1d1d; color: white; }
|
font-size: 24px;
|
||||||
.pill.level-high { background: #b91c1c; color: white; }
|
font-weight: 900;
|
||||||
.pill.level-medium { background: #92400e; color: white; }
|
letter-spacing: -.5px;
|
||||||
.pill.level-low { background: #166534; color: white; }
|
margin: 0 0 6px;
|
||||||
.pill.level-opportunity { background: #1e40af; color: white; }
|
color: var(--navy);
|
||||||
|
|
||||||
.page { max-width: 1100px; margin: 0 auto; padding: 32px; }
|
|
||||||
.page h2 { font-size: 22px; letter-spacing: -.5px; margin: 0 0 16px; }
|
|
||||||
|
|
||||||
.cat-bar {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
}
|
||||||
|
.page > .muted { margin-bottom: 18px; }
|
||||||
|
|
||||||
|
/* ── Category filter chips ─────────────────────────────────── */
|
||||||
|
.cat-bar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 22px; }
|
||||||
.cat-btn {
|
.cat-btn {
|
||||||
padding: 4px 12px;
|
padding: 6px 15px;
|
||||||
border-radius: 20px;
|
border-radius: 999px;
|
||||||
border: 1px solid var(--rule);
|
border: 1px solid var(--rule);
|
||||||
background: transparent;
|
background: var(--surface);
|
||||||
color: var(--fg);
|
color: var(--ink-2);
|
||||||
font-size: 12px;
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background .15s, color .15s;
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.cat-btn:hover { border-color: var(--gold); color: var(--navy); }
|
||||||
|
.cat-btn.active {
|
||||||
|
background: var(--navy);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--navy);
|
||||||
}
|
}
|
||||||
.cat-btn:hover { background: var(--rule); }
|
|
||||||
.cat-btn.active { background: var(--accent, #2563eb); color: #fff; border-color: transparent; }
|
|
||||||
|
|
||||||
|
/* ── Content-type picker grid ──────────────────────────────── */
|
||||||
.type-grid {
|
.type-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
@ -196,107 +305,176 @@ textarea { resize: vertical; min-height: 80px; }
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
padding: 20px 20px 18px;
|
padding: 22px;
|
||||||
border: 1px solid var(--rule);
|
border: 1px solid var(--rule);
|
||||||
border-radius: 4px;
|
border-radius: var(--r-md);
|
||||||
background: white;
|
background: var(--surface);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
transition: border-color .15s, box-shadow .15s;
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: border-color .16s, box-shadow .16s, transform .16s;
|
||||||
}
|
}
|
||||||
.type-card:hover { border-color: var(--ink); box-shadow: 0 2px 8px rgba(0,0,0,.08); background: white; color: var(--ink); }
|
.type-card:hover {
|
||||||
.type-icon { font-size: 28px; line-height: 1; }
|
border-color: var(--gold);
|
||||||
.type-card strong { font-size: 15px; font-weight: 800; }
|
box-shadow: var(--shadow-md);
|
||||||
.type-desc { font-size: 12px; color: var(--ink-5); font-weight: 400; line-height: 1.4; }
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
.type-icon {
|
||||||
|
font-size: 26px; line-height: 1;
|
||||||
|
width: 50px; height: 50px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--gold-soft);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
}
|
||||||
|
.type-card strong { font-size: 15px; font-weight: 800; color: var(--navy); }
|
||||||
|
.type-desc { font-size: 12px; color: var(--ink-3); font-weight: 400; line-height: 1.5; }
|
||||||
|
|
||||||
|
/* ── List + rows (card style) ──────────────────────────────── */
|
||||||
.list {
|
.list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1px;
|
gap: 10px;
|
||||||
background: var(--rule);
|
background: transparent;
|
||||||
border: 1px solid var(--rule);
|
border: none;
|
||||||
}
|
}
|
||||||
.row {
|
.row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 88px 1fr auto;
|
grid-template-columns: 92px 1fr auto;
|
||||||
gap: 16px;
|
gap: 18px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: white;
|
background: var(--surface);
|
||||||
padding: 14px 18px;
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: border-color .16s, box-shadow .16s, transform .16s;
|
||||||
}
|
}
|
||||||
|
.row:hover { border-color: var(--gold); box-shadow: var(--shadow-md); transform: translateY(-2px); }
|
||||||
.row-cover {
|
.row-cover {
|
||||||
width: 88px;
|
width: 92px;
|
||||||
height: 66px;
|
height: 68px;
|
||||||
background-color: #eee;
|
background-color: var(--surface-2);
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
border: 1px solid var(--rule);
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
}
|
}
|
||||||
.row-main h3 { font-size: 14px; font-weight: 700; margin: 4px 0; }
|
.row-main { min-width: 0; }
|
||||||
.row-main p { margin: 0; }
|
.row-main h3 { font-size: 14.5px; font-weight: 800; margin: 5px 0; color: var(--ink); }
|
||||||
|
.row-main p { margin: 0; color: var(--ink-3); }
|
||||||
.row-meta { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
.row-meta { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
.row-actions { display: flex; gap: 8px; }
|
.row-actions { display: flex; gap: 8px; }
|
||||||
|
.row-actions button { padding: 7px 14px; font-size: 12.5px; }
|
||||||
|
|
||||||
|
/* ── Pills / badges ────────────────────────────────────────── */
|
||||||
.pill {
|
.pill {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
font-size: 10px;
|
align-items: center;
|
||||||
|
font-size: 10.5px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 2px 8px;
|
padding: 3px 9px;
|
||||||
background: #f2efe8;
|
background: var(--gold-soft);
|
||||||
color: var(--ink-3);
|
color: var(--gold-700);
|
||||||
letter-spacing: .5px;
|
letter-spacing: .3px;
|
||||||
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
.pill.red { background: var(--red); color: white; }
|
.pill.red { background: var(--red); color: #fff; }
|
||||||
.pill.green { background: var(--green); color: white; }
|
.pill.green { background: var(--green); color: #fff; }
|
||||||
|
|
||||||
|
.pill.level-critical { background: #7f1d1d; color: #fff; }
|
||||||
|
.pill.level-high { background: #b91c1c; color: #fff; }
|
||||||
|
.pill.level-medium { background: #b45309; color: #fff; }
|
||||||
|
.pill.level-low { background: #166534; color: #fff; }
|
||||||
|
.pill.level-opportunity { background: #1e40af; color: #fff; }
|
||||||
|
|
||||||
|
.risk-row { grid-template-columns: 1fr auto; }
|
||||||
|
.risk-row .row-meta strong { font-size: 13px; color: var(--navy); }
|
||||||
|
|
||||||
|
/* ── Avatar ────────────────────────────────────────────────── */
|
||||||
.row-avatar {
|
.row-avatar {
|
||||||
width: 44px; height: 44px; border-radius: 50%;
|
width: 46px; height: 46px; border-radius: 50%;
|
||||||
background: #032340; color: #CD9E53;
|
background: linear-gradient(140deg, var(--navy), var(--navy-600));
|
||||||
|
color: var(--gold);
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
font-size: 16px; font-weight: 800; flex-shrink: 0;
|
font-size: 16px; font-weight: 800; flex-shrink: 0;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor .grid {
|
/* ── Editor forms ──────────────────────────────────────────── */
|
||||||
|
.editor .grid, form.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 18px;
|
gap: 20px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
padding: 26px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
.editor .col.span-2 { grid-column: 1 / -1; }
|
.editor .col.span-2, .col.span-2 { grid-column: 1 / -1; }
|
||||||
.editor .row-end { display: flex; justify-content: flex-start; }
|
.editor .row-end, .row-end { display: flex; justify-content: flex-start; }
|
||||||
|
|
||||||
.checkbox {
|
.checkbox {
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
flex-direction: row !important;
|
flex-direction: row !important;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 9px;
|
||||||
margin-top: 22px;
|
margin-top: 22px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
padding: 10px 14px;
|
||||||
}
|
}
|
||||||
.checkbox input { width: auto; margin: 0; }
|
.checkbox input { width: auto; margin: 0; accent-color: var(--gold); width: 17px; height: 17px; }
|
||||||
.checkbox span { font-weight: 700; color: var(--ink); }
|
.checkbox span { font-weight: 700; color: var(--ink); }
|
||||||
|
|
||||||
.cover-row {
|
.cover-row { display: flex; gap: 16px; align-items: center; margin-top: 6px; }
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
.cover-row input[type=file] { flex: 1; }
|
.cover-row input[type=file] { flex: 1; }
|
||||||
.cover-row img {
|
.cover-row img {
|
||||||
width: 120px;
|
width: 130px; height: 86px;
|
||||||
height: 80px;
|
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
background: #eee;
|
background: var(--surface-2);
|
||||||
border: 1px solid var(--rule);
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
}
|
}
|
||||||
.cover-row img[src=""] { visibility: hidden; }
|
.cover-row img[src=""] { visibility: hidden; }
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
/* block editor (article body) */
|
||||||
.editor .grid { grid-template-columns: 1fr; }
|
.blocks-editor {
|
||||||
.editor .col.span-2 { grid-column: 1; }
|
display: flex;
|
||||||
.topbar { padding: 12px 16px; }
|
flex-direction: column;
|
||||||
.page { padding: 16px; }
|
gap: 12px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px dashed var(--rule);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
padding: 16px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* file inputs */
|
||||||
|
input[type=file] {
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
input[type=file]::file-selector-button {
|
||||||
|
font-family: inherit; font-weight: 700; font-size: 12px;
|
||||||
|
border: 1px solid var(--rule); background: var(--surface);
|
||||||
|
color: var(--navy); padding: 7px 14px; border-radius: var(--r-sm);
|
||||||
|
cursor: pointer; margin-inline-end: 12px;
|
||||||
|
}
|
||||||
|
input[type=file]::file-selector-button:hover { border-color: var(--gold); }
|
||||||
|
|
||||||
|
/* ── Responsive ────────────────────────────────────────────── */
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.editor .grid, form.grid { grid-template-columns: 1fr; padding: 18px; }
|
||||||
|
.editor .col.span-2, .col.span-2 { grid-column: 1; }
|
||||||
|
.topbar-row { padding: 12px 16px; }
|
||||||
|
.tabs { padding: 0 12px; }
|
||||||
|
.page, .page-body { padding: 20px 16px 48px; }
|
||||||
.row { grid-template-columns: 64px 1fr; }
|
.row { grid-template-columns: 64px 1fr; }
|
||||||
.row-actions { grid-column: 1 / -1; }
|
.row-actions { grid-column: 1 / -1; }
|
||||||
.row-cover { width: 64px; height: 48px; }
|
.row-cover { width: 64px; height: 50px; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
103
panel/server.js
103
panel/server.js
|
|
@ -99,10 +99,14 @@ app.set('trust proxy', 1); // honor X-Forwarded-Proto from the reverse proxy
|
||||||
const cookieSecure = (req) => req.secure || req.headers['x-forwarded-proto'] === 'https';
|
const cookieSecure = (req) => req.secure || req.headers['x-forwarded-proto'] === 'https';
|
||||||
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || 'http://localhost:5173')
|
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || 'http://localhost:5173')
|
||||||
.split(',').map(s => s.trim());
|
.split(',').map(s => s.trim());
|
||||||
|
// any localhost/127.0.0.1 origin (any port) is allowed in addition to ALLOWED_ORIGINS —
|
||||||
|
// the panel serves its own SPA, whose module scripts fetch same-origin with an Origin
|
||||||
|
// header; rejecting that 500s app.js. Disallowed origins get cb(null,false) (no CORS
|
||||||
|
// headers) rather than a thrown error, so a bad cross-origin request never 500s static assets.
|
||||||
|
const isLocalhost = (o) => /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(o);
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: (origin, cb) => {
|
origin: (origin, cb) => {
|
||||||
if (!origin || ALLOWED_ORIGINS.includes(origin)) cb(null, true);
|
cb(null, !origin || ALLOWED_ORIGINS.includes(origin) || isLocalhost(origin));
|
||||||
else cb(new Error('Not allowed by CORS'));
|
|
||||||
},
|
},
|
||||||
credentials: true,
|
credentials: true,
|
||||||
}));
|
}));
|
||||||
|
|
@ -1324,6 +1328,80 @@ app.delete('/api/users/:id', authRequired, ownerRequired, async (req, res) => {
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------- site members (phone/OTP users) — admin management ----------
|
||||||
|
app.get('/api/members', authRequired, async (_req, res) => {
|
||||||
|
const rows = await db.prepare(`
|
||||||
|
SELECT m.id, m.name, m.email, m.phone, m.is_active, m.created_at, m.last_login,
|
||||||
|
(SELECT COUNT(*) FROM purchases p WHERE p.member_id = m.id) AS purchases
|
||||||
|
FROM members m ORDER BY m.last_login DESC NULLS LAST, m.created_at DESC
|
||||||
|
`).all();
|
||||||
|
res.json(rows.map((r) => ({
|
||||||
|
id: r.id, name: r.name, email: r.email, phone: r.phone,
|
||||||
|
isActive: !!r.is_active, createdAt: r.created_at, lastLogin: r.last_login,
|
||||||
|
purchases: Number(r.purchases) || 0,
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/members/:id', authRequired, async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const r = await db.prepare('SELECT id, name, email, phone, avatar, is_active, created_at, updated_at, last_login FROM members WHERE id = ?').get(id);
|
||||||
|
if (!r) return res.status(404).json({ error: 'not_found' });
|
||||||
|
const purchases = await db.prepare(`
|
||||||
|
SELECT p.article_id, p.amount, p.purchased_at, a.title
|
||||||
|
FROM purchases p LEFT JOIN articles a ON a.id = p.article_id
|
||||||
|
WHERE p.member_id = ? ORDER BY p.purchased_at DESC
|
||||||
|
`).all(id);
|
||||||
|
res.json({
|
||||||
|
id: r.id, name: r.name, email: r.email, phone: r.phone, avatar: r.avatar,
|
||||||
|
isActive: !!r.is_active, createdAt: r.created_at, updatedAt: r.updated_at, lastLogin: r.last_login,
|
||||||
|
purchases: purchases.map((p) => ({ articleId: p.article_id, title: p.title, amount: p.amount, purchasedAt: p.purchased_at })),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// block / unblock — body { isActive: boolean }
|
||||||
|
app.patch('/api/members/:id', authRequired, async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const isActive = req.body?.isActive ? 1 : 0;
|
||||||
|
const info = await db.prepare('UPDATE members SET is_active = ?, updated_at = now() WHERE id = ?').run(isActive, id);
|
||||||
|
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
|
||||||
|
res.json({ id, isActive: !!isActive });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/members/:id', authRequired, ownerRequired, async (req, res) => {
|
||||||
|
const info = await db.prepare('DELETE FROM members WHERE id = ?').run(Number(req.params.id));
|
||||||
|
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// admin: recent activity (page views + clicks) for one member
|
||||||
|
app.get('/api/members/:id/activity', authRequired, async (req, res) => {
|
||||||
|
const rows = await db.prepare(
|
||||||
|
'SELECT kind, path, label, created_at FROM member_activity WHERE member_id = ? ORDER BY created_at DESC LIMIT 200'
|
||||||
|
).all(Number(req.params.id));
|
||||||
|
res.json(rows.map((r) => ({ kind: r.kind, path: r.path, label: r.label, at: r.created_at })));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- newsletter subscribers ----------
|
||||||
|
// public: footer signup. dedupes silently on repeat email.
|
||||||
|
app.post('/api/newsletter', contactLimiter, async (req, res) => {
|
||||||
|
const email = String(req.body?.email || '').trim().toLowerCase();
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return res.status(400).json({ error: 'ایمیل نامعتبر است' });
|
||||||
|
await db.prepare('INSERT INTO subscribers (email) VALUES (?) ON CONFLICT (email) DO NOTHING').run(email);
|
||||||
|
res.status(201).json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// admin: list subscribers
|
||||||
|
app.get('/api/newsletter', authRequired, async (_req, res) => {
|
||||||
|
const rows = await db.prepare('SELECT id, email, created_at FROM subscribers ORDER BY created_at DESC').all();
|
||||||
|
res.json(rows.map((r) => ({ id: r.id, email: r.email, createdAt: r.created_at })));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/newsletter/:id', authRequired, async (req, res) => {
|
||||||
|
const info = await db.prepare('DELETE FROM subscribers WHERE id = ?').run(Number(req.params.id));
|
||||||
|
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- prices (scraped from tgju.org every 2 min) ----------
|
// ---------- prices (scraped from tgju.org every 2 min) ----------
|
||||||
app.get('/api/prices', async (_req, res) => {
|
app.get('/api/prices', async (_req, res) => {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
|
|
@ -1587,6 +1665,7 @@ app.post('/api/members/register', loginLimiter, async (req, res) => {
|
||||||
const info = await db.prepare('INSERT INTO members (name, email, password_hash, phone) VALUES (?, ?, ?, ?) RETURNING id').run(name.trim(), email.toLowerCase().trim(), hash, phone ? String(phone).trim() : null);
|
const info = await db.prepare('INSERT INTO members (name, email, password_hash, phone) VALUES (?, ?, ?, ?) RETURNING id').run(name.trim(), email.toLowerCase().trim(), hash, phone ? String(phone).trim() : null);
|
||||||
const token = jwt.sign({ sub: info.lastInsertRowid, email, name: name.trim(), role: 'member' }, JWT_SECRET, { expiresIn: '30d' });
|
const token = jwt.sign({ sub: info.lastInsertRowid, email, name: name.trim(), role: 'member' }, JWT_SECRET, { expiresIn: '30d' });
|
||||||
res.cookie('member_session', token, memberCookieOpts(req));
|
res.cookie('member_session', token, memberCookieOpts(req));
|
||||||
|
await db.prepare('UPDATE members SET last_login = now() WHERE id = ?').run(info.lastInsertRowid);
|
||||||
res.status(201).json({ user: { id: info.lastInsertRowid, name: name.trim(), email } });
|
res.status(201).json({ user: { id: info.lastInsertRowid, name: name.trim(), email } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1600,6 +1679,7 @@ app.post('/api/members/login', loginLimiter, async (req, res) => {
|
||||||
if (!row.is_active) return res.status(403).json({ error: 'حساب کاربری غیرفعال است' });
|
if (!row.is_active) return res.status(403).json({ error: 'حساب کاربری غیرفعال است' });
|
||||||
const token = jwt.sign({ sub: row.id, email: row.email, name: row.name, role: 'member' }, JWT_SECRET, { expiresIn: '30d' });
|
const token = jwt.sign({ sub: row.id, email: row.email, name: row.name, role: 'member' }, JWT_SECRET, { expiresIn: '30d' });
|
||||||
res.cookie('member_session', token, memberCookieOpts(req));
|
res.cookie('member_session', token, memberCookieOpts(req));
|
||||||
|
await db.prepare('UPDATE members SET last_login = now() WHERE id = ?').run(row.id);
|
||||||
res.json({ user: { id: row.id, name: row.name, email: row.email } });
|
res.json({ user: { id: row.id, name: row.name, email: row.email } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1613,7 +1693,12 @@ app.post('/api/members/logout', (req, res) => {
|
||||||
const otpStore = new Map(); // phone -> { code, expires }
|
const otpStore = new Map(); // phone -> { code, expires }
|
||||||
async function sendOtpSms(phone, code, template) {
|
async function sendOtpSms(phone, code, template) {
|
||||||
const key = process.env.KAVENEGAR_API_KEY;
|
const key = process.env.KAVENEGAR_API_KEY;
|
||||||
if (!key) throw new Error('otp_not_configured');
|
if (!key) {
|
||||||
|
// ponytail: dev fallback — no SMS provider configured (local only). Print the code
|
||||||
|
// so OTP login can be tested without Kavenegar. In prod the key is always set.
|
||||||
|
console.log(`\n[otp:dev] no KAVENEGAR_API_KEY — code for ${phone}: ${code}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const url = `https://api.kavenegar.com/v1/${key}/verify/lookup.json?receptor=${encodeURIComponent(phone)}&token=${encodeURIComponent(code)}&template=${encodeURIComponent(template)}`;
|
const url = `https://api.kavenegar.com/v1/${key}/verify/lookup.json?receptor=${encodeURIComponent(phone)}&token=${encodeURIComponent(code)}&template=${encodeURIComponent(template)}`;
|
||||||
const r = await fetch(url);
|
const r = await fetch(url);
|
||||||
const j = await r.json().catch(() => null);
|
const j = await r.json().catch(() => null);
|
||||||
|
|
@ -1652,6 +1737,7 @@ app.post('/api/members/otp/verify', loginLimiter, async (req, res) => {
|
||||||
if (!member.is_active) return res.status(403).json({ error: 'حساب کاربری غیرفعال است' });
|
if (!member.is_active) return res.status(403).json({ error: 'حساب کاربری غیرفعال است' });
|
||||||
const token = jwt.sign({ sub: member.id, email: member.email, name: member.name, role: 'member' }, JWT_SECRET, { expiresIn: '30d' });
|
const token = jwt.sign({ sub: member.id, email: member.email, name: member.name, role: 'member' }, JWT_SECRET, { expiresIn: '30d' });
|
||||||
res.cookie('member_session', token, memberCookieOpts(req));
|
res.cookie('member_session', token, memberCookieOpts(req));
|
||||||
|
await db.prepare('UPDATE members SET last_login = now() WHERE id = ?').run(member.id);
|
||||||
res.json({ user: { id: member.id, name: member.name, email: member.email } });
|
res.json({ user: { id: member.id, name: member.name, email: member.email } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1672,9 +1758,20 @@ app.post('/api/members/register-verify', loginLimiter, async (req, res) => {
|
||||||
.run(name.trim(), email.toLowerCase().trim(), hash, phone);
|
.run(name.trim(), email.toLowerCase().trim(), hash, phone);
|
||||||
const token = jwt.sign({ sub: info.lastInsertRowid, email, name: name.trim(), role: 'member' }, JWT_SECRET, { expiresIn: '30d' });
|
const token = jwt.sign({ sub: info.lastInsertRowid, email, name: name.trim(), role: 'member' }, JWT_SECRET, { expiresIn: '30d' });
|
||||||
res.cookie('member_session', token, memberCookieOpts(req));
|
res.cookie('member_session', token, memberCookieOpts(req));
|
||||||
|
await db.prepare('UPDATE members SET last_login = now() WHERE id = ?').run(info.lastInsertRowid);
|
||||||
res.status(201).json({ user: { id: info.lastInsertRowid, name: name.trim(), email } });
|
res.status(201).json({ user: { id: info.lastInsertRowid, name: name.trim(), email } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* activity beacon — logged-in members only. fire-and-forget from the site. */
|
||||||
|
app.post('/api/members/activity', memberRequired, async (req, res) => {
|
||||||
|
const kind = req.body?.kind === 'click' ? 'click' : 'view';
|
||||||
|
const path = String(req.body?.path || '').slice(0, 300);
|
||||||
|
const label = req.body?.label != null ? String(req.body.label).slice(0, 200) : null;
|
||||||
|
await db.prepare('INSERT INTO member_activity (member_id, kind, path, label) VALUES (?, ?, ?, ?)')
|
||||||
|
.run(req.member.sub, kind, path, label);
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
/* set password via SMS code — for members who don't know their current password (e.g. OTP signups) */
|
/* set password via SMS code — for members who don't know their current password (e.g. OTP signups) */
|
||||||
app.post('/api/members/me/password-otp/send', memberRequired, async (req, res) => {
|
app.post('/api/members/me/password-otp/send', memberRequired, async (req, res) => {
|
||||||
const row = await db.prepare('SELECT phone FROM members WHERE id = ?').get(req.member.sub);
|
const row = await db.prepare('SELECT phone FROM members WHERE id = ?').get(req.member.sub);
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
{"name":"اندیشکده فولاد آینده","short_name":"فولاد آینده","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#032340","background_color":"#faf7f4","display":"standalone"}
|
{"name":"اندیشکده فولاد آینده","short_name":"فولاد آینده","start_url":"/","scope":"/","display":"standalone","orientation":"portrait","theme_color":"#032340","background_color":"#faf7f4","lang":"fa","dir":"rtl","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
// ponytail: minimal runtime-cache SW. enough to make the app installable
|
||||||
|
// (Chrome needs a fetch handler) + offline shell. no precache list — Vite
|
||||||
|
// hashes bundle names at build, so we cache on first fetch instead.
|
||||||
|
const CACHE = 'sf-v1'
|
||||||
|
|
||||||
|
self.addEventListener('install', () => self.skipWaiting())
|
||||||
|
|
||||||
|
self.addEventListener('activate', (e) => {
|
||||||
|
e.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
|
||||||
|
).then(() => self.clients.claim())
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (e) => {
|
||||||
|
const req = e.request
|
||||||
|
if (req.method !== 'GET' || new URL(req.url).origin !== self.location.origin) return
|
||||||
|
// network-first: always try fresh, fall back to cache offline
|
||||||
|
e.respondWith(
|
||||||
|
fetch(req)
|
||||||
|
.then((res) => {
|
||||||
|
const copy = res.clone()
|
||||||
|
caches.open(CACHE).then((c) => c.put(req, copy))
|
||||||
|
return res
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(req).then((hit) => hit || caches.match('/')))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, type FC } from 'react'
|
import { useState, type FC } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { MapPin, Phone, Mail, ChevronsUp } from 'lucide-react'
|
import { MapPin, Phone, Mail, ChevronsUp } from 'lucide-react'
|
||||||
|
import { BASE } from '@/context/AuthContext'
|
||||||
|
|
||||||
function scrollToTop() {
|
function scrollToTop() {
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
|
@ -146,12 +147,37 @@ const CONTACT = [
|
||||||
function NewsletterBox() {
|
function NewsletterBox() {
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [done, setDone] = useState(false)
|
const [done, setDone] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
const e = email.trim()
|
||||||
|
if (!e || busy) return
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)) { setErr('ایمیل نامعتبر است'); return }
|
||||||
|
setBusy(true); setErr('')
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE}/api/newsletter`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: e }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error((await res.json().catch(() => ({})))?.error || 'خطا در ثبت')
|
||||||
|
setDone(true)
|
||||||
|
} catch (x) {
|
||||||
|
setErr(x instanceof Error ? x.message : 'خطا در ثبت')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 13, fontWeight: 700, color: '#fff', marginBottom: 12 }}>عضویت در خبرنامه اندیشکده</div>
|
<div style={{ fontSize: 13, fontWeight: 700, color: '#fff', marginBottom: 12 }}>عضویت در خبرنامه اندیشکده</div>
|
||||||
<div style={{ display: 'flex', border: '1px solid rgba(255,255,255,0.18)', borderRadius: 8, overflow: 'hidden', background: 'rgba(255,255,255,0.05)' }}>
|
<div style={{ display: 'flex', border: '1px solid rgba(255,255,255,0.18)', borderRadius: 8, overflow: 'hidden', background: 'rgba(255,255,255,0.05)' }}>
|
||||||
<input
|
<input
|
||||||
type="email" value={email} onChange={e => setEmail(e.target.value)}
|
type="email" value={email}
|
||||||
|
onChange={e => { setEmail(e.target.value); if (err) setErr('') }}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && submit()}
|
||||||
placeholder="ایمیل خود را وارد کنید..." disabled={done}
|
placeholder="ایمیل خود را وارد کنید..." disabled={done}
|
||||||
style={{
|
style={{
|
||||||
flex: 1, border: 'none', padding: '11px 14px', fontSize: 13, fontFamily: 'inherit',
|
flex: 1, border: 'none', padding: '11px 14px', fontSize: 13, fontFamily: 'inherit',
|
||||||
|
|
@ -159,15 +185,16 @@ function NewsletterBox() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (email) setDone(true) }} disabled={done}
|
onClick={submit} disabled={done || busy}
|
||||||
style={{
|
style={{
|
||||||
background: GOLD, color: NAVY, border: 'none', padding: '11px 22px',
|
background: GOLD, color: NAVY, border: 'none', padding: '11px 22px',
|
||||||
fontSize: 12, fontWeight: 800, fontFamily: 'inherit', cursor: done ? 'default' : 'pointer', whiteSpace: 'nowrap', flexShrink: 0,
|
fontSize: 12, fontWeight: 800, fontFamily: 'inherit', cursor: (done || busy) ? 'default' : 'pointer', whiteSpace: 'nowrap', flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{done ? '✓ ثبت شد' : 'ثبت'}
|
{done ? '✓ ثبت شد' : busy ? '...' : 'ثبت'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{err && <div style={{ fontSize: 11, color: '#fca5a5', marginTop: 6 }}>{err}</div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import type { CSSProperties } from 'react'
|
import type { CSSProperties } from 'react'
|
||||||
import { NavLink, Link } from 'react-router-dom'
|
import { NavLink, Link, useNavigate } from 'react-router-dom'
|
||||||
import { Search, Menu, X, ChevronDown, Radar, Leaf, Globe, Cpu, BarChart3, Video, Mic, CalendarDays, Users, Eye, Phone, type LucideIcon } from 'lucide-react'
|
import { Search, Menu, X, ChevronDown, Radar, Leaf, Globe, Cpu, BarChart3, Video, Mic, CalendarDays, Users, Eye, Phone, type LucideIcon } from 'lucide-react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
|
|
@ -221,8 +221,17 @@ export default function Header() {
|
||||||
const [mobileOpen, setMobileOpen] = useState(false)
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const runSearch = () => {
|
||||||
|
const q = searchQuery.trim()
|
||||||
|
if (!q) return
|
||||||
|
navigate(`/search?q=${encodeURIComponent(q)}`)
|
||||||
|
setSearchOpen(false)
|
||||||
|
setSearchQuery('')
|
||||||
|
}
|
||||||
const { lang, toggle: toggleLang } = useLang()
|
const { lang, toggle: toggleLang } = useLang()
|
||||||
const { member } = useAuth()
|
const { member } = useAuth()
|
||||||
|
|
||||||
|
|
@ -387,7 +396,12 @@ export default function Header() {
|
||||||
ref={searchInputRef} type="search"
|
ref={searchInputRef} type="search"
|
||||||
placeholder="جستجو در گزارشها، تحلیلها، رویدادها..."
|
placeholder="جستجو در گزارشها، تحلیلها، رویدادها..."
|
||||||
aria-label="جستجو"
|
aria-label="جستجو"
|
||||||
onKeyDown={e => e.key === 'Escape' && setSearchOpen(false)}
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Escape') setSearchOpen(false)
|
||||||
|
else if (e.key === 'Enter') runSearch()
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
width: '100%', border: `1px solid ${T.ink}`, padding: '12px 16px',
|
width: '100%', border: `1px solid ${T.ink}`, padding: '12px 16px',
|
||||||
fontSize: 14, background: T.paper, color: T.ink, outline: 'none',
|
fontSize: 14, background: T.paper, color: T.ink, outline: 'none',
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,44 @@ import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||||
import Header from './Header'
|
import Header from './Header'
|
||||||
import { Footer } from './Footer'
|
import { Footer } from './Footer'
|
||||||
import { LangProvider, useLang } from '@/context/LangContext'
|
import { LangProvider, useLang } from '@/context/LangContext'
|
||||||
|
import { useAuth, trackActivity } from '@/context/AuthContext'
|
||||||
|
|
||||||
gsap.registerPlugin(ScrollTrigger)
|
gsap.registerPlugin(ScrollTrigger)
|
||||||
|
|
||||||
|
/* ── Activity tracker (logged-in members only) ──
|
||||||
|
Logs each page view on route change, and each click on a meaningful element
|
||||||
|
(links, buttons, or anything with data-track). The API is memberRequired, so
|
||||||
|
anonymous visitors are never recorded. */
|
||||||
|
function ActivityTracker() {
|
||||||
|
const { member } = useAuth()
|
||||||
|
const { pathname, search } = useLocation()
|
||||||
|
|
||||||
|
// page views
|
||||||
|
useEffect(() => {
|
||||||
|
if (!member) return
|
||||||
|
trackActivity('view', pathname + search)
|
||||||
|
}, [member, pathname, search])
|
||||||
|
|
||||||
|
// element clicks
|
||||||
|
useEffect(() => {
|
||||||
|
if (!member) return
|
||||||
|
const onClick = (e: MouseEvent) => {
|
||||||
|
const el = (e.target as Element)?.closest('a,button,[role="button"],[data-track]')
|
||||||
|
if (!el) return
|
||||||
|
const label =
|
||||||
|
el.getAttribute('data-track') ||
|
||||||
|
el.getAttribute('aria-label') ||
|
||||||
|
(el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 80) ||
|
||||||
|
el.tagName.toLowerCase()
|
||||||
|
trackActivity('click', window.location.pathname, label)
|
||||||
|
}
|
||||||
|
document.addEventListener('click', onClick, true)
|
||||||
|
return () => document.removeEventListener('click', onClick, true)
|
||||||
|
}, [member])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Persian-digit localizer ──────────────────────────────────────────
|
/* ── Persian-digit localizer ──────────────────────────────────────────
|
||||||
In fa, every Latin digit shown anywhere on the page is rendered as a
|
In fa, every Latin digit shown anywhere on the page is rendered as a
|
||||||
Persian digit (۰-۹). Skips inputs, code, SVG charts and [data-latin]. */
|
Persian digit (۰-۹). Skips inputs, code, SVG charts and [data-latin]. */
|
||||||
|
|
@ -106,6 +141,7 @@ export default function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<LangProvider>
|
<LangProvider>
|
||||||
<DigitLocalizer />
|
<DigitLocalizer />
|
||||||
|
<ActivityTracker />
|
||||||
<div style={{ minHeight: '100dvh', display: 'flex', flexDirection: 'column' }}>
|
<div style={{ minHeight: '100dvh', display: 'flex', flexDirection: 'column' }}>
|
||||||
<Header />
|
<Header />
|
||||||
<main style={{ flex: 1 }}>
|
<main style={{ flex: 1 }}>
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import ProfilePage from '@/pages/Profile/ProfilePage'
|
||||||
import Podcast from '@/pages/Podcast/Podcast'
|
import Podcast from '@/pages/Podcast/Podcast'
|
||||||
import SpecializedMeetings from '@/pages/SpecializedMeetings/SpecializedMeetings'
|
import SpecializedMeetings from '@/pages/SpecializedMeetings/SpecializedMeetings'
|
||||||
import SteelDashboard from '@/pages/SteelDashboard/SteelDashboard'
|
import SteelDashboard from '@/pages/SteelDashboard/SteelDashboard'
|
||||||
|
import SearchResults from '@/pages/Search/SearchResults'
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
|
|
@ -41,6 +42,7 @@ export const router = createBrowserRouter([
|
||||||
{ path: 'events', element: <Events /> },
|
{ path: 'events', element: <Events /> },
|
||||||
{ path: 'team', element: <Team /> },
|
{ path: 'team', element: <Team /> },
|
||||||
{ path: 'archive', element: <Archive /> },
|
{ path: 'archive', element: <Archive /> },
|
||||||
|
{ path: 'search', element: <SearchResults /> },
|
||||||
{ path: 'special', element: <Special /> },
|
{ path: 'special', element: <Special /> },
|
||||||
{ path: 'risks', element: <Risks /> },
|
{ path: 'risks', element: <Risks /> },
|
||||||
{ path: 'scanner', element: <Scanner /> },
|
{ path: 'scanner', element: <Scanner /> },
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,17 @@ function load(): AuthState {
|
||||||
return { member: null }
|
return { member: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// localStorage throws "Access is denied" when the browser blocks site data
|
||||||
|
// (strict privacy / third-party-storage / sandboxed iframe). The cache is just
|
||||||
|
// a render optimization — the real session is the HttpOnly cookie — so writes
|
||||||
|
// must never break login. Swallow storage failures.
|
||||||
|
function cacheMember(m: Member | null) {
|
||||||
|
try {
|
||||||
|
if (m) localStorage.setItem('member_info', JSON.stringify(m))
|
||||||
|
else localStorage.removeItem('member_info')
|
||||||
|
} catch { /* storage blocked — session still lives in the cookie */ }
|
||||||
|
}
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [state, setState] = useState<AuthState>(load)
|
const [state, setState] = useState<AuthState>(load)
|
||||||
|
|
||||||
|
|
@ -55,7 +66,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || 'خطا در ورود')
|
if (!res.ok) throw new Error(data.error || 'خطا در ورود')
|
||||||
const member = toMember(data.user)
|
const member = toMember(data.user)
|
||||||
localStorage.setItem('member_info', JSON.stringify(member))
|
cacheMember(member)
|
||||||
setState({ member })
|
setState({ member })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
@ -69,7 +80,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || 'کد نامعتبر است')
|
if (!res.ok) throw new Error(data.error || 'کد نامعتبر است')
|
||||||
const member = toMember(data.user)
|
const member = toMember(data.user)
|
||||||
localStorage.setItem('member_info', JSON.stringify(member))
|
cacheMember(member)
|
||||||
setState({ member })
|
setState({ member })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
@ -83,7 +94,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || 'خطا در ثبتنام')
|
if (!res.ok) throw new Error(data.error || 'خطا در ثبتنام')
|
||||||
const member = toMember(data.user)
|
const member = toMember(data.user)
|
||||||
localStorage.setItem('member_info', JSON.stringify(member))
|
cacheMember(member)
|
||||||
setState({ member })
|
setState({ member })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
@ -97,13 +108,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || 'خطا در ثبتنام')
|
if (!res.ok) throw new Error(data.error || 'خطا در ثبتنام')
|
||||||
const member = toMember(data.user)
|
const member = toMember(data.user)
|
||||||
localStorage.setItem('member_info', JSON.stringify(member))
|
cacheMember(member)
|
||||||
setState({ member })
|
setState({ member })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
fetch(`${BASE}/api/members/logout`, { method: 'POST', credentials: 'include' }).catch(() => {})
|
fetch(`${BASE}/api/members/logout`, { method: 'POST', credentials: 'include' }).catch(() => {})
|
||||||
localStorage.removeItem('member_info')
|
cacheMember(null)
|
||||||
setState({ member: null })
|
setState({ member: null })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
@ -111,7 +122,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
setState(prev => {
|
setState(prev => {
|
||||||
if (!prev.member) return prev
|
if (!prev.member) return prev
|
||||||
const updated = { ...prev.member, ...data }
|
const updated = { ...prev.member, ...data }
|
||||||
localStorage.setItem('member_info', JSON.stringify(updated))
|
cacheMember(updated)
|
||||||
return { ...prev, member: updated }
|
return { ...prev, member: updated }
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
@ -141,4 +152,19 @@ export function authFetch(path: string, opts: RequestInit = {}) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fire-and-forget activity beacon (logged-in members only; the API is memberRequired,
|
||||||
|
// so calls without a session cookie just 401 and are ignored). keepalive lets it
|
||||||
|
// survive a page navigation.
|
||||||
|
export function trackActivity(kind: 'view' | 'click', path: string, label?: string) {
|
||||||
|
try {
|
||||||
|
fetch(`${BASE}/api/members/activity`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
keepalive: true,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ kind, path, label }),
|
||||||
|
}).catch(() => {})
|
||||||
|
} catch { /* never let tracking break the app */ }
|
||||||
|
}
|
||||||
|
|
||||||
export { BASE }
|
export { BASE }
|
||||||
|
|
|
||||||
|
|
@ -288,7 +288,7 @@
|
||||||
/* scroll-behavior:smooth removed — it fights Lenis JS smoothing. Lenis owns scroll now. */
|
/* scroll-behavior:smooth removed — it fights Lenis JS smoothing. Lenis owns scroll now. */
|
||||||
/* overflow-x: clip (not hidden) — suppresses horizontal scroll WITHOUT making <html>
|
/* overflow-x: clip (not hidden) — suppresses horizontal scroll WITHOUT making <html>
|
||||||
a scroll container, which would break the sticky header. */
|
a scroll container, which would break the sticky header. */
|
||||||
html { direction: rtl; overflow-x: clip; }
|
html { direction: rtl; overflow-x: clip; touch-action: pan-x pan-y; -webkit-text-size-adjust: 100%; }
|
||||||
html.lenis, html.lenis body { height: auto; }
|
html.lenis, html.lenis body { height: auto; }
|
||||||
.lenis.lenis-smooth { scroll-behavior: auto !important; }
|
.lenis.lenis-smooth { scroll-behavior: auto !important; }
|
||||||
.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; }
|
.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; }
|
||||||
|
|
@ -349,8 +349,8 @@ body {
|
||||||
TICKER ANIMATION (RTL direction)
|
TICKER ANIMATION (RTL direction)
|
||||||
═══════════════════════════════════════════════ */
|
═══════════════════════════════════════════════ */
|
||||||
@keyframes ticker-rtl {
|
@keyframes ticker-rtl {
|
||||||
0% { transform: translateX(50%); }
|
0% { transform: translateX(-50%); }
|
||||||
100% { transform: translateX(-50%); }
|
100% { transform: translateX(50%); }
|
||||||
}
|
}
|
||||||
.ticker-track { animation: ticker-rtl 34s linear infinite; white-space: nowrap; }
|
.ticker-track { animation: ticker-rtl 34s linear infinite; white-space: nowrap; }
|
||||||
@media (prefers-reduced-motion: reduce) { .ticker-track { animation: none; } }
|
@media (prefers-reduced-motion: reduce) { .ticker-track { animation: none; } }
|
||||||
|
|
|
||||||
10
src/main.tsx
10
src/main.tsx
|
|
@ -26,6 +26,16 @@ await Promise.all([
|
||||||
bootstrapFactoryReports(), bootstrapIntegrations(), bootstrapAbout(), bootstrapMedia(), bootstrapPosts(), bootstrapRadarFuture(),
|
bootstrapFactoryReports(), bootstrapIntegrations(), bootstrapAbout(), bootstrapMedia(), bootstrapPosts(), bootstrapRadarFuture(),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// PWA: register service worker in production builds only (skip in dev — avoids stale-cache pain)
|
||||||
|
if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(() => {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// iOS Safari ignores user-scalable=no — block pinch + double-tap zoom manually
|
||||||
|
document.addEventListener('gesturestart', (e) => e.preventDefault())
|
||||||
|
document.addEventListener('dblclick', (e) => e.preventDefault(), { passive: false })
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { reports } from '@/data/reports'
|
import { reports } from '@/data/reports'
|
||||||
import type { ReportCategory, ReportType } from '@/data/reports'
|
import type { ReportType } from '@/data/reports'
|
||||||
|
|
||||||
/* ─── constants ──────────────────────────────────────────── */
|
/* ─── constants ──────────────────────────────────────────── */
|
||||||
const TYPE_LABELS: Record<ReportType, string> = {
|
const TYPE_LABELS: Record<ReportType, string> = {
|
||||||
|
|
@ -35,12 +35,6 @@ function parseDateGroup(dateStr: string): number {
|
||||||
return year * 100 + month
|
return year * 100 + month
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── unique categories from data ───────────────────────── */
|
|
||||||
const ALL_CATEGORIES: (ReportCategory | 'همه')[] = [
|
|
||||||
'همه',
|
|
||||||
...Array.from(new Set(reports.map((r) => r.category))) as ReportCategory[],
|
|
||||||
]
|
|
||||||
|
|
||||||
/* ─── price formatter ────────────────────────────────────── */
|
/* ─── price formatter ────────────────────────────────────── */
|
||||||
function formatPrice(price: number): string {
|
function formatPrice(price: number): string {
|
||||||
if (price === 0) return 'رایگان'
|
if (price === 0) return 'رایگان'
|
||||||
|
|
@ -161,8 +155,16 @@ function ReportRow({ report }: { report: (typeof reports)[0] }) {
|
||||||
|
|
||||||
/* ─── main component ─────────────────────────────────────── */
|
/* ─── main component ─────────────────────────────────────── */
|
||||||
export default function Archive() {
|
export default function Archive() {
|
||||||
const [search, setSearch] = useState('')
|
const [params] = useSearchParams()
|
||||||
const [activeCategory, setActiveCategory] = useState<ReportCategory | 'همه'>('همه')
|
const [search, setSearch] = useState(params.get('q') ?? '')
|
||||||
|
const [activeCategory, setActiveCategory] = useState<string>('همه')
|
||||||
|
|
||||||
|
// categories come from the live (bootstrapped) content, not the static fallback,
|
||||||
|
// so the chips always reflect what's actually published in the CMS.
|
||||||
|
const allCategories = useMemo(
|
||||||
|
() => ['همه', ...Array.from(new Set(reports.map((r) => r.category).filter(Boolean)))],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let result = [...reports]
|
let result = [...reports]
|
||||||
|
|
@ -173,12 +175,13 @@ export default function Archive() {
|
||||||
|
|
||||||
if (search.trim()) {
|
if (search.trim()) {
|
||||||
const q = search.trim().toLowerCase()
|
const q = search.trim().toLowerCase()
|
||||||
|
const has = (v: unknown) => String(v ?? '').toLowerCase().includes(q)
|
||||||
result = result.filter(
|
result = result.filter(
|
||||||
(r) =>
|
(r) =>
|
||||||
r.title.toLowerCase().includes(q) ||
|
has(r.title) ||
|
||||||
r.author.toLowerCase().includes(q) ||
|
has(r.author) ||
|
||||||
r.category.toLowerCase().includes(q) ||
|
has(r.category) ||
|
||||||
r.tags.some((t) => t.toLowerCase().includes(q))
|
(r.tags ?? []).some((t) => has(t))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -285,12 +288,12 @@ export default function Archive() {
|
||||||
borderBottom: '1px solid var(--rule-thin)',
|
borderBottom: '1px solid var(--rule-thin)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ALL_CATEGORIES.map((cat) => {
|
{allCategories.map((cat) => {
|
||||||
const isActive = activeCategory === cat
|
const isActive = activeCategory === cat
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={cat}
|
key={cat}
|
||||||
onClick={() => setActiveCategory(cat as ReportCategory | 'همه')}
|
onClick={() => setActiveCategory(cat)}
|
||||||
style={{
|
style={{
|
||||||
padding: '6px 16px',
|
padding: '6px 16px',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect, type CSSProperties } from 'react'
|
import { useState, useEffect, type CSSProperties } from 'react'
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { X, Globe, Calendar, LayoutGrid, ExternalLink } from 'lucide-react'
|
import { X, Globe, Calendar, LayoutGrid, ExternalLink, Newspaper, LineChart, ChevronDown } from 'lucide-react'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
|
|
||||||
const NAVY = '#032340'
|
const NAVY = '#032340'
|
||||||
|
|
@ -227,70 +227,93 @@ const NEWS_ITEMS = [
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
/* ─── modal ─── */
|
/* ─── modal — pixel-matched to Figma node 6791-15495 ─── */
|
||||||
|
const SectionHead = ({ Icon, children }: { Icon: typeof Newspaper; children: string }) => (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
|
<Icon size={22} color="#0084E6" strokeWidth={1.6} />
|
||||||
|
<span style={{ fontSize: 16, fontWeight: 700, color: '#262626', lineHeight: '28px' }}>{children}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
function CardModal({ card, lang, onClose }: { card: Card; lang: 'fa' | 'en'; onClose: () => void }) {
|
function CardModal({ card, lang, onClose }: { card: Card; lang: 'fa' | 'en'; onClose: () => void }) {
|
||||||
const isFa = lang === 'fa'
|
const isFa = lang === 'fa'
|
||||||
const dir = isFa ? 'rtl' : 'ltr'
|
const dir = isFa ? 'rtl' : 'ltr'
|
||||||
const body = isFa ? card.bodyFa : card.bodyEn
|
const body = isFa ? card.bodyFa : card.bodyEn
|
||||||
|
const bodyText: CSSProperties = { fontSize: 14, lineHeight: '24px', color: '#676F71', textAlign: 'justify', margin: 0 }
|
||||||
|
const metaItem: CSSProperties = { display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#868686', lineHeight: '21px' }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(3,35,64,0.65)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px 16px' }}
|
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(3,35,64,0.55)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px 16px' }}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 32, scale: 0.96 }}
|
initial={{ opacity: 0, y: 32, scale: 0.97 }}
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, y: 32, scale: 0.96 }}
|
exit={{ opacity: 0, y: 32, scale: 0.97 }}
|
||||||
transition={{ duration: 0.38, ease: [0.22, 1, 0.36, 1] }}
|
transition={{ duration: 0.36, ease: [0.22, 1, 0.36, 1] }}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
style={{ background: '#faf7f4', borderRadius: 20, width: '100%', maxWidth: 680, maxHeight: '90vh', overflowY: 'auto', position: 'relative', boxShadow: '0 40px 100px -20px rgba(3,35,64,0.5)' }}
|
style={{ background: '#fff', border: '1px solid #E1E2E3', borderRadius: 16, width: '100%', maxWidth: 600, maxHeight: '92vh', overflowY: 'auto', position: 'relative', boxShadow: '0 24px 70px -16px rgba(0,0,0,0.32)' }}
|
||||||
>
|
>
|
||||||
{/* cover */}
|
{/* cover */}
|
||||||
<div style={{ position: 'relative', height: 260 }}>
|
<div style={{ position: 'relative', height: 284, minHeight: 284 }}>
|
||||||
<img src={card.img} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '20px 20px 0 0' }} />
|
<img src={card.img} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(3,35,64,0.55) 0%, transparent 50%)', borderRadius: '20px 20px 0 0' }} />
|
<button onClick={onClose} style={{ position: 'absolute', top: 14, ...(isFa ? { left: 14 } : { right: 14 }), width: 34, height: 34, borderRadius: '50%', border: 'none', background: 'rgba(3,35,64,0.55)', color: '#fff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(4px)' }}>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* close */}
|
|
||||||
<button onClick={onClose} style={{ position: 'absolute', top: 14, ...(isFa ? { left: 14 } : { right: 14 }), width: 34, height: 34, borderRadius: '50%', border: 'none', background: 'rgba(3,35,64,0.7)', color: '#fff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(4px)' }}>
|
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div style={{ padding: '24px 28px 32px' }}>
|
|
||||||
{/* tags */}
|
|
||||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 14, direction: 'ltr', justifyContent: isFa ? 'flex-end' : 'flex-start' }}>
|
|
||||||
{card.tags.map(t => (
|
|
||||||
<span key={t} style={{ fontSize: 10, fontWeight: 700, color: NAVY, background: `${GOLD}25`, borderRadius: 6, padding: '3px 10px' }}>{t}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 style={{ fontSize: 'clamp(17px,2vw,22px)', fontWeight: 900, color: NAVY, lineHeight: 1.4, margin: '0 0 14px' }}>
|
{/* content */}
|
||||||
|
<div style={{ padding: '16px 32px 0' }}>
|
||||||
|
{/* title row */}
|
||||||
|
<div style={{ borderBottom: '1px solid #E1E2E3', padding: '8px 0', marginBottom: 16 }}>
|
||||||
|
<h2 style={{ fontSize: 16, fontWeight: 700, color: '#151617', lineHeight: '28px', textAlign: isFa ? 'right' : 'left', margin: 0 }}>
|
||||||
{isFa ? card.titleFa : card.titleEn}
|
{isFa ? card.titleFa : card.titleEn}
|
||||||
</h2>
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* meta */}
|
{/* category badge (right) + meta (left) */}
|
||||||
<div style={{ display: 'flex', gap: 18, marginBottom: 20, direction: 'ltr', justifyContent: isFa ? 'flex-end' : 'flex-start' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 20 }}>
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 12, color: 'rgba(7,29,73,0.55)', fontWeight: 600 }}>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: GOLD, color: '#fff', fontSize: 14, fontWeight: 500, lineHeight: '24px', padding: '0 8px', borderRadius: 3 }}>
|
||||||
<Globe size={13} color={GOLD} />{card.source}
|
<span>{catLabel(card.tab, isFa)}</span>
|
||||||
</span>
|
<LayoutGrid size={16} color="#fff" strokeWidth={1.6} />
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 12, color: 'rgba(7,29,73,0.55)', fontWeight: 600 }}>
|
|
||||||
<Calendar size={13} color={GOLD} />{isFa ? card.dateFa : card.dateEn}
|
|
||||||
</span>
|
</span>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
|
||||||
|
<span style={metaItem}><span>{card.source}</span><Globe size={18} color={GOLD} strokeWidth={1.6} /></span>
|
||||||
|
<span style={metaItem}><span>{isFa ? card.dateFa : card.dateEn}</span><Calendar size={18} color={GOLD} strokeWidth={1.6} /></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* body */}
|
{/* اصل رویداد */}
|
||||||
<div>
|
<div style={{ marginBottom: 16 }}>
|
||||||
{body.map((p, i) => <p key={i} style={{ fontSize: 14, lineHeight: 2, color: 'rgba(7,29,73,0.8)', margin: '0 0 14px' }}>{p}</p>)}
|
<SectionHead Icon={Newspaper}>{isFa ? 'اصل رویداد' : 'The Event'}</SectionHead>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{body.map((p, i) => <p key={i} style={bodyText}>{p}</p>)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* analysis */}
|
{/* دلالتهای راهبردی */}
|
||||||
<div style={{ marginTop: 20, borderRadius: 12, padding: '16px 18px', background: `${NAVY}08`, border: `1.5px solid ${NAVY}15` }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<div style={{ fontSize: 10, fontWeight: 800, letterSpacing: '2px', color: GOLD, marginBottom: 8 }}>{isFa ? 'تحلیل راهبردی' : 'STRATEGIC ANALYSIS'}</div>
|
<SectionHead Icon={LineChart}>{isFa ? 'دلالتهای راهبردی' : 'Strategic Implications'}</SectionHead>
|
||||||
<p style={{ fontSize: 13, lineHeight: 1.9, color: NAVY, margin: 0 }}>{isFa ? card.analysisFa : card.analysisEn}</p>
|
<p style={bodyText}>{isFa ? card.analysisFa : card.analysisEn}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* tags */}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 20 }}>
|
||||||
|
{card.tags.map(t => (
|
||||||
|
<span key={t} style={{ background: '#EDEDED', color: '#262626', fontSize: 12, lineHeight: '21px', padding: '4px 8px', borderRadius: 16 }}>#{''}{t.replace(/ /g, '_')}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* footer */}
|
||||||
|
<div style={{ background: NAVY, borderRadius: '0 0 16px 16px', padding: '16px 24px 24px', display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
|
||||||
|
<button onClick={onClose} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: GOLD, color: '#fff', border: 'none', borderRadius: 100, padding: '8px 16px', fontSize: 14, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||||
|
<X size={18} color="#fff" />
|
||||||
|
{isFa ? 'بستن' : 'Close'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
@ -370,8 +393,13 @@ export default function AtAGlance() {
|
||||||
return [...m.entries()].map(([k, n]) => ({ k, n }))
|
return [...m.entries()].map(([k, n]) => ({ k, n }))
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
// today's date — Jalali for fa (day number on the right, month on the left via dir=rtl)
|
||||||
|
const todayDate = new Date()
|
||||||
|
const todayFa = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { day: 'numeric', month: 'long' }).format(todayDate)
|
||||||
|
const todayEn = new Intl.DateTimeFormat('en-US', { day: 'numeric', month: 'short' }).format(todayDate)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div dir={isFa ? 'rtl' : 'ltr'} style={{ background: '#faf7f4', minHeight: '100vh' }}>
|
<div dir={isFa ? 'rtl' : 'ltr'} style={{ background: 'var(--paper)', minHeight: '100vh' }}>
|
||||||
|
|
||||||
{/* ── breaking-news ticker ── */}
|
{/* ── breaking-news ticker ── */}
|
||||||
<div style={{ background: NAVY, color: '#fff' }}>
|
<div style={{ background: NAVY, color: '#fff' }}>
|
||||||
|
|
@ -389,15 +417,15 @@ export default function AtAGlance() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="max-md:!hidden" style={{ fontSize: 12, color: GOLD, fontWeight: 700, flexShrink: 0, direction: 'ltr', whiteSpace: 'nowrap' }}>
|
<span className="max-md:!hidden" style={{ fontSize: 12, color: GOLD, fontWeight: 700, flexShrink: 0, direction: 'rtl', whiteSpace: 'nowrap' }}>
|
||||||
{NEWS_ITEMS[0] ? (isFa ? NEWS_ITEMS[0].dateFa : NEWS_ITEMS[0].dateEn) : ''}
|
{isFa ? todayFa : todayEn}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── heading ── */}
|
{/* ── heading ── */}
|
||||||
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 34, paddingBottom: 6 }}>
|
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 34, paddingBottom: 6 }}>
|
||||||
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '3px', color: GOLD, marginBottom: 6 }}>{isFa ? 'دیدهبانِ تحولات' : 'AT A GLANCE'}</div>
|
<div style={{ fontSize: 20, fontWeight: 800, letterSpacing: isFa ? '1px' : '3px', color: GOLD, marginBottom: 8 }}>{isFa ? 'دیدهبانِ تحولات' : 'AT A GLANCE'}</div>
|
||||||
<h1 style={{ fontSize: 'clamp(24px,3vw,38px)', fontWeight: 900, color: NAVY, margin: 0 }}>{isFa ? 'در یک نگاه' : 'At a Glance'}</h1>
|
<h1 style={{ fontSize: 'clamp(24px,3vw,38px)', fontWeight: 900, color: NAVY, margin: 0 }}>{isFa ? 'در یک نگاه' : 'At a Glance'}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -437,9 +465,10 @@ export default function AtAGlance() {
|
||||||
{/* main news list */}
|
{/* main news list */}
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 22, justifyContent: isFa ? 'flex-start' : 'flex-end', flexDirection: isFa ? 'row' : 'row-reverse' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 22, justifyContent: isFa ? 'flex-start' : 'flex-end', flexDirection: isFa ? 'row' : 'row-reverse' }}>
|
||||||
<h2 style={{ fontSize: 20, fontWeight: 900, color: NAVY, margin: 0 }}>{isFa ? 'آخرین تحلیلها' : 'Latest Analyses'}</h2>
|
|
||||||
<span style={{ width: 3, height: 26, background: GOLD, borderRadius: 2 }} />
|
<span style={{ width: 3, height: 26, background: GOLD, borderRadius: 2 }} />
|
||||||
|
<h2 style={{ fontSize: 20, fontWeight: 900, color: NAVY, margin: 0 }}>{isFa ? 'آخرین تحولات' : 'Latest Developments'}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
{visibleList.map(card => (
|
{visibleList.map(card => (
|
||||||
<div key={card.id} onClick={() => setSelectedCard(card)} style={{ display: 'flex', gap: 14, background: '#ededed', borderRadius: 16, cursor: 'pointer', padding: 8, boxShadow: '0 4px 18px -12px rgba(3,35,64,0.3)' }} className="max-sm:!flex-col">
|
<div key={card.id} onClick={() => setSelectedCard(card)} style={{ display: 'flex', gap: 14, background: '#ededed', borderRadius: 16, cursor: 'pointer', padding: 8, boxShadow: '0 4px 18px -12px rgba(3,35,64,0.3)' }} className="max-sm:!flex-col">
|
||||||
|
|
@ -455,14 +484,22 @@ export default function AtAGlance() {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{/* fade + blur the last card, float the CTA on top of it */}
|
||||||
{!showAllList && listCards.length > 4 && (
|
{!showAllList && listCards.length > 4 && (
|
||||||
<div style={{ textAlign: 'center', marginTop: 28 }}>
|
<div style={{ position: 'absolute', left: 0, right: 0, bottom: -28, height: 220, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: 0, borderRadius: 16, pointerEvents: 'none', background: 'linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.5) 42%, rgba(255,255,255,0.9) 74%, #ffffff 92%)', backdropFilter: 'blur(2.5px)', WebkitBackdropFilter: 'blur(2.5px)' }}>
|
||||||
<button onClick={() => setShowAllList(true)} style={{ background: 'transparent', border: `1.5px solid ${GOLD}`, color: NAVY, borderRadius: 10, padding: '11px 36px', fontSize: 13.5, fontWeight: 800, cursor: 'pointer', fontFamily: 'inherit' }}>
|
<button
|
||||||
|
onClick={() => setShowAllList(true)}
|
||||||
|
style={{ pointerEvents: 'auto', display: 'inline-flex', alignItems: 'center', gap: 8, background: GOLD, color: '#fff', border: 'none', borderRadius: 100, padding: '12px 38px', fontSize: 14, fontWeight: 800, cursor: 'pointer', fontFamily: 'inherit', boxShadow: '0 14px 30px -8px rgba(205,158,83,0.8)', transition: 'transform .18s, filter .18s' }}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.filter = 'brightness(1.08)'; e.currentTarget.style.transform = 'translateY(-2px)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.filter = 'none'; e.currentTarget.style.transform = 'none' }}
|
||||||
|
>
|
||||||
{isFa ? 'مشاهده همه' : 'View all'}
|
{isFa ? 'مشاهده همه' : 'View all'}
|
||||||
|
<ChevronDown size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* sidebar */}
|
{/* sidebar */}
|
||||||
<aside style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
<aside style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||||
|
|
@ -487,7 +524,7 @@ export default function AtAGlance() {
|
||||||
|
|
||||||
{/* ── promo banner ── */}
|
{/* ── promo banner ── */}
|
||||||
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingBottom: 76 }}>
|
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingBottom: 76 }}>
|
||||||
<div style={{ position: 'relative', marginTop: 56 }}>
|
<div style={{ position: 'relative', marginTop: 152 }}>
|
||||||
<div style={{ position: 'relative', borderRadius: 16, overflow: 'hidden', background: `linear-gradient(160deg, #0a2f52 0%, ${NAVY} 55%, #021a30 100%)`, minHeight: 210, direction: 'rtl' }}>
|
<div style={{ position: 'relative', borderRadius: 16, overflow: 'hidden', background: `linear-gradient(160deg, #0a2f52 0%, ${NAVY} 55%, #021a30 100%)`, minHeight: 210, direction: 'rtl' }}>
|
||||||
{/* soft glow */}
|
{/* soft glow */}
|
||||||
<div aria-hidden style={{ position: 'absolute', top: -130, insetInlineEnd: '32%', width: 360, height: 340, borderRadius: '50%', background: 'radial-gradient(circle, rgba(255,255,255,0.14) 0%, transparent 70%)', pointerEvents: 'none' }} />
|
<div aria-hidden style={{ position: 'absolute', top: -130, insetInlineEnd: '32%', width: 360, height: 340, borderRadius: '50%', background: 'radial-gradient(circle, rgba(255,255,255,0.14) 0%, transparent 70%)', pointerEvents: 'none' }} />
|
||||||
|
|
|
||||||
|
|
@ -310,6 +310,58 @@ function RegisterForm({ lang, navigate }: { lang: 'fa' | 'en'; navigate: (to?: s
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Login keyword animation ─── */
|
||||||
|
const KW_FA = ['آینده', 'فناوری', 'بازار', 'رقابت', 'نوآوری', 'پایداری', 'فولاد']
|
||||||
|
const KW_EN = ['Future', 'Technology', 'Market', 'Competition', 'Innovation', 'Sustainability', 'Steel']
|
||||||
|
|
||||||
|
// scattered positions (center点 + base size) — a word-cloud layout
|
||||||
|
const KW_POS = [
|
||||||
|
{ t: '12%', l: '26%', s: 3.3 }, // آینده
|
||||||
|
{ t: '25%', l: '72%', s: 2.6 }, // فناوری
|
||||||
|
{ t: '45%', l: '15%', s: 3.0 }, // بازار
|
||||||
|
{ t: '40%', l: '83%', s: 2.4 }, // رقابت
|
||||||
|
{ t: '78%', l: '24%', s: 2.8 }, // نوآوری
|
||||||
|
{ t: '83%', l: '66%', s: 2.5 }, // پایداری
|
||||||
|
{ t: '58%', l: '50%', s: 4.0 }, // فولاد
|
||||||
|
]
|
||||||
|
|
||||||
|
function KeywordCycle({ lang }: { lang: 'fa' | 'en' }) {
|
||||||
|
const words = lang === 'fa' ? KW_FA : KW_EN
|
||||||
|
const [active, setActive] = useState(0)
|
||||||
|
React.useEffect(() => {
|
||||||
|
const t = setInterval(() => setActive(p => (p + 1) % words.length), 1700)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [words.length])
|
||||||
|
// word cloud: all words scattered + dim; the selected one lights gold, scales and glows
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative', zIndex: 1, width: '100%', height: 360 }}>
|
||||||
|
{words.map((w, idx) => {
|
||||||
|
const p = KW_POS[idx % KW_POS.length]
|
||||||
|
const isActive = idx === active
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={w}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: p.t, left: p.l,
|
||||||
|
transform: `translate(-50%, -50%) scale(${isActive ? 1.35 : 1})`,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontSize: `${p.s}rem`,
|
||||||
|
fontWeight: isActive ? 900 : 700,
|
||||||
|
color: isActive ? GOLD : 'rgba(255,255,255,0.16)',
|
||||||
|
textShadow: isActive ? `0 0 26px ${GOLD}66` : 'none',
|
||||||
|
transition: 'color .55s ease, transform .55s cubic-bezier(0.22,1,0.36,1), text-shadow .55s',
|
||||||
|
zIndex: isActive ? 2 : 1,
|
||||||
|
willChange: 'transform',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{w}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Brand Panel ─── */
|
/* ─── Brand Panel ─── */
|
||||||
function BrandPanel({ mode, lang }: { mode: Mode; lang: 'fa' | 'en' }) {
|
function BrandPanel({ mode, lang }: { mode: Mode; lang: 'fa' | 'en' }) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -349,61 +401,48 @@ function BrandPanel({ mode, lang }: { mode: Mode; lang: 'fa' | 'en' }) {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{mode === 'login' ? (
|
||||||
|
<>
|
||||||
|
<KeywordCycle lang={lang} />
|
||||||
|
<div />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{/* Center copy */}
|
{/* Center copy */}
|
||||||
<div style={{ position: 'relative', zIndex: 1, direction: 'rtl' }}>
|
<div style={{ position: 'relative', zIndex: 1, direction: 'rtl' }}>
|
||||||
<div style={{
|
|
||||||
display: 'inline-block', background: `${GOLD}22`, border: `1px solid ${GOLD}44`,
|
|
||||||
color: GOLD, padding: '0.3rem 0.85rem', borderRadius: 4,
|
|
||||||
fontSize: '0.72rem', fontWeight: 600, marginBottom: '1.1rem',
|
|
||||||
}}>
|
|
||||||
{mode === 'login'
|
|
||||||
? (lang === 'fa' ? 'پلتفرم تخصصی فولاد' : 'Steel Industry Platform')
|
|
||||||
: (lang === 'fa' ? 'عضویت رایگان' : 'Free Membership')}
|
|
||||||
</div>
|
|
||||||
<h1 style={{ color: '#fff', fontSize: 'clamp(1.5rem, 2.3vw, 2.2rem)', fontWeight: 800, lineHeight: 1.45, margin: '0 0 1rem' }}>
|
<h1 style={{ color: '#fff', fontSize: 'clamp(1.5rem, 2.3vw, 2.2rem)', fontWeight: 800, lineHeight: 1.45, margin: '0 0 1rem' }}>
|
||||||
{mode === 'login'
|
{lang === 'fa' ? 'بینشی نو برای ' : 'A New Vision for '}<span style={{ color: GOLD, whiteSpace: 'nowrap' }}>{lang === 'fa' ? 'توسعه صنعتی' : 'Industrial Growth'}</span>
|
||||||
? <>{lang === 'fa' ? 'دسترسی به تحلیلهای تخصصی' : 'Access Expert Analysis'}<br/><span style={{ color: GOLD }}>{lang === 'fa' ? 'صنعت فولاد ایران' : 'Steel Industry'}</span></>
|
|
||||||
: <>{lang === 'fa' ? 'عضو اندیشکده شوید' : 'Join the Institute'}<br/><span style={{ color: GOLD }}>{lang === 'fa' ? 'و به تحلیلها دسترسی داشته باشید' : 'Get Exclusive Access'}</span></>
|
|
||||||
}
|
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ color: 'rgba(255,255,255,0.5)', fontSize: '0.9rem', lineHeight: 1.85, margin: 0, maxWidth: 320 }}>
|
<p style={{ color: 'rgba(255,255,255,0.5)', fontSize: '0.9rem', lineHeight: 1.85, margin: 0 }}>
|
||||||
{lang === 'fa'
|
{lang === 'fa'
|
||||||
? 'گزارشهای بازار، رصد فناوری، و تحلیل ژئوپلیتیک — همه در یک پلتفرم'
|
? 'رصد آیندهپژوهانه بازار، فناوری، رقابت و سیاستهای صنعت فولاد برای مدیران و تصمیمسازان.'
|
||||||
: 'Market reports, technology intelligence, and geopolitical analysis — all in one platform'}
|
: 'Forward-looking monitoring of markets, technology, competition and steel-industry policy for managers and decision-makers.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom stats / checklist */}
|
{/* Bottom stats / checklist */}
|
||||||
<div style={{ position: 'relative', zIndex: 1, direction: 'rtl' }}>
|
<div style={{ position: 'relative', zIndex: 1, direction: 'rtl' }}>
|
||||||
{mode === 'login'
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.85rem' }}>
|
||||||
? (
|
<div style={{ color: GOLD, fontSize: '0.95rem', fontWeight: 800 }}>{lang === 'fa' ? 'آیندهپژوهی صنعت فولاد' : 'Steel Industry Foresight'}</div>
|
||||||
<div style={{ display: 'flex', gap: '2rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||||
{[['+۲۰۰', lang === 'fa' ? 'گزارش' : 'Reports'], ['۵۰+', lang === 'fa' ? 'کارشناس' : 'Experts'], ['۳ سال', lang === 'fa' ? 'سابقه' : 'History']].map(([n, l]) => (
|
|
||||||
<div key={l as string}>
|
|
||||||
<div style={{ color: GOLD, fontSize: '1.35rem', fontWeight: 800 }}>{n}</div>
|
|
||||||
<div style={{ color: 'rgba(255,255,255,0.4)', fontSize: '0.72rem', marginTop: 2 }}>{l}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem' }}>
|
|
||||||
{[
|
{[
|
||||||
lang === 'fa' ? 'دسترسی به گزارشهای تخصصی' : 'Access to expert reports',
|
lang === 'fa' ? 'رصد فناوریهای نوظهور' : 'Emerging technology monitoring',
|
||||||
lang === 'fa' ? 'رصد بازار و قیمتها' : 'Market and price monitoring',
|
lang === 'fa' ? 'هوشمندی رقابتی' : 'Competitive intelligence',
|
||||||
|
lang === 'fa' ? 'سناریونگاری آینده' : 'Future scenario planning',
|
||||||
|
lang === 'fa' ? 'تحلیلهای تخصصی بازار' : 'Expert market analysis',
|
||||||
lang === 'fa' ? 'شبکه خبرگان صنعت' : 'Industry expert network',
|
lang === 'fa' ? 'شبکه خبرگان صنعت' : 'Industry expert network',
|
||||||
].map(item => (
|
].map(item => (
|
||||||
<div key={item} style={{ display: 'flex', alignItems: 'center', gap: 10, color: 'rgba(255,255,255,0.65)', fontSize: '0.85rem' }}>
|
<div key={item} style={{ display: 'flex', alignItems: 'center', gap: 10, color: 'rgba(255,255,255,0.72)', fontSize: '0.875rem' }}>
|
||||||
<div style={{ width: 20, height: 20, borderRadius: '50%', background: `${GOLD}30`, border: `1px solid ${GOLD}55`, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
<span style={{ color: GOLD, fontSize: '0.7rem', flexShrink: 0, lineHeight: 1 }}>◉</span>
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke={GOLD} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
|
||||||
</div>
|
|
||||||
{item}
|
{item}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
|
import { useLang } from '@/context/LangContext'
|
||||||
|
import { reports } from '@/data/reports'
|
||||||
|
import { POSTS } from '@/content/posts'
|
||||||
|
import { RADAR_ITEMS, RADAR_CATEGORY_LABEL } from '@/content/radar'
|
||||||
|
import { RADAR_FUTURE } from '@/content/radarFuture'
|
||||||
|
import { videos, podcasts } from '@/data/media'
|
||||||
|
import { events } from '@/data/events'
|
||||||
|
|
||||||
|
const GOLD = '#CD9E53'
|
||||||
|
const NAVY = '#032340'
|
||||||
|
|
||||||
|
type Result = { id: string; title: string; summary: string; meta: string; badge: string; color: string; url: string }
|
||||||
|
|
||||||
|
/* Aggregate every searchable content source into one flat, normalized list.
|
||||||
|
All of these are already bootstrapped from the panel into memory at startup,
|
||||||
|
so this is a pure client-side search — no extra API call. */
|
||||||
|
function buildIndex(isFa: boolean): Result[] {
|
||||||
|
const out: Result[] = []
|
||||||
|
const L = (o: { fa: string; en: string }) => (isFa ? o.fa : o.en)
|
||||||
|
|
||||||
|
for (const r of reports) {
|
||||||
|
out.push({ id: `report-${r.id}`, title: r.title, summary: r.summary || '', meta: r.category || '', badge: 'گزارش', color: '#1d4ed8', url: `/reports/${r.id}` })
|
||||||
|
}
|
||||||
|
for (const p of POSTS) {
|
||||||
|
const t = isFa ? p.fa : p.en
|
||||||
|
out.push({ id: `post-${p.id}`, title: t.title, summary: t.lead || '', meta: L(p.category), badge: 'مطلب', color: '#9b1c1c', url: `/posts/${p.id}` })
|
||||||
|
}
|
||||||
|
for (const it of RADAR_ITEMS) {
|
||||||
|
const t = isFa ? it.fa : it.en
|
||||||
|
out.push({ id: `glance-${it.id}`, title: t.title, summary: t.excerpt || '', meta: L(RADAR_CATEGORY_LABEL[it.category]), badge: 'در یک نگاه', color: GOLD, url: `/radar/post/${it.id}` })
|
||||||
|
}
|
||||||
|
for (const it of RADAR_FUTURE) {
|
||||||
|
const t = isFa ? it.fa : it.en
|
||||||
|
out.push({ id: `radar-${it.id}`, title: t.title, summary: t.excerpt || '', meta: it.category || '', badge: 'رادار آینده', color: '#166534', url: `/radar/post/${it.id}` })
|
||||||
|
}
|
||||||
|
for (const v of videos) {
|
||||||
|
const t = isFa ? v.fa : v.en
|
||||||
|
out.push({ id: `video-${v.id}`, title: t.title, summary: t.body || '', meta: t.guest || '', badge: 'ویدیوکست', color: '#4a1d96', url: '/podcast' })
|
||||||
|
}
|
||||||
|
for (const p of podcasts) {
|
||||||
|
const t = isFa ? p.fa : p.en
|
||||||
|
out.push({ id: `podcast-${p.id}`, title: t.title, summary: t.body || '', meta: t.ep || '', badge: 'پادکست', color: '#7c2d12', url: '/podcast' })
|
||||||
|
}
|
||||||
|
for (const e of events) {
|
||||||
|
out.push({ id: `event-${e.id}`, title: e.title, summary: '', meta: '', badge: 'رویداد', color: '#0f766e', url: '/events' })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchResults() {
|
||||||
|
const { lang } = useLang()
|
||||||
|
const isFa = lang === 'fa'
|
||||||
|
const [params] = useSearchParams()
|
||||||
|
const q = (params.get('q') || '').trim()
|
||||||
|
|
||||||
|
const results = useMemo(() => {
|
||||||
|
if (!q) return []
|
||||||
|
const needle = q.toLowerCase()
|
||||||
|
const has = (v: unknown) => String(v ?? '').toLowerCase().includes(needle)
|
||||||
|
return buildIndex(isFa).filter((r) => has(r.title) || has(r.summary) || has(r.meta))
|
||||||
|
}, [q, isFa])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div dir={isFa ? 'rtl' : 'ltr'} style={{ maxWidth: 1000, margin: '0 auto', padding: 'clamp(24px,5vw,56px) clamp(16px,4vw,40px)', minHeight: '60vh' }}>
|
||||||
|
<div style={{ borderBottom: `3px solid ${NAVY}`, paddingBottom: 14, marginBottom: 28 }}>
|
||||||
|
<h1 style={{ fontSize: 24, fontWeight: 900, color: NAVY, margin: 0 }}>
|
||||||
|
{isFa ? 'نتایج جستجو' : 'Search results'}
|
||||||
|
</h1>
|
||||||
|
{q && (
|
||||||
|
<p style={{ fontSize: 14, color: 'var(--ink-4)', margin: '8px 0 0' }}>
|
||||||
|
{isFa
|
||||||
|
? `${results.length.toLocaleString('fa-IR')} نتیجه برای «${q}»`
|
||||||
|
: `${results.length} results for “${q}”`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!q ? (
|
||||||
|
<p style={{ color: 'var(--ink-5)', fontSize: 15 }}>{isFa ? 'عبارتی برای جستجو وارد کنید.' : 'Enter a search term.'}</p>
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<div style={{ padding: '64px 0', textAlign: 'center', color: 'var(--ink-5)' }}>
|
||||||
|
<p style={{ fontSize: 15 }}>{isFa ? 'نتیجهای یافت نشد' : 'No results found'}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{results.map((r) => (
|
||||||
|
<Link
|
||||||
|
key={r.id}
|
||||||
|
to={r.url}
|
||||||
|
style={{
|
||||||
|
display: 'block', textDecoration: 'none', border: '1px solid var(--rule-thin)',
|
||||||
|
borderRadius: 12, padding: '16px 18px', background: '#fff', transition: 'box-shadow 150ms, transform 150ms',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.boxShadow = '0 8px 24px rgba(3,35,64,0.10)'; e.currentTarget.style.transform = 'translateY(-2px)' }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.boxShadow = 'none'; e.currentTarget.style.transform = 'none' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color: '#fff', background: r.color, borderRadius: 6, padding: '2px 8px' }}>{r.badge}</span>
|
||||||
|
{r.meta && <span style={{ fontSize: 11, color: 'var(--ink-5)' }}>{r.meta}</span>}
|
||||||
|
</div>
|
||||||
|
<h3 style={{ fontSize: 16, fontWeight: 800, color: NAVY, margin: '0 0 4px' }}>{r.title}</h3>
|
||||||
|
{r.summary && (
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--ink-4)', margin: 0, lineHeight: 1.7, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||||
|
{r.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue