feat: implement news aggregation pipeline and expand frontend infrastructure with new site sections and pages

This commit is contained in:
alireza 2026-07-04 08:52:12 +03:30
parent 245097c0f2
commit 3622299b08
19 changed files with 2256 additions and 55 deletions

3
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
# local generator secrets
scripts/gen.config.mjs

View File

@ -11,17 +11,21 @@
"@radix-ui/react-slot": "^1.3.0",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1",
"@types/leaflet": "^1.9.21",
"apexcharts": "^5.13.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.40.0",
"gsap": "^3.15.0",
"leaflet": "^1.9.4",
"lenis": "^1.3.23",
"lucide-react": "^1.16.0",
"mind-elixir": "^5.13.0",
"react": "^19.2.6",
"react-apexcharts": "^2.1.0",
"react-dom": "^19.2.6",
"react-globe.gl": "^2.38.0",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.15.1",
"tailwind-merge": "^3.6.0",
"three": "^0.184.0",
@ -657,6 +661,17 @@
}
}
},
"node_modules/@react-leaflet/core": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
"license": "Hippocratic-2.1",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/@react-three/drei": {
"version": "10.7.7",
"resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz",
@ -1374,6 +1389,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": {
"version": "24.12.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
@ -2959,6 +2983,12 @@
"json-buffer": "3.0.1"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/lenis": {
"version": "1.3.23",
"resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.23.tgz",
@ -3362,6 +3392,12 @@
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
"license": "MIT"
},
"node_modules/mind-elixir": {
"version": "5.13.0",
"resolved": "https://registry.npmjs.org/mind-elixir/-/mind-elixir-5.13.0.tgz",
"integrity": "sha512-ilvXVwrMmBclwyA+46nIrqOZot4Rv7arxInJwbK4XL83qMoPFb7AbwSYJIsMUSjJ+XPxMYYZ2SoMuT06eWAMRQ==",
"license": "MIT"
},
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@ -3713,6 +3749,20 @@
"react": ">=16.13.1"
}
},
"node_modules/react-leaflet": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
"license": "Hippocratic-2.1",
"dependencies": {
"@react-leaflet/core": "^3.0.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/react-router": {
"version": "7.15.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz",

View File

@ -13,17 +13,21 @@
"@radix-ui/react-slot": "^1.3.0",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1",
"@types/leaflet": "^1.9.21",
"apexcharts": "^5.13.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.40.0",
"gsap": "^3.15.0",
"leaflet": "^1.9.4",
"lenis": "^1.3.23",
"lucide-react": "^1.16.0",
"mind-elixir": "^5.13.0",
"react": "^19.2.6",
"react-apexcharts": "^2.1.0",
"react-dom": "^19.2.6",
"react-globe.gl": "^2.38.0",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.15.1",
"tailwind-merge": "^3.6.0",
"three": "^0.184.0",

View File

@ -0,0 +1,117 @@
// Refreshable news fetcher for the /observe map.
// Scrapes Iranian steel/mining news sites (all WordPress → search-RSS at
// /?s=<query>&feed=rss2) per company and writes src/content/iranSitesNews.json.
//
// node scripts/fetch-news.mjs
//
// Each item: real title, article link (clickable Iranian source), date, and
// the post excerpt as a 1-paragraph summary. Re-run anytime to refresh.
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const ROOT = path.join(__dirname, '..')
const PER_SITE = 5
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36'
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
// WordPress sources (base + Persian display name)
const SRC = {
chilan: { base: 'https://www.chilanonline.com', name: 'چیلان' },
felezat: { base: 'https://felezatonline.ir', name: 'فلزات آنلاین' },
madanmedia: { base: 'https://madanmedia.ir', name: 'رسانه معدن' },
madannews: { base: 'https://www.madannews.ir', name: 'معدن‌نیوز' },
madanpress: { base: 'https://madanpress.com', name: 'معدن‌پرس' },
madanbidar: { base: 'https://madanbidar.ir', name: 'معدن بیدار' },
madanotoseeh: { base: 'https://madanotoseeh.ir', name: 'معدن و توسعه' },
}
const STEEL_SRC = ['chilan', 'felezat', 'madanmedia']
const MINE_SRC = ['madannews', 'madanpress', 'madanbidar', 'madanmedia', 'madanotoseeh', 'felezat']
// generic words to drop when picking a company's distinctive tokens
const STOP = new Set(['فولاد', 'سنگ‌آهن', 'سنگ', 'آهن', 'مس', 'و', 'معدن', 'سرب', 'روی', 'طلای', 'طلا', 'بوکسیت', 'کرومیت', 'زغال‌سنگ', 'زغال', 'گروه', 'ملی', 'صنعتی', 'ایران', 'شرکت', 'مجتمع'])
const norm = (s) => s.replace(//g, '').replace(/ي/g, 'ی').replace(/ك/g, 'ک').replace(/\(.*?\)/g, '').trim()
const decode = (s) =>
s
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(+n))
.replace(/&#xA0;|&#160;/g, ' ')
.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, ' ')
.trim()
const stripTags = (s) => decode(s.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' '))
async function get(url, timeout = 18000) {
const ac = new AbortController()
const t = setTimeout(() => ac.abort(), timeout)
try {
const r = await fetch(url, { headers: { 'User-Agent': UA }, redirect: 'follow', signal: ac.signal })
return r.ok ? await r.text() : ''
} catch {
return ''
} finally {
clearTimeout(t)
}
}
function tag(b, name) {
const m = b.match(new RegExp(`<${name}[^>]*>([\\s\\S]*?)<\\/${name}>`, 'i'))
return m ? m[1] : ''
}
function parseRss(xml, sourceName) {
return [...xml.matchAll(/<item>([\s\S]*?)<\/item>/g)].map((m) => {
const b = m[1]
let summary = stripTags(tag(b, 'description'))
summary = summary.replace(/\[…\]|\[&hellip;\]|…\s*$/g, '').trim()
return {
title: decode(tag(b, 'title')),
url: decode(tag(b, 'link')).trim(),
source: sourceName,
date: tag(b, 'pubDate').trim(),
summary,
}
})
}
async function main() {
const src = await fs.readFile(path.join(ROOT, 'src/content/iranSites.ts'), 'utf8')
const sites = [...src.matchAll(/id:\s*'([^']+)',\s*name:\s*\{\s*fa:\s*'([^']+)'[\s\S]*?type:\s*'([^']+)'/g)].map((m) => ({ id: m[1], fa: m[2], type: m[3] }))
console.log(`Fetching news for ${sites.length} sites…`)
const out = {}
for (const s of sites) {
const tokens = norm(s.fa).split(/\s+/).filter((w) => w.length > 1 && !STOP.has(w)).map(norm)
const srcKeys = s.type === 'steel' ? STEEL_SRC : MINE_SRC
const collected = []
for (const key of srcKeys) {
const { base, name } = SRC[key]
const xml = await get(`${base}/?s=${encodeURIComponent(s.fa)}&feed=rss2`)
if (xml) collected.push(...parseRss(xml, name))
await sleep(400)
}
// relevance: title or summary must contain a distinctive token of the company
const hay = (it) => norm(it.title + ' ' + it.summary)
const relevant = tokens.length ? collected.filter((it) => tokens.some((t) => hay(it).includes(t))) : collected
// dedup by url, then by title
const seen = new Set()
const unique = relevant.filter((it) => {
const k = it.url || norm(it.title).slice(0, 50)
if (seen.has(k)) return false
seen.add(k)
return true
})
unique.sort((a, b) => (Date.parse(b.date) || 0) - (Date.parse(a.date) || 0))
out[s.id] = unique.slice(0, PER_SITE)
console.log(` ${s.id} (${s.type}): ${out[s.id].length} items [${collected.length} raw]`)
}
const dest = path.join(ROOT, 'src/content/iranSitesNews.json')
await fs.writeFile(dest, JSON.stringify(out, null, 2), 'utf8')
console.log(`Wrote ${dest}`)
}
main()

View File

@ -0,0 +1,151 @@
// Auto-draft generator for رادار آینده / در یک نگاه.
// Scrapes recent Iranian steel news per section → Gemini (via Liara) writes
// Persian analytical items → pushes them to the panel as DRAFTS for review.
//
// node scripts/generate-drafts.mjs
//
// Config + tone live in ./gen.config.mjs (gitignored). Drafts stay hidden from
// the public site until you press «انتشار» in the CMS.
import CFG from './gen.config.mjs'
// allow pointing at a local panel for testing without editing the config
const PANEL = {
baseUrl: process.env.PANEL_BASE || CFG.panel.baseUrl,
username: process.env.PANEL_USER || CFG.panel.username,
password: process.env.PANEL_PASS || CFG.panel.password,
}
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36'
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
// section → { backend category code, scrape query, page label }
const CATEGORIES = [
{ code: 'market', label: 'بازار و زنجیره فولاد', query: 'قیمت بازار فولاد' },
{ code: 'energy', label: 'پایداری و فولاد سبز', query: 'فولاد سبز محیط زیست انرژی' },
{ code: 'geo', label: 'ژئوپلیتیک و اقتصاد جهانی', query: 'تعرفه صادرات فولاد اقتصاد جهانی' },
{ code: 'tech', label: 'فناوری و نوآوری', query: 'فناوری نوآوری فولاد' },
]
const NEWS_SITES = ['https://www.chilanonline.com', 'https://felezatonline.ir', 'https://madanmedia.ir', 'https://www.madannews.ir']
// ---------- helpers ----------
const decode = (s) =>
s.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(+n))
.replace(/&#xA0;|&#160;|&nbsp;/g, ' ')
.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
.trim()
const stripTags = (s) => decode(s.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' '))
const tag = (b, n) => { const m = b.match(new RegExp(`<${n}[^>]*>([\\s\\S]*?)<\\/${n}>`, 'i')); return m ? m[1] : '' }
async function get(url, timeout = 18000) {
const ac = new AbortController()
const t = setTimeout(() => ac.abort(), timeout)
try { const r = await fetch(url, { headers: { 'User-Agent': UA }, redirect: 'follow', signal: ac.signal }); return r.ok ? await r.text() : '' }
catch { return '' } finally { clearTimeout(t) }
}
function parseRss(xml) {
return [...xml.matchAll(/<item>([\s\S]*?)<\/item>/g)].map((m) => {
const b = m[1]
return { title: decode(tag(b, 'title')), url: decode(tag(b, 'link')).trim(), date: tag(b, 'pubDate').trim(), excerpt: stripTags(tag(b, 'description')).slice(0, 260) }
})
}
async function scrape(query) {
const all = []
for (const base of NEWS_SITES) {
const xml = await get(`${base}/?s=${encodeURIComponent(query)}&feed=rss2`)
if (xml) all.push(...parseRss(xml))
await sleep(300)
}
const seen = new Set()
return all
.filter((it) => it.title && it.url && !seen.has(it.title) && seen.add(it.title))
.sort((a, b) => (Date.parse(b.date) || 0) - (Date.parse(a.date) || 0))
.slice(0, 8)
}
async function panelLogin() {
const res = await fetch(`${PANEL.baseUrl}/api/auth/login`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: PANEL.username, password: PANEL.password }),
})
if (!res.ok) throw new Error(`panel login failed (${res.status})`)
const cookies = res.headers.getSetCookie?.() || []
const session = cookies.map((c) => c.split(';')[0]).find((c) => c.startsWith('session='))
if (!session) throw new Error('no session cookie returned')
return session
}
function parseItems(text) {
let t = text.replace(/```json|```/g, '').trim()
const s = t.indexOf('{'), e = t.lastIndexOf('}')
if (s === -1 || e === -1) return []
try { return JSON.parse(t.slice(s, e + 1)).items || [] } catch { return [] }
}
async function generate(cat, news) {
const newsText = news.map((n, i) => `${i + 1}. ${n.title}\n منبع: ${n.url}\n ${n.excerpt}`).join('\n\n')
const user = `دسته: «${cat.label}»
از میان خبرهای زیر، ${CFG.perCategory} آیتم تحلیلیِ مستقل و متفاوت برای این دسته بنویس. هر آیتم باید یک رویداد/موضوع واقعی از خبرها را پوشش دهد.
خروجی را «فقط» بهصورت JSON و دقیقاً با این ساختار بده (بدون هیچ توضیح اضافه):
{"items":[{"titleFa":"تیتر کوتاه","excerptFa":"خلاصهٔ یک تا دو جمله‌ای","bodyFa":"متن اصلی ۳ تا ۵ پاراگراف","analysisFa":"تحلیل راهبردی یک پاراگراف","tags":["برچسب","برچسب"],"sourceUrl":"نشانی منبع مرتبط"}]}
خبرها:
${newsText}`
const res = await fetch(`${CFG.ai.baseUrl}/chat/completions`, {
method: 'POST',
headers: { Authorization: `Bearer ${CFG.ai.apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ model: CFG.ai.model, messages: [{ role: 'system', content: CFG.systemPrompt }, { role: 'user', content: user }], max_tokens: 8000, temperature: 0.7 }),
})
if (!res.ok) throw new Error(`gemini ${res.status}: ${(await res.text()).slice(0, 200)}`)
const j = await res.json()
return parseItems(j.choices?.[0]?.message?.content || '')
}
async function createDraft(session, cat, item, dateFa) {
const body = {
category: cat.code,
titleFa: String(item.titleFa || '').trim(),
excerptFa: item.excerptFa || null,
bodyFa: item.bodyFa || null,
analysisFa: item.analysisFa || null,
tags: Array.isArray(item.tags) ? item.tags : [],
source: item.sourceUrl || null,
dateFa,
status: 'draft',
}
if (!body.titleFa) return null
const res = await fetch(`${PANEL.baseUrl}/api/radar`, {
method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: session },
body: JSON.stringify(body),
})
if (!res.ok) { console.log(` ✗ POST failed ${res.status}: ${(await res.text()).slice(0, 120)}`); return null }
return (await res.json()).id
}
async function main() {
const dateFa = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { year: 'numeric', month: 'long', day: 'numeric' }).format(new Date())
console.log(`Login to panel…`)
const session = await panelLogin()
let total = 0
for (const cat of CATEGORIES) {
console.log(`\n${cat.label} (${cat.code})`)
const news = await scrape(cat.query)
console.log(` scraped ${news.length} articles`)
if (!news.length) { console.log(' (no news, skipping)'); continue }
const items = await generate(cat, news)
console.log(` gemini returned ${items.length} items`)
for (const it of items) {
const id = await createDraft(session, cat, it, dateFa)
if (id) { total++; console.log(` ✓ draft ${id}${String(it.titleFa).slice(0, 50)}`) }
}
}
console.log(`\nDone. ${total} drafts created. Review & publish them in the panel → «در یک نگاه».`)
}
main().catch((e) => { console.error('FATAL:', e.message); process.exit(1) })

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from 'react'
import type { CSSProperties } from 'react'
import { createPortal } from 'react-dom'
import { NavLink, Link, useNavigate } from 'react-router-dom'
import { Search, Menu, X, ChevronDown, Radar, Leaf, Globe, Cpu, BarChart3, Video, CalendarDays, Users, Eye, Phone, type LucideIcon } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
@ -413,7 +414,8 @@ export default function Header() {
)}
</AnimatePresence>
{/* Mobile Drawer */}
{/* Mobile Drawer — portaled to <body> so the header's backdrop-filter doesn't trap position:fixed */}
{createPortal(
<AnimatePresence>
{mobileOpen && (
<>
@ -486,7 +488,9 @@ export default function Header() {
</motion.div>
</>
)}
</AnimatePresence>
</AnimatePresence>,
document.body,
)}
</header>
)
}

View File

@ -29,6 +29,8 @@ import Podcast from '@/pages/Podcast/Podcast'
import SpecializedMeetings from '@/pages/SpecializedMeetings/SpecializedMeetings'
import SteelDashboard from '@/pages/SteelDashboard/SteelDashboard'
import SearchResults from '@/pages/Search/SearchResults'
import Observe from '@/pages/Observe/Observe'
import Newstree from '@/pages/Newstree/Newstree'
export const router = createBrowserRouter([
{
@ -67,4 +69,7 @@ export const router = createBrowserRouter([
{ path: 'steeldashboard', element: <SteelDashboard /> },
],
},
// hidden test pages — not linked anywhere in nav. Reach via the URL directly.
{ path: '/observe', element: <Observe /> },
{ path: '/newstree', element: <Newstree /> },
])

View File

@ -0,0 +1,75 @@
// Iran steel / iron-ore / mining sites for the (hidden) /observe map.
// Coordinates are approximate, placed at the plant or mine locality.
// Extend freely — the /observe page renders every entry as a marker.
export type SiteType = 'steel' | 'iron-ore' | 'mine'
export interface IranSite {
id: string
name: { fa: string; en: string }
type: SiteType
province: string // fa
lat: number
lng: number
note?: { fa: string; en: string }
}
export const SITE_TYPE_LABEL: Record<SiteType, { fa: string; en: string; color: string }> = {
steel: { fa: 'فولادسازی', en: 'Steelworks', color: '#CD9E53' },
'iron-ore': { fa: 'سنگ‌آهن', en: 'Iron ore', color: '#e2562b' },
mine: { fa: 'معدن', en: 'Mine', color: '#3b82f6' },
}
export const IRAN_SITES: IranSite[] = [
// ── Steelworks ─────────────────────────────────────────────
{ id: 'mobarakeh', name: { fa: 'فولاد مبارکه', en: 'Mobarakeh Steel' }, type: 'steel', province: 'اصفهان', lat: 32.28, lng: 51.50, note: { fa: 'بزرگ‌ترین فولادساز ایران', en: 'Largest steelmaker in Iran' } },
{ id: 'khouzestan', name: { fa: 'فولاد خوزستان', en: 'Khouzestan Steel' }, type: 'steel', province: 'خوزستان', lat: 31.32, lng: 48.77 },
{ id: 'esfahan', name: { fa: 'ذوب‌آهن اصفهان', en: 'Esfahan Steel' }, type: 'steel', province: 'اصفهان', lat: 32.39, lng: 51.37, note: { fa: 'قدیمی‌ترین مجتمع فولاد کشور', en: "Iran's oldest integrated steel complex" } },
{ id: 'hormozgan', name: { fa: 'فولاد هرمزگان', en: 'Hormozgan Steel' }, type: 'steel', province: 'هرمزگان', lat: 27.15, lng: 56.10 },
{ id: 'iran-alloy', name: { fa: 'فولاد آلیاژی ایران', en: 'Iran Alloy Steel' }, type: 'steel', province: 'یزد', lat: 31.86, lng: 54.27 },
{ id: 'oxin', name: { fa: 'فولاد اکسین خوزستان', en: 'Oxin Steel' }, type: 'steel', province: 'خوزستان', lat: 31.35, lng: 48.82 },
{ id: 'saba', name: { fa: 'مجتمع فولاد سبا', en: 'Saba Steel' }, type: 'steel', province: 'اصفهان', lat: 32.30, lng: 51.52 },
{ id: 'arfa', name: { fa: 'آهن و فولاد ارفع', en: 'Arfa Iron & Steel' }, type: 'steel', province: 'یزد', lat: 32.31, lng: 54.02 },
{ id: 'azarbaijan', name: { fa: 'فولاد آذربایجان', en: 'Azarbaijan Steel' }, type: 'steel', province: 'آذربایجان شرقی', lat: 37.45, lng: 47.70 },
{ id: 'bonab', name: { fa: 'فولاد بناب', en: 'Bonab Steel' }, type: 'steel', province: 'آذربایجان شرقی', lat: 37.34, lng: 46.06 },
{ id: 'gilan', name: { fa: 'مجتمع فولاد گیلان', en: 'Gilan Steel' }, type: 'steel', province: 'گیلان', lat: 37.28, lng: 49.60 },
{ id: 'sefiddasht', name: { fa: 'فولاد سفیددشت', en: 'Sefiddasht Steel' }, type: 'steel', province: 'چهارمحال و بختیاری', lat: 31.93, lng: 51.10 },
{ id: 'neyriz', name: { fa: 'فولاد غدیر نی‌ریز', en: 'Neyriz Steel' }, type: 'steel', province: 'فارس', lat: 29.20, lng: 54.33 },
{ id: 'kaveh', name: { fa: 'فولاد کاوه جنوب کیش', en: 'Kaveh South Kish Steel' }, type: 'steel', province: 'خوزستان', lat: 30.50, lng: 49.18 },
{ id: 'sabzevar', name: { fa: 'فولاد سبزوار', en: 'Sabzevar Steel' }, type: 'steel', province: 'خراسان رضوی', lat: 36.21, lng: 57.68 },
// ── Iron ore ───────────────────────────────────────────────
{ id: 'golgohar', name: { fa: 'گل‌گهر سیرجان', en: 'Gol Gohar' }, type: 'iron-ore', province: 'کرمان', lat: 29.06, lng: 55.32, note: { fa: 'بزرگ‌ترین معدن سنگ‌آهن ایران', en: "Iran's largest iron ore complex" } },
{ id: 'goharzamin', name: { fa: 'گهرزمین', en: 'Gohar Zamin' }, type: 'iron-ore', province: 'کرمان', lat: 29.10, lng: 55.40 },
{ id: 'chadormalu', name: { fa: 'چادرملو', en: 'Chadormalu' }, type: 'iron-ore', province: 'یزد', lat: 32.46, lng: 55.50 },
{ id: 'choghart', name: { fa: 'چغارت بافق', en: 'Choghart' }, type: 'iron-ore', province: 'یزد', lat: 31.69, lng: 55.45 },
{ id: 'sechahun', name: { fa: 'سه‌چاهون', en: 'Sechahun' }, type: 'iron-ore', province: 'یزد', lat: 31.95, lng: 55.70 },
{ id: 'sangan', name: { fa: 'سنگان خواف', en: 'Sangan' }, type: 'iron-ore', province: 'خراسان رضوی', lat: 34.39, lng: 60.27 },
{ id: 'jalalabad', name: { fa: 'جلال‌آباد زرند', en: 'Jalalabad' }, type: 'iron-ore', province: 'کرمان', lat: 30.80, lng: 56.95 },
// ── Other major mines ──────────────────────────────────────
{ id: 'sarcheshmeh', name: { fa: 'مس سرچشمه', en: 'Sarcheshmeh Copper' }, type: 'mine', province: 'کرمان', lat: 29.95, lng: 55.87, note: { fa: 'یکی از بزرگ‌ترین معادن مس جهان', en: 'One of the largest copper mines globally' } },
{ id: 'angouran', name: { fa: 'انگوران (روی و سرب)', en: 'Angouran (Zn/Pb)' }, type: 'mine', province: 'زنجان', lat: 36.60, lng: 47.40 },
// ── Batch 2: more steelworks ───────────────────────────────
{ id: 'national-steel', name: { fa: 'گروه ملی صنعتی فولاد ایران', en: 'National Iranian Steel Group' }, type: 'steel', province: 'خوزستان', lat: 31.30, lng: 48.69 },
{ id: 'khorasan-steel', name: { fa: 'فولاد خراسان (نیشابور)', en: 'Khorasan Steel' }, type: 'steel', province: 'خراسان رضوی', lat: 36.10, lng: 58.70 },
{ id: 'amirkabir', name: { fa: 'فولاد امیرکبیر کاشان', en: 'Amirkabir Kashan Steel' }, type: 'steel', province: 'اصفهان', lat: 33.98, lng: 51.40 },
{ id: 'kavir', name: { fa: 'فولاد کویر آران', en: 'Kavir Steel (Aran)' }, type: 'steel', province: 'اصفهان', lat: 34.05, lng: 51.50 },
{ id: 'zarand-iranian', name: { fa: 'فولاد زرند ایرانیان', en: 'Zarand Iranian Steel' }, type: 'steel', province: 'کرمان', lat: 30.81, lng: 56.56 },
{ id: 'ghaenat', name: { fa: 'فولاد قائنات', en: 'Ghaenat Steel' }, type: 'steel', province: 'خراسان جنوبی', lat: 33.73, lng: 59.18 },
{ id: 'shadegan', name: { fa: 'فولاد شادگان', en: 'Shadegan Steel' }, type: 'steel', province: 'خوزستان', lat: 30.65, lng: 48.66 },
{ id: 'nab-tabriz', name: { fa: 'فولاد ناب تبریز', en: 'Nab Tabriz Steel' }, type: 'steel', province: 'آذربایجان شرقی', lat: 38.05, lng: 46.20 },
{ id: 'bijar', name: { fa: 'فولاد کردستان (بیجار)', en: 'Kurdistan Steel' }, type: 'steel', province: 'کردستان', lat: 35.87, lng: 47.60 },
{ id: 'baft', name: { fa: 'فولاد بافت', en: 'Baft Steel' }, type: 'steel', province: 'کرمان', lat: 29.23, lng: 56.60 },
// ── Batch 2: more iron ore ─────────────────────────────────
{ id: 'mishdovan', name: { fa: 'سنگ‌آهن میشدوان بافق', en: 'Mishdovan Iron Ore' }, type: 'iron-ore', province: 'یزد', lat: 31.60, lng: 55.40 },
{ id: 'shahrak', name: { fa: 'سنگ‌آهن شهرک سنگان', en: 'Shahrak (Sangan)' }, type: 'iron-ore', province: 'خراسان رضوی', lat: 34.40, lng: 60.20 },
// ── Batch 2: more mines ────────────────────────────────────
{ id: 'sungun', name: { fa: 'مس سونگون', en: 'Sungun Copper' }, type: 'mine', province: 'آذربایجان شرقی', lat: 38.70, lng: 46.70 },
{ id: 'mehdiabad', name: { fa: 'سرب و روی مهدی‌آباد', en: 'Mehdiabad (Zn/Pb)' }, type: 'mine', province: 'یزد', lat: 31.55, lng: 54.95 },
{ id: 'zarshouran', name: { fa: 'طلای زرشوران تکاب', en: 'Zarshouran Gold' }, type: 'mine', province: 'آذربایجان غربی', lat: 36.65, lng: 47.15 },
{ id: 'jajarm', name: { fa: 'بوکسیت جاجرم', en: 'Jajarm Bauxite' }, type: 'mine', province: 'خراسان شمالی', lat: 36.95, lng: 56.38 },
]

File diff suppressed because it is too large Load Diff

View File

@ -301,7 +301,8 @@ body {
font-size: var(--fs-body);
line-height: var(--lh-body);
letter-spacing: normal;
overflow-x: clip;
/* no overflow-x here html already clips horizontal overflow; clip on <body>
breaks position:sticky for the header on mobile (native scroll). */
-webkit-font-smoothing: antialiased;
}

View File

@ -286,8 +286,8 @@ export default function About() {
</div>
</section>
{/* ── POLICY COUNCIL ── */}
<section className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingBottom: 56 }}>
{/* ── POLICY COUNCIL ── (hidden for now: whole section blurred + non-interactive) */}
<section className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingBottom: 56, filter: 'blur(18px)', userSelect: 'none', pointerEvents: 'none' }} aria-hidden="true">
<SectionLabel text={t.councilTitle} />
<div style={{ marginBottom: 36 }}><PillarRow items={COUNCIL_PILLARS} /></div>
<div style={memberGrid} className="max-lg:!grid-cols-2 max-sm:!grid-cols-1">
@ -295,8 +295,8 @@ export default function About() {
</div>
</section>
{/* ── DIRECTOR ── */}
<section style={{ background: '#efefef', padding: '56px 0' }}>
{/* ── DIRECTOR ── (hidden for now: whole section blurred + non-interactive) */}
<section style={{ background: '#efefef', padding: '56px 0', filter: 'blur(18px)', userSelect: 'none', pointerEvents: 'none' }} aria-hidden="true">
<div className="max-w-7xl mx-auto px-12 max-md:px-5">
<SectionLabel text={t.directorTitle} />
<div style={{ marginBottom: 28 }}><PillarRow items={DIRECTOR_PILLARS} /></div>
@ -304,8 +304,8 @@ export default function About() {
</div>
</section>
{/* ── ANALYST TEAM ── */}
<section id="team" className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 64, paddingBottom: 64 }}>
{/* ── ANALYST TEAM ── (hidden for now: whole section blurred + non-interactive) */}
<section id="team" className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 64, paddingBottom: 64, filter: 'blur(18px)', userSelect: 'none', pointerEvents: 'none' }} aria-hidden="true">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 30, justifyContent: isFa ? 'flex-start' : 'flex-end' }}>
{DEPTS.map((d, i) => (
<button key={d} type="button" onClick={() => { setDeptSel(i); setAnalystSel(0) }}

View File

@ -301,7 +301,7 @@ export default function Experts() {
</div>
</div>
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ marginTop: 48 }}>
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ marginTop: 48, filter: 'blur(18px)', userSelect: 'none', pointerEvents: 'none' }} aria-hidden="true">
{/* ─── Top Featured Carousel ─── */}
<section
@ -343,7 +343,7 @@ export default function Experts() {
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.3 }}
style={{ width: '100%', height: '100%', objectFit: 'cover', filter: 'blur(10px)' }}
style={{ width: '100%', height: '100%', objectFit: 'cover', filter: 'blur(18px)' }}
/>
</AnimatePresence>
</div>

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { motion } from 'framer-motion'
import { FileText } from 'lucide-react'
import { FileDown } from 'lucide-react'
import { useLang } from '@/context/LangContext'
const NAVY = '#032340'
@ -8,7 +8,7 @@ const GOLD = '#CD9E53'
const EASE = [0.22, 1, 0.36, 1] as const
type ScrollDir = 'left' | 'right' | 'up'
type Cover = { img: string; num: string; dir: ScrollDir; fileUrl?: string }
type Cover = { img: string; num: string; dir: ScrollDir; fileUrl?: string; title?: string }
/* static fallback — Figma covers; replaced at runtime by panel factory-reports */
const COVERS: Cover[] = [
@ -42,43 +42,81 @@ function ScrollAnimation({ children, direction = 'up' }: { children: React.React
)
}
/* compact card — cover + navy download bar (direct file download) + issue-number badge */
function ReportCard({ img, num, fileUrl, lang }: { img: string; num: string; fileUrl?: string; lang: 'fa' | 'en' }) {
/* magazine card — gray panel with floating cover on top, title + download button below */
function ReportCard({ img, title, fileUrl, lang }: { img: string; title: string; fileUrl?: string; lang: 'fa' | 'en' }) {
const label = lang === 'fa' ? 'دانلود ماهنامه' : 'Download'
const bar = (
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#fff', fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap' }}>
<FileText size={14} color={GOLD} />
{label}
</span>
)
const Tag = fileUrl ? 'a' : 'div'
const linkProps = fileUrl ? { href: fileUrl, download: true, target: '_blank', rel: 'noopener noreferrer' } : {}
return (
<article
<Tag
{...linkProps}
style={{
background: '#e9e9e9', borderRadius: 12, overflow: 'hidden', cursor: fileUrl ? 'pointer' : 'default',
boxShadow: '0 8px 24px -16px rgba(7,29,73,0.35)', display: 'flex', flexDirection: 'column', height: '100%',
display: 'flex',
flexDirection: 'column',
background: '#F9F9F9',
borderRadius: '24px',
textDecoration: 'none',
cursor: fileUrl ? 'pointer' : 'default',
boxShadow: '0 12px 28px -20px rgba(7,29,73,0.3)',
transition: 'transform 180ms, box-shadow 180ms',
height: '100%',
position: 'relative', // برای قرارگیری خط طلایی
paddingBottom: '4px' // ایجاد فضا برای خط طلایی
}}
onMouseEnter={(e) => { const d = e.currentTarget; d.style.transform = 'translateY(-4px)'; d.style.boxShadow = '0 16px 34px -18px rgba(7,29,73,0.42)' }}
onMouseLeave={(e) => { const d = e.currentTarget; d.style.transform = 'translateY(0)'; d.style.boxShadow = '0 8px 24px -16px rgba(7,29,73,0.35)' }}
onMouseEnter={(e) => { const d = e.currentTarget; d.style.transform = 'translateY(-4px)'; d.style.boxShadow = '0 20px 38px -22px rgba(7,29,73,0.4)' }}
onMouseLeave={(e) => { const d = e.currentTarget; d.style.transform = 'translateY(0)'; d.style.boxShadow = '0 12px 28px -20px rgba(7,29,73,0.3)' }}
>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '14px 14px 8px', flexGrow: 1 }}>
<img src={img} alt="" style={{ width: '100%', height: 184, objectFit: 'contain', display: 'block' }} />
{/* تصویر */}
<div style={{
width: '100%',
aspectRatio: '1 / 0.8',
overflow: 'hidden',
clipPath: 'polygon(0 0, 100% 20%, 100% 100%, 0 100%)',
borderTopLeftRadius: '24px',
borderTopRightRadius: '24px'
}}>
<img src={img} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
</div>
{fileUrl ? (
<a
href={fileUrl} download target="_blank" rel="noopener noreferrer"
style={{ background: NAVY, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '9px 12px', textDecoration: 'none', gap: 8, direction: 'ltr' }}
>
{bar}
{num && <span style={{ width: 26, height: 26, borderRadius: '50%', background: GOLD, color: NAVY, fontSize: 10.5, fontWeight: 800, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{num}</span>}
</a>
) : (
<div style={{ background: NAVY, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '9px 12px', gap: 8, direction: 'ltr', opacity: 0.6 }}>
{bar}
{num && <span style={{ width: 26, height: 26, borderRadius: '50%', background: GOLD, color: NAVY, fontSize: 10.5, fontWeight: 800, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{num}</span>}
{/* محتوا */}
<div style={{
padding: '16px 20px',
textAlign: 'right',
display: 'flex',
flexDirection: 'column',
flexGrow: 1
}}>
<div style={{ fontSize: '18px', fontWeight: '800', color: '#000', marginBottom: 'auto' }}>
{title}
</div>
)}
</article>
<div style={{
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
gap: '8px',
color: GOLD,
fontSize: '14px',
fontWeight: '700',
marginTop: '16px'
}}>
<span>{label}</span>
<FileDown size={18} />
</div>
</div>
{/* خط طلایی کاملاً مستقل در پایین */}
<div style={{
position: 'absolute',
bottom: 0,
left: 0,
width: '100%',
height: '4px',
backgroundColor: GOLD,
borderRadius: '0 0 24px 24px'
}} />
</Tag>
)
}
@ -99,6 +137,7 @@ export default function FactoryReportsScroll() {
num: '',
dir: FALLBACK_DIRS[i % FALLBACK_DIRS.length],
fileUrl: safeFile(r.fileUrl),
title: typeof r.titleFa === 'string' ? r.titleFa : undefined,
})))
})
.catch(() => {})
@ -120,7 +159,7 @@ export default function FactoryReportsScroll() {
// 5th+ cover is hidden on phones so the 2-col grid stays a clean 2×2 (no orphan)
<div key={c.img + i} className={i >= 4 ? 'max-sm:!hidden' : undefined}>
<ScrollAnimation direction={c.dir}>
<ReportCard img={c.img} num={c.num} fileUrl={c.fileUrl} lang={lang} />
<ReportCard img={c.img} title={c.title || (c.num ? `ماهنامه شماره ${c.num}` : 'ماهنامه کارخانه')} fileUrl={c.fileUrl} lang={lang} />
</ScrollAnimation>
</div>
))}

View File

@ -24,7 +24,7 @@ const T = {
export default function VideocastPodcastSection({ only }: { only?: 'video' | 'pod' }) {
const { lang } = useLang()
const t = T[lang]
const [activeVideo] = useState(0)
const [activeVideo, setActiveVideo] = useState(0)
const [activePod, setActivePod] = useState(0)
const pod = PODCASTS[activePod]
@ -113,7 +113,7 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
{VIDEOS.slice(0, 3).map((v, i) => (
<button
key={v.id}
onClick={() => {/* setActiveVideo(i) — wired if needed */}}
onClick={() => setActiveVideo(i)}
style={{
textAlign: 'inherit', background: i === activeVideo ? '#efe9e1' : '#fff',
border: `1px solid ${i === activeVideo ? GOLD : 'rgba(3,35,64,0.08)'}`,

View File

@ -0,0 +1,109 @@
import { useEffect, useRef, useState } from 'react'
import MindElixir, { type MindElixirInstance, type MindElixirData } from 'mind-elixir'
import 'mind-elixir/style.css'
import { IRAN_SITES } from '@/content/iranSites'
import newsData from '@/content/iranSitesNews.json'
interface NewsItem { title: string; url: string; source: string; date: string; summary: string }
const NEWS = newsData as Record<string, NewsItem[]>
const NAVY = '#032340'
const GOLD = '#CD9E53'
const norm = (s: string) => s.replace(//g, '').replace(/ي/g, 'ی').replace(/ك/g, 'ک').trim()
const byTime = (a: NewsItem, b: NewsItem) => (Date.parse(b.date) || 0) - (Date.parse(a.date) || 0)
const newsCount = (id: string) => (NEWS[id] ?? []).length
// turn companies + their news into a mind-map: root → company → headline leaves
function buildTree(query: string): MindElixirData {
const q = norm(query)
let companies = IRAN_SITES.filter((s) => newsCount(s.id) > 0)
if (q) {
companies = companies.filter(
(s) => norm(s.name.fa).includes(q) || (NEWS[s.id] ?? []).some((n) => norm(n.title + ' ' + n.summary).includes(q))
)
}
const top = companies.sort((a, b) => newsCount(b.id) - newsCount(a.id)).slice(0, 12)
return {
direction: MindElixir.SIDE,
theme: {
name: 'steel',
palette: [GOLD, '#1f6f8b', '#b3541e', '#3b82f6', '#7a5c3e', '#2f8f6b'],
cssVar: { '--main-color': NAVY, '--main-bgcolor': '#ffffff', '--color': NAVY, '--bgcolor': '#faf7f4' },
},
nodeData: {
id: 'root',
topic: query.trim() || 'اخبار صنعت فولاد ایران',
style: { background: NAVY, color: '#fff', fontWeight: '700' },
children: top.map((c) => ({
id: c.id,
topic: c.name.fa,
children: [...(NEWS[c.id] ?? [])].sort(byTime).slice(0, 4).map((n, i) => ({
id: `${c.id}-${i}`,
topic: n.title.length > 64 ? n.title.slice(0, 64) + '…' : n.title,
hyperLink: n.url,
})),
})),
},
}
}
export default function Newstree() {
const [query, setQuery] = useState('')
const elRef = useRef<HTMLDivElement>(null)
const mindRef = useRef<MindElixirInstance | null>(null)
// init once
useEffect(() => {
if (!elRef.current) return
const mind = new MindElixir({
el: elRef.current,
direction: MindElixir.SIDE,
draggable: true,
editable: false,
contextMenu: false,
toolBar: true,
keypress: false,
})
mind.init(buildTree(''))
// click a headline leaf → open the article
mind.bus.addListener('selectNodes', (nodes: { hyperLink?: string }[]) => {
const link = nodes.find((n) => n?.hyperLink)?.hyperLink
if (link) window.open(link, '_blank', 'noopener')
})
mindRef.current = mind
return () => { mindRef.current = null }
}, [])
const search = () => mindRef.current?.refresh(buildTree(query))
return (
<div style={{ position: 'fixed', inset: 0, background: '#faf7f4', direction: 'rtl', display: 'flex', flexDirection: 'column' }}>
{/* hero / search */}
<div style={{ flexShrink: 0, background: '#fff', borderBottom: '1px solid #eee', padding: '14px 20px', display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ width: 38, height: 38, borderRadius: 10, background: NAVY, color: GOLD, fontWeight: 900, fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>NT</span>
<div>
<div style={{ fontWeight: 900, fontSize: 18, color: NAVY }}>نیوزتری</div>
<div style={{ fontSize: 11.5, color: '#888' }}>نمای درختی اخبار صنعت فولاد ایران</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 240, maxWidth: 560, background: '#f4f1ec', borderRadius: 999, padding: '6px 14px' }}>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') search() }}
placeholder="جست‌وجو در شرکت‌ها و اخبار… (مثلاً مبارکه، گل‌گهر)"
style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 14, color: NAVY, fontFamily: 'inherit' }}
/>
<button onClick={search} style={{ background: GOLD, color: NAVY, border: 'none', borderRadius: 999, padding: '6px 16px', fontWeight: 800, fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}>جستوجو</button>
{query && <button onClick={() => { setQuery(''); mindRef.current?.refresh(buildTree('')) }} style={{ background: 'transparent', border: 'none', color: '#999', fontSize: 18, cursor: 'pointer', lineHeight: 1 }} aria-label="پاک کردن">×</button>}
</div>
<div style={{ fontSize: 11.5, color: '#aaa' }}>روی هر تیتر بزنید تا مقاله باز شود</div>
</div>
{/* mind map */}
<div ref={elRef} style={{ flex: 1, minHeight: 0 }} />
</div>
)
}

View File

@ -0,0 +1,243 @@
import { useState, useEffect, useMemo, type CSSProperties } from 'react'
import { MapContainer, TileLayer, Marker, Tooltip, GeoJSON } from 'react-leaflet'
import L from 'leaflet'
import type { GeoJsonObject } from 'geojson'
import 'leaflet/dist/leaflet.css'
import { IRAN_SITES, SITE_TYPE_LABEL, type IranSite } from '@/content/iranSites'
import newsData from '@/content/iranSitesNews.json'
interface NewsItem { title: string; url: string; source: string; date: string; summary: string }
const NEWS = newsData as Record<string, NewsItem[]>
const fmtDate = (d: string) => {
const t = Date.parse(d)
if (Number.isNaN(t)) return ''
return new Intl.DateTimeFormat('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' }).format(new Date(t))
}
// fan out co-located sites onto a small ring so dots don't stack at country zoom.
// display-only — real lat/lng stay in the data for the card.
type Placed = IranSite & { dlat: number; dlng: number }
function spread(sites: IranSite[]): Placed[] {
const cell = 0.25 // ~25km bucket
const groups = new Map<string, IranSite[]>()
for (const s of sites) {
const k = `${Math.round(s.lat / cell)}_${Math.round(s.lng / cell)}`
const g = groups.get(k)
if (g) g.push(s)
else groups.set(k, [s])
}
const out: Placed[] = []
for (const arr of groups.values()) {
if (arr.length === 1) {
out.push({ ...arr[0], dlat: arr[0].lat, dlng: arr[0].lng })
continue
}
const R = 0.18
const cx = arr.reduce((a, s) => a + s.lat, 0) / arr.length
const cy = arr.reduce((a, s) => a + s.lng, 0) / arr.length
arr.forEach((s, i) => {
const ang = (i / arr.length) * Math.PI * 2
out.push({ ...s, dlat: cx + Math.cos(ang) * R, dlng: cy + Math.sin(ang) * R })
})
}
return out
}
// breathing pulse marker — no image assets, color set per type via --c
const pulseIcon = (color: string, active: boolean) =>
L.divIcon({
className: '',
html: `<span class="obs-pulse${active ? ' obs-active' : ''}" style="--c:${color}"><span class="obs-dot"></span></span>`,
iconSize: [26, 26],
iconAnchor: [13, 13],
})
const GOLD = '#CD9E53'
const NAVY = '#032340'
// ponytail: hidden test page — not in router-as-nav, reachable only by URL /observe.
export default function Observe() {
const [selected, setSelected] = useState<IranSite | null>(null)
const [iran, setIran] = useState<GeoJsonObject | null>(null)
const [isMobile, setIsMobile] = useState(false)
const [sheetOpen, setSheetOpen] = useState(false) // mobile: all-news sheet collapsed by default
const placed = useMemo(() => spread(IRAN_SITES), [])
useEffect(() => {
const mq = window.matchMedia('(max-width: 768px)')
const on = () => setIsMobile(mq.matches)
on()
mq.addEventListener('change', on)
return () => mq.removeEventListener('change', on)
}, [])
const byTime = (a: NewsItem, b: NewsItem) => (Date.parse(b.date) || 0) - (Date.parse(a.date) || 0)
const allNews = useMemo(() => {
const arr: (NewsItem & { siteId: string })[] = []
for (const [id, items] of Object.entries(NEWS)) for (const it of items) arr.push({ ...it, siteId: id })
return arr.sort(byTime)
}, [])
const siteName = (id: string) => IRAN_SITES.find((s) => s.id === id)?.name.fa ?? id
const companyNews = selected ? [...(NEWS[selected.id] ?? [])].sort(byTime) : []
const renderItems = (items: (NewsItem & { siteId?: string })[], showTag: boolean) =>
items.length === 0 ? (
<div style={{ padding: '24px 18px', fontSize: 14, opacity: 0.6, lineHeight: 1.8 }}>
خبری برای این واحد یافت نشد.
</div>
) : (
items.map((it, i) => (
<a
key={i}
href={it.url}
target="_blank"
rel="noopener noreferrer"
style={{ display: 'block', padding: '14px 18px', borderBottom: '1px solid #f0ede6', textDecoration: 'none', color: NAVY }}
>
{showTag && it.siteId && (
<div style={{ fontSize: 11, color: GOLD, fontWeight: 700, marginBottom: 4 }}>{siteName(it.siteId)}</div>
)}
<div style={{ fontWeight: 700, fontSize: 14, lineHeight: 1.6 }}>{it.title}</div>
{it.summary && it.summary !== it.title && (
<p style={{ fontSize: 12.5, lineHeight: 1.7, opacity: 0.75, margin: '6px 0 0' }}>{it.summary}</p>
)}
<div style={{ display: 'flex', gap: 10, marginTop: 8, fontSize: 11.5, color: '#7a7a7a' }}>
{it.source && <span style={{ color: GOLD, fontWeight: 600 }}>{it.source}</span>}
{fmtDate(it.date) && <span>{fmtDate(it.date)}</span>}
</div>
</a>
))
)
// pull Iran's border from the bundled Natural Earth geojson
useEffect(() => {
fetch('/globe-countries.geojson')
.then((r) => r.json())
.then((g) => {
const f = g.features.find((x: any) => x.properties?.ISO_A3 === 'IRN')
if (f) setIran(f as GeoJsonObject)
})
.catch(() => {})
}, [])
const sheetBase: CSSProperties = { background: '#fff', color: NAVY, display: 'flex', flexDirection: 'column' }
const leftStyle: CSSProperties = isMobile
? { ...sheetBase, position: 'absolute', left: 0, right: 0, bottom: 0, height: sheetOpen ? '78vh' : 'auto', maxHeight: '78vh', zIndex: 1001, borderRadius: '18px 18px 0 0', boxShadow: '0 -12px 40px rgba(0,0,0,0.35)', transition: 'height 240ms ease' }
: { ...sheetBase, position: 'absolute', top: 0, left: 0, bottom: 0, width: 360, maxWidth: '90vw', zIndex: 1001, boxShadow: '12px 0 40px rgba(0,0,0,0.35)' }
const rightStyle: CSSProperties = isMobile
? { ...sheetBase, position: 'absolute', left: 0, right: 0, bottom: 0, height: '85vh', zIndex: 1002, borderRadius: '18px 18px 0 0', boxShadow: '0 -12px 40px rgba(0,0,0,0.45)' }
: { ...sheetBase, position: 'absolute', top: 0, right: 0, bottom: 0, width: 360, maxWidth: '90vw', zIndex: 1002, boxShadow: '-12px 0 40px rgba(0,0,0,0.4)' }
const legendStyle: CSSProperties = {
position: 'absolute', left: '50%', transform: 'translateX(-50%)', zIndex: 1000,
display: 'flex', gap: 14, background: 'rgba(3,35,64,0.85)', color: '#fff',
padding: '7px 14px', borderRadius: 999, backdropFilter: 'blur(8px)', fontSize: 12.5,
...(isMobile ? { top: 12 } : { bottom: 20 }),
}
return (
<div style={{ position: 'fixed', inset: 0, background: NAVY, direction: 'rtl' }}>
<style>{`
.obs-pulse { position: relative; display: block; width: 26px; height: 26px; }
.obs-dot { position: absolute; inset: 6px; border-radius: 50%; background: var(--c); border: 1.5px solid rgba(255,255,255,0.95); box-shadow: 0 0 10px var(--c); }
.obs-pulse::before { content: ''; position: absolute; inset: 6px; border-radius: 50%; background: var(--c); animation: obs-breathe 2.4s ease-out infinite; }
.obs-active .obs-dot { inset: 3px; }
.obs-active::before { animation-duration: 1.4s; }
@keyframes obs-breathe { 0% { transform: scale(1); opacity: 0.6; } 100% { transform: scale(3.2); opacity: 0; } }
.leaflet-container { background: #0b1a2a !important; }
`}</style>
<MapContainer
center={[32.4, 53.7]}
zoom={5}
minZoom={4}
maxZoom={12}
maxBounds={[[23, 43], [40, 64]]}
maxBoundsViscosity={1}
style={{ width: '100%', height: '100%' }}
zoomControl
>
<TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png"
subdomains="abcd"
attribution="&copy; OpenStreetMap, &copy; CARTO"
keepBuffer={8}
updateWhenZooming={false}
/>
{iran && (
<GeoJSON
data={iran}
style={{ color: GOLD, weight: 2.5, fillColor: GOLD, fillOpacity: 0.07 }}
interactive={false}
/>
)}
{placed.map((s) => {
const c = SITE_TYPE_LABEL[s.type].color
const active = selected?.id === s.id
return (
<Marker
key={s.id}
position={[s.dlat, s.dlng]}
icon={pulseIcon(c, active)}
eventHandlers={{ click: () => setSelected(s) }}
>
<Tooltip direction="top" offset={[0, -10]}>
{s.name.fa}
</Tooltip>
</Marker>
)
})}
</MapContainer>
{/* LEFT panel — all news, always open (collapsible bottom sheet on mobile) */}
<div style={leftStyle}>
{isMobile && <div onClick={() => setSheetOpen((v) => !v)} style={{ width: 40, height: 4, borderRadius: 2, background: '#d8d2c6', margin: '8px auto 0', cursor: 'pointer' }} />}
<div
onClick={isMobile ? () => setSheetOpen((v) => !v) : undefined}
style={{ background: NAVY, color: '#fff', padding: '14px 18px', flexShrink: 0, borderRadius: isMobile ? '14px 14px 0 0' : 0, cursor: isMobile ? 'pointer' : 'default' }}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div style={{ fontWeight: 800, fontSize: 17 }}>آخرین اخبار صنعت فولاد</div>
{isMobile && <span style={{ fontSize: 13, opacity: 0.85, transform: sheetOpen ? 'rotate(180deg)' : 'none', transition: 'transform 200ms' }}></span>}
</div>
<div style={{ fontSize: 12, opacity: 0.8, marginTop: 4 }}>
{isMobile ? (sheetOpen ? 'روی هر نقطه روی نقشه بزنید تا اخبار همان واحد باز شود' : `${allNews.length} خبر — برای دیدن بزنید`) : 'روی هر نقطه روی نقشه بزنید تا اخبار همان واحد در سمت راست باز شود'}
</div>
</div>
{(!isMobile || sheetOpen) && (
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 0', WebkitOverflowScrolling: 'touch' }}>{renderItems(allNews, true)}</div>
)}
</div>
{/* RIGHT panel — selected company (bottom sheet on mobile) */}
{selected && (
<div style={rightStyle}>
{isMobile && <div style={{ width: 40, height: 4, borderRadius: 2, background: 'rgba(255,255,255,0.4)', margin: '8px auto 0' }} />}
<div style={{ background: NAVY, color: '#fff', padding: '14px 18px', flexShrink: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
<div>
<div style={{ fontWeight: 800, fontSize: 18 }}>{selected.name.fa}</div>
<div style={{ fontSize: 12, opacity: 0.85 }}>{selected.name.en}</div>
</div>
<button onClick={() => setSelected(null)} style={{ background: 'transparent', border: 'none', color: '#fff', fontSize: 22, cursor: 'pointer', lineHeight: 1 }} aria-label="بستن">×</button>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 10 }}>
<span style={{ background: SITE_TYPE_LABEL[selected.type].color, color: '#fff', padding: '3px 10px', borderRadius: 999, fontSize: 12, fontWeight: 700 }}>{SITE_TYPE_LABEL[selected.type].fa}</span>
<span style={{ background: 'rgba(255,255,255,0.15)', padding: '3px 10px', borderRadius: 999, fontSize: 12 }}>{selected.province}</span>
</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 0', WebkitOverflowScrolling: 'touch' }}>{renderItems(companyNews, false)}</div>
</div>
)}
{/* Legend (floating; bottom on desktop, top on mobile) */}
<div style={legendStyle}>
{(Object.keys(SITE_TYPE_LABEL) as (keyof typeof SITE_TYPE_LABEL)[]).map((k) => (
<div key={k} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 11, height: 11, borderRadius: '50%', background: SITE_TYPE_LABEL[k].color, border: '1px solid #fff' }} />
<span>{SITE_TYPE_LABEL[k].fa}</span>
</div>
))}
</div>
</div>
)
}

View File

@ -247,6 +247,7 @@ await pool.query(`
excerpt_en TEXT,
body_en TEXT,
analysis_en TEXT,
status TEXT DEFAULT 'published',
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
@ -419,6 +420,7 @@ for (const sql of [
'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 source TEXT',
"ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'published'",
'ALTER TABLE members ADD COLUMN IF NOT EXISTS last_login TIMESTAMPTZ',
'ALTER TABLE members ADD COLUMN IF NOT EXISTS token_version INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE users ADD COLUMN IF NOT EXISTS token_version INTEGER NOT NULL DEFAULT 0',
@ -711,6 +713,7 @@ export function rowToRadarItem(row) {
excerptEn: row.excerpt_en,
bodyEn: row.body_en || null,
analysisEn: row.analysis_en || null,
status: row.status || 'published',
sortOrder: row.sort_order,
createdAt: row.created_at,
updatedAt: row.updated_at,

View File

@ -1513,6 +1513,7 @@ async function renderRadarList() {
<div class="row-cover" style="background-image:url('${escapeAttr(safeImgUrl(r.img))}')"></div>
<div class="row-main">
<div class="row-meta">
${r.status === 'draft' ? `<span class="pill" style="background:#b45309;color:#fff">پیش‌نویس</span>` : ''}
${r.category ? `<span class="pill">${escapeHtml(catLabel(r.category))}</span>` : ''}
${r.dateFa ? `<span class="muted">${escapeHtml(r.dateFa)}</span>` : ''}
</div>
@ -1520,6 +1521,7 @@ async function renderRadarList() {
<p class="muted clamp-2">${escapeHtml(r.excerptFa||'')}</p>
</div>
<div class="row-actions">
${r.status === 'draft' ? `<button data-action="publish" style="background:#15803d;color:#fff">انتشار</button>` : ''}
<button data-action="edit">ویرایش</button>
<button data-action="delete" class="danger">حذف</button>
</div>
@ -1538,6 +1540,10 @@ async function renderRadarList() {
const btn = ev.target.closest('button[data-action]'); if (!btn) return;
const id = btn.closest('.row').dataset.id;
if (btn.dataset.action === 'edit') renderRadarEditor(await api(`/api/radar/${id}`));
else if (btn.dataset.action === 'publish') {
await api(`/api/radar/${id}/publish`, { method: 'POST' });
renderRadarList();
}
else if (btn.dataset.action === 'delete') {
if (!confirm('این مطلب حذف شود؟')) return;
await api(`/api/radar/${id}`, { method: 'DELETE' });
@ -1546,7 +1552,7 @@ async function renderRadarList() {
});
try {
allItems = await api('/api/radar?limit=500');
allItems = await api('/api/admin/radar?limit=500');
if (!allItems.length) { $('#list').innerHTML = `<p class="muted">هنوز مطلبی ثبت نشده.</p>`; return; }
renderItems();
} catch (err) { $('#list').innerHTML = `<p class="error">خطا: ${escapeHtml(err.message)}</p>`; }

View File

@ -24,6 +24,12 @@ if (!JWT_SECRET || JWT_SECRET.length < 32) {
console.error('FATAL: JWT_SECRET env var is missing or shorter than 32 chars. Set it before starting.');
process.exit(1);
}
// refuse to run with a leftover placeholder secret — a guessable JWT_SECRET means
// anyone can forge an admin session, which defeats every other auth control.
if (/change-me|replace-this|replace-with|your-secret|example|placeholder/i.test(JWT_SECRET)) {
console.error('FATAL: JWT_SECRET is still a placeholder. Set a real random value, e.g. `openssl rand -hex 32`.');
process.exit(1);
}
const PUBLIC_ORIGIN = process.env.PUBLIC_ORIGIN || `http://localhost:${PORT}`;
// Canonical base for media URLs. The object-storage host blocks browser User-Agents,
// so we never hand browsers a direct storage URL — they go through this app's /media proxy.
@ -187,7 +193,22 @@ app.get('/.well-known/security.txt', (_req, res) => {
res.type('text/plain').send(`Contact: mailto:${process.env.SECURITY_CONTACT || 'security@steelforesight.ir'}\nPreferred-Languages: fa, en\nExpires: 2027-01-01T00:00:00.000Z\n`);
});
app.use('/uploads', express.static(uploadsDir, { maxAge: '7d' }));
app.use('/', express.static(path.join(__dirname, 'public')));
// api.steelforesight.ir is API-only — never serve the admin SPA there (it would
// expose the login page). The admin UI stays on the cms-steelforesight.liara.run host.
// /api, /media, /uploads, /.well-known are registered above, so only the SPA reaches here.
const API_ONLY_HOST = process.env.API_ONLY_HOST || 'api.steelforesight.ir';
// optional: lock the admin SPA to specific IPs (comma-separated). Unset = open to all.
const ADMIN_IPS = (process.env.ADMIN_IP_ALLOWLIST || '').split(',').map(s => s.trim()).filter(Boolean);
const serveAdminSPA = express.static(path.join(__dirname, 'public'));
app.use('/', (req, res, next) => {
const host = (req.headers.host || '').split(':')[0];
const isApiPath = req.path.startsWith('/api/');
if (host === API_ONLY_HOST && !isApiPath) return res.status(404).end();
// IP allowlist applies only to the admin SPA, never to the public /api the site needs
if (ADMIN_IPS.length && !isApiPath && !ADMIN_IPS.includes(req.ip)) return res.status(404).end();
return serveAdminSPA(req, res, next);
});
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
@ -205,6 +226,22 @@ const contactLimiter = rateLimit({
legacyHeaders: false,
});
// Per-account login lockout — complements the per-IP loginLimiter by bounding a
// distributed / credential-stuffing attack that rotates IPs against ONE account.
// ponytail: in-memory Map (resets on restart, single-process); fine for this panel.
// Move to Postgres only if you scale to multiple instances.
const loginFails = new Map(); // key -> { n, exp }
const LOCK_MAX = 5;
const LOCK_MS = 15 * 60 * 1000;
const loginLocked = (key) => { const e = loginFails.get(key); return !!e && e.n >= LOCK_MAX && e.exp > Date.now(); };
function bumpLoginFail(key) {
const now = Date.now();
const e = loginFails.get(key);
if (!e || e.exp <= now) loginFails.set(key, { n: 1, exp: now + LOCK_MS });
else { e.n++; e.exp = now + LOCK_MS; }
}
const clearLoginFail = (key) => loginFails.delete(key);
async function authRequired(req, res, next) {
let token = req.cookies?.session;
if (!token) {
@ -274,12 +311,16 @@ function memberCookieOpts(req) {
app.post('/api/auth/login', loginLimiter, async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) return res.status(400).json({ error: 'username_password_required' });
const lockKey = `admin:${String(username).toLowerCase()}`;
if (loginLocked(lockKey)) return res.status(429).json({ error: 'account_locked' });
const row = await db.prepare('SELECT id, username, password_hash, role, token_version FROM users WHERE username = ?').get(username);
// always run a bcrypt compare (dummy hash when no row) so timing can't reveal whether the user exists
const ok = bcrypt.compareSync(password, row?.password_hash || '$2a$10$0000000000000000000000000000000000000000000000000000');
if (!row || !ok) {
bumpLoginFail(lockKey);
return res.status(401).json({ error: 'bad_credentials' });
}
clearLoginFail(lockKey);
const token = jwt.sign({ sub: row.id, username: row.username, role: row.role, tv: row.token_version || 0 }, JWT_SECRET, { expiresIn: '24h' });
res.cookie('session', token, {
httpOnly: true,
@ -630,10 +671,18 @@ app.delete('/api/events/:id', authRequired, async (req, res) => {
const RADAR_FIELDS = `id, category, img, author_name, author_avatar,
title_fa, date_fa, excerpt_fa, body_fa, analysis_fa,
title_en, date_en, excerpt_en, body_en, analysis_en,
tags, source, sort_order, created_at, updated_at`;
tags, source, status, sort_order, created_at, updated_at`;
// public: only published items
app.get('/api/radar', async (req, res) => {
const limit = Math.min(Number(req.query.limit) || 50, 500);
const rows = await db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE status = 'published' ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit);
res.json(rows.map(rowToRadarItem));
});
// admin: all items incl. drafts (for the CMS list + review)
app.get('/api/admin/radar', authRequired, async (req, res) => {
const limit = Math.min(Number(req.query.limit) || 200, 500);
const rows = await db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit);
res.json(rows.map(rowToRadarItem));
});
@ -670,13 +719,14 @@ app.post('/api/radar', authRequired, async (req, res) => {
const r = normalizeRadarBody(req.body || {});
if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' });
const id = nanoid(14);
const status = req.body?.status === 'draft' ? 'draft' : 'published';
await db.prepare(`INSERT INTO radar_items (id,category,img,author_name,author_avatar,
title_fa,date_fa,excerpt_fa,body_fa,analysis_fa,
title_en,date_en,excerpt_en,body_en,analysis_en,tags,source,sort_order)
title_en,date_en,excerpt_en,body_en,analysis_en,tags,source,status,sort_order)
VALUES (@id,@category,@img,@author_name,@author_avatar,
@title_fa,@date_fa,@excerpt_fa,@body_fa,@analysis_fa,
@title_en,@date_en,@excerpt_en,@body_en,@analysis_en,@tags,@source,@sort_order)
`).run({ id, ...r });
@title_en,@date_en,@excerpt_en,@body_en,@analysis_en,@tags,@source,@status,@sort_order)
`).run({ id, status, ...r });
res.status(201).json(rowToRadarItem(await db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(id)));
});
@ -701,6 +751,19 @@ app.delete('/api/radar/:id', authRequired, async (req, res) => {
res.status(204).end();
});
// publish / unpublish a radar item (draft <-> published)
app.post('/api/radar/:id/publish', authRequired, async (req, res) => {
const info = await db.prepare("UPDATE radar_items SET status='published', updated_at=now() WHERE id=?").run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.json(rowToRadarItem(await db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(req.params.id)));
});
app.post('/api/radar/:id/unpublish', authRequired, async (req, res) => {
const info = await db.prepare("UPDATE radar_items SET status='draft', updated_at=now() WHERE id=?").run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.json(rowToRadarItem(await db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(req.params.id)));
});
// ---------- radar pages (per-category hero + banner) ----------
const RADAR_PAGE_FIELDS = `slug, source_categories, label_fa, label_en,
latest_heading_fa, latest_heading_en, featured_title_fa, featured_title_en,
@ -1651,12 +1714,15 @@ app.post('/api/members/login', loginLimiter, async (req, res) => {
const { email, phone, password } = req.body || {};
const identifier = String(email || phone || '').trim();
if (!identifier || !password) return res.status(400).json({ error: 'شماره موبایل یا ایمیل و رمز عبور الزامی است' });
const lockKey = `member:${identifier.toLowerCase()}`;
if (loginLocked(lockKey)) return res.status(429).json({ error: 'تعداد تلاش‌های ناموفق زیاد است؛ چند دقیقه دیگر دوباره تلاش کنید' });
// accept either an email or a phone number as the login identifier
const row = await db.prepare('SELECT id, name, email, password_hash, is_active, token_version FROM members WHERE email = ? OR phone = ?').get(identifier.toLowerCase(), identifier);
// constant-time-ish: always run a compare so timing can't reveal whether the identifier exists
const ok = bcrypt.compareSync(password, row?.password_hash || '$2a$10$0000000000000000000000000000000000000000000000000000');
if (!row || !ok) return res.status(401).json({ error: 'شماره موبایل/ایمیل یا رمز عبور اشتباه است' });
if (!row || !ok) { bumpLoginFail(lockKey); return res.status(401).json({ error: 'شماره موبایل/ایمیل یا رمز عبور اشتباه است' }); }
if (!row.is_active) return res.status(403).json({ error: 'حساب کاربری غیرفعال است' });
clearLoginFail(lockKey);
const token = jwt.sign({ sub: row.id, email: row.email, name: row.name, role: 'member', tv: row.token_version || 0 }, JWT_SECRET, { expiresIn: '30d' });
res.cookie('member_session', token, memberCookieOpts(req));
await db.prepare('UPDATE members SET last_login = now() WHERE id = ?').run(row.id);