feat: implement news aggregation pipeline and expand frontend infrastructure with new site sections and pages
This commit is contained in:
parent
245097c0f2
commit
3622299b08
|
|
@ -0,0 +1,3 @@
|
||||||
|
|
||||||
|
# local generator secrets
|
||||||
|
scripts/gen.config.mjs
|
||||||
|
|
@ -11,17 +11,21 @@
|
||||||
"@radix-ui/react-slot": "^1.3.0",
|
"@radix-ui/react-slot": "^1.3.0",
|
||||||
"@react-three/drei": "^10.7.7",
|
"@react-three/drei": "^10.7.7",
|
||||||
"@react-three/fiber": "^9.6.1",
|
"@react-three/fiber": "^9.6.1",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"apexcharts": "^5.13.0",
|
"apexcharts": "^5.13.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.40.0",
|
"framer-motion": "^12.40.0",
|
||||||
"gsap": "^3.15.0",
|
"gsap": "^3.15.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lenis": "^1.3.23",
|
"lenis": "^1.3.23",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
|
"mind-elixir": "^5.13.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-apexcharts": "^2.1.0",
|
"react-apexcharts": "^2.1.0",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-globe.gl": "^2.38.0",
|
"react-globe.gl": "^2.38.0",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"react-router-dom": "^7.15.1",
|
"react-router-dom": "^7.15.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"three": "^0.184.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": {
|
"node_modules/@react-three/drei": {
|
||||||
"version": "10.7.7",
|
"version": "10.7.7",
|
||||||
"resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz",
|
"resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz",
|
||||||
|
|
@ -1374,6 +1389,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.12.4",
|
"version": "24.12.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
|
||||||
|
|
@ -2959,6 +2983,12 @@
|
||||||
"json-buffer": "3.0.1"
|
"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": {
|
"node_modules/lenis": {
|
||||||
"version": "1.3.23",
|
"version": "1.3.23",
|
||||||
"resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.23.tgz",
|
"resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.23.tgz",
|
||||||
|
|
@ -3362,6 +3392,12 @@
|
||||||
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.2.5",
|
"version": "10.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||||
|
|
@ -3713,6 +3749,20 @@
|
||||||
"react": ">=16.13.1"
|
"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": {
|
"node_modules/react-router": {
|
||||||
"version": "7.15.1",
|
"version": "7.15.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -13,17 +13,21 @@
|
||||||
"@radix-ui/react-slot": "^1.3.0",
|
"@radix-ui/react-slot": "^1.3.0",
|
||||||
"@react-three/drei": "^10.7.7",
|
"@react-three/drei": "^10.7.7",
|
||||||
"@react-three/fiber": "^9.6.1",
|
"@react-three/fiber": "^9.6.1",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"apexcharts": "^5.13.0",
|
"apexcharts": "^5.13.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.40.0",
|
"framer-motion": "^12.40.0",
|
||||||
"gsap": "^3.15.0",
|
"gsap": "^3.15.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lenis": "^1.3.23",
|
"lenis": "^1.3.23",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
|
"mind-elixir": "^5.13.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-apexcharts": "^2.1.0",
|
"react-apexcharts": "^2.1.0",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-globe.gl": "^2.38.0",
|
"react-globe.gl": "^2.38.0",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"react-router-dom": "^7.15.1",
|
"react-router-dom": "^7.15.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"three": "^0.184.0",
|
"three": "^0.184.0",
|
||||||
|
|
|
||||||
|
|
@ -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(/ | /g, ' ')
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /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(/\[…\]|\[…\]|…\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()
|
||||||
|
|
@ -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(/ | | /g, ' ')
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/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) })
|
||||||
|
|
@ -1,5 +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 { createPortal } from 'react-dom'
|
||||||
import { NavLink, Link, useNavigate } from 'react-router-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 { 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'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
|
@ -413,8 +414,9 @@ export default function Header() {
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Mobile Drawer */}
|
{/* Mobile Drawer — portaled to <body> so the header's backdrop-filter doesn't trap position:fixed */}
|
||||||
<AnimatePresence>
|
{createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
{mobileOpen && (
|
{mobileOpen && (
|
||||||
<>
|
<>
|
||||||
<motion.div key="overlay"
|
<motion.div key="overlay"
|
||||||
|
|
@ -486,7 +488,9 @@ export default function Header() {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ 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'
|
import SearchResults from '@/pages/Search/SearchResults'
|
||||||
|
import Observe from '@/pages/Observe/Observe'
|
||||||
|
import Newstree from '@/pages/Newstree/Newstree'
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
|
|
@ -67,4 +69,7 @@ export const router = createBrowserRouter([
|
||||||
{ path: 'steeldashboard', element: <SteelDashboard /> },
|
{ 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 /> },
|
||||||
])
|
])
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -301,7 +301,8 @@ body {
|
||||||
font-size: var(--fs-body);
|
font-size: var(--fs-body);
|
||||||
line-height: var(--lh-body);
|
line-height: var(--lh-body);
|
||||||
letter-spacing: normal;
|
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;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -286,8 +286,8 @@ export default function About() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── POLICY COUNCIL ── */}
|
{/* ── 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 }}>
|
<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} />
|
<SectionLabel text={t.councilTitle} />
|
||||||
<div style={{ marginBottom: 36 }}><PillarRow items={COUNCIL_PILLARS} /></div>
|
<div style={{ marginBottom: 36 }}><PillarRow items={COUNCIL_PILLARS} /></div>
|
||||||
<div style={memberGrid} className="max-lg:!grid-cols-2 max-sm:!grid-cols-1">
|
<div style={memberGrid} className="max-lg:!grid-cols-2 max-sm:!grid-cols-1">
|
||||||
|
|
@ -295,8 +295,8 @@ export default function About() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── DIRECTOR ── */}
|
{/* ── DIRECTOR ── (hidden for now: whole section blurred + non-interactive) */}
|
||||||
<section style={{ background: '#efefef', padding: '56px 0' }}>
|
<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">
|
<div className="max-w-7xl mx-auto px-12 max-md:px-5">
|
||||||
<SectionLabel text={t.directorTitle} />
|
<SectionLabel text={t.directorTitle} />
|
||||||
<div style={{ marginBottom: 28 }}><PillarRow items={DIRECTOR_PILLARS} /></div>
|
<div style={{ marginBottom: 28 }}><PillarRow items={DIRECTOR_PILLARS} /></div>
|
||||||
|
|
@ -304,8 +304,8 @@ export default function About() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── ANALYST TEAM ── */}
|
{/* ── 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 }}>
|
<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' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 30, justifyContent: isFa ? 'flex-start' : 'flex-end' }}>
|
||||||
{DEPTS.map((d, i) => (
|
{DEPTS.map((d, i) => (
|
||||||
<button key={d} type="button" onClick={() => { setDeptSel(i); setAnalystSel(0) }}
|
<button key={d} type="button" onClick={() => { setDeptSel(i); setAnalystSel(0) }}
|
||||||
|
|
|
||||||
|
|
@ -301,7 +301,7 @@ export default function Experts() {
|
||||||
</div>
|
</div>
|
||||||
</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 ─── */}
|
{/* ─── Top Featured Carousel ─── */}
|
||||||
<section
|
<section
|
||||||
|
|
@ -343,7 +343,7 @@ export default function Experts() {
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.98 }}
|
exit={{ opacity: 0, scale: 0.98 }}
|
||||||
transition={{ duration: 0.3 }}
|
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>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { FileText } from 'lucide-react'
|
import { FileDown } from 'lucide-react'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
|
|
||||||
const NAVY = '#032340'
|
const NAVY = '#032340'
|
||||||
|
|
@ -8,7 +8,7 @@ const GOLD = '#CD9E53'
|
||||||
const EASE = [0.22, 1, 0.36, 1] as const
|
const EASE = [0.22, 1, 0.36, 1] as const
|
||||||
|
|
||||||
type ScrollDir = 'left' | 'right' | 'up'
|
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 */
|
/* static fallback — Figma covers; replaced at runtime by panel factory-reports */
|
||||||
const COVERS: Cover[] = [
|
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 */
|
/* magazine card — gray panel with floating cover on top, title + download button below */
|
||||||
function ReportCard({ img, num, fileUrl, lang }: { img: string; num: string; fileUrl?: string; lang: 'fa' | 'en' }) {
|
function ReportCard({ img, title, fileUrl, lang }: { img: string; title: string; fileUrl?: string; lang: 'fa' | 'en' }) {
|
||||||
const label = lang === 'fa' ? 'دانلود ماهنامه' : 'Download'
|
const label = lang === 'fa' ? 'دانلود ماهنامه' : 'Download'
|
||||||
const bar = (
|
const Tag = fileUrl ? 'a' : 'div'
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#fff', fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap' }}>
|
const linkProps = fileUrl ? { href: fileUrl, download: true, target: '_blank', rel: 'noopener noreferrer' } : {}
|
||||||
<FileText size={14} color={GOLD} />
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
return (
|
return (
|
||||||
<article
|
<Tag
|
||||||
|
{...linkProps}
|
||||||
style={{
|
style={{
|
||||||
background: '#e9e9e9', borderRadius: 12, overflow: 'hidden', cursor: fileUrl ? 'pointer' : 'default',
|
display: 'flex',
|
||||||
boxShadow: '0 8px 24px -16px rgba(7,29,73,0.35)', display: 'flex', flexDirection: 'column', height: '100%',
|
flexDirection: 'column',
|
||||||
transition: 'transform 180ms, box-shadow 180ms',
|
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)' }}
|
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 8px 24px -16px rgba(7,29,73,0.35)' }}
|
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>
|
</div>
|
||||||
{fileUrl ? (
|
|
||||||
<a
|
{/* محتوا */}
|
||||||
href={fileUrl} download target="_blank" rel="noopener noreferrer"
|
<div style={{
|
||||||
style={{ background: NAVY, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '9px 12px', textDecoration: 'none', gap: 8, direction: 'ltr' }}
|
padding: '16px 20px',
|
||||||
>
|
textAlign: 'right',
|
||||||
{bar}
|
display: 'flex',
|
||||||
{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>}
|
flexDirection: 'column',
|
||||||
</a>
|
flexGrow: 1
|
||||||
) : (
|
}}>
|
||||||
<div style={{ background: NAVY, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '9px 12px', gap: 8, direction: 'ltr', opacity: 0.6 }}>
|
<div style={{ fontSize: '18px', fontWeight: '800', color: '#000', marginBottom: 'auto' }}>
|
||||||
{bar}
|
{title}
|
||||||
{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>
|
||||||
</div>
|
|
||||||
)}
|
<div style={{
|
||||||
</article>
|
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: '',
|
num: '',
|
||||||
dir: FALLBACK_DIRS[i % FALLBACK_DIRS.length],
|
dir: FALLBACK_DIRS[i % FALLBACK_DIRS.length],
|
||||||
fileUrl: safeFile(r.fileUrl),
|
fileUrl: safeFile(r.fileUrl),
|
||||||
|
title: typeof r.titleFa === 'string' ? r.titleFa : undefined,
|
||||||
})))
|
})))
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.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)
|
// 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}>
|
<div key={c.img + i} className={i >= 4 ? 'max-sm:!hidden' : undefined}>
|
||||||
<ScrollAnimation direction={c.dir}>
|
<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>
|
</ScrollAnimation>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ const T = {
|
||||||
export default function VideocastPodcastSection({ only }: { only?: 'video' | 'pod' }) {
|
export default function VideocastPodcastSection({ only }: { only?: 'video' | 'pod' }) {
|
||||||
const { lang } = useLang()
|
const { lang } = useLang()
|
||||||
const t = T[lang]
|
const t = T[lang]
|
||||||
const [activeVideo] = useState(0)
|
const [activeVideo, setActiveVideo] = useState(0)
|
||||||
const [activePod, setActivePod] = useState(0)
|
const [activePod, setActivePod] = useState(0)
|
||||||
const pod = PODCASTS[activePod]
|
const pod = PODCASTS[activePod]
|
||||||
|
|
||||||
|
|
@ -113,7 +113,7 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
|
||||||
{VIDEOS.slice(0, 3).map((v, i) => (
|
{VIDEOS.slice(0, 3).map((v, i) => (
|
||||||
<button
|
<button
|
||||||
key={v.id}
|
key={v.id}
|
||||||
onClick={() => {/* setActiveVideo(i) — wired if needed */}}
|
onClick={() => setActiveVideo(i)}
|
||||||
style={{
|
style={{
|
||||||
textAlign: 'inherit', background: i === activeVideo ? '#efe9e1' : '#fff',
|
textAlign: 'inherit', background: i === activeVideo ? '#efe9e1' : '#fff',
|
||||||
border: `1px solid ${i === activeVideo ? GOLD : 'rgba(3,35,64,0.08)'}`,
|
border: `1px solid ${i === activeVideo ? GOLD : 'rgba(3,35,64,0.08)'}`,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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="© OpenStreetMap, © 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -247,6 +247,7 @@ await pool.query(`
|
||||||
excerpt_en TEXT,
|
excerpt_en TEXT,
|
||||||
body_en TEXT,
|
body_en TEXT,
|
||||||
analysis_en TEXT,
|
analysis_en TEXT,
|
||||||
|
status TEXT DEFAULT 'published',
|
||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_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 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 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 last_login TIMESTAMPTZ',
|
||||||
'ALTER TABLE members ADD COLUMN IF NOT EXISTS token_version INTEGER NOT NULL DEFAULT 0',
|
'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',
|
'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,
|
excerptEn: row.excerpt_en,
|
||||||
bodyEn: row.body_en || null,
|
bodyEn: row.body_en || null,
|
||||||
analysisEn: row.analysis_en || null,
|
analysisEn: row.analysis_en || null,
|
||||||
|
status: row.status || 'published',
|
||||||
sortOrder: row.sort_order,
|
sortOrder: row.sort_order,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
|
|
|
||||||
|
|
@ -1513,6 +1513,7 @@ async function renderRadarList() {
|
||||||
<div class="row-cover" style="background-image:url('${escapeAttr(safeImgUrl(r.img))}')"></div>
|
<div class="row-cover" style="background-image:url('${escapeAttr(safeImgUrl(r.img))}')"></div>
|
||||||
<div class="row-main">
|
<div class="row-main">
|
||||||
<div class="row-meta">
|
<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.category ? `<span class="pill">${escapeHtml(catLabel(r.category))}</span>` : ''}
|
||||||
${r.dateFa ? `<span class="muted">${escapeHtml(r.dateFa)}</span>` : ''}
|
${r.dateFa ? `<span class="muted">${escapeHtml(r.dateFa)}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1520,6 +1521,7 @@ async function renderRadarList() {
|
||||||
<p class="muted clamp-2">${escapeHtml(r.excerptFa||'')}</p>
|
<p class="muted clamp-2">${escapeHtml(r.excerptFa||'')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-actions">
|
<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="edit">ویرایش</button>
|
||||||
<button data-action="delete" class="danger">حذف</button>
|
<button data-action="delete" class="danger">حذف</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1538,6 +1540,10 @@ async function renderRadarList() {
|
||||||
const btn = ev.target.closest('button[data-action]'); if (!btn) return;
|
const btn = ev.target.closest('button[data-action]'); if (!btn) return;
|
||||||
const id = btn.closest('.row').dataset.id;
|
const id = btn.closest('.row').dataset.id;
|
||||||
if (btn.dataset.action === 'edit') renderRadarEditor(await api(`/api/radar/${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') {
|
else if (btn.dataset.action === 'delete') {
|
||||||
if (!confirm('این مطلب حذف شود؟')) return;
|
if (!confirm('این مطلب حذف شود؟')) return;
|
||||||
await api(`/api/radar/${id}`, { method: 'DELETE' });
|
await api(`/api/radar/${id}`, { method: 'DELETE' });
|
||||||
|
|
@ -1546,7 +1552,7 @@ async function renderRadarList() {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
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; }
|
if (!allItems.length) { $('#list').innerHTML = `<p class="muted">هنوز مطلبی ثبت نشده.</p>`; return; }
|
||||||
renderItems();
|
renderItems();
|
||||||
} catch (err) { $('#list').innerHTML = `<p class="error">خطا: ${escapeHtml(err.message)}</p>`; }
|
} catch (err) { $('#list').innerHTML = `<p class="error">خطا: ${escapeHtml(err.message)}</p>`; }
|
||||||
|
|
|
||||||
|
|
@ -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.');
|
console.error('FATAL: JWT_SECRET env var is missing or shorter than 32 chars. Set it before starting.');
|
||||||
process.exit(1);
|
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}`;
|
const PUBLIC_ORIGIN = process.env.PUBLIC_ORIGIN || `http://localhost:${PORT}`;
|
||||||
// Canonical base for media URLs. The object-storage host blocks browser User-Agents,
|
// 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.
|
// 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`);
|
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('/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({
|
const loginLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000,
|
windowMs: 15 * 60 * 1000,
|
||||||
|
|
@ -205,6 +226,22 @@ const contactLimiter = rateLimit({
|
||||||
legacyHeaders: false,
|
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) {
|
async function authRequired(req, res, next) {
|
||||||
let token = req.cookies?.session;
|
let token = req.cookies?.session;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
|
@ -274,12 +311,16 @@ function memberCookieOpts(req) {
|
||||||
app.post('/api/auth/login', loginLimiter, async (req, res) => {
|
app.post('/api/auth/login', loginLimiter, async (req, res) => {
|
||||||
const { username, password } = req.body || {};
|
const { username, password } = req.body || {};
|
||||||
if (!username || !password) return res.status(400).json({ error: 'username_password_required' });
|
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);
|
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
|
// 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');
|
const ok = bcrypt.compareSync(password, row?.password_hash || '$2a$10$0000000000000000000000000000000000000000000000000000');
|
||||||
if (!row || !ok) {
|
if (!row || !ok) {
|
||||||
|
bumpLoginFail(lockKey);
|
||||||
return res.status(401).json({ error: 'bad_credentials' });
|
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' });
|
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, {
|
res.cookie('session', token, {
|
||||||
httpOnly: true,
|
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,
|
const RADAR_FIELDS = `id, category, img, author_name, author_avatar,
|
||||||
title_fa, date_fa, excerpt_fa, body_fa, analysis_fa,
|
title_fa, date_fa, excerpt_fa, body_fa, analysis_fa,
|
||||||
title_en, date_en, excerpt_en, body_en, analysis_en,
|
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) => {
|
app.get('/api/radar', async (req, res) => {
|
||||||
const limit = Math.min(Number(req.query.limit) || 50, 500);
|
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);
|
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));
|
res.json(rows.map(rowToRadarItem));
|
||||||
});
|
});
|
||||||
|
|
@ -670,13 +719,14 @@ app.post('/api/radar', authRequired, async (req, res) => {
|
||||||
const r = normalizeRadarBody(req.body || {});
|
const r = normalizeRadarBody(req.body || {});
|
||||||
if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' });
|
if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' });
|
||||||
const id = nanoid(14);
|
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,
|
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_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,
|
VALUES (@id,@category,@img,@author_name,@author_avatar,
|
||||||
@title_fa,@date_fa,@excerpt_fa,@body_fa,@analysis_fa,
|
@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)
|
||||||
`).run({ id, ...r });
|
`).run({ id, status, ...r });
|
||||||
res.status(201).json(rowToRadarItem(await db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(id)));
|
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();
|
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) ----------
|
// ---------- radar pages (per-category hero + banner) ----------
|
||||||
const RADAR_PAGE_FIELDS = `slug, source_categories, label_fa, label_en,
|
const RADAR_PAGE_FIELDS = `slug, source_categories, label_fa, label_en,
|
||||||
latest_heading_fa, latest_heading_en, featured_title_fa, featured_title_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 { email, phone, password } = req.body || {};
|
||||||
const identifier = String(email || phone || '').trim();
|
const identifier = String(email || phone || '').trim();
|
||||||
if (!identifier || !password) return res.status(400).json({ error: 'شماره موبایل یا ایمیل و رمز عبور الزامی است' });
|
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
|
// 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);
|
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
|
// 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');
|
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: 'حساب کاربری غیرفعال است' });
|
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' });
|
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));
|
res.cookie('member_session', token, memberCookieOpts(req));
|
||||||
await db.prepare('UPDATE members SET last_login = now() WHERE id = ?').run(row.id);
|
await db.prepare('UPDATE members SET last_login = now() WHERE id = ?').run(row.id);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue