diff --git a/.dockerignore b/.dockerignore index 6943949..9b2ab89 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,6 +9,9 @@ dist-ssr build .cache +# The admin panel is a separate app — not part of the frontend build +panel + # Dependencies (reinstalled in the image) node_modules npm-debug.log* @@ -29,8 +32,7 @@ Thumbs.db .env.* !.env.example -# Docker -Dockerfile +# Docker — keep Dockerfile in the context (Liara builds from it) docker-compose.yml .dockerignore diff --git a/.gitignore b/.gitignore index 88963d2..fa974b1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -<<<<<<< HEAD # Logs logs *.log @@ -13,6 +12,14 @@ dist dist-ssr *.local +# Secrets / env — never commit (keep the example template) +.env +.env.* +!.env.example +*.pem +*.key +secrets/ + # Editor directories and files .vscode/* !.vscode/extensions.json @@ -28,34 +35,3 @@ dist-ssr /[0-9]*.png brand_pages/ /موکاپ*.jpg -======= -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? -# Root-level temp images (not website assets) -/[0-9]*.jpg -/[0-9]*.png -brand_pages/ -/موکاپ*.jpg ->>>>>>> space/main diff --git a/.playwright-mcp/page-2026-06-17T09-01-35-435Z.yml b/.playwright-mcp/page-2026-06-17T09-01-35-435Z.yml new file mode 100644 index 0000000..70a42a4 --- /dev/null +++ b/.playwright-mcp/page-2026-06-17T09-01-35-435Z.yml @@ -0,0 +1,14 @@ +- iframe [ref=e2]: + - generic [active] [ref=f3e1]: + - generic [ref=f3e5]: "502" + - generic [ref=f3e6]: + - list [ref=f3e7]: + - listitem [ref=f3e8]: شاید پورتی که برنامه‌ی شما به آن listen می‌کند را اشتباه وارد کرده‌اید. + - listitem [ref=f3e9]: لازم است که برنامه‌ی شما حتما روی آدرس 0.0.0.0 اجرا شود. + - listitem [ref=f3e10]: از صفحه‌ی برنامک‌ها، وضعیت آخرین استقرار خود را بررسی کنید. + - listitem [ref=f3e11]: و یا ممکن است هیچ مشکلی وجود نداشته باشد و صرفا باید کمی صبر کنید تا سرویس‌تان آماده شود. + - link "مشاهده‌ی لاگ‌ها" [ref=f3e13] [cursor=pointer]: + - /url: https://console.liara.ir/apps + - contentinfo [ref=f3e14]: + - link [ref=f3e15] [cursor=pointer]: + - /url: https://liara.ir \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 471360c..035ac0f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ # ===================================================================== # Stage 1 — Build the Vite/React app # ===================================================================== -FROM node:22-alpine AS builder +FROM docker-mirror.liara.ir/library/node:22-alpine AS builder WORKDIR /app @@ -13,7 +13,11 @@ WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --no-audit --no-fund --legacy-peer-deps -# Build the static bundle +# Build the static bundle. +# VITE_PANEL_API is inlined into the bundle at build time (Vite reads it during +# `npm run build`), so it must be present as an env var here — not at runtime. +ARG VITE_PANEL_API=https://cms-steelforesight.liara.run +ENV VITE_PANEL_API=$VITE_PANEL_API COPY . . RUN npm run build @@ -23,7 +27,7 @@ RUN npm run build # ===================================================================== # nginx-unprivileged runs as uid 101 by default — required by HF Spaces, # which forbids running as root. -FROM nginxinc/nginx-unprivileged:alpine AS runner +FROM docker-mirror.liara.ir/nginxinc/nginx-unprivileged:alpine AS runner # SPA-aware server config that listens on 7860 (HF Spaces default port) COPY --chown=nginx:nginx nginx.conf /etc/nginx/conf.d/default.conf diff --git a/liara-deploy.md b/liara-deploy.md new file mode 100644 index 0000000..5ada0d1 --- /dev/null +++ b/liara-deploy.md @@ -0,0 +1,229 @@ +# Liara Deployment Playbook (عمومی) + +راهنمای عمومی دیپلوی روی Liara — جمع‌بندی مشکلات رایج و راه‌حل‌هاشون. +برای **هر پروژه‌ای** قابل استفاده‌ست. جای `<...>`ها مقدار پروژه‌ی خودت رو بذار. + +> قرارداد نام‌گذاری در این سند: +> `` = اسم اپ، `` = اپ فرانت، `` = اپ بک‌اند/پنل، +> `` = هاست داخلی دیتابیس، `` = API token لیارا، +> `` = پورتی که اپ روش گوش می‌ده (Docker static: معمولاً 7860، Node: 3000). + +--- + +## ۱) CLI — احراز هویت + +**مشکل:** دستورها با `Authentication failed` رد می‌شن، حتی بعد از `liara login` (توکن بین شل‌ها/محیط‌ها share نمی‌شه). + +**راه‌حل:** روی **هر** دستور `--api-token` بده: +```bash +liara deploy --app --api-token "" +``` +توکن از داشبورد Liara → API. + +--- + +## ۲) `liara deploy` روی prompt پورت هنگ می‌کنه + +**مشکل:** دیپلوی روی `Enter the port your app listens to:` گیر می‌کنه و چیزی به Liara نمی‌رسه (مخصوصاً در حالت غیرتعاملی). + +**راه‌حل:** پورت رو صریح بده — هم فلگ، هم `liara.json`: +```bash +liara deploy --app --port ... +``` +```json +{ "platform": "docker", "port": } +``` +اپ هم باید روی `process.env.PORT` گوش بده (Liara اون رو inject می‌کنه). + +--- + +## ۳) `Dockerfile cannot be empty` + +**علت:** `.dockerignore` خود `Dockerfile` رو exclude کرده. + +**راه‌حل:** `Dockerfile` رو از `.dockerignore` **بردار** — Liara باید Dockerfile رو توی آرشیو ببینه. + +--- + +## ۴) Docker Hub از ایران بلاکه (مهم‌ترین برای Docker) + +**مشکل:** `FROM ` با `Get "https://registry-1.docker.io/...": Client.Timeout exceeded` تایم‌اوت می‌خوره. + +**راه‌حل:** image پایه رو از **میرور Liara** بکش: +```dockerfile +# ایمیج رسمی (node, nginx, postgres, python, ...): +FROM docker-mirror.liara.ir/library/: +# ایمیج یوزر/سازمان: +FROM docker-mirror.liara.ir//: +``` + +میرورهای دیگر Liara (در صورت نیاز داخل build): +| نوع | میرور | +|---|---| +| Docker Hub | `docker-mirror.liara.ir` | +| Alpine (apk) | `https://linux-mirror.liara.ir/repository/alpine/` | +| npm | `https://package-mirror.liara.ir/repository/npm/` | + +> `npm ci`/`pip install` معمولاً از ایران بدون میرور کار می‌کنن؛ فقط Docker Hub همیشه مشکل‌سازه. +> اگه داخل build `apk add` داری، میرور apk رو هم ست کن. + +--- + +## ۵) لوکیشن build + +**نکته‌ی کلیدی:** **لوکیشن build ≠ لوکیشن میزبانی.** اپ همیشه روی منطقه‌ی خودش سرو می‌شه؛ `--build-location` فقط می‌گه image کجا کامپایل شه. + +- build ایران + میرورهای بالا → سریع و بدون تحریم: + ```bash + liara deploy --app --build-location iran ... + ``` +- اگه از میرور استفاده نمی‌کنی، build ایران به Docker Hub نمی‌رسه (از مورد ۴ استفاده کن). + +--- + +## ۶) متغیرهای build-time در فرانت (Vite / CRA / Next static) + +**مشکل:** متغیر API URL رو به‌عنوان env روی اپ ست کردی ولی فرانت بهش وصل نشد. + +**علت:** باندلرها متغیرهای `VITE_*` / `REACT_APP_*` / `NEXT_PUBLIC_*` رو **موقع build داخل باندل می‌نویسن** — env runtime روی کانتینر استاتیک بی‌اثره. + +**راه‌حل:** توی Dockerfile قبل از build: +```dockerfile +ARG VITE_API_URL=https://.liara.run +ENV VITE_API_URL=$VITE_API_URL +RUN npm run build +``` + +--- + +## ۷) اتصال اپ به دیتابیس (Private Network) + +**مشکل‌ها:** +- آدرس داخلی دیتابیس → `getaddrinfo ENOTFOUND `. +- آدرس عمومی دیتابیس از داخل اپ Liara → `ECONNREFUSED`. + +**علت:** اپ‌های Liara به endpoint عمومی DB وصل نمی‌شن؛ و هاست داخلی فقط وقتی resolve می‌شه که اپ و DB **روی یک Private Network مشترک** باشن. + +**راه‌حل:** +1. هم دیتابیس هم اپ رو به **یک Private Network** وصل کن. +2. **connection string کاملِ** داخلی (نه فقط host:port): + ``` + DATABASE_URL=postgresql://:@:5432/ + ``` +3. روی شبکه‌ی داخلی TLS لازم نیست (برعکس endpoint عمومی که TLS می‌خواد). + +--- + +## ۸) اپ موقع بوت کرش می‌کنه چون env ست نیست + +**نشانه:** کانتینر `unhealthy` / `exit 255` و لاگ مثل `FATAL: is not set`. + +**راه‌حل:** env های حیاتی رو **قبل** از انتظارِ سالم‌بودن ست کن. تغییر env خودش ری‌استارت می‌کنه. +ست با CLI (به‌جای داشبورد): +```bash +liara env:set "KEY=VALUE" "KEY2=VALUE2" --app --force --api-token "" +``` +> اگه کد بدون یه env حیاتی `process.exit` می‌کنه، تا ست نشه اصلاً بالا نمیاد. + +--- + +## ۹) فایل‌سیستم Ephemeral + +**مشکل‌ها:** دیتابیس فایلی (SQLite) و پوشه‌ی آپلود با هر ری‌دیپلوی پاک می‌شن؛ نوشتن/`mkdir` روی مسیرهای read-only کرش می‌کنه. + +**راه‌حل:** +- دیتای دائمی → **دیتابیس مدیریت‌شده**. +- فایل/مدیا → **Object Storage** (مورد ۱۰). +- اگه واقعاً دیسک لازمه → یه **Disk** بساز و mount کن؛ مسیر رو **env-based** کن. +- هر نوشتن روی دیسک رو دفاعی بنویس که موقع بوت کرش نکنه: + ```js + const dir = process.env.UPLOADS_DIR || path.join(__dirname, 'uploads'); + try { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } catch {} + ``` + +--- + +## ۱۰) Object Storage (S3) برای مدیا + +- S3-compatible؛ endpoint مثل `storage.iran.liara.site` + bucket + access key + secret key. +- در کد: SDK اس‌تری (`@aws-sdk/client-s3`)، `forcePathStyle: true`، endpoint = `https://`. +- URL عمومی فایل: `https://./`. +- **باکت رو public کن** وگرنه فایل‌ها 403 می‌شن. + +--- + +## ۱۱) پلتفرم اپ ثابته + +نمی‌شه پلتفرم یه اپ رو عوض کرد (Node↔Docker↔Static). اگه اشتباه ساختی، **پاک و با پلتفرم درست دوباره بساز** (env و اتصال شبکه‌ی خصوصی رو هم از نو ست کن). + +--- + +## ۱۲) CORS + +**مشکل:** بک‌اند درخواست‌ها رو با `Not allowed by CORS` رد می‌کنه (حتی origin خودش). + +**راه‌حل:** allow-list رو شامل **دامنه‌ی خود اپ + دامنه‌ی فرانت** کن: +``` +ALLOWED_ORIGINS=https://.liara.run,https://.liara.run +``` + +--- + +## ۱۳) Seed / یوزر ادمین + +دیتابیس تازه خالیه → لاگین «رمز اشتباه». seed رو از Console اپ بزن. اگه اسکریپت seed یوزر موجود رو رد می‌کنه و رمز عوض نمی‌شه، از **upsert** استفاده کن. +نکته‌ی مهم برای اسکریپت‌های seed: آدرس و رمز رو **env-based** کن تا هم لوکال هم روی Liara کار کنه: +```js +const BASE = process.env.SEED_BASE || `http://localhost:${process.env.PORT || 3000}`; +``` +> روش بدون TLS-dependency: اسکریپت seed که فقط `fetch` می‌زنه رو می‌تونی از ماشین خودت با `SEED_BASE=https://.liara.run` اجرا کنی. + +--- + +## ۱۴) همگام‌سازی lockfile + +بعد از هر تغییر وابستگی‌ها، **لوکال `npm install`** بزن تا `package-lock.json` به‌روز شه، بعد دیپلوی — وگرنه `npm ci` روی Liara به‌خاطر mismatch خطا می‌ده. `node_modules` رو پوش نکن (`.liaraignore` / `.dockerignore`). + +--- + +## ۱۵) لاگ و exit code گمراه‌کننده + +- `liara deploy` گاهی با `exit code 2` تموم می‌شه ولی واقعاً **موفق** بوده (خطا فقط مرحله‌ی خوندن لاگ بعد از دیپلوی). +- `liara logs` گاهی لاگ **کش‌شده** می‌ده. + +**راه‌حل:** وضعیت واقعی رو از خروجی کامل دیپلوی (`√ Release created` / `Deployment finished successfully`) و یه **درخواست HTTP واقعی به دامنه** بگیر؛ به exit code تنها اعتماد نکن. + +--- + +## فایل‌های پایه‌ی هر اپ + +`liara.json` (Docker): +```json +{ "platform": "docker", "port": } +``` +`liara.json` (Node): +```json +{ "platform": "node" } +``` +`.liaraignore`: +``` +node_modules +.env +*.db +uploads +``` + +--- + +## چک‌لیست دیپلوی (ترتیب درست) + +1. **دیتابیس** بساز → Private Network روشن. +2. **اپ بک‌اند** بساز → به همون Private Network وصلش کن. +3. env های بک‌اند رو ست کن (`DATABASE_URL` داخلیِ کامل، سکرت‌ها، `ALLOWED_ORIGINS`، کلیدهای Object Storage، `NODE_ENV=production`). +4. `package-lock.json` رو لوکال sync کن. +5. (Docker) Dockerfile: میرور `docker-mirror.liara.ir` + `ARG` برای متغیرهای build-time. `.dockerignore` نباید `Dockerfile` رو حذف کنه. +6. `liara deploy --app --port --build-location iran --api-token ""`. +7. سلامت رو با درخواست HTTP به دامنه چک کن (نه فقط exit code). +8. **seed محتوا/ادمین** (قبل از دیپلوی فرانت، تا سایت خالی نباشه). +9. باکت Object Storage رو public کن. +10. **فرانت** رو با متغیر build-time = دامنه‌ی بک‌اند دیپلوی کن؛ `ALLOWED_ORIGINS` بک‌اند رو شامل دامنه‌ی فرانت کن. diff --git a/liara.json b/liara.json new file mode 100644 index 0000000..a6b6e7a --- /dev/null +++ b/liara.json @@ -0,0 +1,4 @@ +{ + "platform": "docker", + "port": 7860 +} diff --git a/panel/.env.example b/panel/.env.example index fd1eca5..59819e0 100644 --- a/panel/.env.example +++ b/panel/.env.example @@ -1,5 +1,12 @@ # Copy this file to ".env" and fill in real values. +# PostgreSQL connection — REQUIRED. Copy from the Liara database dashboard. +# Format: postgresql://root:@.liara.cloud:/ +DATABASE_URL=postgresql://root:password@localhost:5432/andishkade +# Set to "disable" only when connecting over Liara's internal private network (no TLS). +# Leave unset (TLS on) for the public connection string. +DATABASE_SSL= + # JWT signing secret — REQUIRED. Must be 32+ random chars. Never commit the real value. # Generate: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" JWT_SECRET=replace-with-64-random-hex-chars @@ -17,9 +24,20 @@ PUBLIC_ORIGIN=http://localhost:3001 # Comma-separated origins allowed to call the API (your frontend URL) ALLOWED_ORIGINS=http://localhost:5173 -# Contact form recipients (comma-separated) -MAIL_TO=alirezaameria2@gmail.com, mtdemne@gmail.com +# Contact form recipients (comma-separated) — REQUIRED for the contact form to send +MAIL_TO=recipient@example.com, second@example.com # Gmail credentials for contact form emails MAIL_USER=your-gmail@gmail.com MAIL_PASS=your-gmail-app-password + +# Local uploads dir — on Liara point this at a mounted Disk (e.g. /var/lib/uploads). +# Ignored when object storage (below) is configured. +UPLOADS_DIR= + +# Object storage (Liara / S3-compatible). When all four are set, image + video +# uploads go to the bucket instead of local disk. Bucket must allow public reads. +LIARA_ENDPOINT=storage.iran.liara.site +LIARA_BUCKET=your-bucket-name +LIARA_ACCESS_KEY=your-access-key +LIARA_SECRET_KEY=your-secret-key diff --git a/panel/.liaraignore b/panel/.liaraignore new file mode 100644 index 0000000..ca4002e --- /dev/null +++ b/panel/.liaraignore @@ -0,0 +1,9 @@ +node_modules +.env +*.db +*.db-journal +*.db-wal +*.db-shm +uploads +.DS_Store +npm-debug.log* diff --git a/panel/db.js b/panel/db.js index 57d3bdf..f8abfc7 100644 --- a/panel/db.js +++ b/panel/db.js @@ -1,26 +1,103 @@ -import Database from 'better-sqlite3'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import pg from 'pg'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const dbPath = path.join(__dirname, 'data.db'); +const { Pool } = pg; -export const db = new Database(dbPath); -db.pragma('journal_mode = WAL'); -db.pragma('foreign_keys = ON'); +if (!process.env.DATABASE_URL) { + console.error('FATAL: DATABASE_URL is not set. Point it at your Liara PostgreSQL connection string.'); + process.exit(1); +} -// safe migrations — ignore if column already exists -for (const sql of [ - "ALTER TABLE articles ADD COLUMN status TEXT DEFAULT 'published'", - "ALTER TABLE articles ADD COLUMN key_data TEXT", -]) { try { db.exec(sql) } catch {} } +// Liara managed Postgres requires TLS when reached over the public endpoint. +// Set DATABASE_SSL=disable only when connecting over the internal private network. +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + ssl: process.env.DATABASE_SSL === 'disable' ? false : { rejectUnauthorized: false }, +}); +export { pool }; -db.exec(` +// pg dates come back as JS Date objects; the API historically returned ISO-ish +// strings from SQLite. Coerce timestamp/date OIDs back to the raw string pg sends. +pg.types.setTypeParser(1114, (v) => v); // timestamp without time zone +pg.types.setTypeParser(1184, (v) => v); // timestamptz + +/** + * Translate a better-sqlite3-style statement into a pg query. + * - named params (`@name`) when a single plain-object arg is passed + * - positional params (`?`) otherwise + * Returns { text, values } for pool.query. + */ +function buildQuery(sql, args) { + const isNamed = + args.length === 1 && + args[0] != null && + typeof args[0] === 'object' && + !Array.isArray(args[0]) && + /@\w+/.test(sql); + + if (isNamed) { + const obj = args[0]; + const values = []; + const text = sql.replace(/@(\w+)/g, (_, name) => { + values.push(name in obj ? obj[name] : null); + return '$' + values.length; + }); + return { text, values }; + } + + let i = 0; + const text = sql.replace(/\?/g, () => '$' + ++i); + return { text, values: args.flat() }; +} + +/** + * better-sqlite3-shaped async facade over pg. + * db.prepare(sql).get(params) -> first row | undefined + * db.prepare(sql).all(params) -> rows[] + * db.prepare(sql).run(params) -> { changes, lastInsertRowid, rows } + * NOTE: every method returns a Promise — callers MUST await. + * For INSERTs that need the new id, append `RETURNING id` to the SQL; + * run() exposes it as `lastInsertRowid`. + */ +export const db = { + prepare(sql) { + return { + async get(...args) { + const { text, values } = buildQuery(sql, args); + const res = await pool.query(text, values); + return res.rows[0]; + }, + async all(...args) { + const { text, values } = buildQuery(sql, args); + const res = await pool.query(text, values); + return res.rows; + }, + async run(...args) { + const { text, values } = buildQuery(sql, args); + const res = await pool.query(text, values); + return { + changes: res.rowCount, + lastInsertRowid: res.rows[0]?.id, + rows: res.rows, + }; + }, + }; + }, + async exec(sql) { + await pool.query(sql); + }, + async query(text, params) { + return pool.query(text, params); + }, +}; + +// ── schema (idempotent) — runs once at import via top-level await ── +await pool.query(` CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, + id SERIAL PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + role TEXT NOT NULL DEFAULT 'admin', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS articles ( @@ -40,10 +117,11 @@ db.exec(` cover_image TEXT, tags TEXT, featured INTEGER DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + status TEXT DEFAULT 'published', + key_data TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); - CREATE INDEX IF NOT EXISTS idx_articles_created ON articles(created_at DESC); CREATE INDEX IF NOT EXISTS idx_articles_featured ON articles(featured); @@ -54,10 +132,9 @@ db.exec(` date TEXT, level TEXT NOT NULL CHECK (level IN ('critical','high','medium','low','opportunity')), sort_order INTEGER DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); - CREATE INDEX IF NOT EXISTS idx_risks_sort ON risk_signals(sort_order, created_at); CREATE TABLE IF NOT EXISTS events ( @@ -78,8 +155,8 @@ db.exec(` description_en TEXT, registration_open INTEGER DEFAULT 1, sort_order INTEGER DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_events_sort ON events(sort_order, created_at DESC); @@ -91,8 +168,8 @@ db.exec(` desc_fa TEXT, desc_en TEXT, sort_order INTEGER DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_vision_sort ON vision_items(sort_order, created_at DESC); @@ -103,8 +180,8 @@ db.exec(` role_fa TEXT, role_en TEXT, sort_order INTEGER DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_advisory_sort ON advisory_board(sort_order, created_at DESC); @@ -129,8 +206,8 @@ db.exec(` twitter TEXT, website TEXT, sort_order INTEGER DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_team_sort ON team_members(sort_order, created_at DESC); @@ -148,8 +225,8 @@ db.exec(` cta_fa TEXT, cta_en TEXT, sort_order INTEGER DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_plans_sort ON plans(sort_order); @@ -162,12 +239,16 @@ db.exec(` title_fa TEXT, date_fa TEXT, excerpt_fa TEXT, + body_fa TEXT, + analysis_fa TEXT, title_en TEXT, date_en TEXT, excerpt_en TEXT, + body_en TEXT, + analysis_en TEXT, sort_order INTEGER DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_radar_sort ON radar_items(sort_order, created_at DESC); @@ -191,8 +272,8 @@ db.exec(` banner_desc_en TEXT, banner_book_img TEXT, sort_order INTEGER DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_radar_pages_sort ON radar_pages(sort_order); @@ -211,8 +292,8 @@ db.exec(` position TEXT DEFAULT 'hero', active INTEGER DEFAULT 1, sort_order INTEGER DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_banners_active ON banners(active, sort_order); @@ -229,8 +310,8 @@ db.exec(` excerpt_en TEXT, scroll_dir TEXT CHECK(scroll_dir IN ('left','right','up')) DEFAULT 'up', sort_order INTEGER DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_factory_reports_sort ON factory_reports(sort_order, created_at DESC); @@ -243,8 +324,8 @@ db.exec(` grid_col INTEGER DEFAULT 1, grid_row INTEGER DEFAULT 1, sort_order INTEGER DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_integrations_sort ON integrations(sort_order, created_at DESC); @@ -255,8 +336,8 @@ db.exec(` value INTEGER DEFAULT 0, suffix_fa TEXT, sort_order INTEGER DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_institute_stats_sort ON institute_stats(sort_order); @@ -269,8 +350,8 @@ db.exec(` change_percent REAL DEFAULT 0, trend TEXT CHECK(trend IN ('up','down','flat')) DEFAULT 'flat', sort_order INTEGER DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_market_prices_sort ON market_prices(sort_order); @@ -281,8 +362,8 @@ db.exec(` value REAL DEFAULT 0, meta TEXT, sort_order INTEGER DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_chart_points_series ON market_chart_points(series, sort_order); @@ -294,42 +375,53 @@ db.exec(` change_value REAL, change_pct REAL, source_url TEXT, - fetched_at TEXT NOT NULL DEFAULT (datetime('now')) + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS members ( - id INTEGER PRIMARY KEY AUTOINCREMENT, + id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, phone TEXT, avatar TEXT, is_active INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_members_email ON members(email); CREATE TABLE IF NOT EXISTS purchases ( - id INTEGER PRIMARY KEY AUTOINCREMENT, + id SERIAL PRIMARY KEY, member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE, article_id TEXT NOT NULL REFERENCES articles(id) ON DELETE CASCADE, amount INTEGER NOT NULL DEFAULT 0, - purchased_at TEXT NOT NULL DEFAULT (datetime('now')), + purchased_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE(member_id, article_id) ); CREATE INDEX IF NOT EXISTS idx_purchases_member ON purchases(member_id, purchased_at DESC); `); -/* idempotent column migrations for already-created tables */ -function ensureColumn(table, col, def) { - const cols = db.prepare(`PRAGMA table_info(${table})`).all(); - if (!cols.some((c) => c.name === col)) { - db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`); - } +// idempotent column adds (Postgres supports IF NOT EXISTS on ADD COLUMN) +for (const sql of [ + "ALTER TABLE articles ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'published'", + 'ALTER TABLE articles ADD COLUMN IF NOT EXISTS key_data TEXT', + 'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS body_fa TEXT', + 'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS body_en TEXT', + 'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS analysis_fa TEXT', + 'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS analysis_en TEXT', + 'ALTER TABLE banners ADD COLUMN IF NOT EXISTS overline_fa TEXT', + 'ALTER TABLE banners ADD COLUMN IF NOT EXISTS overline_en TEXT', + "ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'admin'", +]) { + await pool.query(sql); +} + +// promote the founding admin (lowest id) to owner if none exists yet +const { rows: ownerRows } = await pool.query("SELECT 1 FROM users WHERE role = 'owner' LIMIT 1"); +if (ownerRows.length === 0) { + await pool.query("UPDATE users SET role = 'owner' WHERE id = (SELECT id FROM users ORDER BY id LIMIT 1)"); } -ensureColumn('banners', 'overline_fa', 'TEXT'); -ensureColumn('banners', 'overline_en', 'TEXT'); export function rowToPrice(row) { if (!row) return null; @@ -345,27 +437,28 @@ export function rowToPrice(row) { }; } -export function upsertPrice(p) { - db.prepare(` - INSERT INTO prices (symbol, name, value, unit, change_value, change_pct, source_url, fetched_at) - VALUES (@symbol, @name, @value, @unit, @change_value, @change_pct, @source_url, datetime('now')) - ON CONFLICT(symbol) DO UPDATE SET - name = excluded.name, - value = excluded.value, - unit = excluded.unit, - change_value = excluded.change_value, - change_pct = excluded.change_pct, - source_url = excluded.source_url, - fetched_at = excluded.fetched_at - `).run({ - symbol: p.symbol, - name: p.name, - value: p.value ?? null, - unit: p.unit ?? null, - change_value: p.changeValue ?? null, - change_pct: p.changePct ?? null, - source_url: p.sourceUrl ?? null, - }); +export async function upsertPrice(p) { + await pool.query( + `INSERT INTO prices (symbol, name, value, unit, change_value, change_pct, source_url, fetched_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, now()) + ON CONFLICT(symbol) DO UPDATE SET + name = EXCLUDED.name, + value = EXCLUDED.value, + unit = EXCLUDED.unit, + change_value = EXCLUDED.change_value, + change_pct = EXCLUDED.change_pct, + source_url = EXCLUDED.source_url, + fetched_at = EXCLUDED.fetched_at`, + [ + p.symbol, + p.name, + p.value ?? null, + p.unit ?? null, + p.changeValue ?? null, + p.changePct ?? null, + p.sourceUrl ?? null, + ], + ); } export function rowToEvent(row) { @@ -576,9 +669,13 @@ export function rowToRadarItem(row) { titleFa: row.title_fa, dateFa: row.date_fa, excerptFa: row.excerpt_fa, + bodyFa: row.body_fa || null, + analysisFa: row.analysis_fa || null, titleEn: row.title_en, dateEn: row.date_en, excerptEn: row.excerpt_en, + bodyEn: row.body_en || null, + analysisEn: row.analysis_en || null, sortOrder: row.sort_order, createdAt: row.created_at, updatedAt: row.updated_at, diff --git a/panel/insert-events.mjs b/panel/insert-events.mjs index ec13092..4db071d 100644 --- a/panel/insert-events.mjs +++ b/panel/insert-events.mjs @@ -1,9 +1,4 @@ -import Database from 'better-sqlite3'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const db = new Database(path.join(__dirname, 'data.db')); +import { db, pool } from './db.js'; // Gregorian ISO → Jalali parts using Intl (same engine the site's calendar uses) function jalali(iso) { @@ -75,7 +70,7 @@ const events = [ ]; // replace the old mock events with the real future ones -db.prepare('DELETE FROM events').run(); +await db.prepare('DELETE FROM events').run(); const stmt = db.prepare(` INSERT INTO events @@ -88,9 +83,10 @@ const stmt = db.prepare(` @registration_open, @sort_order) `); -events.forEach((e, i) => { +for (let i = 0; i < events.length; i++) { + const e = events[i]; const j = jalali(e.iso); - stmt.run({ + await stmt.run({ id: e.id, title_fa: e.titleFa, title_en: e.titleEn, date_fa: j.day, date_en: j.day, @@ -103,6 +99,8 @@ events.forEach((e, i) => { registration_open: 1, sort_order: i, }); console.log(`✓ ${e.id} → ${j.day} ${j.monthFa} ${j.year}`); -}); +} console.log(`\nDone: ${events.length} events inserted`); + +await pool.end(); diff --git a/panel/insert-factory.mjs b/panel/insert-factory.mjs index 7d4d6c4..f22c2c5 100644 --- a/panel/insert-factory.mjs +++ b/panel/insert-factory.mjs @@ -1,9 +1,4 @@ -import Database from 'better-sqlite3'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const db = new Database(path.join(__dirname, 'data.db')); +import { db, pool } from './db.js'; // Real issues of Mobarakeh Steel's "ماهنامه تحلیلی کارخانه" (titles/descriptions/images // taken from msc.ir). The listing page itself is WAF-gated, so only the latest 3 issues @@ -36,7 +31,7 @@ const reports = [ }, ]; -db.prepare('DELETE FROM factory_reports').run(); +await db.prepare('DELETE FROM factory_reports').run(); const stmt = db.prepare(` INSERT INTO factory_reports @@ -45,8 +40,9 @@ const stmt = db.prepare(` (@id, @img, @tag_fa, @tag_en, @title_fa, @title_en, @date_fa, @date_en, @excerpt_fa, @excerpt_en, @scroll_dir, @sort_order) `); -reports.forEach((r, i) => { - stmt.run({ +for (let i = 0; i < reports.length; i++) { + const r = reports[i]; + await stmt.run({ id: r.id, img: r.img, tag_fa: 'ماهنامه تحلیلی کارخانه', tag_en: 'Factory Analysis', title_fa: r.titleFa, title_en: r.titleEn, @@ -55,6 +51,8 @@ reports.forEach((r, i) => { scroll_dir: r.dir, sort_order: i, }); console.log(`✓ ${r.id} → ${r.titleFa}`); -}); +} console.log(`\nDone: ${reports.length} factory reports inserted`); + +await pool.end(); diff --git a/panel/liara.json b/panel/liara.json new file mode 100644 index 0000000..a5a6dc1 --- /dev/null +++ b/panel/liara.json @@ -0,0 +1,4 @@ +{ + "platform": "node", + "port": 3000 +} diff --git a/panel/package-lock.json b/panel/package-lock.json index 572d41b..9d67090 100644 --- a/panel/package-lock.json +++ b/panel/package-lock.json @@ -9,13 +9,14 @@ "version": "1.0.0", "dependencies": { "@anthropic-ai/sdk": "^0.104.1", + "@aws-sdk/client-s3": "^3.700.0", "bcryptjs": "^2.4.3", - "better-sqlite3": "^11.3.0", "cheerio": "^1.2.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.0", + "express-async-errors": "^3.1.1", "express-rate-limit": "^7.5.0", "helmet": "^8.0.0", "jsonwebtoken": "^9.0.2", @@ -24,6 +25,7 @@ "nodemailer": "^8.0.10", "openai": "^6.42.0", "pdf-parse": "^2.4.5", + "pg": "^8.13.1", "sharp": "^0.35.1" }, "engines": { @@ -51,6 +53,436 @@ } } }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.6.tgz", + "integrity": "sha512-RMCrCteiUwYTEv2G9zfP/BEuKHv57665vVieJyp9cf8VgilWxP/KrWVtMdfdDlIH8nFhvu3rIMc29z3ebGEZ1w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.974.21", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1070.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1070.0.tgz", + "integrity": "sha512-B/OUiCqGQ4Zr7v9gFFyiuitKN2c0PIgvOlQb5bYg1SM2y0F8a5JQ7FNsjRcl+d2PqYWLHwHx12CvZDyLn4KxIw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.21", + "@aws-sdk/credential-provider-node": "^3.972.56", + "@aws-sdk/middleware-flexible-checksums": "^3.974.31", + "@aws-sdk/middleware-sdk-s3": "^3.972.52", + "@aws-sdk/signature-v4-multi-region": "^3.996.35", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.974.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.21.tgz", + "integrity": "sha512-P5JAHvn4dTi96UsAGS67LVOqqpUNNRhnfFXqzCYtdBIGZtqBue4CXvRr9YenOO7PALj/Pn8uuyw53FBCiCYw8w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.13", + "@aws-sdk/xml-builder": "^3.972.30", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.6", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.47", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.47.tgz", + "integrity": "sha512-3YoPwJczcc+MtX2xxXaYaOOWO6xKUJr1ZIIDIFuninr51BYONVVcF/CP8K2xfVRC/PztJjqKWxNGFH7BWQAw1Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.21", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.49", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.49.tgz", + "integrity": "sha512-2UtGUPy+x3lqyceHrtC1uEuVxBZbDalPF6KAFqBwYgm4edWdBrZKNnCqzDs7KynWUvEC6mrR+ojRk+ZgQz9C2w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.21", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.54.tgz", + "integrity": "sha512-Hx4gO4YRjFwitf3MVl3cDwYe1aryJthC4txVl9b+JAURovA50M2ywf9r8j1E/Q6SCTPT4qQpjOAbKYIC9CG+Vw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.21", + "@aws-sdk/credential-provider-env": "^3.972.47", + "@aws-sdk/credential-provider-http": "^3.972.49", + "@aws-sdk/credential-provider-login": "^3.972.53", + "@aws-sdk/credential-provider-process": "^3.972.47", + "@aws-sdk/credential-provider-sso": "^3.972.53", + "@aws-sdk/credential-provider-web-identity": "^3.972.53", + "@aws-sdk/nested-clients": "^3.997.21", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.53", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.53.tgz", + "integrity": "sha512-+71sluhkgPqdhbbD3UDwUpj24GCkng9HQx6z7qoBFb8dwkF4ktpOcVKDeHpgg8PvBgLYwAnUYLTEGRC/PniCiQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.21", + "@aws-sdk/nested-clients": "^3.997.21", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.56", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.56.tgz", + "integrity": "sha512-iI+4o0dvQQ4NHel4FMDiFy5q2gaU/ryLK3niOsoPccAt9WLFRkV4XTYPWRr9XvmBUqEzXG73S4p/8gm0Lu/W3A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.47", + "@aws-sdk/credential-provider-http": "^3.972.49", + "@aws-sdk/credential-provider-ini": "^3.972.54", + "@aws-sdk/credential-provider-process": "^3.972.47", + "@aws-sdk/credential-provider-sso": "^3.972.53", + "@aws-sdk/credential-provider-web-identity": "^3.972.53", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.47", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.47.tgz", + "integrity": "sha512-tAizPm9IFo/PHn06c+LQJlzfY2AGOlyF0CUljFejrU6LcZBjnk8pmbZK3/xoIDdnIzjEdbClfvY3mXfr818ZEg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.21", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.53", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.53.tgz", + "integrity": "sha512-pUXE3fu4tfEDV8BksIgf4dXvuIH10FhwHMl/wu8rBD5T1sMpryQWFVitH3kdPS90wlgrGYJQ/meQTSPacyZfeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.21", + "@aws-sdk/nested-clients": "^3.997.21", + "@aws-sdk/token-providers": "3.1069.0", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.53", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.53.tgz", + "integrity": "sha512-JmMGlhVvSj8uSG9CpeDkJAXT35H89tc6v84iMgEIE75q4yp1MKVVKvopv6Gg28HJIR7hMNkojRF8H2m5W44wyg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.21", + "@aws-sdk/nested-clients": "^3.997.21", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.974.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.31.tgz", + "integrity": "sha512-Yzj6NRYVZdBaCp7o1BwHGyeDBfixdeToLIAMprshIITEdl9wKVSiidVOfeaiH8FyeC1hBmBfDZFvs/aH1Y3xpw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.52.tgz", + "integrity": "sha512-rerjP08onRqkBh0AcCqip6GkKvESapmLoTgi1xysZ4C6a1xMrIMtTBcEbUb6EY71oeajnigeUD4KwZjtIO+aWQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.21", + "@aws-sdk/signature-v4-multi-region": "^3.996.35", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.21.tgz", + "integrity": "sha512-eC7Vl7Qom/BGhZjG9GEqPwdQ/fk45hg1t5LP4EUxG5d1fdshLbaxCiwh/tszUzDX/4mW40mu2QsbeJJRPBbqUw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.21", + "@aws-sdk/signature-v4-multi-region": "^3.996.35", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.35.tgz", + "integrity": "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.13", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1069.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1069.0.tgz", + "integrity": "sha512-ks4X+kngC3PA5howV7Qu1TgG4bfC4jPykKdvw3nmBSXR9yZxRJouBholFSNQ5kY3L+Fgwyw+LCjzQmNi+KR91g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.21", + "@aws-sdk/nested-clients": "^3.997.21", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", + "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.8.tgz", + "integrity": "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.30.tgz", + "integrity": "sha512-StElZPEoBquWwNqw1AcfpzEyZqJvFxouG+mpDNYlcH6ZOrqd2CuIryv+8LV8gNHZUOyKyJF3Dq9vxaXEmDR9TQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.3", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", @@ -754,6 +1186,138 @@ "node": ">= 10" } }, + "node_modules/@nodable/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@smithy/core": { + "version": "3.25.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.25.0.tgz", + "integrity": "sha512-TTD6el7tvKyafkXBf7XO3jLOE+qVxOTrLjp/fEGiV3BMfUHK/LfdYlQO9YgZvzxC7kqA3H/IhJXNqQgnbgjb7A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.0.tgz", + "integrity": "sha512-pPQmNdEvMJttv9z2kdYxoui83p/nr32zjMf0aMfmzmGmFEgKXUfy0vXiNg0fx4R5XLQzmJBLM9Wg0guEq2/q8A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.25.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.5.0.tgz", + "integrity": "sha512-OG8kBYAgX7lf32+xLzgirvuLffn1KNoszaSiButt45i2cRa5irk8LQXLYQ5Smij1SBTN4KMNcBsRwRrLPfIGyA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.25.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.8.0.tgz", + "integrity": "sha512-Mq7TNt/VhlEWiYRLQGpzUWeUxh899UGpjKh7Ru0WVIDIjnE+cTRAn0NYlFQ6bWfsQnKnpCbWJj86HzmcG0qEdg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.25.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.5.0.tgz", + "integrity": "sha512-vW6UdK7e7gV2wU/tXRsPq4pMQMusb8VymdVOyIFNA1FtyRmEClRFkYDtYI8UcO/HM0wK3qqjvvQs3HOlbgMbdg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.25.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz", + "integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@stablelib/base64": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", @@ -773,6 +1337,18 @@ "node": ">= 0.6" } }, + "node_modules/anynum": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.0.tgz", + "integrity": "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", @@ -785,77 +1361,12 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", "license": "MIT" }, - "node_modules/better-sqlite3": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", - "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - } - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/bl/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/body-parser": { "version": "1.20.5", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", @@ -898,29 +1409,11 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", @@ -1025,12 +1518,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, "node_modules/concat-stream": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", @@ -1155,30 +1642,6 @@ "ms": "2.0.0" } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1325,15 +1788,6 @@ "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" } }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -1391,15 +1845,6 @@ "node": ">= 0.6" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, "node_modules/express": { "version": "4.22.2", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", @@ -1446,6 +1891,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-async-errors": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/express-async-errors/-/express-async-errors-3.1.1.tgz", + "integrity": "sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng==", + "license": "ISC", + "peerDependencies": { + "express": "^4.16.2" + } + }, "node_modules/express-rate-limit": { "version": "7.5.1", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", @@ -1467,11 +1921,42 @@ "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", "license": "Unlicense" }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } }, "node_modules/finalhandler": { "version": "1.3.2", @@ -1509,12 +1994,6 @@ "node": ">= 0.6" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1561,12 +2040,6 @@ "node": ">= 0.4" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1678,38 +2151,12 @@ "node": ">=0.10.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1898,18 +2345,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -1931,12 +2366,6 @@ "mkdirp": "bin/cmd.js" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -1980,12 +2409,6 @@ "node": "^18 || >=20" } }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -1995,18 +2418,6 @@ "node": ">= 0.6" } }, - "node_modules/node-abi": { - "version": "3.92.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", - "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/nodemailer": { "version": "8.0.11", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz", @@ -2061,15 +2472,6 @@ "node": ">= 0.8" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/openai": { "version": "6.42.0", "resolved": "https://registry.npmjs.org/openai/-/openai-6.42.0.tgz", @@ -2146,6 +2548,21 @@ "node": ">= 0.8" } }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", @@ -2184,31 +2601,132 @@ "@napi-rs/canvas": "^0.1.80" } }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", "license": "MIT", "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" }, "engines": { - "node": ">=10" + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" } }, "node_modules/process-nextick-args": { @@ -2230,16 +2748,6 @@ "node": ">= 0.10" } }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -2291,21 +2799,6 @@ "node": ">=0.10.0" } }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -2532,49 +3025,13 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" } }, "node_modules/standardwebhooks": { @@ -2619,55 +3076,19 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "node_modules/strnum": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.0.tgz", + "integrity": "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], "license": "MIT", "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tar-stream/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" + "anynum": "^1.0.0" } }, "node_modules/toidentifier": { @@ -2689,20 +3110,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } + "license": "0BSD" }, "node_modules/type-is": { "version": "1.6.18", @@ -2787,11 +3195,20 @@ "node": ">=18" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } }, "node_modules/xtend": { "version": "4.0.2", diff --git a/panel/package.json b/panel/package.json index 889b46d..f83ff3b 100644 --- a/panel/package.json +++ b/panel/package.json @@ -12,13 +12,14 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.104.1", + "@aws-sdk/client-s3": "^3.700.0", "bcryptjs": "^2.4.3", - "better-sqlite3": "^11.3.0", "cheerio": "^1.2.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.0", + "express-async-errors": "^3.1.1", "express-rate-limit": "^7.5.0", "helmet": "^8.0.0", "jsonwebtoken": "^9.0.2", @@ -27,6 +28,7 @@ "nodemailer": "^8.0.10", "openai": "^6.42.0", "pdf-parse": "^2.4.5", + "pg": "^8.13.1", "sharp": "^0.35.1" }, "engines": { diff --git a/panel/public/app.js b/panel/public/app.js index d6611b8..f45d143 100644 --- a/panel/public/app.js +++ b/panel/public/app.js @@ -1,6 +1,21 @@ const $ = (sel, root = document) => root.querySelector(sel); const root = $('#app'); +const TAB_GROUPS = [ + { label: 'محتوا', tabs: ['articles', 'events', 'radar', 'radarPages', 'factoryReports'] }, + { label: 'داده', tabs: ['risks', 'mprices', 'stats', 'charts', 'integrations'] }, + { label: 'سایت', tabs: ['vision', 'advisory', 'team', 'experts', 'plans', 'banners'] }, + { label: 'ابزار', tabs: ['pdfAnalyze', 'import', 'users'] }, +]; +const TAB_LABELS = { + articles: 'مطالب', risks: 'ریسک‌ها', events: 'رویدادها', + radar: 'رادار', radarPages: 'صفحات رادار', factoryReports: 'گزارش کارخانه', + integrations: 'منابع داده', stats: 'آمار', mprices: 'قیمت‌ها', + charts: 'نمودارها', vision: 'چشم‌انداز', advisory: 'شورای مشورتی', + team: 'تیم', experts: 'خبرگان', plans: 'پلان‌ها', banners: 'بنرها', + pdfAnalyze: '📄 PDF', import: '⬇️ ایمپورت', users: 'کاربران', +}; + const CATEGORIES = [ 'بازار جهانی', 'سیاست‌گذاری', 'انرژی و ESG', 'صادرات', 'تولید داخلی', 'ریسک و بحران', @@ -18,6 +33,25 @@ const TYPES = [ { value: 'radar', label: 'رادار آینده' }, ]; +const CONTENT_TYPES = [ + { key: 'report', label: 'گزارش تحلیلی', icon: '📊', desc: 'گزارش فصلی، فلش یا ویژه با تحلیل بازار', defaultType: 'quarterly', defaultCategory: '' }, + { key: 'news', label: 'خبر', icon: '📰', desc: 'اخبار صنعت فولاد', defaultType: 'news', defaultCategory: 'اخبار صنعت' }, + { key: 'video', label: 'ویدیوکست', icon: '🎬', desc: 'ویدیوی تحلیلی یا مصاحبه', defaultType: 'video', defaultCategory: '' }, + { key: 'podcast', label: 'پادکست', icon: '🎙️', desc: 'اپیزود پادکست صوتی', defaultType: 'podcast', defaultCategory: '' }, + { key: 'factory', label: 'گزارش کارخانه', icon: '🏭', desc: 'گزارش تولید و عملیات کارخانه', defaultType: 'factoryReport', defaultCategory: 'گزارش کارخانه' }, + { key: 'radar', label: 'رادار آینده', icon: '🔭', desc: 'از تب «رادار» در منوی بالا مدیریت می‌شود', defaultType: '', defaultCategory: '', externalTab: 'radar' }, +]; + +function inferContentType(a) { + if (!a) return 'report'; + if (a.type === 'video') return 'video'; + if (a.type === 'podcast') return 'podcast'; + if (a.type === 'radar') return 'radar'; + if (a.type === 'factoryReport' || a.category === 'گزارش کارخانه') return 'factory'; + if (a.type === 'news') return 'news'; + return 'report'; +} + const EVENT_TYPES = [ { value: 'conference', label: 'کنگره' }, { value: 'exhibition', label: 'نمایشگاه' }, @@ -111,7 +145,6 @@ function renderLogin(errorMsg = '') { method: 'POST', body: { username: fd.get('username'), password: fd.get('password') }, }); - if (data.token) sessionStorage.setItem('sf_token', data.token); renderList(); } catch { renderLogin('نام کاربری یا رمز عبور اشتباه است'); @@ -120,34 +153,24 @@ function renderLogin(errorMsg = '') { } function topbarHtml(active, primaryLabel) { + const activeGroup = TAB_GROUPS.find(g => g.tabs.includes(active)) || TAB_GROUPS[0]; + const groupBtns = TAB_GROUPS.map(g => ` + + `).join(''); + const subTabs = activeGroup.tabs.map(t => ` + + `).join(''); return `
-
پنل اندیشکده فولاد
- -
- - +
+
پنل اندیشکده فولاد
+ +
+ + +
+
`; } @@ -187,20 +210,28 @@ async function renderList() { ${topbarHtml('articles', '+ مطلب جدید')}

مطالب

+
+ + ${CATEGORIES.map(c => ``).join('')} +

در حال بارگذاری…

`; wireTabs(); $('#newBtn').addEventListener('click', () => renderEditor(null)); - try { - const items = await api('/api/articles?limit=200'); - if (!items.length) { - $('#list').innerHTML = `

هنوز مطلبی ثبت نشده است.

`; + let allItems = []; + let activeCat = ''; + + const safeCoverUrl = (u) => /^\/uploads\/[a-zA-Z0-9._-]+$/.test(String(u || '')) ? u : ''; + + function renderItems() { + const filtered = activeCat ? allItems.filter(a => a.category === activeCat) : allItems; + if (!filtered.length) { + $('#list').innerHTML = `

مطلبی در این دسته‌بندی یافت نشد.

`; return; } - const safeCoverUrl = (u) => /^\/uploads\/[a-zA-Z0-9._-]+$/.test(String(u || '')) ? u : ''; - $('#list').innerHTML = items.map((a) => ` + $('#list').innerHTML = filtered.map((a) => `
@@ -218,164 +249,284 @@ async function renderList() {
`).join(''); + } - $('#list').addEventListener('click', async (e) => { - const btn = e.target.closest('button[data-action]'); - if (!btn) return; - const row = btn.closest('.row'); - const id = row.dataset.id; - if (btn.dataset.action === 'edit') { - const a = await api(`/api/articles/${id}`); - renderEditor(a); - } else if (btn.dataset.action === 'delete') { - if (!confirm('این مطلب حذف شود؟')) return; - await api(`/api/articles/${id}`, { method: 'DELETE' }); - renderList(); - } - }); + $('#cat-bar').addEventListener('click', (e) => { + const btn = e.target.closest('.cat-btn'); + if (!btn) return; + activeCat = btn.dataset.cat; + document.querySelectorAll('.cat-btn').forEach(b => b.classList.toggle('active', b === btn)); + renderItems(); + }); + + $('#list').addEventListener('click', async (e) => { + const btn = e.target.closest('button[data-action]'); + if (!btn) return; + const row = btn.closest('.row'); + const id = row.dataset.id; + if (btn.dataset.action === 'edit') { + const a = await api(`/api/articles/${id}`); + renderEditor(a); + } else if (btn.dataset.action === 'delete') { + if (!confirm('این مطلب حذف شود؟')) return; + await api(`/api/articles/${id}`, { method: 'DELETE' }); + renderList(); + } + }); + + try { + allItems = await api('/api/articles?limit=500'); + if (!allItems.length) { + $('#list').innerHTML = `

هنوز مطلبی ثبت نشده است.

`; + return; + } + renderItems(); } catch (err) { console.error(err); $('#list').innerHTML = `

خطا در بارگذاری مطالب.

`; } } +function renderTypeSelector() { + root.innerHTML = ` +
+
+
مطلب جدید
+ +
+ +
+
+
+
+
+

چه نوع محتوایی می‌خواهید منتشر کنید؟

+
+ ${CONTENT_TYPES.map(ct => ` + + `).join('')} +
+
+ `; + $('#backBtn').addEventListener('click', () => renderList()); + document.querySelectorAll('.type-card').forEach(btn => { + btn.addEventListener('click', () => { + const ct = CONTENT_TYPES.find(c => c.key === btn.dataset.ctype); + if (ct.externalTab === 'radar') { renderRadarList(); return; } + renderEditor(null, { contentType: ct.key, type: ct.defaultType, category: ct.defaultCategory }); + }); + }); +} + function renderEditor(article, draft = {}) { + if (!article && !draft.contentType) { + renderTypeSelector(); + return; + } + + const contentType = draft.contentType || inferContentType(article); + const ctInfo = CONTENT_TYPES.find(c => c.key === contentType) || CONTENT_TYPES[0]; + const isEdit = !!article; const a = article || { - title: draft.title || '', category: draft.category || '', type: draft.type || '', + title: '', category: draft.category || ctInfo.defaultCategory, + type: draft.type || ctInfo.defaultType, author: '', authorRole: '', authorInitial: '', publishDate: '', pages: 0, price: 0, isFree: true, - summary: draft.summary || '', body: '', - coverImage: '', tags: [], featured: false, + summary: '', body: '', coverImage: '', tags: [], featured: false, }; - const isEdit = !!article; + + const REPORT_TYPES = TYPES.filter(t => ['quarterly','flash','special','risk','free'].includes(t.value)); + const hasCover = ['report','news','video','podcast','factory'].includes(contentType); + const hasCategory = ['report','news'].includes(contentType); + const hasAuthor = ['report','video','podcast'].includes(contentType); + const hasPages = ['report','factory'].includes(contentType); + const hasPrice = contentType === 'report'; + const hasBody = ['report','news','factory'].includes(contentType); + const hasMediaLink = ['video','podcast'].includes(contentType); + const hasReportType = contentType === 'report'; root.innerHTML = `
-
${isEdit ? 'ویرایش مطلب' : 'مطلب جدید'}
-
- +
+
${ctInfo.icon} ${isEdit ? 'ویرایش' : 'ایجاد'} — ${ctInfo.label}
+ +
+ +
+
+
+ ${hasCategory ? ` + ` : ``} -
`; $('#backBtn').addEventListener('click', () => renderList()); + document.getElementById('changeTypeBtn')?.addEventListener('click', () => renderTypeSelector()); - $('#coverFile').addEventListener('change', async (e) => { - const file = e.target.files?.[0]; - if (!file) return; - const fd = new FormData(); - fd.append('file', file); - try { - const { url } = await api('/api/uploads', { method: 'POST', body: fd, isForm: true }); - $('input[name="coverImage"]').value = url; - $('#coverPreview').src = url; - } catch (err) { - alert('آپلود ناموفق بود: ' + err.message); - } - }); + const coverFile = document.getElementById('coverFile'); + if (coverFile) { + coverFile.addEventListener('change', async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + const fd = new FormData(); + fd.append('file', file); + try { + const { url } = await api('/api/uploads', { method: 'POST', body: fd, isForm: true }); + $('input[name="coverImage"]').value = url; + const prev = document.getElementById('coverPreview'); + prev.src = url; prev.style.display = ''; + } catch (err) { alert('آپلود ناموفق بود: ' + err.message); } + }); + } + + // video / podcast media file → object storage (fills the media URL field) + const mediaFile = document.getElementById('mediaFile'); + if (mediaFile) { + mediaFile.addEventListener('change', async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + const status = document.getElementById('mediaStatus'); + if (status) status.textContent = 'در حال آپلود فایل… (بسته به حجم چند لحظه طول می‌کشد)'; + const fd = new FormData(); + fd.append('file', file); + try { + const { url } = await api('/api/uploads/raw', { method: 'POST', body: fd, isForm: true }); + document.getElementById('mediaUrl').value = url; + if (status) status.textContent = '✓ آپلود شد و در آبجکت استوریج ذخیره شد'; + } catch (err) { + if (status) status.textContent = ''; + alert('آپلود فایل ناموفق بود: ' + err.message); + } + }); + } $('#editorForm').addEventListener('submit', async (e) => { e.preventDefault(); const fd = new FormData(e.target); const tagsRaw = String(fd.get('tags') || '').split(/[,،]/).map(s => s.trim()).filter(Boolean); + const isFreeVal = fd.get('isFree'); const payload = { - title: fd.get('title'), - category: fd.get('category') || null, - type: fd.get('type') || null, - author: fd.get('author') || null, - authorRole: fd.get('authorRole') || null, - authorInitial: fd.get('authorInitial') || null, - publishDate: fd.get('publishDate') || null, - pages: Number(fd.get('pages') || 0), - price: Number(fd.get('price') || 0), - isFree: fd.get('isFree') === 'on', - featured: fd.get('featured') === 'on', - summary: fd.get('summary') || null, - body: fd.get('body') || null, - coverImage: fd.get('coverImage') || null, + title: fd.get('title'), + category: fd.get('category') || null, + type: fd.get('type') || null, + author: fd.get('author') || null, + authorRole: fd.get('authorRole') || null, + authorInitial:fd.get('authorInitial') || null, + publishDate: fd.get('publishDate') || null, + pages: Number(fd.get('pages') || 0), + price: Number(fd.get('price') || 0), + isFree: isFreeVal === 'on' || isFreeVal === 'true', + featured: fd.get('featured') === 'on', + summary: fd.get('summary') || null, + body: fd.get('body') || null, + coverImage: fd.get('coverImage') || null, tags: tagsRaw, }; try { @@ -385,9 +536,7 @@ function renderEditor(article, draft = {}) { await api('/api/articles', { method: 'POST', body: payload }); } renderList(); - } catch (err) { - alert('ذخیره ناموفق بود: ' + err.message); - } + } catch (err) { alert('ذخیره ناموفق بود: ' + err.message); } }); } @@ -952,26 +1101,50 @@ function renderPlanEditor(plan) { // ═══════════════════════════════════════════════ // RADAR (رادار آینده) // ═══════════════════════════════════════════════ +// section pages on the site → which raw categories feed them +const RADAR_PAGE_SLUGS = [ + { slug: 'foresight', label: 'آینده‌پژوهی و رصد هوشمند', cats: ['market','tech','commodity','geo','energy'] }, + { slug: 'market', label: 'بازار و زنجیره فولاد', cats: ['market','commodity'] }, + { slug: 'sustainability', label: 'پایداری و فولاد سبز', cats: ['energy','commodity'] }, + { slug: 'geopolitics', label: 'ژئوپلیتیک و اقتصاد جهانی', cats: ['geo','market'] }, + { slug: 'technology', label: 'فناوری و نوآوری', cats: ['tech'] }, +]; + +function sectionLabelsForCategory(cat) { + return RADAR_PAGE_SLUGS.filter(s => s.cats.includes(cat)).map(s => s.label); +} + async function renderRadarList() { root.innerHTML = ` - ${topbarHtml('radar', '+ سیگنال جدید')} + ${topbarHtml('radar', '+ مطلب رادار جدید')}

رادار آینده

+
+ + ${RADAR_PAGE_SLUGS.map(s => ``).join('')} +

در حال بارگذاری…

`; wireTabs(); $('#newBtn').addEventListener('click', () => renderRadarEditor(null)); - try { - const items = await api('/api/radar?limit=200'); - if (!items.length) { $('#list').innerHTML = `

هنوز سیگنالی ثبت نشده.

`; return; } - const safeImgUrl = (u) => /^(\/uploads\/[a-zA-Z0-9._-]+|\/[a-zA-Z0-9._\/-]+)$/.test(String(u || '')) ? u : ''; - $('#list').innerHTML = items.map(r => ` + + let allItems = []; + let activeSlug = ''; + + const safeImgUrl = (u) => /^(\/uploads\/[a-zA-Z0-9._-]+|\/[a-zA-Z0-9._\/-]+)$/.test(String(u || '')) ? u : ''; + + function renderItems() { + const activePage = RADAR_PAGE_SLUGS.find(s => s.slug === activeSlug); + const filtered = activePage ? allItems.filter(r => activePage.cats.includes(r.category)) : allItems; + if (!filtered.length) { $('#list').innerHTML = `

مطلبی در این بخش یافت نشد.

`; return; } + $('#list').innerHTML = filtered.map(r => { + const sections = sectionLabelsForCategory(r.category); + return `
- ${r.category ? `${escapeHtml(RADAR_CATEGORIES.find(c=>c.value===r.category)?.label||r.category)}` : ''} - ${r.authorName ? `${escapeHtml(r.authorName)}` : ''} + ${sections.map(s => `${escapeHtml(s)}`).join('')} ${r.dateFa ? `${escapeHtml(r.dateFa)}` : ''}

${escapeHtml(r.titleFa||'')}

@@ -981,51 +1154,147 @@ async function renderRadarList() {
-
`).join(''); - $('#list').addEventListener('click', async ev => { - const btn = ev.target.closest('button[data-action]'); if (!btn) return; - const id = btn.closest('.row').dataset.id; - if (btn.dataset.action === 'edit') renderRadarEditor(await api(`/api/radar/${id}`)); - else if (btn.dataset.action === 'delete') { - if (!confirm('این سیگنال حذف شود؟')) return; - await api(`/api/radar/${id}`, { method: 'DELETE' }); - renderRadarList(); - } - }); + `; + }).join(''); + } + + $('#section-bar').addEventListener('click', e => { + const btn = e.target.closest('.cat-btn'); if (!btn) return; + activeSlug = btn.dataset.slug; + document.querySelectorAll('#section-bar .cat-btn').forEach(b => b.classList.toggle('active', b === btn)); + renderItems(); + }); + + $('#list').addEventListener('click', async ev => { + const btn = ev.target.closest('button[data-action]'); if (!btn) return; + const id = btn.closest('.row').dataset.id; + if (btn.dataset.action === 'edit') renderRadarEditor(await api(`/api/radar/${id}`)); + else if (btn.dataset.action === 'delete') { + if (!confirm('این مطلب حذف شود؟')) return; + await api(`/api/radar/${id}`, { method: 'DELETE' }); + renderRadarList(); + } + }); + + try { + allItems = await api('/api/radar?limit=500'); + if (!allItems.length) { $('#list').innerHTML = `

هنوز مطلبی ثبت نشده.

`; return; } + renderItems(); } catch (err) { $('#list').innerHTML = `

خطا: ${escapeHtml(err.message)}

`; } } function renderRadarEditor(item) { const r = item || { category:'', img:'', authorName:'', authorAvatar:'', - titleFa:'', dateFa:'', excerptFa:'', titleEn:'', dateEn:'', excerptEn:'', sortOrder:0 }; + titleFa:'', dateFa:'', excerptFa:'', bodyFa:'', analysisFa:'', + titleEn:'', dateEn:'', excerptEn:'', bodyEn:'', analysisEn:'', sortOrder:0 }; const isEdit = !!item; + + function sectionBadges(cat) { + const labels = sectionLabelsForCategory(cat); + if (!labels.length) return ''; + return labels.map(l => `${escapeHtml(l)}`).join(' '); + } + root.innerHTML = `
-
${isEdit?'ویرایش سیگنال':'سیگنال جدید'}
-
+
+
🔭 ${isEdit?'ویرایش':'مطلب جدید'} — رادار آینده
+ +
+
+
- - - - + +
+ +
+ نمایش در بخش‌های سایت: + ${sectionBadges(r.category)} +
+
+ +
+ +
+ +
+ فارسی +
+ +
+ +
+ - -
- - -
-
-
+
+ +
+
+ +
+
+ +
+ +
+ English +
+ +
+ +
+ +
+
+ +
+
+ +
+
+ +
+ + + +
+ +
`; + $('#backBtn').addEventListener('click', () => renderRadarList()); + + document.getElementById('catSelect').addEventListener('change', e => { + const cat = e.target.value; + const badgesEl = document.getElementById('sectionBadges'); + badgesEl.innerHTML = `نمایش در بخش‌های سایت: ${sectionBadges(cat)}`; + }); + + document.getElementById('radarCoverFile').addEventListener('change', async e => { + const file = e.target.files?.[0]; if (!file) return; + const fd = new FormData(); fd.append('file', file); + try { + const { url } = await api('/api/uploads', { method: 'POST', body: fd, isForm: true }); + $('input[name="img"]').value = url; + const prev = document.getElementById('radarCoverPreview'); + prev.src = url; prev.style.display = ''; + } catch (err) { alert('آپلود ناموفق: ' + err.message); } + }); + $('#editorForm').addEventListener('submit', async ev => { ev.preventDefault(); const fd = new FormData(ev.target); @@ -1033,9 +1302,12 @@ function renderRadarEditor(item) { titleFa: fd.get('titleFa'), titleEn: fd.get('titleEn')||null, category: fd.get('category')||null, img: fd.get('img')||null, - authorName: fd.get('authorName')||null, authorAvatar: fd.get('authorAvatar')||null, + authorName: fd.get('authorName')||null, + authorAvatar: r.authorAvatar || null, dateFa: fd.get('dateFa')||null, dateEn: fd.get('dateEn')||null, excerptFa: fd.get('excerptFa')||null, excerptEn: fd.get('excerptEn')||null, + bodyFa: fd.get('bodyFa')||null, bodyEn: fd.get('bodyEn')||null, + analysisFa: fd.get('analysisFa')||null, analysisEn: fd.get('analysisEn')||null, sortOrder: Number(fd.get('sortOrder')||0), }; try { @@ -1046,17 +1318,6 @@ function renderRadarEditor(item) { }); } -// ═══════════════════════════════════════════════ -// RADAR PAGES (صفحات رادار — hero + banner per category) -// ═══════════════════════════════════════════════ -const RADAR_PAGE_SLUGS = [ - { slug: 'foresight', label: 'آینده‌پژوهی و رصد هوشمند' }, - { slug: 'market', label: 'بازار و زنجیره فولاد' }, - { slug: 'sustainability', label: 'پایداری و فولاد سبز' }, - { slug: 'geopolitics', label: 'ژئوپلیتیک و اقتصاد جهانی' }, - { slug: 'technology', label: 'فناوری و نوآوری' }, -]; - async function renderRadarPagesList() { root.innerHTML = ` ${topbarHtml('radarPages', '')} @@ -1958,9 +2219,7 @@ function renderImport() { statusEl.innerHTML = ''; try { - const res = await apiFetch('/api/admin/import', { method:'POST', body: JSON.stringify(data) }); - if (!res.ok) { const e = await res.json(); throw new Error(e.error || res.status); } - const result = await res.json(); + const result = await api('/api/admin/import', { method:'POST', body: data }); resultEl.innerHTML = `
@@ -2023,10 +2282,10 @@ function renderPdfAnalyze() { reader.onerror = reject; reader.readAsDataURL(file); }); - const _token = sessionStorage.getItem('sf_token') || ''; + // session travels in the HttpOnly cookie (credentials:'include'); no token in JS const _res = await fetch('/api/pdf-analyze', { method: 'POST', credentials: 'include', - headers: { 'Content-Type': 'application/json', ...(_token ? { 'Authorization': 'Bearer ' + _token } : {}) }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ base64 }), }); if (!_res.ok) { @@ -2079,11 +2338,11 @@ function renderPdfAnalyze() { async function runGenerate(textSrc, titleSrc, catKey) { const gr = $('#generateResult'); gr.innerHTML = '

در حال تولید محتوا... ⏳

'; - const _token = sessionStorage.getItem('sf_token') || ''; + // session travels in the HttpOnly cookie (credentials:'include'); no token in JS try { const r = await fetch('/api/pdf-generate', { method: 'POST', credentials: 'include', - headers: { 'Content-Type': 'application/json', ...(_token ? { 'Authorization': 'Bearer ' + _token } : {}) }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: textSrc, category: SECTION_LABELS[catKey] || catKey }), }); const d = await r.json(); @@ -2190,11 +2449,11 @@ function renderPdfAnalyze() { $('#generateContentBtn').addEventListener('click', async () => { const gr = $('#generateResult'); gr.innerHTML = '

در حال تولید محتوا... ⏳

'; - const _token = sessionStorage.getItem('sf_token') || ''; + // session travels in the HttpOnly cookie (credentials:'include'); no token in JS try { const r = await fetch('/api/pdf-generate', { method: 'POST', credentials: 'include', - headers: { 'Content-Type': 'application/json', ...(_token ? { 'Authorization': 'Bearer ' + _token } : {}) }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: data.excerpt + '\n\n' + (data.text || '').slice(0, 5000), category: c.label }), }); const d = await r.json(); diff --git a/panel/public/style.css b/panel/public/style.css index f75b1ea..7d477a7 100644 --- a/panel/public/style.css +++ b/panel/public/style.css @@ -104,21 +104,43 @@ textarea { resize: vertical; min-height: 80px; } .card button { width: 100%; margin-top: 20px; } .topbar { + background: white; + border-bottom: 1px solid var(--rule); + position: sticky; + top: 0; + z-index: 10; +} +.topbar-row { display: flex; justify-content: space-between; align-items: center; - padding: 16px 32px; - background: white; + padding: 12px 32px; border-bottom: 1px solid var(--rule); } .topbar .brand { font-weight: 900; font-size: 15px; letter-spacing: -.3px; } .topbar .actions { display: flex; gap: 10px; } -.tabs { +.groups { display: flex; gap: 4px; - flex: 1; - justify-content: center; +} +.groups .group-btn { + border: 1px solid var(--rule); + background: transparent; + color: var(--ink-3); + font-size: 13px; + font-weight: 700; + padding: 6px 16px; + border-radius: 20px; +} +.groups .group-btn:hover { background: var(--rule); color: var(--ink); } +.groups .group-btn.group-active { background: var(--ink); color: var(--paper); border-color: var(--ink); } + +.tabs { + display: flex; + gap: 0; + padding: 0 32px; + background: white; } .tabs button { border: none; @@ -126,18 +148,12 @@ textarea { resize: vertical; min-height: 80px; } color: var(--ink-3); font-size: 13px; font-weight: 700; - padding: 8px 18px; + padding: 10px 16px; border-bottom: 2px solid transparent; border-radius: 0; } -.tabs button:hover { - background: transparent; - color: var(--ink); -} -.tabs button.tab-active { - color: var(--ink); - border-bottom-color: var(--red); -} +.tabs button:hover { background: transparent; color: var(--ink); } +.tabs button.tab-active { color: var(--ink); border-bottom-color: var(--red); } .risk-row { grid-template-columns: 1fr auto; } .risk-row .row-meta strong { font-size: 13px; } @@ -149,7 +165,50 @@ textarea { resize: vertical; min-height: 80px; } .pill.level-opportunity { background: #1e40af; color: white; } .page { max-width: 1100px; margin: 0 auto; padding: 32px; } -.page h2 { font-size: 22px; letter-spacing: -.5px; margin: 0 0 24px; } +.page h2 { font-size: 22px; letter-spacing: -.5px; margin: 0 0 16px; } + +.cat-bar { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 20px; +} +.cat-btn { + padding: 4px 12px; + border-radius: 20px; + border: 1px solid var(--rule); + background: transparent; + color: var(--fg); + font-size: 12px; + cursor: pointer; + transition: background .15s, color .15s; +} +.cat-btn:hover { background: var(--rule); } +.cat-btn.active { background: var(--accent, #2563eb); color: #fff; border-color: transparent; } + +.type-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 16px; + margin-top: 8px; +} +.type-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + padding: 20px 20px 18px; + border: 1px solid var(--rule); + border-radius: 4px; + background: white; + cursor: pointer; + text-align: right; + transition: border-color .15s, box-shadow .15s; +} +.type-card:hover { border-color: var(--ink); box-shadow: 0 2px 8px rgba(0,0,0,.08); background: white; color: var(--ink); } +.type-icon { font-size: 28px; line-height: 1; } +.type-card strong { font-size: 15px; font-weight: 800; } +.type-desc { font-size: 12px; color: var(--ink-5); font-weight: 400; line-height: 1.4; } .list { display: flex; diff --git a/panel/scraper.js b/panel/scraper.js index b75bc9a..98e3aba 100644 --- a/panel/scraper.js +++ b/panel/scraper.js @@ -136,8 +136,8 @@ export async function scrapeOnce() { // At least one source returned — replace the rows from those sources only. // This way a temporary failure of one page doesn't wipe the other's data. for (const { src, items } of successful) { - db.prepare('DELETE FROM prices WHERE source_url = ?').run(src.url); - for (const it of items) upsertPrice(it); + await db.prepare('DELETE FROM prices WHERE source_url = ?').run(src.url); + for (const it of items) await upsertPrice(it); } return successful.reduce((s, x) => s + x.items.length, 0); } diff --git a/panel/seed-content.js b/panel/seed-content.js index 3ba0a2a..1262cee 100644 --- a/panel/seed-content.js +++ b/panel/seed-content.js @@ -1,702 +1,184 @@ -/** - * One-shot content seeder — run once: - * node panel/seed-content.js - * - * Inserts (or replaces) all hardcoded frontend content into the panel DB. - * Safe to re-run; uses INSERT OR REPLACE throughout. - */ -import { db } from './db.js'; +// panel/seed-content.js +// Run: node panel/seed-content.js +// Seeds all hardcoded frontend content into the panel DB via the REST API. +// Idempotent: skips any endpoint that already has data. -const seed = db.transaction(() => { +const BASE = process.env.SEED_BASE || `http://localhost:${process.env.PORT || 3000}`; +const USERNAME = process.env.ADMIN_USERNAME || 'admin'; +const PASSWORD = process.env.ADMIN_PASSWORD || 'Andishkade1403'; - /* ─── REPORTS → articles table ─────────────────────────────── */ - const upsertArticle = db.prepare(` - INSERT OR REPLACE INTO articles - (id, title, category, type, author, author_role, author_initial, - publish_date, pages, price, is_free, summary, tags, featured, - created_at, updated_at) - VALUES - (@id, @title, @category, @type, @author, @author_role, @author_initial, - @publish_date, @pages, @price, @is_free, @summary, @tags, @featured, - datetime('now'), datetime('now')) - `); +let cookie = ''; - const reports = [ - { - id: 'iran-steel-global-trade-scenarios-1404', - title: 'ایران در نقشه جدید تجارت فولاد جهان: چهار سناریو برای ۱۴۰۴', - category: 'بازار جهانی', type: 'special', - author: 'دکتر علی محمدی', author_role: 'مدیر ارشد پژوهش', author_initial: 'م', - publish_date: 'بهمن ۱۴۰۳', pages: 84, price: 850000, is_free: 0, featured: 1, - summary: 'این گزارش ویژه با تحلیل روندهای کلان اقتصاد جهانی، چهار سناریوی محتمل برای جایگاه صادرات فولاد ایران در سال ۱۴۰۴ را بررسی می‌کند. مدل‌سازی کمی بر اساس داده‌های تجارت ۱۵ کشور رقیب انجام شده و توصیه‌های سیاست‌گذاری مشخص ارائه می‌دهد.', - tags: JSON.stringify(['تجارت جهانی', 'سناریوسازی', 'صادرات', 'سیاست‌گذاری', '۱۴۰۴']), - }, - { - id: 'steel-energy-consumption-efficiency-1403', - title: 'مصرف انرژی در فولادسازی ایران: مقایسه با استانداردهای جهانی', - category: 'انرژی و ESG', type: 'free', - author: 'مهندس سارا رضایی', author_role: 'پژوهشگر ارشد انرژی', author_initial: 'ر', - publish_date: 'دی ۱۴۰۳', pages: 28, price: 0, is_free: 1, featured: 0, - summary: 'این گزارش رایگان مصرف انرژی واحدهای فولادسازی ایران را با میانگین جهانی و استانداردهای IEA مقایسه می‌کند. شکاف بهره‌وری انرژی در کوره‌های قوس الکتریکی و کنورتور شناسایی شده است.', - tags: JSON.stringify(['انرژی', 'بهره‌وری', 'ESG', 'کوره قوس الکتریکی', 'استاندارد جهانی']), - }, - { - id: 'global-steel-market-q3-1403', - title: 'گزارش فصلی بازار جهانی فولاد: پاییز ۱۴۰۳', - category: 'بازار جهانی', type: 'quarterly', - author: 'دکتر حسین کریمی', author_role: 'تحلیلگر ارشد بازار', author_initial: 'ک', - publish_date: 'آذر ۱۴۰۳', pages: 52, price: 450000, is_free: 0, featured: 0, - summary: 'تحلیل جامع قیمت‌ها، تقاضا و عرضه در بازار جهانی فولاد طی فصل پاییز ۱۴۰۳. روند کاهش تقاضای چین و افزایش ظرفیت تولید هند به‌تفصیل بررسی شده است. چشم‌انداز قیمتی برای زمستان ۱۴۰۳ نیز ارائه می‌شود.', - tags: JSON.stringify(['بازار جهانی', 'قیمت‌گذاری', 'چین', 'هند', 'پاییز ۱۴۰۳']), - }, - { - id: 'iran-steel-export-sanctions-impact-1403', - title: 'اثر تحریم‌های جدید بر مسیرهای صادراتی فولاد ایران', - category: 'صادرات', type: 'risk', - author: 'دکتر فاطمه احمدی', author_role: 'مدیر تحقیقات ریسک', author_initial: 'ا', - publish_date: 'آذر ۱۴۰۳', pages: 36, price: 380000, is_free: 0, featured: 0, - summary: 'این گزارش اثرات بسته تحریمی اخیر بر کانال‌های صادرات فولاد ایران به بازارهای منطقه‌ای را ارزیابی می‌کند. ریسک‌های لجستیکی، بانکی و قراردادی مستند شده است.', - tags: JSON.stringify(['تحریم', 'صادرات', 'ریسک', 'لجستیک', 'بازارهای منطقه‌ای']), - }, - { - id: 'flash-iron-ore-price-surge-1403', - title: 'فلش: جهش قیمت سنگ‌آهن و تأثیر بر هزینه تمام‌شده فولاد ایران', - category: 'بازار جهانی', type: 'flash', - author: 'مهندس رضا توکلی', author_role: 'تحلیلگر بازار کامودیتی', author_initial: 'ت', - publish_date: 'آبان ۱۴۰۳', pages: 12, price: 120000, is_free: 0, featured: 0, - summary: 'گزارش فوری درباره افزایش ۱۸ درصدی قیمت سنگ‌آهن در بورس دالیان و اثر فوری آن بر هزینه تمام‌شده تولید فولاد در ایران. برآورد اثر بر حاشیه سود کارخانه‌های داخلی ارائه شده است.', - tags: JSON.stringify(['سنگ‌آهن', 'قیمت', 'هزینه تمام‌شده', 'فوری']), - }, - { - id: 'domestic-production-dri-outlook-1403', - title: 'چشم‌انداز تولید آهن احیای مستقیم ایران تا افق ۱۴۰۵', - category: 'تولید داخلی', type: 'quarterly', - author: 'دکتر علی محمدی', author_role: 'مدیر ارشد پژوهش', author_initial: 'م', - publish_date: 'مهر ۱۴۰۳', pages: 44, price: 420000, is_free: 0, featured: 0, - summary: 'ایران با بیش از ۳۰ میلیون تن ظرفیت DRI، رتبه اول جهان را دارد؛ این گزارش مسیر توسعه این صنعت تا ۱۴۰۵ را مدل‌سازی می‌کند. بررسی پروژه‌های در دست احداث و موانع تأمین گاز به‌تفصیل آمده است.', - tags: JSON.stringify(['DRI', 'آهن احیای مستقیم', 'ظرفیت', 'تولید', 'گاز طبیعی']), - }, - { - id: 'esg-carbon-emissions-steel-iran-1403', - title: 'کربن‌زدایی در صنعت فولاد ایران: الزامات و فرصت‌ها', - category: 'انرژی و ESG', type: 'special', - author: 'مهندس سارا رضایی', author_role: 'پژوهشگر ارشد انرژی', author_initial: 'ر', - publish_date: 'شهریور ۱۴۰۳', pages: 58, price: 520000, is_free: 0, featured: 0, - summary: 'الزامات آینده CBAM اتحادیه اروپا و اثر آن بر رقابت‌پذیری صادرات فولاد ایران تحلیل شده است. مسیرهای فناوری کربن‌زدایی شامل هیدروژن سبز و CCS برای شرایط ایران ارزیابی شده‌اند.', - tags: JSON.stringify(['ESG', 'کربن', 'CBAM', 'هیدروژن سبز', 'محیط زیست']), - }, - { - id: 'policy-steel-pricing-reform-1403', - title: 'اصلاح ساختار قیمت‌گذاری فولاد: درس‌هایی از تجربه ترکیه و هند', - category: 'سیاست‌گذاری', type: 'quarterly', - author: 'دکتر فاطمه احمدی', author_role: 'مدیر تحقیقات ریسک', author_initial: 'ا', - publish_date: 'مرداد ۱۴۰۳', pages: 46, price: 390000, is_free: 0, featured: 0, - summary: 'مقایسه تطبیقی ساختار قیمت‌گذاری فولاد در ایران، ترکیه و هند با تمرکز بر نقش دولت و مکانیسم‌های بازار. اثربخشی سیاست‌های قیمت‌گذاری دستوری در حفظ تعادل عرضه-تقاضا ارزیابی شده است.', - tags: JSON.stringify(['سیاست‌گذاری', 'قیمت‌گذاری', 'ترکیه', 'هند', 'اصلاح ساختار']), - }, - { - id: 'flash-china-steel-overcapacity-1403', - title: 'فلش: مازاد ظرفیت فولاد چین و موج صادراتی به آسیای غربی', - category: 'بازار جهانی', type: 'flash', - author: 'دکتر حسین کریمی', author_role: 'تحلیلگر ارشد بازار', author_initial: 'ک', - publish_date: 'تیر ۱۴۰۳', pages: 10, price: 110000, is_free: 0, featured: 0, - summary: 'چین در نیمه اول ۲۰۲۴ رکورد صادرات فولاد را با ۵۴ میلیون تن شکست. این گزارش فوری تأثیر مستقیم این موج صادراتی بر بازارهای ایران، عراق و امارات را تحلیل می‌کند.', - tags: JSON.stringify(['چین', 'مازاد ظرفیت', 'صادرات', 'آسیای غربی', 'رقابت']), - }, - { - id: 'risk-geopolitical-middle-east-steel-1403', - title: 'ریسک‌های ژئوپلیتیک خاورمیانه و زنجیره تأمین فولاد ایران', - category: 'ریسک و بحران', type: 'risk', - author: 'دکتر فاطمه احمدی', author_role: 'مدیر تحقیقات ریسک', author_initial: 'ا', - publish_date: 'خرداد ۱۴۰۳', pages: 40, price: 360000, is_free: 0, featured: 0, - summary: 'تحلیل ریسک ژئوپلیتیک منطقه خاورمیانه بر امنیت زنجیره تأمین فولاد ایران از زاویه واردات مواد اولیه و صادرات محصول نهایی. سناریوهای بحران در مسیرهای دریایی خلیج فارس و دریای سرخ مدل‌سازی شده‌اند.', - tags: JSON.stringify(['ژئوپلیتیک', 'خاورمیانه', 'زنجیره تأمین', 'ریسک', 'دریای سرخ']), - }, - { - id: 'export-iraq-market-analysis-1403', - title: 'تحلیل بازار فولاد عراق: فرصت‌های صادراتی ایران', - category: 'صادرات', type: 'quarterly', - author: 'مهندس رضا توکلی', author_role: 'تحلیلگر بازار کامودیتی', author_initial: 'ت', - publish_date: 'اردیبهشت ۱۴۰۳', pages: 38, price: 340000, is_free: 0, featured: 0, - summary: 'عراق با تقاضای سالانه بیش از ۵ میلیون تن فولاد، مهم‌ترین بازار صادراتی ایران است. این گزارش رقابت‌پذیری فولاد ایران در برابر رقبای ترکیه، چین و اوکراین در بازار عراق را ارزیابی می‌کند.', - tags: JSON.stringify(['عراق', 'صادرات', 'بازار', 'رقابت‌پذیری', 'میلگرد']), - }, - { - id: 'domestic-capacity-utilization-1402', - title: 'ظرفیت بهره‌برداری واحدهای فولادسازی ایران: آسیب‌شناسی و راهکار', - category: 'تولید داخلی', type: 'quarterly', - author: 'دکتر علی محمدی', author_role: 'مدیر ارشد پژوهش', author_initial: 'م', - publish_date: 'فروردین ۱۴۰۳', pages: 50, price: 410000, is_free: 0, featured: 0, - summary: 'میانگین ضریب بهره‌برداری کارخانه‌های فولادسازی ایران به ۶۸ درصد رسیده که پایین‌تر از میانگین جهانی ۷۶ درصد است. این گزارش موانع عملیاتی، تأمین مواد اولیه و بازار را شناسایی کرده است.', - tags: JSON.stringify(['ظرفیت', 'بهره‌برداری', 'کارایی', 'تولید', 'کارخانه']), - }, - { - id: 'flash-rebar-demand-construction-1402', - title: 'فلش: کاهش تقاضای میلگرد در بخش ساختمانی و اثر بر کارخانه‌ها', - category: 'تولید داخلی', type: 'flash', - author: 'مهندس سارا رضایی', author_role: 'پژوهشگر ارشد انرژی', author_initial: 'ر', - publish_date: 'اسفند ۱۴۰۲', pages: 14, price: 115000, is_free: 0, featured: 0, - summary: 'رکود در بازار مسکن منجر به کاهش ۱۲ درصدی تقاضای میلگرد در زمستان ۱۴۰۲ شده است. این گزارش فوری اثر این کاهش بر درآمد و برنامه تولید کارخانه‌های میلگردساز را ارزیابی می‌کند.', - tags: JSON.stringify(['میلگرد', 'ساختمان', 'تقاضا', 'رکود', 'تولید داخلی']), - }, - { - id: 'policy-privatization-steel-companies-1402', - title: 'واگذاری شرکت‌های فولادی دولتی: ارزیابی تجربه یک دهه خصوصی‌سازی', - category: 'سیاست‌گذاری', type: 'special', - author: 'دکتر حسین کریمی', author_role: 'تحلیلگر ارشد بازار', author_initial: 'ک', - publish_date: 'بهمن ۱۴۰۲', pages: 62, price: 560000, is_free: 0, featured: 0, - summary: 'ارزیابی جامع نتایج خصوصی‌سازی شرکت‌های فولادی بزرگ ایران در یک دهه گذشته با شاخص‌های بهره‌وری، اشتغال و سرمایه‌گذاری. الگوی بهینه مشارکت دولتی-خصوصی در صنعت فولاد پیشنهاد می‌شود.', - tags: JSON.stringify(['خصوصی‌سازی', 'سیاست‌گذاری', 'دولت', 'مدیریت', 'بهره‌وری']), - }, - { - id: 'risk-water-scarcity-steel-industry-1402', - title: 'بحران آب و تهدیدات پایداری صنعت فولاد ایران', - category: 'ریسک و بحران', type: 'risk', - author: 'مهندس سارا رضایی', author_role: 'پژوهشگر ارشد انرژی', author_initial: 'ر', - publish_date: 'دی ۱۴۰۲', pages: 42, price: 370000, is_free: 0, featured: 0, - summary: 'استرس آبی در کلاستر فولادسازی اصفهان-یزد-خراسان به نقطه بحرانی رسیده است. این گزارش ریسک تعطیلی یا کاهش تولید ناشی از کمبود آب را برای ۱۴ واحد تولیدی ارزیابی می‌کند.', - tags: JSON.stringify(['آب', 'بحران', 'پایداری', 'اصفهان', 'ریسک محیط زیستی']), - }, - { - id: 'global-market-q1-1402', - title: 'گزارش فصلی بازار جهانی فولاد: بهار ۱۴۰۲', - category: 'بازار جهانی', type: 'quarterly', - author: 'دکتر علی محمدی', author_role: 'مدیر ارشد پژوهش', author_initial: 'م', - publish_date: 'تیر ۱۴۰۲', pages: 54, price: 440000, is_free: 0, featured: 0, - summary: 'بررسی روندهای بازار جهانی فولاد در بهار ۱۴۰۲ با تمرکز بر بازگشایی اقتصاد چین و اثرات آن بر قیمت‌ها. تأثیر افزایش نرخ بهره فدرال رزرو بر سرمایه‌گذاری در ساختمان و زیرساخت تحلیل شده است.', - tags: JSON.stringify(['بازار جهانی', 'چین', 'نرخ بهره', 'تقاضا', 'بهار ۱۴۰۲']), - }, - { - id: 'export-africa-emerging-markets-1402', - title: 'فرصت‌های صادرات فولاد ایران به بازارهای نوظهور آفریقا', - category: 'صادرات', type: 'special', - author: 'دکتر فاطمه احمدی', author_role: 'مدیر تحقیقات ریسک', author_initial: 'ا', - publish_date: 'آبان ۱۴۰۲', pages: 48, price: 480000, is_free: 0, featured: 0, - summary: 'آفریقا با رشد سالانه ۶ درصد در تقاضای فولاد، بازار استراتژیک بلندمدت برای صادرکنندگان ایران محسوب می‌شود. این گزارش ۸ بازار هدف اولویت‌دار آفریقایی را با تحلیل ریسک و فرصت معرفی می‌کند.', - tags: JSON.stringify(['آفریقا', 'بازارهای نوظهور', 'صادرات', 'تنوع‌سازی', 'بلندمدت']), - }, - { - id: 'flash-gas-price-hike-steel-cost-1403', - title: 'فلش: اثر افزایش قیمت گاز بر قیمت تمام‌شده فولاد ایران', - category: 'انرژی و ESG', type: 'flash', - author: 'مهندس رضا توکلی', author_role: 'تحلیلگر بازار کامودیتی', author_initial: 'ت', - publish_date: 'شهریور ۱۴۰۳', pages: 11, price: 105000, is_free: 0, featured: 0, - summary: 'افزایش ۳۰ درصدی قیمت گاز صنعتی در مهر ۱۴۰۳ هزینه تولید هر تن DRI را حدود ۱۲۰۰ تومان افزایش می‌دهد. این گزارش فوری اثر این شوک هزینه‌ای بر حاشیه سود تولیدکنندگان DRI و فولاد را محاسبه می‌کند.', - tags: JSON.stringify(['گاز', 'انرژی', 'هزینه تولید', 'DRI', 'قیمت‌گذاری']), - }, - ]; +async function req(path, { method = 'GET', body } = {}) { + const headers = { 'Content-Type': 'application/json' }; + if (cookie) headers['Cookie'] = cookie; + const res = await fetch(`${BASE}${path}`, { + method, headers, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + if (!res.ok) throw new Error(`${method} ${path} → ${res.status}: ${text}`); + const sc = res.headers.get('set-cookie'); + if (sc) cookie = sc.split(';')[0]; + return text ? JSON.parse(text) : null; +} - for (const r of reports) { - upsertArticle.run(r); +async function seedIfEmpty(label, checkPath, items, postFn) { + const existing = await req(checkPath); + if (Array.isArray(existing) && existing.length > 0) { + console.log(` SKIP ${label} (${existing.length} records exist)`); + return; } - console.log(` ✓ ${reports.length} گزارش وارد شد`); - - /* ─── EVENTS ────────────────────────────────────────────────── */ - const upsertEvent = db.prepare(` - INSERT OR REPLACE INTO events - (id, title_fa, date_fa, month_fa, year, type, - location_fa, city_fa, description_fa, registration_open, sort_order, - created_at, updated_at) - VALUES - (@id, @title_fa, @date_fa, @month_fa, @year, @type, - @location_fa, @city_fa, @description_fa, @registration_open, @sort_order, - datetime('now'), datetime('now')) - `); - - const events = [ - { - id: 'iran-steel-congress-1403', - title_fa: 'کنگره بین‌المللی فولاد ایران ۱۴۰۳', - date_fa: '12', month_fa: 'اسفند', year: '1403', - type: 'conference', - location_fa: 'مرکز همایش‌های بین‌المللی تهران', city_fa: 'تهران', - description_fa: 'بزرگ‌ترین گردهمایی سالانه متخصصان، سیاست‌گذاران و فعالان صنعت فولاد ایران با حضور ۱۲۰۰ نفر شرکت‌کننده از ۲۵ کشور.', - registration_open: 1, sort_order: 1, - }, - { - id: 'middle-east-steel-metals-2024', - title_fa: 'Middle East Steel & Metals 2024', - date_fa: '25', month_fa: 'اسفند', year: '1403', - type: 'exhibition', - location_fa: 'مرکز نمایشگاهی دبی ورلد ترید سنتر', city_fa: 'دبی', - description_fa: 'نمایشگاه تخصصی فولاد و فلزات خاورمیانه با حضور بیش از ۳۰۰ شرکت از ۴۰ کشور؛ فرصتی برای معرفی ظرفیت‌های صادراتی صنعت فولاد ایران.', - registration_open: 1, sort_order: 2, - }, - { - id: 'steel-policy-seminar-1404', - title_fa: 'نشست تخصصی سیاست‌گذاری فولاد ۱۴۰۴', - date_fa: '8', month_fa: 'فروردین', year: '1404', - type: 'seminar', - location_fa: 'پژوهشکده توسعه صنعتی ایران', city_fa: 'تهران', - description_fa: 'نشست تخصصی با حضور نمایندگان وزارت صمت، سازمان ایمیدرو و انجمن‌های تخصصی برای بررسی سیاست‌های سال ۱۴۰۴ در صنعت فولاد.', - registration_open: 1, sort_order: 3, - }, - { - id: 'world-steel-forum-2025', - title_fa: 'World Steel Forum 2025', - date_fa: '20', month_fa: 'فروردین', year: '1404', - type: 'international', - location_fa: 'مرکز کنفرانس برلین', city_fa: 'برلین', - description_fa: 'اجلاس سالانه انجمن جهانی فولاد با تمرکز بر کربن‌زدایی، تجارت جهانی و تحولات ساختاری صنعت فولاد تا افق ۲۰۳۰.', - registration_open: 0, sort_order: 4, - }, - { - id: 'iran-metallurgy-expo-1404', - title_fa: 'نمایشگاه متالورژی ایران', - date_fa: '15', month_fa: 'اردیبهشت', year: '1404', - type: 'exhibition', - location_fa: 'نمایشگاه بین‌المللی اصفهان', city_fa: 'اصفهان', - description_fa: 'نمایشگاه تخصصی متالورژی، فولاد و فلزات غیرآهنی ایران با محوریت فناوری‌های نوین تولید و تجهیزات صنعتی.', - registration_open: 0, sort_order: 5, - }, - { - id: 'oecd-steel-summit-2025', - title_fa: 'اجلاس OECD فولاد', - date_fa: '3', month_fa: 'خرداد', year: '1404', - type: 'international', - location_fa: 'دفتر مرکزی OECD', city_fa: 'پاریس', - description_fa: 'نشست سالانه کمیته فولاد OECD برای بررسی مازاد ظرفیت جهانی، سیاست‌های تجاری و استانداردهای پایداری در صنعت فولاد.', - registration_open: 0, sort_order: 6, - }, - ]; - - for (const e of events) { - upsertEvent.run(e); + console.log(` SEED ${label} (${items.length} items)...`); + for (const item of items) { + try { await postFn(item); } + catch (e) { console.warn(` WARN ${label}:`, e.message); } } - console.log(` ✓ ${events.length} رویداد وارد شد`); + console.log(` OK ${label}`); +} - /* ─── TEAM MEMBERS ──────────────────────────────────────────── */ - const upsertTeam = db.prepare(` - INSERT OR REPLACE INTO team_members - (id, name_fa, role_fa, initial, bio_fa, expertise, - email, report_count, is_expert, sort_order, - created_at, updated_at) - VALUES - (@id, @name_fa, @role_fa, @initial, @bio_fa, @expertise, - @email, @report_count, @is_expert, @sort_order, - datetime('now'), datetime('now')) - `); +// ── DATA ────────────────────────────────────────────────────────────────────── - const team = [ - { - id: 'ali-mohammadi', - name_fa: 'دکتر علی محمدی', role_fa: 'مدیر ارشد پژوهش', initial: 'م', - bio_fa: 'دکتری اقتصاد صنعتی از دانشگاه تهران با بیش از ۱۸ سال تجربه در تحلیل بازارهای فولاد و فلزات. پیش از پیوستن به اندیشکده، مشاور ارشد سازمان توسعه و نوسازی معادن و صنایع معدنی ایران (ایمیدرو) بوده است. بیش از ۵۰ گزارش تحلیلی و ۱۲ مقاله علمی در حوزه اقتصاد فولاد منتشر کرده است.', - expertise: JSON.stringify(['اقتصاد صنعتی', 'تحلیل بازار فولاد', 'سیاست‌گذاری صنعتی', 'مدل‌سازی اقتصادی', 'تجارت بین‌الملل']), - email: 'mohammadi@andishkade-foolad.ir', - report_count: 52, is_expert: 0, sort_order: 1, - }, - { - id: 'fateme-ahmadi', - name_fa: 'دکتر فاطمه احمدی', role_fa: 'مدیر تحقیقات ریسک', initial: 'ا', - bio_fa: 'دکتری مدیریت ریسک از دانشگاه علم و صنعت ایران با تخصص در ارزیابی ریسک‌های ژئوپلیتیک و زنجیره تأمین. سابقه مشاوره به چندین شرکت بزرگ فولادی کشور در حوزه مدیریت ریسک تجاری و قراردادی را دارد. نویسنده کتاب «مدیریت ریسک در صنایع معدنی و فلزی» است.', - expertise: JSON.stringify(['مدیریت ریسک', 'تحریم‌پژوهی', 'ژئوپلیتیک انرژی', 'تأمین مالی پروژه', 'مذاکرات تجاری']), - email: 'ahmadi@andishkade-foolad.ir', - report_count: 38, is_expert: 0, sort_order: 2, - }, - { - id: 'hosein-karimi', - name_fa: 'دکتر حسین کریمی', role_fa: 'تحلیلگر ارشد بازار', initial: 'ک', - bio_fa: 'دکتری مهندسی مواد از دانشگاه صنعتی اصفهان با تخصص ترکیبی در متالورژی و اقتصاد بازار کامودیتی‌ها. پنج سال سابقه کار در واحد پژوهش و توسعه فولاد مبارکه اصفهان دارد. به‌طور منظم گزارش‌های فصلی بازار جهانی فولاد را تهیه و منتشر می‌کند.', - expertise: JSON.stringify(['بازار جهانی فولاد', 'قیمت‌گذاری کامودیتی', 'متالورژی صنعتی', 'پیش‌بینی تقاضا', 'بازارهای آسیایی']), - email: 'karimi@andishkade-foolad.ir', - report_count: 41, is_expert: 0, sort_order: 3, - }, - { - id: 'sara-rezaei', - name_fa: 'مهندس سارا رضایی', role_fa: 'پژوهشگر ارشد انرژی', initial: 'ر', - bio_fa: 'کارشناسی ارشد مهندسی انرژی از دانشگاه شریف با تمرکز بر بهینه‌سازی مصرف انرژی در صنایع سنگین. تجربه عملیاتی در حسابرسی انرژی کارخانه‌های فولادی در اصفهان، خراسان و هرمزگان دارد.', - expertise: JSON.stringify(['انرژی صنعتی', 'ESG و پایداری', 'کربن‌زدایی', 'بهره‌وری انرژی', 'مقررات زیست‌محیطی']), - email: 'rezaei@andishkade-foolad.ir', - report_count: 29, is_expert: 0, sort_order: 4, - }, - { - id: 'reza-tavakoli', - name_fa: 'مهندس رضا توکلی', role_fa: 'تحلیلگر بازار کامودیتی', initial: 'ت', - bio_fa: 'کارشناسی ارشد اقتصاد از دانشگاه تهران با تخصص در تحلیل بنیادی و تکنیکال بازارهای کامودیتی. سابقه فعالیت در کارگزاری‌های بورس کالا و تهیه گزارش‌های فوری برای معامله‌گران فولاد را دارد.', - expertise: JSON.stringify(['بورس کالا', 'تحلیل تکنیکال', 'بازارهای آتی', 'قیمت‌گذاری سنگ‌آهن', 'بازار داخلی فولاد']), - email: 'tavakoli@andishkade-foolad.ir', - report_count: 34, is_expert: 0, sort_order: 5, - }, - { - id: 'maryam-hosseini', - name_fa: 'دکتر مریم حسینی', role_fa: 'پژوهشگر ارشد صادرات', initial: 'ح', - bio_fa: 'دکتری بازاریابی بین‌الملل از دانشگاه الزهرا با تجربه گسترده در مطالعات بازارهای صادراتی آسیا، آفریقا و اروپای شرقی. پیش از این در اتاق بازرگانی ایران و کمیته فولاد آن مشغول به فعالیت بوده است.', - expertise: JSON.stringify(['بازاریابی بین‌الملل', 'توسعه صادرات', 'بازار آفریقا و آسیا', 'استراتژی بازار', 'مذاکرات تجاری']), - email: 'hosseini@andishkade-foolad.ir', - report_count: 22, is_expert: 0, sort_order: 6, - }, - ]; +const EVENTS = [ + { titleFa: 'کنگره بین‌المللی فولاد ایران ۱۴۰۳', titleEn: 'Iran International Steel Congress 1403', dateFa: '12', dateEn: '12', monthFa: 'اسفند', monthEn: 'March', year: 1403, type: 'conference', locationFa: 'مرکز همایش‌های بین‌المللی تهران', locationEn: 'Tehran International Conference Centre', cityFa: 'تهران', cityEn: 'Tehran', descriptionFa: 'بزرگ‌ترین گردهمایی سالانه متخصصان، سیاست‌گذاران و فعالان صنعت فولاد ایران با حضور ۱۲۰۰ نفر شرکت‌کننده از ۲۵ کشور.', descriptionEn: 'The largest annual gathering of Iran steel industry specialists — 1,200 participants from 25 countries.', registrationOpen: 1, sortOrder: 1 }, + { titleFa: 'Middle East Steel & Metals 2024', titleEn: 'Middle East Steel & Metals 2024', dateFa: '25', dateEn: '25', monthFa: 'اسفند', monthEn: 'March', year: 1403, type: 'exhibition', locationFa: 'مرکز نمایشگاهی دبی ورلد ترید سنتر', locationEn: 'Dubai World Trade Centre', cityFa: 'دبی', cityEn: 'Dubai', descriptionFa: 'نمایشگاه تخصصی فولاد و فلزات خاورمیانه با حضور بیش از ۳۰۰ شرکت از ۴۰ کشور.', descriptionEn: 'Middle East steel and metals exhibition — 300+ companies from 40 countries.', registrationOpen: 1, sortOrder: 2 }, + { titleFa: 'نشست تخصصی سیاست‌گذاری فولاد ۱۴۰۴', titleEn: 'Steel Policy Seminar 1404', dateFa: '8', dateEn: '8', monthFa: 'فروردین', monthEn: 'April', year: 1404, type: 'seminar', locationFa: 'پژوهشکده توسعه صنعتی ایران', locationEn: 'Iran Industrial Development Research Institute', cityFa: 'تهران', cityEn: 'Tehran', descriptionFa: 'نشست تخصصی با حضور نمایندگان وزارت صمت، سازمان ایمیدرو و انجمن‌های تخصصی.', descriptionEn: 'Expert session with representatives from MIMT, IMIDRO and industry associations.', registrationOpen: 1, sortOrder: 3 }, + { titleFa: 'World Steel Forum 2025', titleEn: 'World Steel Forum 2025', dateFa: '20', dateEn: '20', monthFa: 'فروردین', monthEn: 'April', year: 1404, type: 'international', locationFa: 'مرکز کنفرانس برلین', locationEn: 'Berlin Conference Centre', cityFa: 'برلین', cityEn: 'Berlin', descriptionFa: 'اجلاس سالانه انجمن جهانی فولاد با تمرکز بر کربن‌زدایی، تجارت جهانی و تحولات ساختاری.', descriptionEn: 'World Steel Association annual summit on decarbonisation, global trade and structural change.', registrationOpen: 0, sortOrder: 4 }, + { titleFa: 'نمایشگاه متالورژی ایران', titleEn: 'Iran Metallurgy Expo', dateFa: '15', dateEn: '15', monthFa: 'اردیبهشت', monthEn: 'May', year: 1404, type: 'exhibition', locationFa: 'نمایشگاه بین‌المللی اصفهان', locationEn: 'Isfahan International Exhibition', cityFa: 'اصفهان', cityEn: 'Isfahan', descriptionFa: 'نمایشگاه تخصصی متالورژی، فولاد و فلزات غیرآهنی ایران با فناوری‌های نوین تولید.', descriptionEn: 'Specialised Iran metallurgy, steel and non-ferrous metals exhibition.', registrationOpen: 0, sortOrder: 5 }, + { titleFa: 'اجلاس OECD فولاد', titleEn: 'OECD Steel Summit', dateFa: '3', dateEn: '3', monthFa: 'خرداد', monthEn: 'June', year: 1404, type: 'international', locationFa: 'دفتر مرکزی OECD', locationEn: 'OECD Headquarters', cityFa: 'پاریس', cityEn: 'Paris', descriptionFa: 'نشست سالانه کمیته فولاد OECD برای بررسی مازاد ظرفیت جهانی و استانداردهای پایداری.', descriptionEn: 'OECD Steel Committee annual session on global overcapacity and sustainability standards.', registrationOpen: 0, sortOrder: 6 }, +]; - for (const m of team) { - upsertTeam.run(m); +const TEAM = [ + { nameFa: 'دکتر علی محمدی', nameEn: 'Dr. Ali Mohammadi', roleFa: 'مدیر ارشد پژوهش', roleEn: 'Chief Research Officer', initial: 'م', bioFa: 'دکتری اقتصاد صنعتی از دانشگاه تهران با بیش از ۱۸ سال تجربه در تحلیل بازارهای فولاد و فلزات.', bioEn: 'PhD in Industrial Economics from University of Tehran, 18+ years in steel/metals market analysis.', expertise: ['اقتصاد صنعتی','تحلیل بازار فولاد','سیاست‌گذاری صنعتی','مدل‌سازی اقتصادی','تجارت بین‌الملل'], reportCount: 52, email: 'mohammadi@andishkade-foolad.ir', isExpert: 0, sortOrder: 1 }, + { nameFa: 'دکتر فاطمه احمدی', nameEn: 'Dr. Fatemeh Ahmadi', roleFa: 'مدیر تحقیقات ریسک', roleEn: 'Risk Research Director', initial: 'ا', bioFa: 'دکتری مدیریت ریسک از دانشگاه علم و صنعت ایران با تخصص در ارزیابی ریسک‌های ژئوپلیتیک.', bioEn: 'PhD in Risk Management from IUST, specialising in geopolitical and supply-chain risk.', expertise: ['مدیریت ریسک','تحریم‌پژوهی','ژئوپلیتیک انرژی','تأمین مالی پروژه','مذاکرات تجاری'], reportCount: 38, email: 'ahmadi@andishkade-foolad.ir', isExpert: 0, sortOrder: 2 }, + { nameFa: 'دکتر حسین کریمی', nameEn: 'Dr. Hossein Karimi', roleFa: 'تحلیلگر ارشد بازار', roleEn: 'Senior Market Analyst', initial: 'ک', bioFa: 'دکتری مهندسی مواد از دانشگاه صنعتی اصفهان با تخصص در متالورژی و اقتصاد بازار کامودیتی‌ها.', bioEn: 'PhD in Materials Engineering from Isfahan UT, combined expertise in metallurgy and commodity economics.', expertise: ['بازار جهانی فولاد','قیمت‌گذاری کامودیتی','متالورژی صنعتی','پیش‌بینی تقاضا','بازارهای آسیایی'], reportCount: 41, email: 'karimi@andishkade-foolad.ir', isExpert: 0, sortOrder: 3 }, + { nameFa: 'مهندس سارا رضایی', nameEn: 'Eng. Sara Rezaei', roleFa: 'پژوهشگر ارشد انرژی', roleEn: 'Senior Energy Researcher', initial: 'ر', bioFa: 'کارشناسی ارشد مهندسی انرژی از دانشگاه شریف با تمرکز بر بهینه‌سازی مصرف انرژی در صنایع سنگین.', bioEn: 'MSc in Energy Engineering from Sharif UT, focusing on energy optimisation in heavy industry.', expertise: ['انرژی صنعتی','ESG و پایداری','کربن‌زدایی','بهره‌وری انرژی','مقررات زیست‌محیطی'], reportCount: 29, email: 'rezaei@andishkade-foolad.ir', isExpert: 0, sortOrder: 4 }, + { nameFa: 'مهندس رضا توکلی', nameEn: 'Eng. Reza Tavakoli', roleFa: 'تحلیلگر بازار کامودیتی', roleEn: 'Commodity Market Analyst', initial: 'ت', bioFa: 'کارشناسی ارشد اقتصاد از دانشگاه تهران با تخصص در تحلیل بنیادی و تکنیکال بازارهای کامودیتی.', bioEn: 'MSc in Economics from University of Tehran, specialising in fundamental and technical commodity analysis.', expertise: ['بورس کالا','تحلیل تکنیکال','بازارهای آتی','قیمت‌گذاری سنگ‌آهن','بازار داخلی فولاد'], reportCount: 34, email: 'tavakoli@andishkade-foolad.ir', isExpert: 0, sortOrder: 5 }, + { nameFa: 'دکتر مریم حسینی', nameEn: 'Dr. Maryam Hosseini', roleFa: 'پژوهشگر ارشد صادرات', roleEn: 'Senior Export Researcher', initial: 'ح', bioFa: 'دکتری بازاریابی بین‌الملل از دانشگاه الزهرا با تجربه گسترده در بازارهای صادراتی آسیا، آفریقا و اروپای شرقی.', bioEn: 'PhD in International Marketing from Alzahra University, extensive experience in Asia, Africa and Eastern Europe export markets.', expertise: ['بازاریابی بین‌الملل','توسعه صادرات','بازار آفریقا و آسیا','استراتژی بازار','مذاکرات تجاری'], reportCount: 22, email: 'hosseini@andishkade-foolad.ir', isExpert: 0, sortOrder: 6 }, +]; + +const PLANS = [ + { nameFa: 'فردی', nameEn: 'Individual', price: 350000, periodFa: 'ماهانه', periodEn: 'Monthly', features: ['دسترسی به تمام گزارش‌های پایه','دانلود ۵ گزارش در ماه','دریافت خبرنامه هفتگی','دسترسی به داده‌های قیمت لحظه‌ای','آرشیو ۱۲ ماه گذشته'], isFeatured: 0, ctaFa: 'شروع اشتراک', ctaEn: 'Start subscription', sortOrder: 1 }, + { nameFa: 'سازمانی', nameEn: 'Organisational', price: 1200000, periodFa: 'ماهانه', periodEn: 'Monthly', features: ['دسترسی کامل به تمام گزارش‌ها','دانلود نامحدود گزارش','دسترسی برای ۵ کاربر همزمان','گزارش‌های ویژه و تحلیل‌های فوری','آرشیو کامل از ابتدای تأسیس','مشاوره تلفنی ماهانه با تحلیلگران','داشبورد اختصاصی تحلیل بازار'], badgeFa: 'پرطرفدار', badgeEn: 'Popular', isFeatured: 1, ctaFa: 'شروع اشتراک', ctaEn: 'Start subscription', sortOrder: 2 }, + { nameFa: 'وزارتخانه', nameEn: 'Ministry', price: 0, periodFa: 'سالانه', periodEn: 'Annual', features: ['دسترسی نامحدود برای تمام کارکنان','سفارش گزارش‌های تحلیلی اختصاصی','نشست‌های تخصصی حضوری با تیم پژوهشی','پشتیبانی اولویت‌دار ۲۴/۷','داشبورد تصمیم‌گیری یکپارچه','API اتصال به سیستم‌های داخلی','آموزش و کارگاه‌های تخصصی دوره‌ای','گزارش‌دهی سفارشی برای سیاست‌گذاران'], isFeatured: 0, ctaFa: 'درخواست مشاوره', ctaEn: 'Request consultation', sortOrder: 3 }, +]; + +const BANNERS = [ + { position: 'featured', active: 1, image: '/Horizontal_Book_Mockup_6 1.webp', ctaUrl: '', overlineFa: 'ماهنامه شماره ۱۲ منتشر شد...', titleFa: 'دیپلماسی معدنی در عصر انسدادهای ژئوپلیتیک', subtitleFa: 'Mineral Diplomacy in the Era of Geopolitical Blockades', ctaLabelFa: 'دانلود گزارش', overlineEn: 'Monthly issue #12 is out...', titleEn: 'Mineral Diplomacy in the Era of Geopolitical Blockades', subtitleEn: 'دیپلماسی معدنی در عصر انسدادهای ژئوپلیتیک', ctaLabelEn: 'Download report', sortOrder: 1 }, +]; + +const VISION_ITEMS = [ + { icon: 'FlaskConical', titleFa: 'پیشرو در پژوهش', titleEn: 'Research Leadership', descFa: 'تولید تحلیل‌های راهبردی و گزارش‌های تخصصی در حوزه صنعت فولاد', descEn: 'Producing strategic analyses and specialised reports in the steel industry', sortOrder: 1 }, + { icon: 'Network', titleFa: 'شبکه‌سازی خبرگان', titleEn: 'Expert Networking', descFa: 'ایجاد پیوند میان متخصصان، سیاست‌گذاران و فعالان صنعتی', descEn: 'Building connections between specialists, policymakers, and industry practitioners', sortOrder: 2 }, + { icon: 'Leaf', titleFa: 'فولاد سبز', titleEn: 'Green Steel', descFa: 'ترویج رویکردهای پایدار و کاهش اثرات زیست‌محیطی در زنجیره فولاد', descEn: 'Promoting sustainable approaches and reducing environmental impacts across the steel chain', sortOrder: 3 }, + { icon: 'Telescope', titleFa: 'آینده‌پژوهی', titleEn: 'Futures Research', descFa: 'رصد هوشمند تحولات جهانی و ارائه نقشه راه برای صنعت ایران', descEn: "Smart monitoring of global developments and providing roadmaps for Iran's industry", sortOrder: 4 }, +]; + +const ADVISORY_BOARD = [ + { nameFa: 'دکتر علیرضا اکبریان', nameEn: 'Dr. Alireza Akbarian', roleFa: 'مدیر اندیشکده', roleEn: 'Institute Director', sortOrder: 1 }, + { nameFa: 'مهندس سارا محمدی', nameEn: 'Eng. Sara Mohammadi', roleFa: 'پژوهشگر ارشد فولاد سبز', roleEn: 'Senior Green Steel Researcher', sortOrder: 2 }, + { nameFa: 'دکتر محمد جلالی', nameEn: 'Dr. Mohammad Jalali', roleFa: 'تحلیلگر بازارهای جهانی', roleEn: 'Global Markets Analyst', sortOrder: 3 }, + { nameFa: 'دکتر فاطمه رضایی', nameEn: 'Dr. Fatemeh Rezaei', roleFa: 'متخصص ژئوپلیتیک صنعتی', roleEn: 'Industrial Geopolitics Specialist', sortOrder: 4 }, + { nameFa: 'مهندس رضا کریمی', nameEn: 'Eng. Reza Karimi', roleFa: 'کارشناس فناوری و نوآوری', roleEn: 'Technology & Innovation Expert', sortOrder: 5 }, + { nameFa: 'دکتر نیلوفر حسینی', nameEn: 'Dr. Niloofar Hosseini', roleFa: 'پژوهشگر آینده‌پژوهی', roleEn: 'Futures Research Specialist', sortOrder: 6 }, +]; + +const VIDEOS = [ + { title: 'نگاهی به آن سوی مرزهای تولید', author: 'مهندس سیامک فجری', summary: 'فراتر از داده‌های خام قیمت و تناژ، به سراغ مهندسی تغییر و معماری آینده صنعت می‌رویم.', type: 'video', category: 'تولید داخلی', publishDate: '۲۵ خرداد ۱۴۰۵', coverImage: '/news/photo-1504307651254-35680f356dfd.jpg', isFree: true, tags: ['EP.12','ویدیوکست'] }, + { title: 'تحلیل بازار جهانی فلزات پایه', author: 'دکتر نیلوفر احمدی', summary: 'نوسانات قیمت مس، آلومینیوم و روی و تأثیر آن بر زنجیره ارزش فولاد.', type: 'video', category: 'بازار جهانی', publishDate: '۱۸ خرداد ۱۴۰۵', coverImage: '/news/photo-1558618666-fcd25c85cd64.jpg', isFree: true, tags: ['EP.11','ویدیوکست'] }, + { title: 'ژئوپلیتیک و صادرات فولاد ایران', author: 'مهندس فرید تهرانی', summary: 'تأثیر تحولات منطقه‌ای بر بازارهای صادراتی فولاد ایران و فرصت‌های پیش رو.', type: 'video', category: 'صادرات', publishDate: '۱۱ خرداد ۱۴۰۵', coverImage: '/news/photo-1504328345606-18bbc8c9d7d1.jpg', isFree: true, tags: ['EP.10','ویدیوکست'] }, + { title: 'سرمایه‌گذاری در زنجیره ارزش فولاد', author: 'دکتر آرش کریمی', summary: 'فرصت‌ها و چالش‌های سرمایه‌گذاری در بخش‌های مختلف زنجیره ارزش فولاد.', type: 'video', category: 'بازار جهانی', publishDate: '۴ خرداد ۱۴۰۵', coverImage: '/news/photo-1611273426858-450d8e3c9fce.jpg', isFree: true, tags: ['EP.09','ویدیوکست'] }, + { title: 'آینده فولاد سبز در ایران', author: 'مهندس کاوه رستمی', summary: 'مسیر گذار به فولاد کم‌کربن در ایران؛ چالش‌ها، فرصت‌ها و ضرورت سرمایه‌گذاری.', type: 'video', category: 'انرژی و ESG', publishDate: '۲۸ اردیبهشت ۱۴۰۵', coverImage: '/news/photo-1566873535350-6586b0b7a1f2.jpg', isFree: true, tags: ['EP.08','ویدیوکست'] }, + { title: 'هوش مصنوعی و تحول کارخانه‌های فولاد', author: 'دکتر رضا محمدی', summary: 'چگونه یادگیری ماشین کارایی خطوط تولید فولاد را متحول می‌کند.', type: 'video', category: 'تولید داخلی', publishDate: '۲۱ اردیبهشت ۱۴۰۵', coverImage: '/news/photo-1578662996442-48f60103fc96.jpg', isFree: true, tags: ['EP.07','ویدیوکست'] }, +]; + +const PODCASTS = [ + { title: 'کالبدشکافی عقلانی پیچیدگی‌های صنعتی', author: 'مهندس سیامک شجاعی', summary: 'صنعت فولاد در عصر حاضر، در پیچیده‌ترین چرخه حیات خود قرار گرفته است؛ جایی که همگرایی هوش مصنوعی، الزامات زیست‌محیطی و تنش‌های ژئواکونومیک، قواعد کلاسیک تولید و تجارت را به‌کلی بازنویسی کرده‌اند.', type: 'podcast', category: 'تولید داخلی', publishDate: '۲۵ / خرداد / ۱۴۰۵', coverImage: '/news/photo-1540575467063-178a50c2df87.jpg', isFree: true, tags: ['اپیزود ۷۷','پادکست'] }, + { title: 'آزمایشگاه کوره؛ فناوری‌های نوین فولادسازی', author: 'دکتر رضا محمدی', summary: 'در این اپیزود با نگاهی موشکافانه به چالش‌های فنی گذار از کوره‌های بلند به فولادسازی پاک می‌پردازیم.', type: 'podcast', category: 'تولید داخلی', publishDate: '۱۸ / خرداد / ۱۴۰۵', coverImage: '/news/photo-1566873535350-6586b0b7a1f2.jpg', isFree: true, tags: ['اپیزود ۷۶','پادکست'] }, + { title: 'معمار فولاد؛ رهبری در عصر تحول', author: 'مهندس کاوه رستمی', summary: 'داستان ساختن فولاد فردا؛ روایت رهبرانی که مدل‌های سنتی کسب‌وکار را به چالش کشیده‌اند.', type: 'podcast', category: 'تولید داخلی', publishDate: '۱۱ / خرداد / ۱۴۰۵', coverImage: '/news/photo-1578662996442-48f60103fc96.jpg', isFree: true, tags: ['اپیزود ۷۵','پادکست'] }, + { title: 'فولاد سبز؛ رویا یا واقعیت؟', author: 'دکتر نیلوفر احمدی', summary: 'بررسی مسیر دشوار و پرهزینه گذار به تولید فولاد با کمترین انتشار کربن و آنچه صنعت ایران باید بداند.', type: 'podcast', category: 'انرژی و ESG', publishDate: '۴ / خرداد / ۱۴۰۵', coverImage: '/news/photo-1504328345606-18bbc8c9d7d1.jpg', isFree: true, tags: ['اپیزود ۷۴','پادکست'] }, + { title: 'بازی بزرگ؛ چین، هند و آینده بازار فولاد', author: 'مهندس فرید تهرانی', summary: 'چگونه رقابت دو غول آسیایی مسیر بازار جهانی فولاد را در دهه پیش رو تغییر خواهد داد.', type: 'podcast', category: 'بازار جهانی', publishDate: '۲۸ / اردیبهشت / ۱۴۰۵', coverImage: '/news/photo-1558618666-fcd25c85cd64.jpg', isFree: true, tags: ['اپیزود ۷۳','پادکست'] }, + { title: 'هوش مصنوعی در خط تولید فولاد', author: 'دکتر آرش کریمی', summary: 'کاربردهای عملی یادگیری ماشین در بهینه‌سازی فرآیند ذوب، کنترل کیفیت و نگهداری پیش‌گیرانه.', type: 'podcast', category: 'تولید داخلی', publishDate: '۲۱ / اردیبهشت / ۱۴۰۵', coverImage: '/news/photo-1611273426858-450d8e3c9fce.jpg', isFree: true, tags: ['اپیزود ۷۲','پادکست'] }, +]; + +const RADAR_ITEMS = [ + { category: 'commodity', img: '/news/photo-1611273426858-450d8e3c9fce.jpg', authorName: 'دکتر سارا احمدی', authorAvatar: '/news/photo-1494790108377-be9c29b29330.jpg', titleFa: 'پیش‌بینی بازار با هوش مصنوعی', dateFa: '۱۴ اردیبهشت ۱۴۰۵', excerptFa: 'مدل‌های یادگیری ماشین تقاضای لیتیوم و فلزات باتری را در افق ۲۰۳۵ بازتعریف می‌کنند.', titleEn: 'AI-Powered Market Forecasting', dateEn: 'May 4, 2026', excerptEn: 'Machine-learning models are redefining lithium and battery-metal demand on the 2035 horizon.', sortOrder: 1 }, + { category: 'tech', img: '/news/photo-1581092160607-ee22731c9b4e.jpg', authorName: 'مهندس رضا کریمی', authorAvatar: '/news/photo-1500648767791-00dcc994a43e.jpg', titleFa: 'تحول دیجیتال در زنجیره ارزش فولاد', dateFa: '۱۴ اردیبهشت ۱۴۰۵', excerptFa: 'تحول دیجیتال و اتوماسیون هوشمند در حال تغییر بنیادین زنجیره ارزش فولاد هستند.', titleEn: 'Digital Transformation in Steel Value Chain', dateEn: 'May 4, 2026', excerptEn: 'Digital transformation and smart automation are fundamentally reshaping the steel value chain.', sortOrder: 2 }, + { category: 'market', img: '/news/photo-1504307651254-35680f356dfd.jpg', authorName: 'دکتر مریم رضایی', authorAvatar: '/news/photo-1438761681033-6461ffad8d80.jpg', titleFa: 'تأثیر نوسانات طلا بر سرمایه‌گذاری فولاد', dateFa: '۱۴ اردیبهشت ۱۴۰۵', excerptFa: 'تحلیل روند قیمت طلا و فلزات گران‌بها و اثر آن بر سرمایه‌گذاری در زنجیره فولاد.', titleEn: 'Gold Volatility Impact on Steel Investment', dateEn: 'May 4, 2026', excerptEn: 'Gold and precious-metal price trends and their effect on steel-chain investment.', sortOrder: 3 }, + { category: 'geo', img: '/news/photo-1518770660439-4636190af475.jpg', authorName: 'دکتر علی موسوی', authorAvatar: '/news/photo-1472099645785-5658abf4ff4e.jpg', titleFa: 'ژئوپلیتیک و بازارهای صادراتی فولاد ایران', dateFa: '۱۴ اردیبهشت ۱۴۰۵', excerptFa: 'هندسه قدرت جهانی و اثر تحولات ژئوپلیتیک بر بازارهای صادراتی فولاد ایران.', titleEn: "Geopolitics and Iran's Steel Export Markets", dateEn: 'May 4, 2026', excerptEn: "Global power geometry and the impact of geopolitics on Iran's steel export markets.", sortOrder: 4 }, + { category: 'energy', img: '/news/photo-1466611653911-95081537e5b7.jpg', authorName: 'مهندس نازنین حسینی', authorAvatar: '/news/photo-1544005313-94ddf0286df2.jpg', titleFa: 'گذار انرژی و فولاد سبز', dateFa: '۱۲ اردیبهشت ۱۴۰۵', excerptFa: 'هیدروژن سبز و برق تجدیدپذیر مسیر کربن‌زدایی صنعت فولاد را شتاب می‌دهند.', titleEn: 'Energy Transition & Green Steel', dateEn: 'May 2, 2026', excerptEn: 'Green hydrogen and renewable power are accelerating steel decarbonization.', sortOrder: 5 }, + { category: 'tech', img: '/news/photo-1567789884554-0b844b597180.jpg', authorName: 'دکتر سارا احمدی', authorAvatar: '/news/photo-1494790108377-be9c29b29330.jpg', titleFa: 'اتوماسیون خطوط نورد', dateFa: '۱۰ اردیبهشت ۱۴۰۵', excerptFa: 'سیستم‌های کنترل هوشمند بهره‌وری خطوط نورد را تا ۲۲٪ افزایش داده‌اند.', titleEn: 'Rolling Line Automation', dateEn: 'Apr 30, 2026', excerptEn: 'Smart control systems have boosted rolling-line productivity by up to 22%.', sortOrder: 6 }, + { category: 'commodity', img: '/news/photo-1570168007204-dfb528c6958f.jpg', authorName: 'مهندس رضا کریمی', authorAvatar: '/news/photo-1500648767791-00dcc994a43e.jpg', titleFa: 'زنجیره تأمین سنگ‌آهن', dateFa: '۸ اردیبهشت ۱۴۰۵', excerptFa: 'نوسانات عرضه سنگ‌آهن استرالیا و برزیل و پیامد آن برای تولیدکنندگان منطقه.', titleEn: 'Iron Ore Supply Chain', dateEn: 'Apr 28, 2026', excerptEn: 'Australian and Brazilian iron-ore supply swings and their regional fallout.', sortOrder: 7 }, + { category: 'market', img: '/news/photo-1540575467063-178a50c2df87.jpg', authorName: 'دکتر مریم رضایی', authorAvatar: '/news/photo-1438761681033-6461ffad8d80.jpg', titleFa: 'تحلیل بازار جهانی فولاد', dateFa: '۶ اردیبهشت ۱۴۰۵', excerptFa: 'سیل صادرات فولاد چین و فشار قیمتی بر بازارهای هدف صادراتی ایران.', titleEn: 'Global Steel Market Analysis', dateEn: 'Apr 26, 2026', excerptEn: "China's steel export flood and price pressure on Iran's target export markets.", sortOrder: 8 }, +]; + +const FACTORY_REPORTS = [ + { img: '/news/photo-1504307651254-35680f356dfd.jpg', scrollDir: 'left', tagFa: 'تولید و صادرات', titleFa: 'تحلیل عملکرد تولید و صادرات', dateFa: 'بهمن ۱۴۰۳', excerptFa: 'بررسی آمار تولید ماهانه، مقایسه با بودجه سالانه و روند صادرات فولاد تخت در بازارهای منطقه‌ای.', tagEn: 'Output & Exports', titleEn: 'Production & Export Performance', dateEn: 'Feb 2025', excerptEn: 'Monthly output vs. annual budget and the flat-steel export trend across regional markets.', sortOrder: 1 }, + { img: '/news/photo-1581092160607-ee22731c9b4e.jpg', scrollDir: 'left', tagFa: 'بازار و قیمت', titleFa: 'وضعیت بازار و قیمت‌گذاری', dateFa: 'دی ۱۴۰۳', excerptFa: 'تحلیل نوسانات قیمت فولاد تخت در بازار داخلی و اثر نرخ ارز بر چشم‌انداز زمستان.', tagEn: 'Market & Price', titleEn: 'Market & Pricing Outlook', dateEn: 'Jan 2025', excerptEn: 'Flat-steel price swings in the domestic market and the FX effect on the winter outlook.', sortOrder: 2 }, + { img: '/news/photo-1518770660439-4636190af475.jpg', scrollDir: 'left', tagFa: 'فناوری', titleFa: 'دیجیتال‌سازی خطوط تولید', dateFa: 'آذر ۱۴۰۳', excerptFa: 'گزارشی از پیاده‌سازی سامانه‌های کنترل هوشمند و اثر آن بر بهره‌وری خطوط نورد.', tagEn: 'Technology', titleEn: 'Production Line Digitalization', dateEn: 'Dec 2024', excerptEn: 'Rolling-out smart control systems and their impact on rolling-line productivity.', sortOrder: 3 }, + { img: '/news/photo-1567789884554-0b844b597180.jpg', scrollDir: 'up', tagFa: 'نیروی انسانی', titleFa: 'تحلیل نیروی انسانی و بهره‌وری', dateFa: 'آبان ۱۴۰۳', excerptFa: 'شاخص‌های بهره‌وری نیروی کار، آموزش‌های ماهانه و آمار ایمنی شغلی.', tagEn: 'Workforce', titleEn: 'Workforce & Productivity', dateEn: 'Nov 2024', excerptEn: 'Labor productivity indicators, monthly training and occupational-safety statistics.', sortOrder: 4 }, + { img: '/news/photo-1466611653911-95081537e5b7.jpg', scrollDir: 'up', tagFa: 'پایداری', titleFa: 'گزارش پایداری و محیط زیست', dateFa: 'آذر ۱۴۰۳', excerptFa: 'شاخص‌های زیست‌محیطی ماهانه؛ مصرف انرژی، کاهش انتشار کربن و بازچرخانی پساب صنعتی.', tagEn: 'Sustainability', titleEn: 'Sustainability & Environment', dateEn: 'Dec 2024', excerptEn: 'Monthly environmental metrics: energy use, carbon reduction and industrial water recycling.', sortOrder: 5 }, + { img: '/news/photo-1570168007204-dfb528c6958f.jpg', scrollDir: 'up', tagFa: 'زنجیره تأمین', titleFa: 'تأمین مواد اولیه و لجستیک', dateFa: 'مهر ۱۴۰۳', excerptFa: 'وضعیت تأمین سنگ‌آهن و قراضه، موجودی انبار و تحلیل ریسک زنجیره تأمین.', tagEn: 'Supply Chain', titleEn: 'Raw Materials & Logistics', dateEn: 'Oct 2024', excerptEn: 'Iron-ore and scrap supply, inventory levels and supply-chain risk analysis.', sortOrder: 6 }, + { img: '/news/photo-1611273426858-450d8e3c9fce.jpg', scrollDir: 'right', tagFa: 'سرمایه‌گذاری', titleFa: 'طرح‌های توسعه و سرمایه‌گذاری', dateFa: 'شهریور ۱۴۰۳', excerptFa: 'پیشرفت طرح‌های توسعه ظرفیت و تحلیل بازده سرمایه‌گذاری پروژه‌های جاری.', tagEn: 'Investment', titleEn: 'Expansion & Investment Plans', dateEn: 'Sep 2024', excerptEn: 'Capacity-expansion progress and ROI analysis of ongoing projects.', sortOrder: 7 }, + { img: '/news/photo-1485827404703-89b55fcc595e.jpg', scrollDir: 'right', tagFa: 'کیفیت', titleFa: 'کنترل کیفیت با هوش مصنوعی', dateFa: 'مرداد ۱۴۰۳', excerptFa: 'کاربرد بینایی ماشین در تشخیص عیوب سطحی و کاهش نرخ ضایعات تولید.', tagEn: 'Quality', titleEn: 'AI-Driven Quality Control', dateEn: 'Aug 2024', excerptEn: 'Machine vision for surface-defect detection and lower production scrap rates.', sortOrder: 8 }, + { img: '/news/photo-1613665813446-82a78c468a1d.jpg', scrollDir: 'right', tagFa: 'انرژی', titleFa: 'تحلیل مصرف انرژی و بهینه‌سازی', dateFa: 'تیر ۱۴۰۳', excerptFa: 'الگوی مصرف برق و گاز، فرصت‌های بهینه‌سازی و اثر آن بر بهای تمام‌شده.', tagEn: 'Energy', titleEn: 'Energy Use & Optimization', dateEn: 'Jul 2024', excerptEn: 'Electricity and gas usage patterns, optimization opportunities and cost impact.', sortOrder: 9 }, +]; + +const INTEGRATIONS = [ + { name: 'London Metal Exchange', icon: 'exchange', accent: '#CD9E53', gridCol: 6, gridRow: 1, sortOrder: 1 }, + { name: 'Shanghai Futures Exchange', icon: 'trending', accent: '#c0392b', gridCol: 4, gridRow: 2, sortOrder: 2 }, + { name: 'S&P Global Platts', icon: 'chart', accent: '#2471a3', gridCol: 6, gridRow: 2, sortOrder: 3 }, + { name: 'Reuters', icon: 'newspaper', accent: '#e67e22', gridCol: 3, gridRow: 3, sortOrder: 4 }, + { name: 'Fastmarkets', icon: 'activity', accent: '#1e8449', gridCol: 5, gridRow: 3, sortOrder: 5 }, + { name: 'World Steel Association', icon: 'building', accent: '#7d3c98', gridCol: 4, gridRow: 4, sortOrder: 6 }, + { name: 'Mysteel', icon: 'database', accent: '#16a085', gridCol: 6, gridRow: 4, sortOrder: 7 }, + { name: 'CME Group', icon: 'coins', accent: '#2980b9', gridCol: 5, gridRow: 5, sortOrder: 8 }, + { name: 'Dalian Commodity Exchange', icon: 'globe', accent: '#d35400', gridCol: 3, gridRow: 5, sortOrder: 9 }, +]; + +// ── MAIN ────────────────────────────────────────────────────────────────────── + +async function main() { + console.log('Logging in...'); + await req('/api/auth/login', { method: 'POST', body: { username: USERNAME, password: PASSWORD } }); + console.log('OK. Seeding...\n'); + + await seedIfEmpty('Events', '/api/events?limit=1', EVENTS, i => req('/api/events', { method: 'POST', body: i })); + await seedIfEmpty('Team', '/api/team?limit=1', TEAM, i => req('/api/team', { method: 'POST', body: i })); + await seedIfEmpty('Plans', '/api/plans?limit=1', PLANS, i => req('/api/plans', { method: 'POST', body: i })); + await seedIfEmpty('Banners', '/api/banners?limit=1', BANNERS, i => req('/api/banners', { method: 'POST', body: i })); + await seedIfEmpty('Vision Items', '/api/vision-items?limit=1', VISION_ITEMS, i => req('/api/vision-items', { method: 'POST', body: i })); + await seedIfEmpty('Advisory Board', '/api/advisory-board?limit=1', ADVISORY_BOARD, i => req('/api/advisory-board', { method: 'POST', body: i })); + await seedIfEmpty('Radar Items', '/api/radar?limit=1', RADAR_ITEMS, i => req('/api/radar', { method: 'POST', body: i })); + await seedIfEmpty('Factory Reports','/api/factory-reports?limit=1', FACTORY_REPORTS,i => req('/api/factory-reports', { method: 'POST', body: i })); + await seedIfEmpty('Integrations', '/api/integrations?limit=1', INTEGRATIONS, i => req('/api/integrations', { method: 'POST', body: i })); + + // Videos + Podcasts live in /api/articles — check by type + const existingVideos = await req('/api/articles?type=video&limit=1'); + if (Array.isArray(existingVideos) && existingVideos.length > 0) { + console.log(` SKIP Videos (records exist)`); + } else { + console.log(` SEED Videos (${VIDEOS.length} items)...`); + for (const v of VIDEOS) { + try { await req('/api/articles', { method: 'POST', body: v }); } + catch (e) { console.warn(' WARN Video:', e.message); } + } + console.log(' OK Videos'); } - console.log(` ✓ ${team.length} عضو تیم وارد شد`); - /* ─── PLANS ─────────────────────────────────────────────────── */ - const upsertPlan = db.prepare(` - INSERT OR REPLACE INTO plans - (id, name_fa, price, period_fa, features, badge_fa, is_featured, cta_fa, sort_order, - created_at, updated_at) - VALUES - (@id, @name_fa, @price, @period_fa, @features, @badge_fa, @is_featured, @cta_fa, @sort_order, - datetime('now'), datetime('now')) - `); - - const plans = [ - { - id: 'individual', - name_fa: 'فردی', price: 350000, period_fa: 'ماهانه', - features: JSON.stringify([ - 'دسترسی به تمام گزارش‌های پایه', - 'دانلود ۵ گزارش در ماه', - 'دریافت خبرنامه هفتگی', - 'دسترسی به داده‌های قیمت لحظه‌ای', - 'آرشیو ۱۲ ماه گذشته', - ]), - badge_fa: null, is_featured: 0, cta_fa: 'شروع اشتراک', sort_order: 1, - }, - { - id: 'organizational', - name_fa: 'سازمانی', price: 1200000, period_fa: 'ماهانه', - features: JSON.stringify([ - 'دسترسی کامل به تمام گزارش‌ها', - 'دانلود نامحدود گزارش', - 'دسترسی برای ۵ کاربر همزمان', - 'گزارش‌های ویژه و تحلیل‌های فوری', - 'آرشیو کامل از ابتدای تأسیس', - 'مشاوره تلفنی ماهانه با تحلیلگران', - 'داشبورد اختصاصی تحلیل بازار', - ]), - badge_fa: 'پرطرفدار', is_featured: 1, cta_fa: 'شروع اشتراک', sort_order: 2, - }, - { - id: 'ministry', - name_fa: 'وزارتخانه', price: 0, period_fa: 'سالانه', - features: JSON.stringify([ - 'دسترسی نامحدود برای تمام کارکنان', - 'سفارش گزارش‌های تحلیلی اختصاصی', - 'نشست‌های تخصصی حضوری با تیم پژوهشی', - 'پشتیبانی اولویت‌دار ۲۴/۷', - 'داشبورد تصمیم‌گیری یکپارچه', - 'API اتصال به سیستم‌های داخلی', - 'آموزش و کارگاه‌های تخصصی دوره‌ای', - 'گزارش‌دهی سفارشی برای سیاست‌گذاران', - ]), - badge_fa: null, is_featured: 0, cta_fa: 'درخواست مشاوره', sort_order: 3, - }, - ]; - - for (const p of plans) { - upsertPlan.run(p); + const existingPodcasts = await req('/api/articles?type=podcast&limit=1'); + if (Array.isArray(existingPodcasts) && existingPodcasts.length > 0) { + console.log(` SKIP Podcasts (records exist)`); + } else { + console.log(` SEED Podcasts (${PODCASTS.length} items)...`); + for (const p of PODCASTS) { + try { await req('/api/articles', { method: 'POST', body: p }); } + catch (e) { console.warn(' WARN Podcast:', e.message); } + } + console.log(' OK Podcasts'); } - console.log(` ✓ ${plans.length} پلن اشتراک وارد شد`); - /* ─── RISK SIGNALS ──────────────────────────────────────────── */ - const upsertRisk = db.prepare(` - INSERT OR REPLACE INTO risk_signals - (id, quote, name, date, level, sort_order, created_at, updated_at) - VALUES - (@id, @quote, @name, @date, @level, @sort_order, - datetime('now'), datetime('now')) - `); + console.log('\nDone. Panel DB seeded.'); +} - const risks = [ - { - id: 'china-export-pressure-1404', - quote: 'فشار صادراتی فولاد چین در ۵ ماه اول ۲۰۲۵ به ۵۴ میلیون تن رسید — ۱۸٪ بیشتر از سال قبل. بازارهای هدف ایران در عراق، امارات و آفریقا در معرض فشار قیمتی شدید هستند.', - name: 'فشار صادرات چین', date: 'اردیبهشت ۱۴۰۴', - level: 'high', sort_order: 1, - }, - { - id: 'domestic-gas-restriction-1404', - quote: 'محدودیت‌های گاز صنعتی در زمستان ۱۴۰۳ ظرفیت تولید واحدهای DRI را تا ۳۵٪ کاهش داد. احتمال تکرار این محدودیت در زمستان ۱۴۰۴ با توجه به کمبود زیرساخت انتقال گاز بالاست.', - name: 'محدودیت انرژی داخلی', date: 'فروردین ۱۴۰۴', - level: 'medium', sort_order: 2, - }, - { - id: 'iraq-market-opportunity-1404', - quote: 'کاهش صادرات فولاد ترکیه به عراق در اثر تضعیف لیر ترکیه، فرصت تصاحب ۱۲٪ از سهم بازار را برای صادرکنندگان ایرانی ایجاد کرده است.', - name: 'فرصت بازار عراق', date: 'اردیبهشت ۱۴۰۴', - level: 'opportunity', sort_order: 3, - }, - ]; - - for (const r of risks) { - upsertRisk.run(r); - } - console.log(` ✓ ${risks.length} سیگنال ریسک وارد شد`); - - /* ─── RADAR ITEMS ───────────────────────────────────────────── */ - const upsertRadar = db.prepare(` - INSERT OR REPLACE INTO radar_items - (id, category, img, author_name, author_avatar, - title_fa, date_fa, excerpt_fa, title_en, date_en, excerpt_en, sort_order, - created_at, updated_at) - VALUES - (@id, @category, @img, @author_name, @author_avatar, - @title_fa, @date_fa, @excerpt_fa, @title_en, @date_en, @excerpt_en, @sort_order, - datetime('now'), datetime('now')) - `); - - const radar = [ - { id: 'r1', category: 'commodity', img: '/news/photo-1611273426858-450d8e3c9fce.jpg', - author_name: 'دکتر سارا احمدی', author_avatar: '/news/photo-1494790108377-be9c29b29330.jpg', - title_fa: 'پیش‌بینی بازار با هوش مصنوعی', date_fa: '۱۴ اردیبهشت ۱۴۰۵', - excerpt_fa: 'مدل‌های یادگیری ماشین تقاضای لیتیوم و فلزات باتری را در افق ۲۰۳۵ بازتعریف می‌کنند.', - title_en: 'AI-Powered Market Forecasting', date_en: 'May 4, 2026', - excerpt_en: 'Machine-learning models are redefining lithium and battery-metal demand on the 2035 horizon.', sort_order: 1 }, - { id: 'r2', category: 'tech', img: '/news/photo-1581092160607-ee22731c9b4e.jpg', - author_name: 'مهندس رضا کریمی', author_avatar: '/news/photo-1500648767791-00dcc994a43e.jpg', - title_fa: 'پیش‌بینی بازار با هوش مصنوعی', date_fa: '۱۴ اردیبهشت ۱۴۰۵', - excerpt_fa: 'تحول دیجیتال و اتوماسیون هوشمند در حال تغییر بنیادین زنجیره ارزش فولاد هستند.', - title_en: 'AI-Powered Market Forecasting', date_en: 'May 4, 2026', - excerpt_en: 'Digital transformation and smart automation are fundamentally reshaping the steel value chain.', sort_order: 2 }, - { id: 'r3', category: 'market', img: '/news/photo-1504307651254-35680f356dfd.jpg', - author_name: 'دکتر مریم رضایی', author_avatar: '/news/photo-1438761681033-6461ffad8d80.jpg', - title_fa: 'پیش‌بینی بازار با هوش مصنوعی', date_fa: '۱۴ اردیبهشت ۱۴۰۵', - excerpt_fa: 'تحلیل روند قیمت طلا و فلزات گران‌بها و اثر آن بر سرمایه‌گذاری در زنجیره فولاد.', - title_en: 'AI-Powered Market Forecasting', date_en: 'May 4, 2026', - excerpt_en: 'Gold and precious-metal price trends and their effect on steel-chain investment.', sort_order: 3 }, - { id: 'r4', category: 'geo', img: '/news/photo-1518770660439-4636190af475.jpg', - author_name: 'دکتر علی موسوی', author_avatar: '/news/photo-1472099645785-5658abf4ff4e.jpg', - title_fa: 'پیش‌بینی بازار با هوش مصنوعی', date_fa: '۱۴ اردیبهشت ۱۴۰۵', - excerpt_fa: 'هندسه قدرت جهانی و اثر تحولات ژئوپلیتیک بر بازارهای صادراتی فولاد ایران.', - title_en: 'AI-Powered Market Forecasting', date_en: 'May 4, 2026', - excerpt_en: "Global power geometry and the impact of geopolitics on Iran's steel export markets.", sort_order: 4 }, - { id: 'r5', category: 'energy', img: '/news/photo-1466611653911-95081537e5b7.jpg', - author_name: 'مهندس نازنین حسینی', author_avatar: '/news/photo-1544005313-94ddf0286df2.jpg', - title_fa: 'گذار انرژی و فولاد سبز', date_fa: '۱۲ اردیبهشت ۱۴۰۵', - excerpt_fa: 'هیدروژن سبز و برق تجدیدپذیر مسیر کربن‌زدایی صنعت فولاد را شتاب می‌دهند.', - title_en: 'Energy Transition & Green Steel', date_en: 'May 2, 2026', - excerpt_en: 'Green hydrogen and renewable power are accelerating steel decarbonization.', sort_order: 5 }, - { id: 'r6', category: 'tech', img: '/news/photo-1567789884554-0b844b597180.jpg', - author_name: 'دکتر سارا احمدی', author_avatar: '/news/photo-1494790108377-be9c29b29330.jpg', - title_fa: 'اتوماسیون خطوط نورد', date_fa: '۱۰ اردیبهشت ۱۴۰۵', - excerpt_fa: 'سیستم‌های کنترل هوشمند بهره‌وری خطوط نورد را تا ۲۲٪ افزایش داده‌اند.', - title_en: 'Rolling Line Automation', date_en: 'Apr 30, 2026', - excerpt_en: 'Smart control systems have boosted rolling-line productivity by up to 22%.', sort_order: 6 }, - { id: 'r7', category: 'commodity', img: '/news/photo-1570168007204-dfb528c6958f.jpg', - author_name: 'مهندس رضا کریمی', author_avatar: '/news/photo-1500648767791-00dcc994a43e.jpg', - title_fa: 'زنجیره تأمین سنگ‌آهن', date_fa: '۸ اردیبهشت ۱۴۰۵', - excerpt_fa: 'نوسانات عرضه سنگ‌آهن استرالیا و برزیل و پیامد آن برای تولیدکنندگان منطقه.', - title_en: 'Iron Ore Supply Chain', date_en: 'Apr 28, 2026', - excerpt_en: 'Australian and Brazilian iron-ore supply swings and their regional fallout.', sort_order: 7 }, - { id: 'r8', category: 'market', img: '/news/photo-1540575467063-178a50c2df87.jpg', - author_name: 'دکتر مریم رضایی', author_avatar: '/news/photo-1438761681033-6461ffad8d80.jpg', - title_fa: 'تحلیل بازار جهانی فولاد', date_fa: '۶ اردیبهشت ۱۴۰۵', - excerpt_fa: 'سیل صادرات فولاد چین و فشار قیمتی بر بازارهای هدف صادراتی ایران.', - title_en: 'Global Steel Market Analysis', date_en: 'Apr 26, 2026', - excerpt_en: "China's steel export flood and price pressure on Iran's target markets.", sort_order: 8 }, - ]; - for (const r of radar) { upsertRadar.run(r); } - console.log(` ✓ ${radar.length} سیگنال رادار وارد شد`); - - /* ─── FACTORY REPORTS ───────────────────────────────────────── */ - const upsertFactoryReport = db.prepare(` - INSERT OR REPLACE INTO factory_reports - (id, img, tag_fa, tag_en, title_fa, title_en, date_fa, date_en, - excerpt_fa, excerpt_en, scroll_dir, sort_order, created_at, updated_at) - VALUES - (@id, @img, @tag_fa, @tag_en, @title_fa, @title_en, @date_fa, @date_en, - @excerpt_fa, @excerpt_en, @scroll_dir, @sort_order, datetime('now'), datetime('now')) - `); - const factoryReports = [ - { id: 'fr1', img: '/news/photo-1504307651254-35680f356dfd.jpg', scroll_dir: 'left', sort_order: 1, - tag_fa: 'تولید و صادرات', title_fa: 'تحلیل عملکرد تولید و صادرات', date_fa: 'بهمن ۱۴۰۳', - excerpt_fa: 'بررسی آمار تولید ماهانه، مقایسه با بودجه سالانه و روند صادرات فولاد تخت در بازارهای منطقه‌ای.', - tag_en: 'Output & Exports', title_en: 'Production & Export Performance', date_en: 'Feb 2025', - excerpt_en: 'Monthly output vs. annual budget and the flat-steel export trend across regional markets.' }, - { id: 'fr2', img: '/news/photo-1581092160607-ee22731c9b4e.jpg', scroll_dir: 'left', sort_order: 2, - tag_fa: 'بازار و قیمت', title_fa: 'وضعیت بازار و قیمت‌گذاری', date_fa: 'دی ۱۴۰۳', - excerpt_fa: 'تحلیل نوسانات قیمت فولاد تخت در بازار داخلی و اثر نرخ ارز بر چشم‌انداز زمستان.', - tag_en: 'Market & Price', title_en: 'Market & Pricing Outlook', date_en: 'Jan 2025', - excerpt_en: 'Flat-steel price swings in the domestic market and the FX effect on the winter outlook.' }, - { id: 'fr3', img: '/news/photo-1518770660439-4636190af475.jpg', scroll_dir: 'left', sort_order: 3, - tag_fa: 'فناوری', title_fa: 'دیجیتال‌سازی خطوط تولید', date_fa: 'آذر ۱۴۰۳', - excerpt_fa: 'گزارشی از پیاده‌سازی سامانه‌های کنترل هوشمند و اثر آن بر بهره‌وری خطوط نورد.', - tag_en: 'Technology', title_en: 'Production Line Digitalization', date_en: 'Dec 2024', - excerpt_en: 'Rolling-out smart control systems and their impact on rolling-line productivity.' }, - { id: 'fr4', img: '/news/photo-1567789884554-0b844b597180.jpg', scroll_dir: 'up', sort_order: 4, - tag_fa: 'نیروی انسانی', title_fa: 'تحلیل نیروی انسانی و بهره‌وری', date_fa: 'آبان ۱۴۰۳', - excerpt_fa: 'شاخص‌های بهره‌وری نیروی کار، آموزش‌های ماهانه و آمار ایمنی شغلی.', - tag_en: 'Workforce', title_en: 'Workforce & Productivity', date_en: 'Nov 2024', - excerpt_en: 'Labor productivity indicators, monthly training and occupational-safety statistics.' }, - { id: 'fr5', img: '/news/photo-1466611653911-95081537e5b7.jpg', scroll_dir: 'up', sort_order: 5, - tag_fa: 'پایداری', title_fa: 'گزارش پایداری و محیط زیست', date_fa: 'آذر ۱۴۰۳', - excerpt_fa: 'شاخص‌های زیست‌محیطی ماهانه؛ مصرف انرژی، کاهش انتشار کربن و بازچرخانی پساب صنعتی.', - tag_en: 'Sustainability', title_en: 'Sustainability & Environment', date_en: 'Dec 2024', - excerpt_en: 'Monthly environmental metrics: energy use, carbon reduction and industrial water recycling.' }, - { id: 'fr6', img: '/news/photo-1570168007204-dfb528c6958f.jpg', scroll_dir: 'up', sort_order: 6, - tag_fa: 'زنجیره تأمین', title_fa: 'تأمین مواد اولیه و لجستیک', date_fa: 'مهر ۱۴۰۳', - excerpt_fa: 'وضعیت تأمین سنگ‌آهن و قراضه، موجودی انبار و تحلیل ریسک زنجیره تأمین.', - tag_en: 'Supply Chain', title_en: 'Raw Materials & Logistics', date_en: 'Oct 2024', - excerpt_en: 'Iron-ore and scrap supply, inventory levels and supply-chain risk analysis.' }, - { id: 'fr7', img: '/news/photo-1611273426858-450d8e3c9fce.jpg', scroll_dir: 'right', sort_order: 7, - tag_fa: 'سرمایه‌گذاری', title_fa: 'طرح‌های توسعه و سرمایه‌گذاری', date_fa: 'شهریور ۱۴۰۳', - excerpt_fa: 'پیشرفت طرح‌های توسعه ظرفیت و تحلیل بازده سرمایه‌گذاری پروژه‌های جاری.', - tag_en: 'Investment', title_en: 'Expansion & Investment Plans', date_en: 'Sep 2024', - excerpt_en: 'Capacity-expansion progress and ROI analysis of ongoing projects.' }, - { id: 'fr8', img: '/news/photo-1485827404703-89b55fcc595e.jpg', scroll_dir: 'right', sort_order: 8, - tag_fa: 'کیفیت', title_fa: 'کنترل کیفیت با هوش مصنوعی', date_fa: 'مرداد ۱۴۰۳', - excerpt_fa: 'کاربرد بینایی ماشین در تشخیص عیوب سطحی و کاهش نرخ ضایعات تولید.', - tag_en: 'Quality', title_en: 'AI-Driven Quality Control', date_en: 'Aug 2024', - excerpt_en: 'Machine vision for surface-defect detection and lower production scrap rates.' }, - { id: 'fr9', img: '/news/photo-1613665813446-82a78c468a1d.jpg', scroll_dir: 'right', sort_order: 9, - tag_fa: 'انرژی', title_fa: 'تحلیل مصرف انرژی و بهینه‌سازی', date_fa: 'تیر ۱۴۰۳', - excerpt_fa: 'الگوی مصرف برق و گاز، فرصت‌های بهینه‌سازی و اثر آن بر بهای تمام‌شده.', - tag_en: 'Energy', title_en: 'Energy Use & Optimization', date_en: 'Jul 2024', - excerpt_en: 'Electricity and gas usage patterns, optimization opportunities and cost impact.' }, - ]; - for (const r of factoryReports) { upsertFactoryReport.run(r); } - console.log(` ✓ ${factoryReports.length} گزارش کارخانه وارد شد`); - - /* ─── INTEGRATIONS ──────────────────────────────────────────── */ - const upsertIntegration = db.prepare(` - INSERT OR REPLACE INTO integrations - (id, name, icon, logo, accent, grid_col, grid_row, sort_order, created_at, updated_at) - VALUES - (@id, @name, @icon, @logo, @accent, @grid_col, @grid_row, @sort_order, datetime('now'), datetime('now')) - `); - const integrations = [ - { id: 'lme', name: 'London Metal Exchange', icon: 'exchange', logo: null, accent: '#CD9E53', grid_col: 6, grid_row: 1, sort_order: 1 }, - { id: 'shfe', name: 'Shanghai Futures Exchange', icon: 'trending', logo: null, accent: '#c0392b', grid_col: 4, grid_row: 2, sort_order: 2 }, - { id: 'platts', name: 'S&P Global Platts', icon: 'chart', logo: null, accent: '#2471a3', grid_col: 6, grid_row: 2, sort_order: 3 }, - { id: 'reuters', name: 'Reuters', icon: 'newspaper', logo: null, accent: '#e67e22', grid_col: 3, grid_row: 3, sort_order: 4 }, - { id: 'fastmarkets', name: 'Fastmarkets', icon: 'activity', logo: null, accent: '#1e8449', grid_col: 5, grid_row: 3, sort_order: 5 }, - { id: 'worldsteel', name: 'World Steel Association', icon: 'building', logo: null, accent: '#7d3c98', grid_col: 4, grid_row: 4, sort_order: 6 }, - { id: 'mysteel', name: 'Mysteel', icon: 'database', logo: null, accent: '#16a085', grid_col: 6, grid_row: 4, sort_order: 7 }, - { id: 'cme', name: 'CME Group', icon: 'coins', logo: null, accent: '#2980b9', grid_col: 5, grid_row: 5, sort_order: 8 }, - { id: 'dce', name: 'Dalian Commodity Exchange', icon: 'globe', logo: null, accent: '#d35400', grid_col: 3, grid_row: 5, sort_order: 9 }, - ]; - for (const it of integrations) { upsertIntegration.run(it); } - console.log(` ✓ ${integrations.length} منبع داده وارد شد`); - - /* ─── INSTITUTE STATS ───────────────────────────────────────── */ - const upsertStat = db.prepare(` - INSERT OR REPLACE INTO institute_stats - (id, label_fa, label_en, value, suffix_fa, sort_order, created_at, updated_at) - VALUES - (@id, @label_fa, @label_en, @value, @suffix_fa, @sort_order, datetime('now'), datetime('now')) - `); - const instituteStats = [ - { id: 'total-reports', label_fa: 'گزارش منتشرشده', label_en: 'Published Reports', value: 280, suffix_fa: '+', sort_order: 1 }, - { id: 'years', label_fa: 'سال تجربه', label_en: 'Years of Experience', value: 12, suffix_fa: null, sort_order: 2 }, - { id: 'experts', label_fa: 'کارشناس', label_en: 'Experts', value: 45, suffix_fa: null, sort_order: 3 }, - { id: 'member-orgs', label_fa: 'سازمان عضو', label_en: 'Member Organizations', value: 60, suffix_fa: '+', sort_order: 4 }, - { id: 'subscribers', label_fa: 'مشترک', label_en: 'Subscribers', value: 2800, suffix_fa: '+', sort_order: 5 }, - ]; - for (const s of instituteStats) { upsertStat.run(s); } - console.log(` ✓ ${instituteStats.length} آمار وارد شد`); - - /* ─── MARKET PRICES ─────────────────────────────────────────── */ - const upsertMarketPrice = db.prepare(` - INSERT OR REPLACE INTO market_prices - (id, name, value, unit, change, change_percent, trend, sort_order, created_at, updated_at) - VALUES - (@id, @name, @value, @unit, @change, @change_percent, @trend, @sort_order, datetime('now'), datetime('now')) - `); - const marketPrices = [ - { id: 'steel-billet', name: 'شمش فولاد', value: 18400, unit: 'تومان/کیلو', change: 220, change_percent: 1.2, trend: 'up', sort_order: 1 }, - { id: 'rebar-a3', name: 'میلگرد A3', value: 17800, unit: 'تومان/کیلو', change: -90, change_percent: -0.5, trend: 'down', sort_order: 2 }, - { id: 'beam-14', name: 'تیرآهن ۱۴', value: 16900, unit: 'تومان/کیلو', change: 135, change_percent: 0.8, trend: 'up', sort_order: 3 }, - { id: 'scrap-iron', name: 'آهن قراضه', value: 550, unit: 'دلار/تن', change: -11, change_percent: -2.0, trend: 'down', sort_order: 4 }, - { id: 'cold-rolled-sheet', name: 'ورق سرد', value: 22100, unit: 'تومان/کیلو', change: 66, change_percent: 0.3, trend: 'up', sort_order: 5 }, - { id: 'angle-bar-80', name: 'نبشی ۸۰', value: 15600, unit: 'تومان/کیلو', change: -172, change_percent: -1.1, trend: 'down', sort_order: 6 }, - { id: 'mobarakeh-steel', name: 'فولاد مبارکه', value: 12840, unit: 'ریال', change: -143, change_percent: -1.1, trend: 'down', sort_order: 7 }, - ]; - for (const p of marketPrices) { upsertMarketPrice.run(p); } - console.log(` ✓ ${marketPrices.length} قیمت بازار وارد شد`); - - /* ─── MARKET CHART POINTS ───────────────────────────────────── */ - const upsertChartPoint = db.prepare(` - INSERT OR REPLACE INTO market_chart_points - (id, series, label, value, meta, sort_order, created_at, updated_at) - VALUES - (@id, @series, @label, @value, @meta, @sort_order, datetime('now'), datetime('now')) - `); - const priceHistory = [ - { label: 'خرداد ۱۴۰۳', value: 15200 }, { label: 'تیر ۱۴۰۳', value: 15600 }, - { label: 'مرداد ۱۴۰۳', value: 16100 }, { label: 'شهریور ۱۴۰۳', value: 15800 }, - { label: 'مهر ۱۴۰۳', value: 16400 }, { label: 'آبان ۱۴۰۳', value: 17000 }, - { label: 'آذر ۱۴۰۳', value: 17500 }, { label: 'دی ۱۴۰۳', value: 17200 }, - { label: 'بهمن ۱۴۰۳', value: 17800 }, { label: 'اسفند ۱۴۰۳', value: 18100 }, - { label: 'فروردین ۱۴۰۴', value: 18600 }, { label: 'اردیبهشت ۱۴۰۴', value: 18400 }, - ]; - priceHistory.forEach((p, i) => upsertChartPoint.run({ id: `history-${i + 1}`, series: 'history', label: p.label, value: p.value, meta: null, sort_order: i + 1 })); - const monthlyExport = [ - { label: 'خرداد ۱۴۰۳', value: 285000 }, { label: 'تیر ۱۴۰۳', value: 310000 }, - { label: 'مرداد ۱۴۰۳', value: 295000 }, { label: 'شهریور ۱۴۰۳', value: 320000 }, - { label: 'مهر ۱۴۰۳', value: 350000 }, { label: 'آبان ۱۴۰۳', value: 375000 }, - { label: 'آذر ۱۴۰۳', value: 390000 }, { label: 'دی ۱۴۰۳', value: 360000 }, - { label: 'بهمن ۱۴۰۳', value: 415000 }, { label: 'اسفند ۱۴۰۳', value: 420000 }, - { label: 'فروردین ۱۴۰۴', value: 400000 }, { label: 'اردیبهشت ۱۴۰۴', value: 410000 }, - ]; - monthlyExport.forEach((p, i) => upsertChartPoint.run({ id: `export-${i + 1}`, series: 'export', label: p.label, value: p.value, meta: null, sort_order: i + 1 })); - const globalComparison = [ - { label: 'ایران', value: 18400, meta: { flag: '🇮🇷', currency: 'IRR', unit: 'تومان/کیلو' } }, - { label: 'ترکیه', value: 530, meta: { flag: '🇹🇷', currency: 'USD', unit: 'دلار/تن' } }, - { label: 'هند', value: 510, meta: { flag: '🇮🇳', currency: 'USD', unit: 'دلار/تن' } }, - { label: 'چین', value: 460, meta: { flag: '🇨🇳', currency: 'USD', unit: 'دلار/تن' } }, - { label: 'روسیه', value: 485, meta: { flag: '🇷🇺', currency: 'USD', unit: 'دلار/تن' } }, - ]; - globalComparison.forEach((p, i) => upsertChartPoint.run({ id: `comparison-${i + 1}`, series: 'comparison', label: p.label, value: p.value, meta: JSON.stringify(p.meta), sort_order: i + 1 })); - console.log(` ✓ ${priceHistory.length + monthlyExport.length + globalComparison.length} نقطه نمودار وارد شد`); - - /* ─── VISION ITEMS ──────────────────────────────────────────── */ - const upsertVision = db.prepare(` - INSERT OR REPLACE INTO vision_items - (id, icon, title_fa, title_en, desc_fa, desc_en, sort_order, created_at, updated_at) - VALUES - (@id, @icon, @title_fa, @title_en, @desc_fa, @desc_en, @sort_order, datetime('now'), datetime('now')) - `); - const visionItems = [ - { id:'research-leadership', icon:'FlaskConical', title_fa:'پیشرو در پژوهش', title_en:'Research Leadership', desc_fa:'تولید تحلیل‌های راهبردی و گزارش‌های تخصصی در حوزه صنعت فولاد', desc_en:'Producing strategic analyses and specialized reports in the steel industry', sort_order:0 }, - { id:'expert-networking', icon:'Network', title_fa:'شبکه‌سازی خبرگان', title_en:'Expert Networking', desc_fa:'ایجاد پیوند میان متخصصان، سیاست‌گذاران و فعالان صنعتی', desc_en:'Building connections between specialists, policymakers, and industry practitioners', sort_order:1 }, - { id:'green-steel', icon:'Leaf', title_fa:'فولاد سبز', title_en:'Green Steel', desc_fa:'ترویج رویکردهای پایدار و کاهش اثرات زیست‌محیطی در زنجیره فولاد', desc_en:'Promoting sustainable approaches and reducing environmental impacts across the steel chain', sort_order:2 }, - { id:'futures-research', icon:'Telescope', title_fa:'آینده‌پژوهی', title_en:'Futures Research', desc_fa:'رصد هوشمند تحولات جهانی و ارائه نقشه راه برای صنعت ایران', desc_en:"Smart monitoring of global developments and providing roadmaps for Iran's industry", sort_order:3 }, - ]; - for (const v of visionItems) { upsertVision.run(v); } - console.log(` ✓ ${visionItems.length} آیتم چشم‌انداز وارد شد`); - - /* ─── ADVISORY BOARD ────────────────────────────────────────── */ - const upsertAdvisory = db.prepare(` - INSERT OR REPLACE INTO advisory_board - (id, name_fa, name_en, role_fa, role_en, sort_order, created_at, updated_at) - VALUES - (@id, @name_fa, @name_en, @role_fa, @role_en, @sort_order, datetime('now'), datetime('now')) - `); - const advisoryBoard = [ - { id:'alireza-akbarian', name_fa:'دکتر علیرضا اکبریان', name_en:'Dr. Alireza Akbarian', role_fa:'مدیر اندیشکده', role_en:'Institute Director', sort_order:0 }, - { id:'sara-mohammadi', name_fa:'مهندس سارا محمدی', name_en:'Eng. Sara Mohammadi', role_fa:'پژوهشگر ارشد فولاد سبز', role_en:'Senior Green Steel Researcher', sort_order:1 }, - { id:'mohammad-jalali', name_fa:'دکتر محمد جلالی', name_en:'Dr. Mohammad Jalali', role_fa:'تحلیلگر بازارهای جهانی', role_en:'Global Markets Analyst', sort_order:2 }, - { id:'fatemeh-rezaei', name_fa:'دکتر فاطمه رضایی', name_en:'Dr. Fatemeh Rezaei', role_fa:'متخصص ژئوپلیتیک صنعتی', role_en:'Industrial Geopolitics Specialist', sort_order:3 }, - { id:'reza-karimi', name_fa:'مهندس رضا کریمی', name_en:'Eng. Reza Karimi', role_fa:'کارشناس فناوری و نوآوری', role_en:'Technology & Innovation Expert', sort_order:4 }, - { id:'niloofar-hosseini', name_fa:'دکتر نیلوفر حسینی', name_en:'Dr. Niloofar Hosseini', role_fa:'پژوهشگر آینده‌پژوهی', role_en:'Futures Research Specialist', sort_order:5 }, - ]; - for (const m of advisoryBoard) { upsertAdvisory.run(m); } - console.log(` ✓ ${advisoryBoard.length} عضو شورای مشورتی وارد شد`); - - /* ─── VIDEOCAST / PODCAST (reuse articles) ──────────────────── */ - const upsertMediaArticle = db.prepare(` - INSERT OR REPLACE INTO articles - (id, title, category, type, author, publish_date, cover_image, summary, is_free, tags, pages, price, featured, created_at, updated_at) - VALUES - (@id, @title, @category, @type, @author, @publish_date, @cover_image, @summary, 1, '[]', 0, 0, 0, datetime('now'), datetime('now')) - `); - const mediaArticles = [ - { id:'video-1', type:'video', category:'اخبار صنعت', title:'نگاهی به آن سوی مرزهای تولید', author:'گفتگو با مهندس سیامک فجری', publish_date:'۲۵ خرداد ۱۴۰۵', cover_image:'/news/photo-1504307651254-35680f356dfd.jpg', summary:'در ویدیوکست «Steel Horizon»، فراتر از داده‌های خام قیمت و تناژ، به سراغ مهندسی تغییر و معماری آینده صنعت می‌رویم.' }, - { id:'video-2', type:'video', category:'بازار جهانی', title:'تحلیل بازار جهانی فلزات پایه', author:'گفتگو با کارشناسان بازار', publish_date:'۱۸ خرداد ۱۴۰۵', cover_image:'/news/photo-1558618666-fcd25c85cd64.jpg', summary:'نوسانات قیمت مس، آلومینیوم و روی و تأثیر آن بر زنجیره فولاد را با تحلیل‌گران بازار بررسی می‌کنیم.' }, - { id:'video-3', type:'video', category:'صادرات', title:'ژئوپلیتیک و صادرات فولاد', author:'گفتگو با تحلیل‌گران ژئواکونومی', publish_date:'۱۱ خرداد ۱۴۰۵', cover_image:'/news/photo-1504328345606-18bbc8c9d7d1.jpg', summary:'تأثیر تحولات منطقه‌ای بر بازارهای صادراتی فولاد ایران و فرصت‌های پیش رو.' }, - { id:'video-4', type:'video', category:'تولید داخلی', title:'سرمایه‌گذاری در صنعت فولاد', author:'گفتگو با مدیران سرمایه‌گذاری', publish_date:'۴ خرداد ۱۴۰۵', cover_image:'/news/photo-1611273426858-450d8e3c9fce.jpg', summary:'فرصت‌ها و چالش‌های سرمایه‌گذاری در بخش‌های مختلف زنجیره ارزش فولاد.' }, - { id:'podcast-1', type:'podcast', category:'اخبار صنعت', title:'کالبدشکافی عقلانی پیچیدگی‌های صنعتی', author:'مهندس سیامک شجاعی', publish_date:'۲۵ / خرداد / ۱۴۰۵', cover_image:'/news/photo-1540575467063-178a50c2df87.jpg', summary:'صنعت فولاد در عصر حاضر در پیچیده‌ترین چرخه حیات خود قرار گرفته است؛ جایی که همگرایی هوش مصنوعی، الزامات زیست‌محیطی و تنش‌های ژئواکونومیک قواعد کلاسیک تولید و تجارت را بازنویسی کرده‌اند.' }, - { id:'podcast-2', type:'podcast', category:'اخبار صنعت', title:'آزمایشگاه کوره', author:'کارشناسان بازار', publish_date:'۱۸ / خرداد / ۱۴۰۵', cover_image:'/news/photo-1566873535350-6586b0b7a1f2.jpg', summary:'«The Foundry Lab» محلی برای کالبدشکافی دقیق تحولات فناورانه و مدل‌های نوین فولادسازی است.' }, - { id:'podcast-3', type:'podcast', category:'اخبار صنعت', title:'معمار فولاد', author:'رهبران صنعت', publish_date:'۱۱ خرداد ۱۴۰۵', cover_image:'/news/photo-1578662996442-48f60103fc96.jpg', summary:'در «Steel Architect» به سراغ داستان ساختن فولاد فردا می‌رویم؛ روایت رهبرانی که مدل‌های سنتی کسب‌وکار را به چالش کشیده‌اند.' }, - ]; - for (const a of mediaArticles) { upsertMediaArticle.run(a); } - console.log(` ✓ ${mediaArticles.length} ویدیوکست/پادکست وارد شد`); -}); - -console.log('\n⏳ در حال وارد کردن محتوا به پایگاه داده...\n'); -seed(); -console.log('\n✅ همه محتوا با موفقیت در CMS وارد شد.\n'); +main().catch(err => { console.error('FATAL:', err.message); process.exit(1); }); diff --git a/panel/seed.js b/panel/seed.js index e586b99..4c04756 100644 --- a/panel/seed.js +++ b/panel/seed.js @@ -1,6 +1,6 @@ import 'dotenv/config'; import bcrypt from 'bcryptjs'; -import { db } from './db.js'; +import { db, pool } from './db.js'; const username = process.env.ADMIN_USERNAME || 'admin'; const password = process.env.ADMIN_PASSWORD; @@ -9,16 +9,19 @@ if (!password || password.length < 12) { process.exit(1); } -const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username); +const existing = await db.prepare('SELECT id FROM users WHERE username = ?').get(username); if (existing) { console.log(`User "${username}" already exists (id=${existing.id}). Skipping.`); + await pool.end(); process.exit(0); } const hash = bcrypt.hashSync(password, 10); -const info = db - .prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)') +const info = await db + .prepare("INSERT INTO users (username, password_hash, role) VALUES (?, ?, 'owner') RETURNING id") .run(username, hash); console.log(`Created admin user "${username}" (id=${info.lastInsertRowid}).`); console.log(`Login with the password from your .env (ADMIN_PASSWORD).`); + +await pool.end(); diff --git a/panel/server.js b/panel/server.js index f663a31..83dba88 100644 --- a/panel/server.js +++ b/panel/server.js @@ -1,3 +1,4 @@ +import 'express-async-errors'; import 'dotenv/config'; import express from 'express'; import cors from 'cors'; @@ -5,6 +6,7 @@ import cookieParser from 'cookie-parser'; import rateLimit from 'express-rate-limit'; import helmet from 'helmet'; import multer from 'multer'; +import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; import sharp from 'sharp'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; @@ -24,8 +26,16 @@ if (!JWT_SECRET || JWT_SECRET.length < 32) { } const PUBLIC_ORIGIN = process.env.PUBLIC_ORIGIN || `http://localhost:${PORT}`; -const uploadsDir = path.join(__dirname, 'uploads'); -if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true }); +// On Liara, set UPLOADS_DIR to a mounted Disk path (e.g. /var/lib/uploads) so +// uploaded images survive redeploys. Defaults to a local folder for dev. +const uploadsDir = process.env.UPLOADS_DIR || path.join(__dirname, 'uploads'); +// Only needed for local-disk fallback. On Liara uploads go to object storage and +// the app dir may be read-only — never let a failed mkdir crash startup. +try { + if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true }); +} catch (e) { + console.warn(`[uploads] could not create ${uploadsDir}: ${e.message} (ok when using object storage)`); +} // buffer the upload in memory so we can transcode it to WebP before writing to disk const upload = multer({ @@ -37,7 +47,53 @@ const upload = multer({ }, }); +// raw (non-image) uploads — e.g. videos — streamed straight to object storage. +const uploadRaw = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 200 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + if (/^(video\/(mp4|webm|ogg|quicktime|x-matroska)|audio\/(mpeg|mp3|wav|x-wav|ogg|webm|aac|x-m4a|mp4)|application\/pdf)$/.test(file.mimetype)) cb(null, true); + else cb(new Error('Only video, audio, or pdf uploads are allowed')); + }, +}); + +// ── Object storage (Liara / any S3-compatible) ── +// Enabled when all LIARA_* env vars are set; otherwise uploads fall back to the local disk. +const S3_ENABLED = !!( + process.env.LIARA_ENDPOINT && + process.env.LIARA_BUCKET && + process.env.LIARA_ACCESS_KEY && + process.env.LIARA_SECRET_KEY +); +const s3Host = (process.env.LIARA_ENDPOINT || '').replace(/^https?:\/\//, '').replace(/\/+$/, ''); +const s3 = S3_ENABLED + ? new S3Client({ + region: 'default', + endpoint: `https://${s3Host}`, + credentials: { + accessKeyId: process.env.LIARA_ACCESS_KEY, + secretAccessKey: process.env.LIARA_SECRET_KEY, + }, + forcePathStyle: true, + }) + : null; + +// upload a buffer to the bucket and return its public URL (bucket must allow public reads) +async function putObject(key, body, contentType) { + await s3.send(new PutObjectCommand({ + Bucket: process.env.LIARA_BUCKET, + Key: key, + Body: body, + ContentType: contentType, + })); + return `https://${process.env.LIARA_BUCKET}.${s3Host}/${key}`; +} + const app = express(); +app.set('trust proxy', 1); // honor X-Forwarded-Proto from the reverse proxy +// Send the session cookie with Secure whenever the request actually arrived over HTTPS, +// so a forgotten NODE_ENV can't downgrade the cookie to plaintext behind a TLS proxy. +const cookieSecure = (req) => req.secure || req.headers['x-forwarded-proto'] === 'https'; const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || 'http://localhost:5173') .split(',').map(s => s.trim()); app.use(cors({ @@ -98,13 +154,26 @@ function authRequired(req, res, next) { } } -function memberRequired(req, res, next) { - const auth = req.headers['authorization']; - const token = (auth?.startsWith('Bearer ') ? auth.slice(7) : null) ?? req.query.token ?? null; +// only an 'owner' admin may manage other admin accounts +function ownerRequired(req, res, next) { + if (req.user?.role !== 'owner') return res.status(403).json({ error: 'forbidden' }); + next(); +} + +async function memberRequired(req, res, next) { + // accept the HttpOnly member cookie first, then a Bearer header — never a URL query param + let token = req.cookies?.member_session; + if (!token) { + const auth = req.headers['authorization']; + if (auth?.startsWith('Bearer ')) token = auth.slice(7); + } if (!token) return res.status(401).json({ error: 'unauthorized' }); try { const payload = jwt.verify(token, JWT_SECRET); if (payload.role !== 'member') return res.status(403).json({ error: 'forbidden' }); + // a 30-day token must not outlive a deactivated account — verify per request + const acct = await db.prepare('SELECT is_active FROM members WHERE id = ?').get(payload.sub); + if (!acct || !acct.is_active) return res.status(403).json({ error: 'حساب کاربری غیرفعال است' }); req.member = payload; next(); } catch { @@ -112,28 +181,39 @@ function memberRequired(req, res, next) { } } -app.post('/api/auth/login', loginLimiter, (req, res) => { +// member session cookie options — cross-site capable when served over HTTPS +function memberCookieOpts(req) { + const secure = cookieSecure(req); + return { + httpOnly: true, + secure, + sameSite: secure ? 'none' : 'lax', + maxAge: 30 * 24 * 60 * 60 * 1000, + path: '/', + }; +} + +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 row = db.prepare('SELECT id, username, password_hash FROM users WHERE username = ?').get(username); + const row = await db.prepare('SELECT id, username, password_hash, role FROM users WHERE username = ?').get(username); if (!row || !bcrypt.compareSync(password, row.password_hash)) { return res.status(401).json({ error: 'bad_credentials' }); } - const token = jwt.sign({ sub: row.id, username: row.username }, JWT_SECRET, { expiresIn: '24h' }); - const isProd = process.env.NODE_ENV === 'production'; + const token = jwt.sign({ sub: row.id, username: row.username, role: row.role }, JWT_SECRET, { expiresIn: '24h' }); res.cookie('session', token, { httpOnly: true, - secure: isProd, + secure: cookieSecure(req), sameSite: 'strict', maxAge: 24 * 60 * 60 * 1000, path: '/', }); - res.json({ user: { id: row.id, username: row.username }, token }); + // token is delivered only via the HttpOnly cookie — never in the body (XSS can't read it) + res.json({ user: { id: row.id, username: row.username, role: row.role } }); }); -app.post('/api/auth/logout', (_req, res) => { - const isProd = process.env.NODE_ENV === 'production'; - res.clearCookie('session', { httpOnly: true, secure: isProd, sameSite: 'strict', path: '/' }); +app.post('/api/auth/logout', (req, res) => { + res.clearCookie('session', { httpOnly: true, secure: cookieSecure(req), sameSite: 'strict', path: '/' }); res.json({ ok: true }); }); @@ -147,7 +227,7 @@ const PUBLIC_FIELDS = ` tags, featured, created_at, updated_at `; -app.get('/api/articles', (req, res) => { +app.get('/api/articles', async (req, res) => { const limit = Math.min(Number(req.query.limit) || 50, 200); const { category, type } = req.query; let query = `SELECT ${PUBLIC_FIELDS} FROM articles`; @@ -158,12 +238,12 @@ app.get('/api/articles', (req, res) => { if (conditions.length) query += ' WHERE ' + conditions.join(' AND '); query += ' ORDER BY created_at DESC LIMIT ?'; params.push(limit); - const rows = db.prepare(query).all(...params); + const rows = await db.prepare(query).all(...params); res.json(rows.map(rowToArticle)); }); -app.get('/api/articles/:id', (req, res) => { - const row = db.prepare(`SELECT ${PUBLIC_FIELDS} FROM articles WHERE id = ?`).get(req.params.id); +app.get('/api/articles/:id', async (req, res) => { + const row = await db.prepare(`SELECT ${PUBLIC_FIELDS} FROM articles WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToArticle(row)); }); @@ -193,12 +273,20 @@ app.post('/api/uploads', authRequired, upload.single('file'), async (req, res) = .webp({ quality: 80 }).toBuffer(); const filename = `${nanoid(12)}.webp`; - fs.writeFileSync(path.join(uploadsDir, filename), finalBuf); - const relative = `/uploads/${filename}`; + let url, absoluteUrl; + if (s3) { + absoluteUrl = await putObject(filename, finalBuf, 'image/webp'); + url = absoluteUrl; // store the full bucket URL so the frontend loads it directly + } else { + fs.writeFileSync(path.join(uploadsDir, filename), finalBuf); + url = `/uploads/${filename}`; + absoluteUrl = `${PUBLIC_ORIGIN}${url}`; + } + res.json({ - url: relative, - absoluteUrl: `${PUBLIC_ORIGIN}${relative}`, + url, + absoluteUrl, originalSize, size: finalBuf.length, reduction: Math.round((1 - finalBuf.length / originalSize) * 100), @@ -209,6 +297,21 @@ app.post('/api/uploads', authRequired, upload.single('file'), async (req, res) = } }); +// raw file upload (videos / pdf) → object storage. Requires S3 to be configured. +app.post('/api/uploads/raw', authRequired, uploadRaw.single('file'), async (req, res) => { + if (!req.file) return res.status(400).json({ error: 'no_file' }); + if (!s3) return res.status(503).json({ error: 'object_storage_not_configured' }); + try { + const ext = (req.file.originalname.match(/\.[a-z0-9]+$/i)?.[0] || '').toLowerCase(); + const key = `${nanoid(12)}${ext}`; + const url = await putObject(key, req.file.buffer, req.file.mimetype); + res.json({ url, absoluteUrl: url, size: req.file.size, contentType: req.file.mimetype }); + } catch (err) { + console.error('[upload:raw] failed:', err.message); + res.status(500).json({ error: 'upload_failed' }); + } +}); + function normalizeArticleBody(b) { return { title: String(b.title || '').trim(), @@ -229,11 +332,11 @@ function normalizeArticleBody(b) { }; } -app.post('/api/articles', authRequired, (req, res) => { +app.post('/api/articles', authRequired, async (req, res) => { const a = normalizeArticleBody(req.body || {}); if (!a.title) return res.status(400).json({ error: 'title_required' }); const id = nanoid(14); - db.prepare(` + await db.prepare(` INSERT INTO articles ( id, title, category, type, author, author_role, author_initial, publish_date, pages, price, is_free, summary, body, cover_image, @@ -244,31 +347,31 @@ app.post('/api/articles', authRequired, (req, res) => { @tags, @featured ) `).run({ id, ...a }); - const row = db.prepare(`SELECT ${PUBLIC_FIELDS} FROM articles WHERE id = ?`).get(id); + const row = await db.prepare(`SELECT ${PUBLIC_FIELDS} FROM articles WHERE id = ?`).get(id); res.status(201).json(rowToArticle(row)); }); -app.put('/api/articles/:id', authRequired, (req, res) => { - const existing = db.prepare('SELECT id FROM articles WHERE id = ?').get(req.params.id); +app.put('/api/articles/:id', authRequired, async (req, res) => { + const existing = await db.prepare('SELECT id FROM articles WHERE id = ?').get(req.params.id); if (!existing) return res.status(404).json({ error: 'not_found' }); const a = normalizeArticleBody(req.body || {}); if (!a.title) return res.status(400).json({ error: 'title_required' }); - db.prepare(` + await db.prepare(` UPDATE articles SET title = @title, category = @category, type = @type, author = @author, author_role = @author_role, author_initial = @author_initial, publish_date = @publish_date, pages = @pages, price = @price, is_free = @is_free, summary = @summary, body = @body, cover_image = @cover_image, tags = @tags, featured = @featured, - updated_at = datetime('now') + updated_at = now() WHERE id = @id `).run({ id: req.params.id, ...a }); - const row = db.prepare(`SELECT ${PUBLIC_FIELDS} FROM articles WHERE id = ?`).get(req.params.id); + const row = await db.prepare(`SELECT ${PUBLIC_FIELDS} FROM articles WHERE id = ?`).get(req.params.id); res.json(rowToArticle(row)); }); -app.delete('/api/articles/:id', authRequired, (req, res) => { - const info = db.prepare('DELETE FROM articles WHERE id = ?').run(req.params.id); +app.delete('/api/articles/:id', authRequired, async (req, res) => { + const info = await db.prepare('DELETE FROM articles WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); @@ -295,7 +398,7 @@ function deepSanitize(v) { return v; } -app.post('/api/admin/import', authRequired, (req, res) => { +app.post('/api/admin/import', authRequired, async (req, res) => { const { report, radar_items = [], fa_summary_long, key_data } = deepSanitize(req.body || {}); if (!report?.title) return res.status(400).json({ error: 'report.title required' }); @@ -307,13 +410,20 @@ app.post('/api/admin/import', authRequired, (req, res) => { ? (key_data ? fa_summary_long + '\n\n---KEY_DATA---\n' + JSON.stringify(key_data, null, 2) : fa_summary_long) : (key_data ? JSON.stringify(key_data, null, 2) : null); - db.prepare(` - INSERT OR REPLACE INTO articles + await db.prepare(` + INSERT INTO articles (id, title, category, type, author, author_role, author_initial, publish_date, pages, price, is_free, summary, body, tags, cover_image, featured, status) VALUES (@id, @title, @category, @type, @author, @author_role, @author_initial, @publish_date, @pages, @price, @is_free, @summary, @body, @tags, @cover_image, @featured, 'published') + ON CONFLICT (id) DO UPDATE SET + title = EXCLUDED.title, category = EXCLUDED.category, type = EXCLUDED.type, + author = EXCLUDED.author, author_role = EXCLUDED.author_role, author_initial = EXCLUDED.author_initial, + publish_date = EXCLUDED.publish_date, pages = EXCLUDED.pages, price = EXCLUDED.price, + is_free = EXCLUDED.is_free, summary = EXCLUDED.summary, body = EXCLUDED.body, + tags = EXCLUDED.tags, cover_image = EXCLUDED.cover_image, featured = EXCLUDED.featured, + status = EXCLUDED.status, updated_at = now() `).run({ id: articleId, title: report.title, @@ -336,13 +446,18 @@ app.post('/api/admin/import', authRequired, (req, res) => { // upsert radar items const insertRadar = db.prepare(` - INSERT OR REPLACE INTO radar_items + INSERT INTO radar_items (id, category, img, title_fa, excerpt_fa, date_fa, author_name, sort_order) VALUES (@id, @category, @img, @title_fa, @excerpt_fa, @date_fa, @author_name, @sort_order) + ON CONFLICT (id) DO UPDATE SET + category = EXCLUDED.category, img = EXCLUDED.img, title_fa = EXCLUDED.title_fa, + excerpt_fa = EXCLUDED.excerpt_fa, date_fa = EXCLUDED.date_fa, author_name = EXCLUDED.author_name, + sort_order = EXCLUDED.sort_order, updated_at = now() `); - radar_items.forEach((item, i) => { + for (let i = 0; i < radar_items.length; i++) { + const item = radar_items[i]; const rid = item.id || nanoid(12); - insertRadar.run({ + await insertRadar.run({ id: rid, category: item.category || 'market', img: item.img || item.image || report.coverImage || '', @@ -353,7 +468,7 @@ app.post('/api/admin/import', authRequired, (req, res) => { sort_order: i, }); results.radar.push(rid); - }); + } res.json({ ok: true, ...results }); }); @@ -363,14 +478,14 @@ const EVENT_FIELDS = `id, title_fa, title_en, date_fa, date_en, month_fa, month_ location_fa, location_en, city_fa, city_en, description_fa, description_en, registration_open, sort_order, created_at, updated_at`; -app.get('/api/events', (req, res) => { +app.get('/api/events', async (req, res) => { const limit = Math.min(Number(req.query.limit) || 100, 500); - const rows = db.prepare(`SELECT ${EVENT_FIELDS} FROM events ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit); + const rows = await db.prepare(`SELECT ${EVENT_FIELDS} FROM events ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit); res.json(rows.map(rowToEvent)); }); -app.get('/api/events/:id', (req, res) => { - const row = db.prepare(`SELECT ${EVENT_FIELDS} FROM events WHERE id = ?`).get(req.params.id); +app.get('/api/events/:id', async (req, res) => { + const row = await db.prepare(`SELECT ${EVENT_FIELDS} FROM events WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToEvent(row)); }); @@ -391,49 +506,50 @@ function normalizeEventBody(b) { }; } -app.post('/api/events', authRequired, (req, res) => { +app.post('/api/events', authRequired, async (req, res) => { const e = normalizeEventBody(req.body || {}); if (!e.title_fa) return res.status(400).json({ error: 'title_fa_required' }); const id = nanoid(14); - db.prepare(`INSERT INTO events (id,title_fa,title_en,date_fa,date_en,month_fa,month_en,year,type, + await db.prepare(`INSERT INTO events (id,title_fa,title_en,date_fa,date_en,month_fa,month_en,year,type, location_fa,location_en,city_fa,city_en,description_fa,description_en,registration_open,sort_order) VALUES (@id,@title_fa,@title_en,@date_fa,@date_en,@month_fa,@month_en,@year,@type, @location_fa,@location_en,@city_fa,@city_en,@description_fa,@description_en,@registration_open,@sort_order) `).run({ id, ...e }); - res.status(201).json(rowToEvent(db.prepare(`SELECT ${EVENT_FIELDS} FROM events WHERE id = ?`).get(id))); + res.status(201).json(rowToEvent(await db.prepare(`SELECT ${EVENT_FIELDS} FROM events WHERE id = ?`).get(id))); }); -app.put('/api/events/:id', authRequired, (req, res) => { - if (!db.prepare('SELECT id FROM events WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); +app.put('/api/events/:id', authRequired, async (req, res) => { + if (!await db.prepare('SELECT id FROM events WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); const e = normalizeEventBody(req.body || {}); if (!e.title_fa) return res.status(400).json({ error: 'title_fa_required' }); - db.prepare(`UPDATE events SET title_fa=@title_fa,title_en=@title_en,date_fa=@date_fa,date_en=@date_en, + await db.prepare(`UPDATE events SET title_fa=@title_fa,title_en=@title_en,date_fa=@date_fa,date_en=@date_en, month_fa=@month_fa,month_en=@month_en,year=@year,type=@type,location_fa=@location_fa,location_en=@location_en, city_fa=@city_fa,city_en=@city_en,description_fa=@description_fa,description_en=@description_en, - registration_open=@registration_open,sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id + registration_open=@registration_open,sort_order=@sort_order,updated_at=now() WHERE id=@id `).run({ id: req.params.id, ...e }); - res.json(rowToEvent(db.prepare(`SELECT ${EVENT_FIELDS} FROM events WHERE id = ?`).get(req.params.id))); + res.json(rowToEvent(await db.prepare(`SELECT ${EVENT_FIELDS} FROM events WHERE id = ?`).get(req.params.id))); }); -app.delete('/api/events/:id', authRequired, (req, res) => { - const info = db.prepare('DELETE FROM events WHERE id = ?').run(req.params.id); +app.delete('/api/events/:id', authRequired, async (req, res) => { + const info = await db.prepare('DELETE FROM events WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); // ---------- radar ---------- const RADAR_FIELDS = `id, category, img, author_name, author_avatar, - title_fa, date_fa, excerpt_fa, title_en, date_en, excerpt_en, + title_fa, date_fa, excerpt_fa, body_fa, analysis_fa, + title_en, date_en, excerpt_en, body_en, analysis_en, sort_order, created_at, updated_at`; -app.get('/api/radar', (req, res) => { +app.get('/api/radar', async (req, res) => { const limit = Math.min(Number(req.query.limit) || 50, 500); - const rows = 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)); }); -app.get('/api/radar/:id', (req, res) => { - const row = db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(req.params.id); +app.get('/api/radar/:id', async (req, res) => { + const row = await db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToRadarItem(row)); }); @@ -447,39 +563,47 @@ function normalizeRadarBody(b) { title_fa: String(b.titleFa || '').trim(), date_fa: b.dateFa || null, excerpt_fa: b.excerptFa || null, + body_fa: b.bodyFa || null, + analysis_fa: b.analysisFa || null, title_en: b.titleEn || null, date_en: b.dateEn || null, excerpt_en: b.excerptEn || null, + body_en: b.bodyEn || null, + analysis_en: b.analysisEn || null, sort_order: Number(b.sortOrder) || 0, }; } -app.post('/api/radar', authRequired, (req, res) => { +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); - db.prepare(`INSERT INTO radar_items (id,category,img,author_name,author_avatar, - title_fa,date_fa,excerpt_fa,title_en,date_en,excerpt_en,sort_order) + 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,sort_order) VALUES (@id,@category,@img,@author_name,@author_avatar, - @title_fa,@date_fa,@excerpt_fa,@title_en,@date_en,@excerpt_en,@sort_order) + @title_fa,@date_fa,@excerpt_fa,@body_fa,@analysis_fa, + @title_en,@date_en,@excerpt_en,@body_en,@analysis_en,@sort_order) `).run({ id, ...r }); - res.status(201).json(rowToRadarItem(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))); }); -app.put('/api/radar/:id', authRequired, (req, res) => { - if (!db.prepare('SELECT id FROM radar_items WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); +app.put('/api/radar/:id', authRequired, async (req, res) => { + if (!await db.prepare('SELECT id FROM radar_items WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); const r = normalizeRadarBody(req.body || {}); if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' }); - db.prepare(`UPDATE radar_items SET category=@category,img=@img,author_name=@author_name, + await db.prepare(`UPDATE radar_items SET category=@category,img=@img,author_name=@author_name, author_avatar=@author_avatar,title_fa=@title_fa,date_fa=@date_fa,excerpt_fa=@excerpt_fa, + body_fa=@body_fa,analysis_fa=@analysis_fa, title_en=@title_en,date_en=@date_en,excerpt_en=@excerpt_en, - sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id + body_en=@body_en,analysis_en=@analysis_en, + sort_order=@sort_order,updated_at=now() WHERE id=@id `).run({ id: req.params.id, ...r }); - res.json(rowToRadarItem(db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(req.params.id))); + res.json(rowToRadarItem(await db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(req.params.id))); }); -app.delete('/api/radar/:id', authRequired, (req, res) => { - const info = db.prepare('DELETE FROM radar_items WHERE id = ?').run(req.params.id); +app.delete('/api/radar/:id', authRequired, async (req, res) => { + const info = await db.prepare('DELETE FROM radar_items WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); @@ -492,13 +616,13 @@ const RADAR_PAGE_FIELDS = `slug, source_categories, label_fa, label_en, banner_desc_fa, banner_desc_en, banner_book_img, sort_order, created_at, updated_at`; -app.get('/api/radar-pages', (req, res) => { - const rows = db.prepare(`SELECT ${RADAR_PAGE_FIELDS} FROM radar_pages ORDER BY sort_order ASC`).all(); +app.get('/api/radar-pages', async (req, res) => { + const rows = await db.prepare(`SELECT ${RADAR_PAGE_FIELDS} FROM radar_pages ORDER BY sort_order ASC`).all(); res.json(rows.map(rowToRadarPage)); }); -app.get('/api/radar-pages/:slug', (req, res) => { - const row = db.prepare(`SELECT ${RADAR_PAGE_FIELDS} FROM radar_pages WHERE slug = ?`).get(req.params.slug); +app.get('/api/radar-pages/:slug', async (req, res) => { + const row = await db.prepare(`SELECT ${RADAR_PAGE_FIELDS} FROM radar_pages WHERE slug = ?`).get(req.params.slug); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToRadarPage(row)); }); @@ -527,23 +651,23 @@ function normalizeRadarPageBody(b) { } // upsert by slug (slug is the stable key for the 5 fixed pages) -app.put('/api/radar-pages/:slug', authRequired, (req, res) => { +app.put('/api/radar-pages/:slug', authRequired, async (req, res) => { const slug = req.params.slug; const r = normalizeRadarPageBody(req.body || {}); if (!r.label_fa) return res.status(400).json({ error: 'label_fa_required' }); - const exists = db.prepare('SELECT slug FROM radar_pages WHERE slug = ?').get(slug); + const exists = await db.prepare('SELECT slug FROM radar_pages WHERE slug = ?').get(slug); if (exists) { - db.prepare(`UPDATE radar_pages SET source_categories=@source_categories,label_fa=@label_fa,label_en=@label_en, + await db.prepare(`UPDATE radar_pages SET source_categories=@source_categories,label_fa=@label_fa,label_en=@label_en, latest_heading_fa=@latest_heading_fa,latest_heading_en=@latest_heading_en, featured_title_fa=@featured_title_fa,featured_title_en=@featured_title_en, featured_date_fa=@featured_date_fa,featured_date_en=@featured_date_en,featured_img=@featured_img, banner_kicker_fa=@banner_kicker_fa,banner_kicker_en=@banner_kicker_en, banner_title_fa=@banner_title_fa,banner_title_en=@banner_title_en, banner_desc_fa=@banner_desc_fa,banner_desc_en=@banner_desc_en,banner_book_img=@banner_book_img, - sort_order=@sort_order,updated_at=datetime('now') WHERE slug=@slug + sort_order=@sort_order,updated_at=now() WHERE slug=@slug `).run({ slug, ...r }); } else { - db.prepare(`INSERT INTO radar_pages (slug,source_categories,label_fa,label_en, + await db.prepare(`INSERT INTO radar_pages (slug,source_categories,label_fa,label_en, latest_heading_fa,latest_heading_en,featured_title_fa,featured_title_en, featured_date_fa,featured_date_en,featured_img,banner_kicker_fa,banner_kicker_en, banner_title_fa,banner_title_en,banner_desc_fa,banner_desc_en,banner_book_img,sort_order) @@ -553,21 +677,21 @@ app.put('/api/radar-pages/:slug', authRequired, (req, res) => { @banner_desc_fa,@banner_desc_en,@banner_book_img,@sort_order) `).run({ slug, ...r }); } - res.json(rowToRadarPage(db.prepare(`SELECT ${RADAR_PAGE_FIELDS} FROM radar_pages WHERE slug = ?`).get(slug))); + res.json(rowToRadarPage(await db.prepare(`SELECT ${RADAR_PAGE_FIELDS} FROM radar_pages WHERE slug = ?`).get(slug))); }); // ---------- factory reports ---------- const FACTORY_REPORT_FIELDS = `id, img, tag_fa, tag_en, title_fa, title_en, date_fa, date_en, excerpt_fa, excerpt_en, scroll_dir, sort_order, created_at, updated_at`; -app.get('/api/factory-reports', (req, res) => { +app.get('/api/factory-reports', async (req, res) => { const limit = Math.min(Number(req.query.limit) || 50, 200); - const rows = db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit); + const rows = await db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit); res.json(rows.map(rowToFactoryReport)); }); -app.get('/api/factory-reports/:id', (req, res) => { - const row = db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports WHERE id = ?`).get(req.params.id); +app.get('/api/factory-reports/:id', async (req, res) => { + const row = await db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToFactoryReport(row)); }); @@ -585,31 +709,31 @@ function normalizeFactoryReportBody(b) { }; } -app.post('/api/factory-reports', authRequired, (req, res) => { +app.post('/api/factory-reports', authRequired, async (req, res) => { const r = normalizeFactoryReportBody(req.body || {}); if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' }); const id = nanoid(14); - db.prepare(`INSERT INTO factory_reports (id,img,tag_fa,tag_en,title_fa,title_en,date_fa,date_en, + await db.prepare(`INSERT INTO factory_reports (id,img,tag_fa,tag_en,title_fa,title_en,date_fa,date_en, excerpt_fa,excerpt_en,scroll_dir,sort_order) VALUES (@id,@img,@tag_fa,@tag_en,@title_fa,@title_en,@date_fa,@date_en, @excerpt_fa,@excerpt_en,@scroll_dir,@sort_order) `).run({ id, ...r }); - res.status(201).json(rowToFactoryReport(db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports WHERE id = ?`).get(id))); + res.status(201).json(rowToFactoryReport(await db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports WHERE id = ?`).get(id))); }); -app.put('/api/factory-reports/:id', authRequired, (req, res) => { - if (!db.prepare('SELECT id FROM factory_reports WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); +app.put('/api/factory-reports/:id', authRequired, async (req, res) => { + if (!await db.prepare('SELECT id FROM factory_reports WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); const r = normalizeFactoryReportBody(req.body || {}); if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' }); - db.prepare(`UPDATE factory_reports SET img=@img,tag_fa=@tag_fa,tag_en=@tag_en,title_fa=@title_fa,title_en=@title_en, + await db.prepare(`UPDATE factory_reports SET img=@img,tag_fa=@tag_fa,tag_en=@tag_en,title_fa=@title_fa,title_en=@title_en, date_fa=@date_fa,date_en=@date_en,excerpt_fa=@excerpt_fa,excerpt_en=@excerpt_en, - scroll_dir=@scroll_dir,sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id + scroll_dir=@scroll_dir,sort_order=@sort_order,updated_at=now() WHERE id=@id `).run({ id: req.params.id, ...r }); - res.json(rowToFactoryReport(db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports WHERE id = ?`).get(req.params.id))); + res.json(rowToFactoryReport(await db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports WHERE id = ?`).get(req.params.id))); }); -app.delete('/api/factory-reports/:id', authRequired, (req, res) => { - const info = db.prepare('DELETE FROM factory_reports WHERE id = ?').run(req.params.id); +app.delete('/api/factory-reports/:id', authRequired, async (req, res) => { + const info = await db.prepare('DELETE FROM factory_reports WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); @@ -618,14 +742,14 @@ app.delete('/api/factory-reports/:id', authRequired, (req, res) => { const INTEGRATION_FIELDS = `id, name, icon, logo, accent, grid_col, grid_row, sort_order, created_at, updated_at`; const INTEGRATION_ICONS = ['exchange','globe','chart','activity','database','newspaper','building','trending','coins']; -app.get('/api/integrations', (req, res) => { +app.get('/api/integrations', async (req, res) => { const limit = Math.min(Number(req.query.limit) || 50, 200); - const rows = db.prepare(`SELECT ${INTEGRATION_FIELDS} FROM integrations ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit); + const rows = await db.prepare(`SELECT ${INTEGRATION_FIELDS} FROM integrations ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit); res.json(rows.map(rowToIntegration)); }); -app.get('/api/integrations/:id', (req, res) => { - const row = db.prepare(`SELECT ${INTEGRATION_FIELDS} FROM integrations WHERE id = ?`).get(req.params.id); +app.get('/api/integrations/:id', async (req, res) => { + const row = await db.prepare(`SELECT ${INTEGRATION_FIELDS} FROM integrations WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToIntegration(row)); }); @@ -642,28 +766,28 @@ function normalizeIntegrationBody(b) { }; } -app.post('/api/integrations', authRequired, (req, res) => { +app.post('/api/integrations', authRequired, async (req, res) => { const it = normalizeIntegrationBody(req.body || {}); if (!it.name) return res.status(400).json({ error: 'name_required' }); const id = nanoid(14); - db.prepare(`INSERT INTO integrations (id,name,icon,logo,accent,grid_col,grid_row,sort_order) + await db.prepare(`INSERT INTO integrations (id,name,icon,logo,accent,grid_col,grid_row,sort_order) VALUES (@id,@name,@icon,@logo,@accent,@grid_col,@grid_row,@sort_order) `).run({ id, ...it }); - res.status(201).json(rowToIntegration(db.prepare(`SELECT ${INTEGRATION_FIELDS} FROM integrations WHERE id = ?`).get(id))); + res.status(201).json(rowToIntegration(await db.prepare(`SELECT ${INTEGRATION_FIELDS} FROM integrations WHERE id = ?`).get(id))); }); -app.put('/api/integrations/:id', authRequired, (req, res) => { - if (!db.prepare('SELECT id FROM integrations WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); +app.put('/api/integrations/:id', authRequired, async (req, res) => { + if (!await db.prepare('SELECT id FROM integrations WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); const it = normalizeIntegrationBody(req.body || {}); if (!it.name) return res.status(400).json({ error: 'name_required' }); - db.prepare(`UPDATE integrations SET name=@name,icon=@icon,logo=@logo,accent=@accent, - grid_col=@grid_col,grid_row=@grid_row,sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id + await db.prepare(`UPDATE integrations SET name=@name,icon=@icon,logo=@logo,accent=@accent, + grid_col=@grid_col,grid_row=@grid_row,sort_order=@sort_order,updated_at=now() WHERE id=@id `).run({ id: req.params.id, ...it }); - res.json(rowToIntegration(db.prepare(`SELECT ${INTEGRATION_FIELDS} FROM integrations WHERE id = ?`).get(req.params.id))); + res.json(rowToIntegration(await db.prepare(`SELECT ${INTEGRATION_FIELDS} FROM integrations WHERE id = ?`).get(req.params.id))); }); -app.delete('/api/integrations/:id', authRequired, (req, res) => { - const info = db.prepare('DELETE FROM integrations WHERE id = ?').run(req.params.id); +app.delete('/api/integrations/:id', authRequired, async (req, res) => { + const info = await db.prepare('DELETE FROM integrations WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); @@ -671,12 +795,12 @@ app.delete('/api/integrations/:id', authRequired, (req, res) => { // ---------- institute stats ---------- const INSTITUTE_STATS_FIELDS = `id,label_fa,label_en,value,suffix_fa,sort_order,created_at,updated_at`; -app.get('/api/institute-stats', (_req, res) => { - res.json(db.prepare(`SELECT ${INSTITUTE_STATS_FIELDS} FROM institute_stats ORDER BY sort_order ASC`).all().map(rowToInstituteStat)); +app.get('/api/institute-stats', async (_req, res) => { + res.json((await db.prepare(`SELECT ${INSTITUTE_STATS_FIELDS} FROM institute_stats ORDER BY sort_order ASC`).all()).map(rowToInstituteStat)); }); -app.get('/api/institute-stats/:id', (req, res) => { - const row = db.prepare(`SELECT ${INSTITUTE_STATS_FIELDS} FROM institute_stats WHERE id = ?`).get(req.params.id); +app.get('/api/institute-stats/:id', async (req, res) => { + const row = await db.prepare(`SELECT ${INSTITUTE_STATS_FIELDS} FROM institute_stats WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToInstituteStat(row)); }); @@ -691,28 +815,28 @@ function normalizeInstituteStatBody(b) { }; } -app.post('/api/institute-stats', authRequired, (req, res) => { +app.post('/api/institute-stats', authRequired, async (req, res) => { const s = normalizeInstituteStatBody(req.body || {}); if (!s.label_fa) return res.status(400).json({ error: 'label_fa_required' }); const id = (req.body && req.body.id ? String(req.body.id).trim() : '') || nanoid(14); - db.prepare(`INSERT INTO institute_stats (id,label_fa,label_en,value,suffix_fa,sort_order) + await db.prepare(`INSERT INTO institute_stats (id,label_fa,label_en,value,suffix_fa,sort_order) VALUES (@id,@label_fa,@label_en,@value,@suffix_fa,@sort_order) `).run({ id, ...s }); - res.status(201).json(rowToInstituteStat(db.prepare(`SELECT ${INSTITUTE_STATS_FIELDS} FROM institute_stats WHERE id = ?`).get(id))); + res.status(201).json(rowToInstituteStat(await db.prepare(`SELECT ${INSTITUTE_STATS_FIELDS} FROM institute_stats WHERE id = ?`).get(id))); }); -app.put('/api/institute-stats/:id', authRequired, (req, res) => { - if (!db.prepare('SELECT id FROM institute_stats WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); +app.put('/api/institute-stats/:id', authRequired, async (req, res) => { + if (!await db.prepare('SELECT id FROM institute_stats WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); const s = normalizeInstituteStatBody(req.body || {}); if (!s.label_fa) return res.status(400).json({ error: 'label_fa_required' }); - db.prepare(`UPDATE institute_stats SET label_fa=@label_fa,label_en=@label_en,value=@value, - suffix_fa=@suffix_fa,sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id + await db.prepare(`UPDATE institute_stats SET label_fa=@label_fa,label_en=@label_en,value=@value, + suffix_fa=@suffix_fa,sort_order=@sort_order,updated_at=now() WHERE id=@id `).run({ id: req.params.id, ...s }); - res.json(rowToInstituteStat(db.prepare(`SELECT ${INSTITUTE_STATS_FIELDS} FROM institute_stats WHERE id = ?`).get(req.params.id))); + res.json(rowToInstituteStat(await db.prepare(`SELECT ${INSTITUTE_STATS_FIELDS} FROM institute_stats WHERE id = ?`).get(req.params.id))); }); -app.delete('/api/institute-stats/:id', authRequired, (req, res) => { - const info = db.prepare('DELETE FROM institute_stats WHERE id = ?').run(req.params.id); +app.delete('/api/institute-stats/:id', authRequired, async (req, res) => { + const info = await db.prepare('DELETE FROM institute_stats WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); @@ -720,12 +844,12 @@ app.delete('/api/institute-stats/:id', authRequired, (req, res) => { // ---------- market prices ---------- const MARKET_PRICE_FIELDS = `id,name,value,unit,change,change_percent,trend,sort_order,created_at,updated_at`; -app.get('/api/market-prices', (_req, res) => { - res.json(db.prepare(`SELECT ${MARKET_PRICE_FIELDS} FROM market_prices ORDER BY sort_order ASC`).all().map(rowToMarketPrice)); +app.get('/api/market-prices', async (_req, res) => { + res.json((await db.prepare(`SELECT ${MARKET_PRICE_FIELDS} FROM market_prices ORDER BY sort_order ASC`).all()).map(rowToMarketPrice)); }); -app.get('/api/market-prices/:id', (req, res) => { - const row = db.prepare(`SELECT ${MARKET_PRICE_FIELDS} FROM market_prices WHERE id = ?`).get(req.params.id); +app.get('/api/market-prices/:id', async (req, res) => { + const row = await db.prepare(`SELECT ${MARKET_PRICE_FIELDS} FROM market_prices WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToMarketPrice(row)); }); @@ -742,28 +866,28 @@ function normalizeMarketPriceBody(b) { }; } -app.post('/api/market-prices', authRequired, (req, res) => { +app.post('/api/market-prices', authRequired, async (req, res) => { const p = normalizeMarketPriceBody(req.body || {}); if (!p.name) return res.status(400).json({ error: 'name_required' }); const id = (req.body && req.body.id ? String(req.body.id).trim() : '') || nanoid(14); - db.prepare(`INSERT INTO market_prices (id,name,value,unit,change,change_percent,trend,sort_order) + await db.prepare(`INSERT INTO market_prices (id,name,value,unit,change,change_percent,trend,sort_order) VALUES (@id,@name,@value,@unit,@change,@change_percent,@trend,@sort_order) `).run({ id, ...p }); - res.status(201).json(rowToMarketPrice(db.prepare(`SELECT ${MARKET_PRICE_FIELDS} FROM market_prices WHERE id = ?`).get(id))); + res.status(201).json(rowToMarketPrice(await db.prepare(`SELECT ${MARKET_PRICE_FIELDS} FROM market_prices WHERE id = ?`).get(id))); }); -app.put('/api/market-prices/:id', authRequired, (req, res) => { - if (!db.prepare('SELECT id FROM market_prices WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); +app.put('/api/market-prices/:id', authRequired, async (req, res) => { + if (!await db.prepare('SELECT id FROM market_prices WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); const p = normalizeMarketPriceBody(req.body || {}); if (!p.name) return res.status(400).json({ error: 'name_required' }); - db.prepare(`UPDATE market_prices SET name=@name,value=@value,unit=@unit,change=@change, - change_percent=@change_percent,trend=@trend,sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id + await db.prepare(`UPDATE market_prices SET name=@name,value=@value,unit=@unit,change=@change, + change_percent=@change_percent,trend=@trend,sort_order=@sort_order,updated_at=now() WHERE id=@id `).run({ id: req.params.id, ...p }); - res.json(rowToMarketPrice(db.prepare(`SELECT ${MARKET_PRICE_FIELDS} FROM market_prices WHERE id = ?`).get(req.params.id))); + res.json(rowToMarketPrice(await db.prepare(`SELECT ${MARKET_PRICE_FIELDS} FROM market_prices WHERE id = ?`).get(req.params.id))); }); -app.delete('/api/market-prices/:id', authRequired, (req, res) => { - const info = db.prepare('DELETE FROM market_prices WHERE id = ?').run(req.params.id); +app.delete('/api/market-prices/:id', authRequired, async (req, res) => { + const info = await db.prepare('DELETE FROM market_prices WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); @@ -772,7 +896,7 @@ app.delete('/api/market-prices/:id', authRequired, (req, res) => { const CHART_POINT_FIELDS = `id,series,label,value,meta,sort_order,created_at,updated_at`; const ALLOWED_SERIES = new Set(['history','export','comparison']); -app.get('/api/market-chart-points', (req, res) => { +app.get('/api/market-chart-points', async (req, res) => { const limit = Math.min(Number(req.query.limit) || 200, 1000); const series = ALLOWED_SERIES.has(req.query.series) ? req.query.series : null; let q = `SELECT ${CHART_POINT_FIELDS} FROM market_chart_points`; @@ -780,11 +904,11 @@ app.get('/api/market-chart-points', (req, res) => { if (series) { q += ' WHERE series = ?'; params.push(series); } q += ' ORDER BY series ASC, sort_order ASC LIMIT ?'; params.push(limit); - res.json(db.prepare(q).all(...params).map(rowToMarketChartPoint)); + res.json((await db.prepare(q).all(...params)).map(rowToMarketChartPoint)); }); -app.get('/api/market-chart-points/:id', (req, res) => { - const row = db.prepare(`SELECT ${CHART_POINT_FIELDS} FROM market_chart_points WHERE id = ?`).get(req.params.id); +app.get('/api/market-chart-points/:id', async (req, res) => { + const row = await db.prepare(`SELECT ${CHART_POINT_FIELDS} FROM market_chart_points WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToMarketChartPoint(row)); }); @@ -803,30 +927,30 @@ function normalizeChartPointBody(b) { }; } -app.post('/api/market-chart-points', authRequired, (req, res) => { +app.post('/api/market-chart-points', authRequired, async (req, res) => { const c = normalizeChartPointBody(req.body || {}); if (!c.series) return res.status(400).json({ error: 'series_required' }); if (!c.label) return res.status(400).json({ error: 'label_required' }); const id = (req.body && req.body.id ? String(req.body.id).trim() : '') || nanoid(14); - db.prepare(`INSERT INTO market_chart_points (id,series,label,value,meta,sort_order) + await db.prepare(`INSERT INTO market_chart_points (id,series,label,value,meta,sort_order) VALUES (@id,@series,@label,@value,@meta,@sort_order) `).run({ id, ...c }); - res.status(201).json(rowToMarketChartPoint(db.prepare(`SELECT ${CHART_POINT_FIELDS} FROM market_chart_points WHERE id = ?`).get(id))); + res.status(201).json(rowToMarketChartPoint(await db.prepare(`SELECT ${CHART_POINT_FIELDS} FROM market_chart_points WHERE id = ?`).get(id))); }); -app.put('/api/market-chart-points/:id', authRequired, (req, res) => { - if (!db.prepare('SELECT id FROM market_chart_points WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); +app.put('/api/market-chart-points/:id', authRequired, async (req, res) => { + if (!await db.prepare('SELECT id FROM market_chart_points WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); const c = normalizeChartPointBody(req.body || {}); if (!c.series) return res.status(400).json({ error: 'series_required' }); if (!c.label) return res.status(400).json({ error: 'label_required' }); - db.prepare(`UPDATE market_chart_points SET series=@series,label=@label,value=@value,meta=@meta, - sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id + await db.prepare(`UPDATE market_chart_points SET series=@series,label=@label,value=@value,meta=@meta, + sort_order=@sort_order,updated_at=now() WHERE id=@id `).run({ id: req.params.id, ...c }); - res.json(rowToMarketChartPoint(db.prepare(`SELECT ${CHART_POINT_FIELDS} FROM market_chart_points WHERE id = ?`).get(req.params.id))); + res.json(rowToMarketChartPoint(await db.prepare(`SELECT ${CHART_POINT_FIELDS} FROM market_chart_points WHERE id = ?`).get(req.params.id))); }); -app.delete('/api/market-chart-points/:id', authRequired, (req, res) => { - const info = db.prepare('DELETE FROM market_chart_points WHERE id = ?').run(req.params.id); +app.delete('/api/market-chart-points/:id', authRequired, async (req, res) => { + const info = await db.prepare('DELETE FROM market_chart_points WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); @@ -834,14 +958,14 @@ app.delete('/api/market-chart-points/:id', authRequired, (req, res) => { // ---------- vision items (About page) ---------- const VISION_FIELDS = `id, icon, title_fa, title_en, desc_fa, desc_en, sort_order, created_at, updated_at`; -app.get('/api/vision-items', (req, res) => { +app.get('/api/vision-items', async (req, res) => { const limit = Math.min(Number(req.query.limit) || 100, 500); - const rows = db.prepare(`SELECT ${VISION_FIELDS} FROM vision_items ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit); + const rows = await db.prepare(`SELECT ${VISION_FIELDS} FROM vision_items ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit); res.json(rows.map(rowToVisionItem)); }); -app.get('/api/vision-items/:id', (req, res) => { - const row = db.prepare(`SELECT ${VISION_FIELDS} FROM vision_items WHERE id = ?`).get(req.params.id); +app.get('/api/vision-items/:id', async (req, res) => { + const row = await db.prepare(`SELECT ${VISION_FIELDS} FROM vision_items WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToVisionItem(row)); }); @@ -857,28 +981,28 @@ function normalizeVisionBody(b) { }; } -app.post('/api/vision-items', authRequired, (req, res) => { +app.post('/api/vision-items', authRequired, async (req, res) => { const v = normalizeVisionBody(req.body || {}); if (!v.title_fa) return res.status(400).json({ error: 'title_fa_required' }); const id = nanoid(14); - db.prepare(`INSERT INTO vision_items (id,icon,title_fa,title_en,desc_fa,desc_en,sort_order) + await db.prepare(`INSERT INTO vision_items (id,icon,title_fa,title_en,desc_fa,desc_en,sort_order) VALUES (@id,@icon,@title_fa,@title_en,@desc_fa,@desc_en,@sort_order) `).run({ id, ...v }); - res.status(201).json(rowToVisionItem(db.prepare(`SELECT ${VISION_FIELDS} FROM vision_items WHERE id = ?`).get(id))); + res.status(201).json(rowToVisionItem(await db.prepare(`SELECT ${VISION_FIELDS} FROM vision_items WHERE id = ?`).get(id))); }); -app.put('/api/vision-items/:id', authRequired, (req, res) => { - if (!db.prepare('SELECT id FROM vision_items WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); +app.put('/api/vision-items/:id', authRequired, async (req, res) => { + if (!await db.prepare('SELECT id FROM vision_items WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); const v = normalizeVisionBody(req.body || {}); if (!v.title_fa) return res.status(400).json({ error: 'title_fa_required' }); - db.prepare(`UPDATE vision_items SET icon=@icon,title_fa=@title_fa,title_en=@title_en, - desc_fa=@desc_fa,desc_en=@desc_en,sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id + await db.prepare(`UPDATE vision_items SET icon=@icon,title_fa=@title_fa,title_en=@title_en, + desc_fa=@desc_fa,desc_en=@desc_en,sort_order=@sort_order,updated_at=now() WHERE id=@id `).run({ id: req.params.id, ...v }); - res.json(rowToVisionItem(db.prepare(`SELECT ${VISION_FIELDS} FROM vision_items WHERE id = ?`).get(req.params.id))); + res.json(rowToVisionItem(await db.prepare(`SELECT ${VISION_FIELDS} FROM vision_items WHERE id = ?`).get(req.params.id))); }); -app.delete('/api/vision-items/:id', authRequired, (req, res) => { - const info = db.prepare('DELETE FROM vision_items WHERE id = ?').run(req.params.id); +app.delete('/api/vision-items/:id', authRequired, async (req, res) => { + const info = await db.prepare('DELETE FROM vision_items WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); @@ -886,14 +1010,14 @@ app.delete('/api/vision-items/:id', authRequired, (req, res) => { // ---------- advisory board (About page) ---------- const ADVISORY_FIELDS = `id, name_fa, name_en, role_fa, role_en, sort_order, created_at, updated_at`; -app.get('/api/advisory-board', (req, res) => { +app.get('/api/advisory-board', async (req, res) => { const limit = Math.min(Number(req.query.limit) || 100, 500); - const rows = db.prepare(`SELECT ${ADVISORY_FIELDS} FROM advisory_board ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit); + const rows = await db.prepare(`SELECT ${ADVISORY_FIELDS} FROM advisory_board ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit); res.json(rows.map(rowToAdvisoryMember)); }); -app.get('/api/advisory-board/:id', (req, res) => { - const row = db.prepare(`SELECT ${ADVISORY_FIELDS} FROM advisory_board WHERE id = ?`).get(req.params.id); +app.get('/api/advisory-board/:id', async (req, res) => { + const row = await db.prepare(`SELECT ${ADVISORY_FIELDS} FROM advisory_board WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToAdvisoryMember(row)); }); @@ -908,28 +1032,28 @@ function normalizeAdvisoryBody(b) { }; } -app.post('/api/advisory-board', authRequired, (req, res) => { +app.post('/api/advisory-board', authRequired, async (req, res) => { const m = normalizeAdvisoryBody(req.body || {}); if (!m.name_fa) return res.status(400).json({ error: 'name_fa_required' }); const id = nanoid(14); - db.prepare(`INSERT INTO advisory_board (id,name_fa,name_en,role_fa,role_en,sort_order) + await db.prepare(`INSERT INTO advisory_board (id,name_fa,name_en,role_fa,role_en,sort_order) VALUES (@id,@name_fa,@name_en,@role_fa,@role_en,@sort_order) `).run({ id, ...m }); - res.status(201).json(rowToAdvisoryMember(db.prepare(`SELECT ${ADVISORY_FIELDS} FROM advisory_board WHERE id = ?`).get(id))); + res.status(201).json(rowToAdvisoryMember(await db.prepare(`SELECT ${ADVISORY_FIELDS} FROM advisory_board WHERE id = ?`).get(id))); }); -app.put('/api/advisory-board/:id', authRequired, (req, res) => { - if (!db.prepare('SELECT id FROM advisory_board WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); +app.put('/api/advisory-board/:id', authRequired, async (req, res) => { + if (!await db.prepare('SELECT id FROM advisory_board WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); const m = normalizeAdvisoryBody(req.body || {}); if (!m.name_fa) return res.status(400).json({ error: 'name_fa_required' }); - db.prepare(`UPDATE advisory_board SET name_fa=@name_fa,name_en=@name_en,role_fa=@role_fa, - role_en=@role_en,sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id + await db.prepare(`UPDATE advisory_board SET name_fa=@name_fa,name_en=@name_en,role_fa=@role_fa, + role_en=@role_en,sort_order=@sort_order,updated_at=now() WHERE id=@id `).run({ id: req.params.id, ...m }); - res.json(rowToAdvisoryMember(db.prepare(`SELECT ${ADVISORY_FIELDS} FROM advisory_board WHERE id = ?`).get(req.params.id))); + res.json(rowToAdvisoryMember(await db.prepare(`SELECT ${ADVISORY_FIELDS} FROM advisory_board WHERE id = ?`).get(req.params.id))); }); -app.delete('/api/advisory-board/:id', authRequired, (req, res) => { - const info = db.prepare('DELETE FROM advisory_board WHERE id = ?').run(req.params.id); +app.delete('/api/advisory-board/:id', authRequired, async (req, res) => { + const info = await db.prepare('DELETE FROM advisory_board WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); @@ -938,16 +1062,17 @@ app.delete('/api/advisory-board/:id', authRequired, (req, res) => { const TEAM_FIELDS = `id,name_fa,name_en,role_fa,role_en,bio_fa,bio_en,expertise,email,initial,photo, report_count,is_expert,expert_room_fa,expert_room_en,telegram,linkedin,twitter,website,sort_order,created_at,updated_at`; -app.get('/api/team', (req, res) => { +app.get('/api/team', async (req, res) => { const isExpert = req.query.expert === '1' ? 1 : req.query.expert === '0' ? 0 : null; let query = `SELECT ${TEAM_FIELDS} FROM team_members`; - if (isExpert !== null) query += ` WHERE is_expert = ${isExpert}`; + const params = []; + if (isExpert !== null) { query += ' WHERE is_expert = ?'; params.push(isExpert); } query += ' ORDER BY sort_order ASC, created_at DESC'; - res.json(db.prepare(query).all().map(rowToTeamMember)); + res.json((await db.prepare(query).all(...params)).map(rowToTeamMember)); }); -app.get('/api/team/:id', (req, res) => { - const row = db.prepare(`SELECT ${TEAM_FIELDS} FROM team_members WHERE id = ?`).get(req.params.id); +app.get('/api/team/:id', async (req, res) => { + const row = await db.prepare(`SELECT ${TEAM_FIELDS} FROM team_members WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToTeamMember(row)); }); @@ -969,33 +1094,33 @@ function normalizeTeamBody(b) { }; } -app.post('/api/team', authRequired, (req, res) => { +app.post('/api/team', authRequired, async (req, res) => { const m = normalizeTeamBody(req.body || {}); if (!m.name_fa) return res.status(400).json({ error: 'name_fa_required' }); const id = nanoid(14); - db.prepare(`INSERT INTO team_members (id,name_fa,name_en,role_fa,role_en,bio_fa,bio_en,expertise,email,initial,photo, + await db.prepare(`INSERT INTO team_members (id,name_fa,name_en,role_fa,role_en,bio_fa,bio_en,expertise,email,initial,photo, report_count,is_expert,expert_room_fa,expert_room_en,telegram,linkedin,twitter,website,sort_order) VALUES (@id,@name_fa,@name_en,@role_fa,@role_en,@bio_fa,@bio_en,@expertise,@email,@initial,@photo, @report_count,@is_expert,@expert_room_fa,@expert_room_en,@telegram,@linkedin,@twitter,@website,@sort_order) `).run({ id, ...m }); - res.status(201).json(rowToTeamMember(db.prepare(`SELECT ${TEAM_FIELDS} FROM team_members WHERE id = ?`).get(id))); + res.status(201).json(rowToTeamMember(await db.prepare(`SELECT ${TEAM_FIELDS} FROM team_members WHERE id = ?`).get(id))); }); -app.put('/api/team/:id', authRequired, (req, res) => { - if (!db.prepare('SELECT id FROM team_members WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); +app.put('/api/team/:id', authRequired, async (req, res) => { + if (!await db.prepare('SELECT id FROM team_members WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); const m = normalizeTeamBody(req.body || {}); if (!m.name_fa) return res.status(400).json({ error: 'name_fa_required' }); - db.prepare(`UPDATE team_members SET name_fa=@name_fa,name_en=@name_en,role_fa=@role_fa,role_en=@role_en, + await db.prepare(`UPDATE team_members SET name_fa=@name_fa,name_en=@name_en,role_fa=@role_fa,role_en=@role_en, bio_fa=@bio_fa,bio_en=@bio_en,expertise=@expertise,email=@email,initial=@initial,photo=@photo, report_count=@report_count,is_expert=@is_expert,expert_room_fa=@expert_room_fa,expert_room_en=@expert_room_en, telegram=@telegram,linkedin=@linkedin,twitter=@twitter,website=@website, - sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id + sort_order=@sort_order,updated_at=now() WHERE id=@id `).run({ id: req.params.id, ...m }); - res.json(rowToTeamMember(db.prepare(`SELECT ${TEAM_FIELDS} FROM team_members WHERE id = ?`).get(req.params.id))); + res.json(rowToTeamMember(await db.prepare(`SELECT ${TEAM_FIELDS} FROM team_members WHERE id = ?`).get(req.params.id))); }); -app.delete('/api/team/:id', authRequired, (req, res) => { - const info = db.prepare('DELETE FROM team_members WHERE id = ?').run(req.params.id); +app.delete('/api/team/:id', authRequired, async (req, res) => { + const info = await db.prepare('DELETE FROM team_members WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); @@ -1003,12 +1128,12 @@ app.delete('/api/team/:id', authRequired, (req, res) => { // ---------- plans ---------- const PLAN_FIELDS = `id,name_fa,name_en,price,period_fa,period_en,features,badge_fa,badge_en,is_featured,cta_fa,cta_en,sort_order,created_at,updated_at`; -app.get('/api/plans', (req, res) => { - res.json(db.prepare(`SELECT ${PLAN_FIELDS} FROM plans ORDER BY sort_order ASC`).all().map(rowToPlan)); +app.get('/api/plans', async (req, res) => { + res.json((await db.prepare(`SELECT ${PLAN_FIELDS} FROM plans ORDER BY sort_order ASC`).all()).map(rowToPlan)); }); -app.get('/api/plans/:id', (req, res) => { - const row = db.prepare(`SELECT ${PLAN_FIELDS} FROM plans WHERE id = ?`).get(req.params.id); +app.get('/api/plans/:id', async (req, res) => { + const row = await db.prepare(`SELECT ${PLAN_FIELDS} FROM plans WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToPlan(row)); }); @@ -1027,29 +1152,29 @@ function normalizePlanBody(b) { }; } -app.post('/api/plans', authRequired, (req, res) => { +app.post('/api/plans', authRequired, async (req, res) => { const p = normalizePlanBody(req.body || {}); if (!p.name_fa) return res.status(400).json({ error: 'name_fa_required' }); const id = nanoid(14); - db.prepare(`INSERT INTO plans (id,name_fa,name_en,price,period_fa,period_en,features,badge_fa,badge_en,is_featured,cta_fa,cta_en,sort_order) + await db.prepare(`INSERT INTO plans (id,name_fa,name_en,price,period_fa,period_en,features,badge_fa,badge_en,is_featured,cta_fa,cta_en,sort_order) VALUES (@id,@name_fa,@name_en,@price,@period_fa,@period_en,@features,@badge_fa,@badge_en,@is_featured,@cta_fa,@cta_en,@sort_order) `).run({ id, ...p }); - res.status(201).json(rowToPlan(db.prepare(`SELECT ${PLAN_FIELDS} FROM plans WHERE id = ?`).get(id))); + res.status(201).json(rowToPlan(await db.prepare(`SELECT ${PLAN_FIELDS} FROM plans WHERE id = ?`).get(id))); }); -app.put('/api/plans/:id', authRequired, (req, res) => { - if (!db.prepare('SELECT id FROM plans WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); +app.put('/api/plans/:id', authRequired, async (req, res) => { + if (!await db.prepare('SELECT id FROM plans WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); const p = normalizePlanBody(req.body || {}); if (!p.name_fa) return res.status(400).json({ error: 'name_fa_required' }); - db.prepare(`UPDATE plans SET name_fa=@name_fa,name_en=@name_en,price=@price,period_fa=@period_fa,period_en=@period_en, + await db.prepare(`UPDATE plans SET name_fa=@name_fa,name_en=@name_en,price=@price,period_fa=@period_fa,period_en=@period_en, features=@features,badge_fa=@badge_fa,badge_en=@badge_en,is_featured=@is_featured,cta_fa=@cta_fa,cta_en=@cta_en, - sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id + sort_order=@sort_order,updated_at=now() WHERE id=@id `).run({ id: req.params.id, ...p }); - res.json(rowToPlan(db.prepare(`SELECT ${PLAN_FIELDS} FROM plans WHERE id = ?`).get(req.params.id))); + res.json(rowToPlan(await db.prepare(`SELECT ${PLAN_FIELDS} FROM plans WHERE id = ?`).get(req.params.id))); }); -app.delete('/api/plans/:id', authRequired, (req, res) => { - const info = db.prepare('DELETE FROM plans WHERE id = ?').run(req.params.id); +app.delete('/api/plans/:id', authRequired, async (req, res) => { + const info = await db.prepare('DELETE FROM plans WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); @@ -1057,16 +1182,16 @@ app.delete('/api/plans/:id', authRequired, (req, res) => { // ---------- risk signals ---------- const RISK_FIELDS = `id, quote, name, date, level, sort_order, created_at, updated_at`; -app.get('/api/risks', (req, res) => { +app.get('/api/risks', async (req, res) => { const limit = Math.min(Number(req.query.limit) || 100, 500); - const rows = db + const rows = await db .prepare(`SELECT ${RISK_FIELDS} FROM risk_signals ORDER BY sort_order ASC, created_at DESC LIMIT ?`) .all(limit); res.json(rows.map(rowToRiskSignal)); }); -app.get('/api/risks/:id', (req, res) => { - const row = db.prepare(`SELECT ${RISK_FIELDS} FROM risk_signals WHERE id = ?`).get(req.params.id); +app.get('/api/risks/:id', async (req, res) => { + const row = await db.prepare(`SELECT ${RISK_FIELDS} FROM risk_signals WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToRiskSignal(row)); }); @@ -1083,93 +1208,94 @@ function normalizeRiskBody(b) { }; } -app.post('/api/risks', authRequired, (req, res) => { +app.post('/api/risks', authRequired, async (req, res) => { const r = normalizeRiskBody(req.body || {}); if (!r.quote || !r.name || !r.level) return res.status(400).json({ error: 'quote_name_level_required' }); const id = nanoid(14); - db.prepare(` + await db.prepare(` INSERT INTO risk_signals (id, quote, name, date, level, sort_order) VALUES (@id, @quote, @name, @date, @level, @sort_order) `).run({ id, ...r }); - const row = db.prepare(`SELECT ${RISK_FIELDS} FROM risk_signals WHERE id = ?`).get(id); + const row = await db.prepare(`SELECT ${RISK_FIELDS} FROM risk_signals WHERE id = ?`).get(id); res.status(201).json(rowToRiskSignal(row)); }); -app.put('/api/risks/:id', authRequired, (req, res) => { - const existing = db.prepare('SELECT id FROM risk_signals WHERE id = ?').get(req.params.id); +app.put('/api/risks/:id', authRequired, async (req, res) => { + const existing = await db.prepare('SELECT id FROM risk_signals WHERE id = ?').get(req.params.id); if (!existing) return res.status(404).json({ error: 'not_found' }); const r = normalizeRiskBody(req.body || {}); if (!r.quote || !r.name || !r.level) return res.status(400).json({ error: 'quote_name_level_required' }); - db.prepare(` + await db.prepare(` UPDATE risk_signals SET quote = @quote, name = @name, date = @date, level = @level, - sort_order = @sort_order, updated_at = datetime('now') + sort_order = @sort_order, updated_at = now() WHERE id = @id `).run({ id: req.params.id, ...r }); - const row = db.prepare(`SELECT ${RISK_FIELDS} FROM risk_signals WHERE id = ?`).get(req.params.id); + const row = await db.prepare(`SELECT ${RISK_FIELDS} FROM risk_signals WHERE id = ?`).get(req.params.id); res.json(rowToRiskSignal(row)); }); -app.delete('/api/risks/:id', authRequired, (req, res) => { - const info = db.prepare('DELETE FROM risk_signals WHERE id = ?').run(req.params.id); +app.delete('/api/risks/:id', authRequired, async (req, res) => { + const info = await db.prepare('DELETE FROM risk_signals WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); // ---------- users (admin management) ---------- // password_hash is NEVER returned to the client. -app.get('/api/users', authRequired, (_req, res) => { - const rows = db.prepare('SELECT id, username, created_at FROM users ORDER BY id').all(); - res.json(rows.map((r) => ({ id: r.id, username: r.username, createdAt: r.created_at }))); +app.get('/api/users', authRequired, async (_req, res) => { + const rows = await db.prepare('SELECT id, username, role, created_at FROM users ORDER BY id').all(); + res.json(rows.map((r) => ({ id: r.id, username: r.username, role: r.role, createdAt: r.created_at }))); }); -app.post('/api/users', authRequired, (req, res) => { +app.post('/api/users', authRequired, ownerRequired, async (req, res) => { const username = String(req.body?.username || '').trim(); const password = String(req.body?.password || ''); if (username.length < 3) return res.status(400).json({ error: 'username_too_short' }); if (password.length < 8) return res.status(400).json({ error: 'password_too_short' }); - const exists = db.prepare('SELECT 1 FROM users WHERE username = ?').get(username); + const exists = await db.prepare('SELECT 1 FROM users WHERE username = ?').get(username); if (exists) return res.status(409).json({ error: 'username_taken' }); const hash = bcrypt.hashSync(password, 10); - const info = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)').run(username, hash); - res.status(201).json({ id: info.lastInsertRowid, username }); + const role = req.body?.role === 'owner' ? 'owner' : 'admin'; + const info = await db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?) RETURNING id').run(username, hash, role); + res.status(201).json({ id: info.lastInsertRowid, username, role }); }); -app.put('/api/users/:id', authRequired, (req, res) => { +app.put('/api/users/:id', authRequired, async (req, res) => { const id = Number(req.params.id); - const row = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id); + const row = await db.prepare('SELECT id, username FROM users WHERE id = ?').get(id); if (!row) return res.status(404).json({ error: 'not_found' }); const username = req.body?.username != null ? String(req.body.username).trim() : row.username; const password = req.body?.password != null ? String(req.body.password) : ''; if (username.length < 3) return res.status(400).json({ error: 'username_too_short' }); if (username !== row.username) { - const taken = db.prepare('SELECT 1 FROM users WHERE username = ? AND id != ?').get(username, id); + const taken = await db.prepare('SELECT 1 FROM users WHERE username = ? AND id != ?').get(username, id); if (taken) return res.status(409).json({ error: 'username_taken' }); } if (password) { if (password.length < 8) return res.status(400).json({ error: 'password_too_short' }); const hash = bcrypt.hashSync(password, 10); - db.prepare('UPDATE users SET username = ?, password_hash = ? WHERE id = ?').run(username, hash, id); + await db.prepare('UPDATE users SET username = ?, password_hash = ? WHERE id = ?').run(username, hash, id); } else { - db.prepare('UPDATE users SET username = ? WHERE id = ?').run(username, id); + await db.prepare('UPDATE users SET username = ? WHERE id = ?').run(username, id); } res.json({ id, username }); }); -app.delete('/api/users/:id', authRequired, (req, res) => { +app.delete('/api/users/:id', authRequired, ownerRequired, async (req, res) => { const id = Number(req.params.id); if (req.user?.sub === id) return res.status(400).json({ error: 'cannot_delete_self' }); - const count = db.prepare('SELECT COUNT(*) AS n FROM users').get().n; + const count = (await db.prepare('SELECT COUNT(*) AS n FROM users').get()).n; if (count <= 1) return res.status(400).json({ error: 'cannot_delete_last_user' }); - const info = db.prepare('DELETE FROM users WHERE id = ?').run(id); + const info = await db.prepare('DELETE FROM users WHERE id = ?').run(id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); // ---------- prices (scraped from tgju.org every 2 min) ---------- -app.get('/api/prices', (_req, res) => { - const rows = db +app.get('/api/prices', async (_req, res) => { + const rows = await db .prepare('SELECT symbol, name, value, unit, change_value, change_pct, source_url, fetched_at FROM prices ORDER BY name') .all(); res.json(rows.map(rowToPrice)); @@ -1188,16 +1314,16 @@ app.post('/api/prices/refresh', authRequired, async (_req, res) => { // ---------- banners ---------- const BANNER_FIELDS = `id,overline_fa,overline_en,title_fa,title_en,subtitle_fa,subtitle_en,cta_label_fa,cta_label_en,cta_url,image,position,active,sort_order,created_at,updated_at`; -app.get('/api/banners', (req, res) => { +app.get('/api/banners', async (req, res) => { const onlyActive = req.query.active === '1'; let q = `SELECT ${BANNER_FIELDS} FROM banners`; if (onlyActive) q += ' WHERE active = 1'; q += ' ORDER BY sort_order ASC, created_at DESC'; - res.json(db.prepare(q).all().map(rowToBanner)); + res.json((await db.prepare(q).all()).map(rowToBanner)); }); -app.get('/api/banners/:id', (req, res) => { - const row = db.prepare(`SELECT ${BANNER_FIELDS} FROM banners WHERE id = ?`).get(req.params.id); +app.get('/api/banners/:id', async (req, res) => { + const row = await db.prepare(`SELECT ${BANNER_FIELDS} FROM banners WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'not_found' }); res.json(rowToBanner(row)); }); @@ -1220,29 +1346,29 @@ function normalizeBannerBody(b) { }; } -app.post('/api/banners', authRequired, (req, res) => { +app.post('/api/banners', authRequired, async (req, res) => { const bn = normalizeBannerBody(req.body || {}); if (!bn.title_fa) return res.status(400).json({ error: 'title_fa_required' }); const id = nanoid(14); - db.prepare(`INSERT INTO banners (id,overline_fa,overline_en,title_fa,title_en,subtitle_fa,subtitle_en,cta_label_fa,cta_label_en,cta_url,image,position,active,sort_order) + await db.prepare(`INSERT INTO banners (id,overline_fa,overline_en,title_fa,title_en,subtitle_fa,subtitle_en,cta_label_fa,cta_label_en,cta_url,image,position,active,sort_order) VALUES (@id,@overline_fa,@overline_en,@title_fa,@title_en,@subtitle_fa,@subtitle_en,@cta_label_fa,@cta_label_en,@cta_url,@image,@position,@active,@sort_order) `).run({ id, ...bn }); - res.status(201).json(rowToBanner(db.prepare(`SELECT ${BANNER_FIELDS} FROM banners WHERE id = ?`).get(id))); + res.status(201).json(rowToBanner(await db.prepare(`SELECT ${BANNER_FIELDS} FROM banners WHERE id = ?`).get(id))); }); -app.put('/api/banners/:id', authRequired, (req, res) => { - if (!db.prepare('SELECT id FROM banners WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); +app.put('/api/banners/:id', authRequired, async (req, res) => { + if (!await db.prepare('SELECT id FROM banners WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' }); const bn = normalizeBannerBody(req.body || {}); if (!bn.title_fa) return res.status(400).json({ error: 'title_fa_required' }); - db.prepare(`UPDATE banners SET overline_fa=@overline_fa,overline_en=@overline_en,title_fa=@title_fa,title_en=@title_en,subtitle_fa=@subtitle_fa,subtitle_en=@subtitle_en, + await db.prepare(`UPDATE banners SET overline_fa=@overline_fa,overline_en=@overline_en,title_fa=@title_fa,title_en=@title_en,subtitle_fa=@subtitle_fa,subtitle_en=@subtitle_en, cta_label_fa=@cta_label_fa,cta_label_en=@cta_label_en,cta_url=@cta_url,image=@image, - position=@position,active=@active,sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id + position=@position,active=@active,sort_order=@sort_order,updated_at=now() WHERE id=@id `).run({ id: req.params.id, ...bn }); - res.json(rowToBanner(db.prepare(`SELECT ${BANNER_FIELDS} FROM banners WHERE id = ?`).get(req.params.id))); + res.json(rowToBanner(await db.prepare(`SELECT ${BANNER_FIELDS} FROM banners WHERE id = ?`).get(req.params.id))); }); -app.delete('/api/banners/:id', authRequired, (req, res) => { - const info = db.prepare('DELETE FROM banners WHERE id = ?').run(req.params.id); +app.delete('/api/banners/:id', authRequired, async (req, res) => { + const info = await db.prepare('DELETE FROM banners WHERE id = ?').run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: 'not_found' }); res.status(204).end(); }); @@ -1260,7 +1386,11 @@ app.post('/api/contact', contactLimiter, async (req, res) => { const { name, phone, email } = req.body || {}; if (!name || !phone) return res.status(400).json({ error: 'name and phone required' }); - const mailTo = process.env.MAIL_TO || 'alirezaameria2@gmail.com, mtdemne@gmail.com'; + const mailTo = process.env.MAIL_TO; + if (!mailTo) { + console.error('Mail error: MAIL_TO is not configured'); + return res.status(500).json({ error: 'mail failed' }); + } const transporter = nodemailer.createTransport({ service: 'gmail', auth: { user: process.env.MAIL_USER, pass: process.env.MAIL_PASS }, @@ -1339,6 +1469,10 @@ app.post('/api/pdf-analyze', authRequired, async (req, res) => { try { const { base64 } = req.body || {}; if (!base64) return res.status(400).json({ error: 'فایل PDF ارسال نشده' }); + // cap before decode: ~15MB base64 ≈ 11MB PDF — prevents memory DoS + if (typeof base64 !== 'string' || base64.length > 15_000_000) { + return res.status(413).json({ error: 'حجم فایل بیش از حد مجاز است' }); + } const buf = Buffer.from(base64, 'base64'); const { PDFParse } = await import('pdf-parse'); const parser = new PDFParse({ data: buf }); @@ -1411,57 +1545,64 @@ ${text.slice(0, 6000)} // Member auth & profile (public users, not admins) // ────────────────────────────────────────── -app.post('/api/members/register', loginLimiter, (req, res) => { +app.post('/api/members/register', loginLimiter, async (req, res) => { const { name, email, password } = req.body || {}; if (!name || !email || !password) return res.status(400).json({ error: 'همه فیلدها الزامی است' }); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return res.status(400).json({ error: 'ایمیل نامعتبر است' }); if (password.length < 6) return res.status(400).json({ error: 'رمز عبور باید حداقل ۶ کاراکتر باشد' }); - const exists = db.prepare('SELECT 1 FROM members WHERE email = ?').get(email); + const exists = await db.prepare('SELECT 1 FROM members WHERE email = ?').get(email); if (exists) return res.status(409).json({ error: 'این ایمیل قبلاً ثبت شده است' }); const hash = bcrypt.hashSync(password, 10); - const info = db.prepare('INSERT INTO members (name, email, password_hash) VALUES (?, ?, ?)').run(name.trim(), email.toLowerCase().trim(), hash); + const info = await db.prepare('INSERT INTO members (name, email, password_hash) VALUES (?, ?, ?) RETURNING id').run(name.trim(), email.toLowerCase().trim(), hash); const token = jwt.sign({ sub: info.lastInsertRowid, email, name: name.trim(), role: 'member' }, JWT_SECRET, { expiresIn: '30d' }); - res.status(201).json({ token, user: { id: info.lastInsertRowid, name: name.trim(), email } }); + res.cookie('member_session', token, memberCookieOpts(req)); + res.status(201).json({ user: { id: info.lastInsertRowid, name: name.trim(), email } }); }); -app.post('/api/members/login', loginLimiter, (req, res) => { +app.post('/api/members/login', loginLimiter, async (req, res) => { const { email, password } = req.body || {}; if (!email || !password) return res.status(400).json({ error: 'ایمیل و رمز عبور الزامی است' }); - const row = db.prepare('SELECT id, name, email, password_hash, is_active FROM members WHERE email = ?').get(email.toLowerCase().trim()); + const row = await db.prepare('SELECT id, name, email, password_hash, is_active FROM members WHERE email = ?').get(email.toLowerCase().trim()); if (!row || !bcrypt.compareSync(password, row.password_hash)) return res.status(401).json({ error: 'ایمیل یا رمز عبور اشتباه است' }); if (!row.is_active) return res.status(403).json({ error: 'حساب کاربری غیرفعال است' }); const token = jwt.sign({ sub: row.id, email: row.email, name: row.name, role: 'member' }, JWT_SECRET, { expiresIn: '30d' }); - res.json({ token, user: { id: row.id, name: row.name, email: row.email } }); + res.cookie('member_session', token, memberCookieOpts(req)); + res.json({ user: { id: row.id, name: row.name, email: row.email } }); }); -app.get('/api/members/me', memberRequired, (req, res) => { - const row = db.prepare('SELECT id, name, email, phone, avatar, created_at FROM members WHERE id = ?').get(req.member.sub); +app.post('/api/members/logout', (req, res) => { + res.clearCookie('member_session', { ...memberCookieOpts(req), maxAge: undefined }); + res.json({ ok: true }); +}); + +app.get('/api/members/me', memberRequired, async (req, res) => { + const row = await db.prepare('SELECT id, name, email, phone, avatar, created_at FROM members WHERE id = ?').get(req.member.sub); if (!row) return res.status(404).json({ error: 'not_found' }); res.json({ id: row.id, name: row.name, email: row.email, phone: row.phone, avatar: row.avatar, createdAt: row.created_at }); }); -app.put('/api/members/me', memberRequired, (req, res) => { +app.put('/api/members/me', memberRequired, async (req, res) => { const { name, phone } = req.body || {}; - const row = db.prepare('SELECT id FROM members WHERE id = ?').get(req.member.sub); + const row = await db.prepare('SELECT id FROM members WHERE id = ?').get(req.member.sub); if (!row) return res.status(404).json({ error: 'not_found' }); const newName = name?.trim() || req.member.name; - db.prepare("UPDATE members SET name = ?, phone = ?, updated_at = datetime('now') WHERE id = ?").run(newName, phone || null, req.member.sub); + await db.prepare("UPDATE members SET name = ?, phone = ?, updated_at = now() WHERE id = ?").run(newName, phone || null, req.member.sub); res.json({ ok: true }); }); -app.put('/api/members/me/password', memberRequired, (req, res) => { +app.put('/api/members/me/password', memberRequired, async (req, res) => { const { currentPassword, newPassword } = req.body || {}; if (!currentPassword || !newPassword) return res.status(400).json({ error: 'فیلدها الزامی است' }); if (newPassword.length < 6) return res.status(400).json({ error: 'رمز جدید باید حداقل ۶ کاراکتر باشد' }); - const row = db.prepare('SELECT password_hash FROM members WHERE id = ?').get(req.member.sub); + const row = await db.prepare('SELECT password_hash FROM members WHERE id = ?').get(req.member.sub); if (!row || !bcrypt.compareSync(currentPassword, row.password_hash)) return res.status(401).json({ error: 'رمز فعلی اشتباه است' }); const hash = bcrypt.hashSync(newPassword, 10); - db.prepare("UPDATE members SET password_hash = ?, updated_at = datetime('now') WHERE id = ?").run(hash, req.member.sub); + await db.prepare("UPDATE members SET password_hash = ?, updated_at = now() WHERE id = ?").run(hash, req.member.sub); res.json({ ok: true }); }); -app.get('/api/members/me/purchases', memberRequired, (req, res) => { - const rows = db.prepare(` +app.get('/api/members/me/purchases', memberRequired, async (req, res) => { + const rows = await db.prepare(` SELECT p.id, p.amount, p.purchased_at, a.id AS article_id, a.title, a.category, a.type, a.cover_image, a.pages, a.summary, a.author, a.publish_date FROM purchases p JOIN articles a ON p.article_id = a.id @@ -1475,30 +1616,31 @@ app.get('/api/members/me/purchases', memberRequired, (req, res) => { }); // admin: grant purchase to a member -app.post('/api/members/:memberId/purchases', authRequired, (req, res) => { +app.post('/api/members/:memberId/purchases', authRequired, async (req, res) => { const { articleId, amount = 0 } = req.body || {}; if (!articleId) return res.status(400).json({ error: 'articleId الزامی است' }); - const member = db.prepare('SELECT id FROM members WHERE id = ?').get(req.params.memberId); + const member = await db.prepare('SELECT id FROM members WHERE id = ?').get(req.params.memberId); if (!member) return res.status(404).json({ error: 'عضو پیدا نشد' }); - const article = db.prepare('SELECT id, cover_image FROM articles WHERE id = ?').get(articleId); + const article = await db.prepare('SELECT id, cover_image FROM articles WHERE id = ?').get(articleId); if (!article) return res.status(404).json({ error: 'مقاله پیدا نشد' }); - try { - db.prepare('INSERT OR IGNORE INTO purchases (member_id, article_id, amount) VALUES (?, ?, ?)').run(req.params.memberId, articleId, amount); - res.status(201).json({ ok: true }); - } catch (e) { - res.status(409).json({ error: 'قبلاً خریداری شده' }); - } + const info = await db.prepare('INSERT INTO purchases (member_id, article_id, amount) VALUES (?, ?, ?) ON CONFLICT (member_id, article_id) DO NOTHING').run(req.params.memberId, articleId, amount); + if (info.changes === 0) return res.status(409).json({ error: 'قبلاً خریداری شده' }); + res.status(201).json({ ok: true }); }); // download purchased article file (cover image for now; swap for PDF path later) -app.get('/api/members/me/purchases/:purchaseId/download', memberRequired, (req, res) => { - const row = db.prepare(` +// auth rides on the HttpOnly member_session cookie sent with the navigation — no token in the URL +app.get('/api/members/me/purchases/:purchaseId/download', memberRequired, async (req, res) => { + const row = await db.prepare(` SELECT a.cover_image, a.title FROM purchases p JOIN articles a ON p.article_id = a.id WHERE p.id = ? AND p.member_id = ? `).get(req.params.purchaseId, req.member.sub); if (!row) return res.status(404).json({ error: 'خرید پیدا نشد' }); - const filePath = row.cover_image ? path.join(__dirname, 'uploads', path.basename(row.cover_image)) : null; - if (!filePath || !fs.existsSync(filePath)) return res.status(404).json({ error: 'فایل موجود نیست' }); + if (!row.cover_image) return res.status(404).json({ error: 'فایل موجود نیست' }); + // object-storage assets are full URLs → redirect; local uploads are served from disk + if (/^https?:\/\//i.test(row.cover_image)) return res.redirect(row.cover_image); + const filePath = path.join(uploadsDir, path.basename(row.cover_image)); + if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'فایل موجود نیست' }); res.download(filePath, row.title + path.extname(filePath)); }); diff --git a/panel/update-radar-images.mjs b/panel/update-radar-images.mjs index b0a9049..a339ffe 100644 --- a/panel/update-radar-images.mjs +++ b/panel/update-radar-images.mjs @@ -1,9 +1,4 @@ -import Database from 'better-sqlite3'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const db = new Database(path.join(__dirname, 'data.db')); +import { db, pool } from './db.js'; // id → image (served from the frontend /public/news folder at /news/...) const radarImages = { @@ -24,7 +19,7 @@ const radarImages = { const stmt = db.prepare('UPDATE radar_items SET img = ? WHERE id = ?'); let updated = 0; for (const [id, img] of Object.entries(radarImages)) { - updated += stmt.run(img, id).changes; + updated += (await stmt.run(img, id)).changes; } console.log(`✓ radar images updated: ${updated}/${Object.keys(radarImages).length}`); @@ -38,6 +33,8 @@ const articleImages = { const aStmt = db.prepare('UPDATE articles SET cover_image = ? WHERE id = ?'); let aUpdated = 0; for (const [id, img] of Object.entries(articleImages)) { - aUpdated += aStmt.run(img, id).changes; + aUpdated += (await aStmt.run(img, id)).changes; } console.log(`✓ article covers updated: ${aUpdated}/${Object.keys(articleImages).length}`); + +await pool.end(); diff --git a/public/hero-bg.mp4 b/public/hero-bg.mp4 index 622b6d3..19c160f 100644 --- a/public/hero-bg.mp4 +++ b/public/hero-bg.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2bdc556fcfd4cb7885eacb42ae86ead654c893dab6fdabb523b2514f2bac43d4 -size 10328238 +oid sha256:fad463f25da4bce23f467705cbead9df08737c04094ff5a6e98dba6a294ba704 +size 2716385 diff --git a/public/icons/Design.svg b/public/icons/Design.svg new file mode 100644 index 0000000..71de506 --- /dev/null +++ b/public/icons/Design.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/Group 1.svg b/public/icons/Group 1.svg new file mode 100644 index 0000000..b9e0f0c --- /dev/null +++ b/public/icons/Group 1.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/icons/Line 5.svg b/public/icons/Line 5.svg new file mode 100644 index 0000000..59e6550 --- /dev/null +++ b/public/icons/Line 5.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/Line 6.svg b/public/icons/Line 6.svg new file mode 100644 index 0000000..59e6550 --- /dev/null +++ b/public/icons/Line 6.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/Line 7.svg b/public/icons/Line 7.svg new file mode 100644 index 0000000..59e6550 --- /dev/null +++ b/public/icons/Line 7.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/Line 8.svg b/public/icons/Line 8.svg new file mode 100644 index 0000000..59e6550 --- /dev/null +++ b/public/icons/Line 8.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/Line 9.svg b/public/icons/Line 9.svg new file mode 100644 index 0000000..59e6550 --- /dev/null +++ b/public/icons/Line 9.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/arrow-left-01-sharp.svg b/public/icons/arrow-left-01-sharp.svg new file mode 100644 index 0000000..b0503e0 --- /dev/null +++ b/public/icons/arrow-left-01-sharp.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/chevron/down-1.svg b/public/icons/chevron/down-1.svg new file mode 100644 index 0000000..b38c5d7 --- /dev/null +++ b/public/icons/chevron/down-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/chevron/down.svg b/public/icons/chevron/down.svg new file mode 100644 index 0000000..64d4fd8 --- /dev/null +++ b/public/icons/chevron/down.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/header/Group 1-1.svg b/public/icons/header/Group 1-1.svg new file mode 100644 index 0000000..b30de3b --- /dev/null +++ b/public/icons/header/Group 1-1.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/icons/header/Group.svg b/public/icons/header/Group.svg new file mode 100644 index 0000000..9a2a740 --- /dev/null +++ b/public/icons/header/Group.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons/header/Vector.svg b/public/icons/header/Vector.svg new file mode 100644 index 0000000..72ddf44 --- /dev/null +++ b/public/icons/header/Vector.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/header/vuesax/outline/sms.svg b/public/icons/header/vuesax/outline/sms.svg new file mode 100644 index 0000000..2505f0b --- /dev/null +++ b/public/icons/header/vuesax/outline/sms.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons/vuesax/linear/arrow-down.svg b/public/icons/vuesax/linear/arrow-down.svg new file mode 100644 index 0000000..a6c4947 --- /dev/null +++ b/public/icons/vuesax/linear/arrow-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/vuesax/linear/calendar-tick.svg b/public/icons/vuesax/linear/calendar-tick.svg new file mode 100644 index 0000000..fb762c7 --- /dev/null +++ b/public/icons/vuesax/linear/calendar-tick.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/icons/vuesax/linear/calendar.svg b/public/icons/vuesax/linear/calendar.svg new file mode 100644 index 0000000..62afabd --- /dev/null +++ b/public/icons/vuesax/linear/calendar.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/icons/vuesax/linear/export.svg b/public/icons/vuesax/linear/export.svg new file mode 100644 index 0000000..a0ffc99 --- /dev/null +++ b/public/icons/vuesax/linear/export.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons/vuesax/linear/location.svg b/public/icons/vuesax/linear/location.svg new file mode 100644 index 0000000..c1c9d45 --- /dev/null +++ b/public/icons/vuesax/linear/location.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons/vuesax/outline/buildings.svg b/public/icons/vuesax/outline/buildings.svg new file mode 100644 index 0000000..73ebcdf --- /dev/null +++ b/public/icons/vuesax/outline/buildings.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/icons/vuesax/outline/search-normal.svg b/public/icons/vuesax/outline/search-normal.svg new file mode 100644 index 0000000..a1f569c --- /dev/null +++ b/public/icons/vuesax/outline/search-normal.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons/vuesax/outline/security-safe.svg b/public/icons/vuesax/outline/security-safe.svg new file mode 100644 index 0000000..3f63e6e --- /dev/null +++ b/public/icons/vuesax/outline/security-safe.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/world-map.webp b/public/world-map.webp new file mode 100644 index 0000000..69d09e9 Binary files /dev/null and b/public/world-map.webp differ diff --git a/public/اندیشکده فولاد آینده_icon.zip b/public/اندیشکده فولاد آینده_icon.zip new file mode 100644 index 0000000..4e5accc Binary files /dev/null and b/public/اندیشکده فولاد آینده_icon.zip differ diff --git a/src/app/layout/Footer.tsx b/src/app/layout/Footer.tsx index e0b06cc..1f36135 100644 --- a/src/app/layout/Footer.tsx +++ b/src/app/layout/Footer.tsx @@ -8,9 +8,8 @@ function scrollToTop() { /* curved top edge with a centered scroll-to-top button (matches the Figma) */ function FooterCurve() { return ( -
+
- {/* white (page) above, navy below, with a smooth dip on the LEFT side */} diff --git a/src/content/factoryReports.ts b/src/content/factoryReports.ts index 093cbee..8956a13 100644 --- a/src/content/factoryReports.ts +++ b/src/content/factoryReports.ts @@ -15,52 +15,27 @@ export type FactoryReport = { en: { tag: string; title: string; date: string; excerpt: string } } -/* TEMP placeholder dataset — swap for panel/API data later. */ export const FACTORY_REPORTS: FactoryReport[] = [ { - id: 'fr1', img: '/news/photo-1504307651254-35680f356dfd.jpg', dir: 'left', - fa: { tag: 'تولید و صادرات', title: 'تحلیل عملکرد تولید و صادرات', date: 'بهمن ۱۴۰۳', excerpt: 'بررسی آمار تولید ماهانه، مقایسه با بودجه سالانه و روند صادرات فولاد تخت در بازارهای منطقه‌ای.' }, - en: { tag: 'Output & Exports', title: 'Production & Export Performance', date: 'Feb 2025', excerpt: 'Monthly output vs. annual budget and the flat-steel export trend across regional markets.' }, + id: 'msc-55', + img: 'https://www.msc.ir/_DouranPortal/Album/Articles/%DB%B5%DB%B5_14050314_083241.jpg', + dir: 'left', + fa: { tag: 'ماهنامه تحلیلی کارخانه', title: 'ماهنامه تحلیلی کارخانه — شماره ۵۵', date: 'خرداد ۱۴۰۵', excerpt: 'راهبرد یکپارچه چگونه انسجام راهبردی و مزیت رقابتی پایدار فولاد مبارکه را تضمین می‌کند؟ هم‌افزایی شش محور استراتژیک، تبدیل ثروت معدنی به قدرت صنعتی، و دوام مزیت‌های نسبی فولاد ایران زیر فشار ناترازی انرژی.' }, + en: { tag: 'Factory Analysis', title: 'Factory Analysis Monthly — No. 55', date: 'May 2026', excerpt: 'Integrated strategy, synergy of six strategic axes, turning mineral wealth into industrial power, and the resilience of Iran\'s steel advantages under energy imbalance.' }, }, { - id: 'fr2', img: '/news/photo-1581092160607-ee22731c9b4e.jpg', dir: 'left', - fa: { tag: 'بازار و قیمت', title: 'وضعیت بازار و قیمت‌گذاری', date: 'دی ۱۴۰۳', excerpt: 'تحلیل نوسانات قیمت فولاد تخت در بازار داخلی و اثر نرخ ارز بر چشم‌انداز زمستان.' }, - en: { tag: 'Market & Price', title: 'Market & Pricing Outlook', date: 'Jan 2025', excerpt: 'Flat-steel price swings in the domestic market and the FX effect on the winter outlook.' }, + id: 'msc-54', + img: 'https://www.msc.ir/_DouranPortal/Album/Articles/;hvohki%2054_14050323_095959.jpg', + dir: 'right', + fa: { tag: 'ماهنامه تحلیلی کارخانه', title: 'ماهنامه تحلیلی کارخانه — شماره ۵۴', date: 'مهر ۱۴۰۴', excerpt: 'شماره ۵۴ ماهنامه تحلیلی راهبردی کارخانه فولاد مبارکه؛ بررسی عملکرد، نوآوری و چشم‌انداز بازار فولاد.' }, + en: { tag: 'Factory Analysis', title: 'Factory Analysis Monthly — No. 54', date: 'Oct 2025', excerpt: 'Issue No. 54 of Mobarakeh Steel\'s strategic factory analysis monthly.' }, }, { - id: 'fr3', img: '/news/photo-1518770660439-4636190af475.jpg', dir: 'left', - fa: { tag: 'فناوری', title: 'دیجیتال‌سازی خطوط تولید', date: 'آذر ۱۴۰۳', excerpt: 'گزارشی از پیاده‌سازی سامانه‌های کنترل هوشمند و اثر آن بر بهره‌وری خطوط نورد.' }, - en: { tag: 'Technology', title: 'Production Line Digitalization', date: 'Dec 2024', excerpt: 'Rolling-out smart control systems and their impact on rolling-line productivity.' }, - }, - { - id: 'fr4', img: '/news/photo-1567789884554-0b844b597180.jpg', dir: 'up', - fa: { tag: 'نیروی انسانی', title: 'تحلیل نیروی انسانی و بهره‌وری', date: 'آبان ۱۴۰۳', excerpt: 'شاخص‌های بهره‌وری نیروی کار، آموزش‌های ماهانه و آمار ایمنی شغلی.' }, - en: { tag: 'Workforce', title: 'Workforce & Productivity', date: 'Nov 2024', excerpt: 'Labor productivity indicators, monthly training and occupational-safety statistics.' }, - }, - { - id: 'fr5', img: '/news/photo-1466611653911-95081537e5b7.jpg', dir: 'up', - fa: { tag: 'پایداری', title: 'گزارش پایداری و محیط زیست', date: 'آذر ۱۴۰۳', excerpt: 'شاخص‌های زیست‌محیطی ماهانه؛ مصرف انرژی، کاهش انتشار کربن و بازچرخانی پساب صنعتی.' }, - en: { tag: 'Sustainability', title: 'Sustainability & Environment', date: 'Dec 2024', excerpt: 'Monthly environmental metrics: energy use, carbon reduction and industrial water recycling.' }, - }, - { - id: 'fr6', img: '/news/photo-1570168007204-dfb528c6958f.jpg', dir: 'up', - fa: { tag: 'زنجیره تأمین', title: 'تأمین مواد اولیه و لجستیک', date: 'مهر ۱۴۰۳', excerpt: 'وضعیت تأمین سنگ‌آهن و قراضه، موجودی انبار و تحلیل ریسک زنجیره تأمین.' }, - en: { tag: 'Supply Chain', title: 'Raw Materials & Logistics', date: 'Oct 2024', excerpt: 'Iron-ore and scrap supply, inventory levels and supply-chain risk analysis.' }, - }, - { - id: 'fr7', img: '/news/photo-1611273426858-450d8e3c9fce.jpg', dir: 'right', - fa: { tag: 'سرمایه‌گذاری', title: 'طرح‌های توسعه و سرمایه‌گذاری', date: 'شهریور ۱۴۰۳', excerpt: 'پیشرفت طرح‌های توسعه ظرفیت و تحلیل بازده سرمایه‌گذاری پروژه‌های جاری.' }, - en: { tag: 'Investment', title: 'Expansion & Investment Plans', date: 'Sep 2024', excerpt: 'Capacity-expansion progress and ROI analysis of ongoing projects.' }, - }, - { - id: 'fr8', img: '/news/photo-1485827404703-89b55fcc595e.jpg', dir: 'right', - fa: { tag: 'کیفیت', title: 'کنترل کیفیت با هوش مصنوعی', date: 'مرداد ۱۴۰۳', excerpt: 'کاربرد بینایی ماشین در تشخیص عیوب سطحی و کاهش نرخ ضایعات تولید.' }, - en: { tag: 'Quality', title: 'AI-Driven Quality Control', date: 'Aug 2024', excerpt: 'Machine vision for surface-defect detection and lower production scrap rates.' }, - }, - { - id: 'fr9', img: '/news/photo-1613665813446-82a78c468a1d.jpg', dir: 'right', - fa: { tag: 'انرژی', title: 'تحلیل مصرف انرژی و بهینه‌سازی', date: 'تیر ۱۴۰۳', excerpt: 'الگوی مصرف برق و گاز، فرصت‌های بهینه‌سازی و اثر آن بر بهای تمام‌شده.' }, - en: { tag: 'Energy', title: 'Energy Use & Optimization', date: 'Jul 2024', excerpt: 'Electricity and gas usage patterns, optimization opportunities and cost impact.' }, + id: 'msc-53', + img: 'https://www.msc.ir/_DouranPortal/Album/Articles/01_20250921_103454.jpg', + dir: 'up', + fa: { tag: 'ماهنامه تحلیلی کارخانه', title: 'ماهنامه تحلیلی کارخانه — شماره ۵۳', date: 'شهریور ۱۴۰۴', excerpt: 'عبور از ملاحظات؛ آیا زمان جراحی نهادی برای تحقق توسعه پایدار صنایع معدنی فرا رسیده است؟' }, + en: { tag: 'Factory Analysis', title: 'Factory Analysis Monthly — No. 53', date: 'Sep 2025', excerpt: 'Beyond considerations: has the time come for institutional surgery to achieve sustainable development of the mining industries?' }, }, ] diff --git a/src/content/posts.ts b/src/content/posts.ts index 88ffd1b..e52520e 100644 --- a/src/content/posts.ts +++ b/src/content/posts.ts @@ -116,3 +116,44 @@ export const POSTS: Post[] = [ export function getPost(id: string): Post | undefined { return POSTS.find(p => p.id === id) || POSTS[0] } + +const PANEL_API = (import.meta as ImportMeta & { env?: Record }).env?.VITE_PANEL_API || '' + +/** + * Replace the static POSTS with articles from the panel. + * The DB article model is monolingual with a single `body` text blob, so we + * split it into paragraph blocks; charts/figures/comments exist only in the + * static mock and are dropped when DB content is present. Falls back to the + * static list when the panel is offline or has no articles. + */ +export async function bootstrapPosts(): Promise { + try { + const res = await fetch(`${PANEL_API}/api/articles`) + if (!res.ok) return + const fetched = await res.json() + if (!Array.isArray(fetched) || fetched.length === 0) return + POSTS.length = 0 + POSTS.push(...fetched.map((a: Record) => { + // drop the KEY_DATA appendix some importers add to the body + const bodyText = String(a.body ?? '').split('---KEY_DATA---')[0] + const body: PostBlock[] = bodyText + .split(/\n{2,}/).map(s => s.trim()).filter(Boolean) + .map(p => ({ type: 'p' as const, fa: p, en: '' })) + const title = String(a.title ?? '') + const author = String(a.author ?? '') + const date = String(a.publishDate ?? '') + const lead = String(a.summary ?? '') + const cat = String(a.category ?? '') + return { + id: String(a.id), + cover: String(a.coverImage ?? '') || IMG('photo-1581092160607-ee22731c9b4e.jpg'), + category: { fa: cat, en: cat }, + fa: { title, author, date, lead }, + en: { title, author, date, lead }, + body, + comments: [], + related: RELATED, + } + })) + } catch { /* panel offline → keep static fallback */ } +} diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 5e209ed..f6f33ae 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -11,7 +11,6 @@ export interface Member { } interface AuthState { - token: string | null member: Member | null } @@ -24,13 +23,21 @@ interface AuthContextValue extends AuthState { const AuthContext = createContext(null) +// Only ever cache these display fields — never spread an unknown server object into +// localStorage (a future API change must not silently leak extra fields to the client store). +function toMember(u: Partial & { id: number; name: string; email: string }): Member { + return { id: u.id, name: u.name, email: u.email, phone: u.phone, avatar: u.avatar } +} + +// The session itself lives in an HttpOnly cookie (set by the panel) and is never +// readable from JS. We only cache the member's display profile in localStorage so +// the UI can render immediately on reload; it carries no credential. function load(): AuthState { try { - const token = localStorage.getItem('member_token') const raw = localStorage.getItem('member_info') - if (token && raw) return { token, member: JSON.parse(raw) } + if (raw) return { member: JSON.parse(raw) } } catch { /* ignore */ } - return { token: null, member: null } + return { member: null } } export function AuthProvider({ children }: { children: React.ReactNode }) { @@ -39,33 +46,35 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const login = useCallback(async (email: string, password: string) => { const res = await fetch(`${BASE}/api/members/login`, { method: 'POST', + credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }) const data = await res.json() if (!res.ok) throw new Error(data.error || 'خطا در ورود') - localStorage.setItem('member_token', data.token) - localStorage.setItem('member_info', JSON.stringify(data.user)) - setState({ token: data.token, member: data.user }) + const member = toMember(data.user) + localStorage.setItem('member_info', JSON.stringify(member)) + setState({ member }) }, []) const register = useCallback(async (name: string, email: string, password: string, phone?: string, org?: string) => { const res = await fetch(`${BASE}/api/members/register`, { method: 'POST', + credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, email, password, phone, org }), }) const data = await res.json() if (!res.ok) throw new Error(data.error || 'خطا در ثبت‌نام') - localStorage.setItem('member_token', data.token) - localStorage.setItem('member_info', JSON.stringify(data.user)) - setState({ token: data.token, member: data.user }) + const member = toMember(data.user) + localStorage.setItem('member_info', JSON.stringify(member)) + setState({ member }) }, []) const logout = useCallback(() => { - localStorage.removeItem('member_token') + fetch(`${BASE}/api/members/logout`, { method: 'POST', credentials: 'include' }).catch(() => {}) localStorage.removeItem('member_info') - setState({ token: null, member: null }) + setState({ member: null }) }, []) const updateMember = useCallback((data: Partial) => { @@ -90,13 +99,16 @@ export function useAuth() { return ctx } -export function authFetch(token: string, path: string, opts: RequestInit = {}) { +// Authenticated calls send the HttpOnly session cookie automatically — no token argument. +export function authFetch(path: string, opts: RequestInit = {}) { return fetch(`${BASE}${path}`, { ...opts, + credentials: 'include', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, ...(opts.headers as Record ?? {}), }, }) } + +export { BASE } diff --git a/src/data/media.ts b/src/data/media.ts index fb602e1..55b9686 100644 --- a/src/data/media.ts +++ b/src/data/media.ts @@ -19,6 +19,7 @@ export interface VideoItem { duration: string; date: string; thumb: string; + mediaUrl?: string; } export interface PodcastItem { @@ -30,6 +31,7 @@ export interface PodcastItem { cover: string; fa: MediaLangText & { ep: string }; en: MediaLangText & { ep: string }; + mediaUrl?: string; } export const videos: VideoItem[] = [ @@ -149,6 +151,7 @@ function articleToVideo(a: Record, i: number): VideoItem { duration: '', date: String(a.publishDate ?? ''), thumb: String(a.coverImage ?? '') || '/news/photo-1504307651254-35680f356dfd.jpg', + mediaUrl: String(a.body ?? ''), // panel stores the media URL in the article body field } } @@ -166,6 +169,7 @@ function articleToPodcast(a: Record, i: number): PodcastItem { cover: String(a.coverImage ?? '') || '/news/photo-1540575467063-178a50c2df87.jpg', fa: { title, guest, body, ep }, en: { title, guest, body, ep }, + mediaUrl: String(a.body ?? ''), // panel stores the media URL in the article body field } } diff --git a/src/main.tsx b/src/main.tsx index f140aa1..9e3231d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -17,11 +17,12 @@ import { bootstrapFactoryReports } from './content/factoryReports' import { bootstrapIntegrations } from './content/integrations' import { bootstrapAbout } from './data/about' import { bootstrapMedia } from './data/media' +import { bootstrapPosts } from './content/posts' await Promise.all([ bootstrapReports(), bootstrapEvents(), bootstrapTeam(), bootstrapRadar(), bootstrapRadarPages(), bootstrapBanners(), bootstrapPlans(), bootstrapRisks(), bootstrapMarket(), - bootstrapFactoryReports(), bootstrapIntegrations(), bootstrapAbout(), bootstrapMedia(), + bootstrapFactoryReports(), bootstrapIntegrations(), bootstrapAbout(), bootstrapMedia(), bootstrapPosts(), ]) createRoot(document.getElementById('root')!).render( diff --git a/src/pages/About/About.tsx b/src/pages/About/About.tsx index 781c5f1..0d084b0 100644 --- a/src/pages/About/About.tsx +++ b/src/pages/About/About.tsx @@ -181,12 +181,12 @@ export default function About() {
-

+

{t.contactTitle}

-

+

{t.contactDesc}

void }) { const isFa = lang === 'fa' const dir = isFa ? 'rtl' : 'ltr' - const isLoggedIn = (() => { try { return localStorage.getItem('sf_logged_in') === '1' } catch { return false } })() + const { member } = useAuth() + const isLoggedIn = member !== null const body = isFa ? card.bodyFa : card.bodyEn return ( diff --git a/src/pages/Events/Events.tsx b/src/pages/Events/Events.tsx index e9e5437..3820552 100644 --- a/src/pages/Events/Events.tsx +++ b/src/pages/Events/Events.tsx @@ -1,80 +1,485 @@ -import { MapPin, Calendar } from 'lucide-react' +import { useState, useEffect } from 'react' +import { ExternalLink, ChevronLeft, ChevronRight } from 'lucide-react' import { useLang } from '@/context/LangContext' import { events } from '@/data/events' const NAVY = '#032340' const GOLD = '#CD9E53' -const TYPE_LABEL: Record = { - conference: { fa: 'کنفرانس', color: '#2471a3' }, - exhibition: { fa: 'نمایشگاه', color: '#1e8449' }, - seminar: { fa: 'نشست تخصصی', color: '#7d3c98' }, - international: { fa: 'بین‌الملل', color: '#c0392b' }, +const TYPE_LABEL: Record = { + conference: { fa: 'کنفرانس', en: 'Conference', color: '#2471a3', bg: '#EAF2FB' }, + exhibition: { fa: 'نمایشگاه', en: 'Exhibition', color: '#1e8449', bg: '#EAFAF1' }, + seminar: { fa: 'نشست تخصصی', en: 'Seminar', color: '#7d3c98', bg: '#F5EEF8' }, + international: { fa: 'بین‌الملل', en: 'International', color: '#c0392b', bg: '#FDEDEC' }, } +const EVENT_IMGS: Record = { + 'iran-steel-congress-1403': '/news/photo-1611273426858-450d8e3c9fce.jpg', + 'middle-east-steel-metals-2024': '/news/photo-1570168007204-dfb528c6958f.jpg', + 'steel-policy-seminar-1404': '/news/photo-1567789884554-0b844b597180.jpg', + 'world-steel-forum-2025': '/news/photo-1504307651254-35680f356dfd.jpg', + 'iran-metallurgy-expo-1404': '/news/photo-1518770660439-4636190af475.jpg', + 'oecd-steel-summit-2025': '/news/photo-1581092160607-ee22731c9b4e.jpg', +} + +/* ── Countdown ─────────────────────────────────────────── */ +const HERO_TARGET = new Date('2026-10-16T09:00:00') +function useCountdown(target: Date) { + const calc = () => { + const diff = Math.max(0, target.getTime() - Date.now()) + return { + days: Math.floor(diff / 86400000), + hours: Math.floor((diff % 86400000) / 3600000), + mins: Math.floor((diff % 3600000) / 60000), + secs: Math.floor((diff % 60000) / 1000), + } + } + const [t, setT] = useState(calc) + useEffect(() => { const id = setInterval(() => setT(calc()), 1000); return () => clearInterval(id) }, []) + return t +} + +/* ── Jalali calendar helpers ────────────────────────────── */ +const WEEKDAYS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنج‌شنبه', 'جمعه'] +const J_MONTHS = ['فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور','مهر','آبان','آذر','دی','بهمن','اسفند'] +const faNum = (n: number) => n.toLocaleString('fa-IR', { useGrouping: false }) + +function jParts(d: Date) { + const p = new Intl.DateTimeFormat('en-US-u-ca-persian', { day:'numeric', month:'numeric', year:'numeric' }).formatToParts(d) + const get = (t: string) => Number(p.find(x => x.type === t)?.value) + return { day: get('day'), month: get('month'), year: get('year') } +} +function buildMonth(anchor: Date) { + const { month, year } = jParts(anchor) + const first = new Date(anchor) + while (jParts(first).day !== 1) first.setDate(first.getDate() - 1) + const days: Date[] = [] + const cur = new Date(first) + while (jParts(cur).month === month && jParts(cur).year === year) { + days.push(new Date(cur)); cur.setDate(cur.getDate() + 1) + } + // Persian week starts Saturday → JS getDay(): Sat=6, Sun=0, Mon=1 … Fri=5 + const dowMap = [1, 2, 3, 4, 5, 6, 0] // Sun→1, Mon→2 … Sat→0 + const firstDow = dowMap[first.getDay()] + return { days, firstDow, month, year } +} + +function MiniCalendar({ anchor, onNav, eventDates, onSelect }: { + anchor: Date; onNav: (d: 1|-1) => void + eventDates: Date[]; onSelect: (d: Date) => void +}) { + const { days, firstDow, month, year } = buildMonth(anchor) + const today = new Date(); const todayJ = jParts(today) + + return ( +
+ {/* تقویم label row */} +
+ تقویم +
+ + {/* month nav */} +
+ + + {J_MONTHS[month - 1]}ماه {faNum(year)} + + +
+ + {/* weekday headers — pill bg #F7F7F7 */} +
+ {WEEKDAYS.map(d => ( +
{d}
+ ))} +
+ + {/* day cells — bg #F7F7F7 */} +
+ {Array.from({ length: firstDow }, (_, i) =>
)} + {days.map(d => { + const jp = jParts(d) + const isToday = jp.day === todayJ.day && jp.month === todayJ.month && jp.year === todayJ.year + const hasEv = eventDates.some(ed => { + const ej = jParts(ed); return ej.day === jp.day && ej.month === jp.month && ej.year === jp.year + }) + return ( + + ) + })} +
+
+ ) +} + +/* ── Countdown box ─────────────────────────────────────── */ +function CBox({ v, label }: { v: number; label: string }) { + return ( +
+
{faNum(v)}
+ {label} +
+ ) +} + +/* ── Main component ────────────────────────────────────── */ export default function Events() { const { lang } = useLang() const isFa = lang === 'fa' + const cd = useCountdown(HERO_TARGET) + + const [calAnchor, setCalAnchor] = useState(new Date()) + const [selEvt, setSelEvt] = useState(events[0]) + const [showAll, setShowAll] = useState(false) + + const handleCalNav = (dir: 1 | -1) => { + const n = new Date(calAnchor); n.setDate(n.getDate() + dir * 30); setCalAnchor(n) + } + + // approximate Gregorian marker dates for each event + const MONTH_OFF: Record = { + فروردین:3, اردیبهشت:4, خرداد:5, تیر:6, مرداد:7, شهریور:8, + مهر:9, آبان:10, آذر:11, دی:12, بهمن:1, اسفند:2, + } + const eventDates = events.map(ev => { + const m = MONTH_OFF[ev.month] ?? 3 + const yr = m < 3 ? 2026 : 2025 + return new Date(yr, m - 1, ev.date) + }) + + const featured = events[0] + const others = events.slice(1) + const visibleOthers = showAll ? others : others.slice(0, 4) + + const selType = TYPE_LABEL[selEvt.type] ?? { fa: selEvt.type, en: selEvt.type, color: GOLD, bg: '#f5f5f5' } + const featType = TYPE_LABEL[featured.type] ?? { fa: featured.type, en: featured.type, color: GOLD, bg: '#f5f5f5' } return ( -
-
-
-
- {isFa ? 'رسانه و رویداد' : 'Media & Events'} +
+ + {/* ═══════════════════════════════════════════════ + HERO BANNER + ═══════════════════════════════════════════════ */} +
+ {/* world map background */} + + {/* overlay + content absolutely on top of the image */} +
+ + {/* ═══════════════════════════════════════════════ + CALENDAR | SELECTED EVENT DETAIL + LTR grid so physical column order: calendar=LEFT, event=RIGHT + ═══════════════════════════════════════════════ */} +
+ {/* Figma: Calendar 409px | gap 37 | Card rest — LTR so calendar LEFT */} +
+ + {/* LEFT — calendar */} +
+ { + const jp = jParts(d) + const hit = events.find(ev => { + const m = MONTH_OFF[ev.month] ?? 3 + const yr = m < 3 ? 2026 : 2025 + const ed = new Date(yr, m - 1, ev.date) + return jParts(ed).day === jp.day && jParts(ed).month === jp.month + }) + if (hit) setSelEvt(hit) + }} + /> +
+ + {/* RIGHT — event card (Figma: white, border #E7E7E7, radius 16, padding 20, gap 20) */} +
-
-
- {events.map((ev) => { - const typeInfo = TYPE_LABEL[ev.type] ?? { fa: ev.type, color: GOLD } + {/* ═══════════════════════════════════════════════ + FEATURED UPCOMING EVENT + ═══════════════════════════════════════════════ */} +
+ {/* heading — gold right border (Figma: border-right 5px solid #CD9E53) */} +
+

+ {isFa ? 'رویداد برجسته پیش رو' : 'Featured Upcoming Event'} +

+
+ + {/* card: image LEFT (1fr) | navy RIGHT (2fr) */} +
+ + {/* IMAGE — gradient overlay + CTA at bottom */} +
+ + {/* bottom gradient overlay */} +
+ {/* bottom: date badge + CTA */} + +
+ + {/* NAVY PANEL */} +
+ {/* gold decorative bar */} +
+ + {/* content */} +
+ {/* ── top block: title + meta + tag | gold bottom border ── */} +
+ {/* title */} +

{featured.title}

+ {/* meta row: location | date | category tag — all right-aligned */} +
+ {/* location */} +
+ + {featured.city} +
+ {/* date */} +
+ + {featured.date} {featured.month} {featured.year} +
+ {/* category tag */} + + {isFa ? featType.fa : featured.type} + +
+
+ + {/* ── bottom block: why + description ── */} +
+

+ {isFa ? 'چرا این رویداد اهمیت دارد؟' : 'Why this event matters?'} +

+

{featured.description}

+
+
+
+
+
+ + {/* ═══════════════════════════════════════════════ + OTHER EVENTS GRID + ═══════════════════════════════════════════════ */} +
+ {/* heading — gold right border */} +
+

+ {isFa ? 'سایر رویدادها' : 'Other Events'} +

+
+ +
+ {visibleOthers.map(ev => { + const t = TYPE_LABEL[ev.type] ?? { fa: ev.type, en: ev.type, color: GOLD, bg: '#f5f5f5' } return (
{ const d = e.currentTarget as HTMLElement; d.style.transform = 'translateY(-3px)'; d.style.boxShadow = '0 12px 32px rgba(7,29,73,0.13)' }} - onMouseLeave={e => { const d = e.currentTarget as HTMLElement; d.style.transform = ''; d.style.boxShadow = '0 2px 16px rgba(7,29,73,0.07)' }} + onMouseEnter={e => { const el = e.currentTarget as HTMLElement; el.style.boxShadow='0 8px 28px rgba(3,35,64,0.13)'; el.style.transform='translateY(-2px)' }} + onMouseLeave={e => { const el = e.currentTarget as HTMLElement; el.style.boxShadow=''; el.style.transform='' }} > -
-
-
- - {ev.date} {ev.month} {ev.year} + {/* IMAGE — left side */} +
+ +
+ + {/* TEXT — right side */} +
+ {/* meta: location + date + category */} +
+
+ + {ev.city}
- - {isFa ? typeInfo.fa : ev.type} +
+ + {ev.date} {ev.month} {ev.year} +
+ + {isFa ? t.fa : ev.type}
- {ev.registrationOpen && ( - - {isFa ? 'ثبت‌نام باز' : 'Open'} - - )} -
-

{ev.title}

-

{ev.description}

-
- - {ev.location} — {ev.city} + + {/* content: title + desc + cta */} +
+

{ev.title}

+

{ev.description}

+ +
) })}
+ + {/* show all / less */} + {others.length > 4 && ( +
+ +
+ )}
) diff --git a/src/pages/Experts/Experts.tsx b/src/pages/Experts/Experts.tsx index 9b2ef49..87075a7 100644 --- a/src/pages/Experts/Experts.tsx +++ b/src/pages/Experts/Experts.tsx @@ -196,10 +196,14 @@ export default function Experts() { return () => clearInterval(interval) }, [isPaused]) + // only allow http(s) destinations — blocks javascript:/data: hrefs once these + // URLs come from the CMS instead of the static seed data + const safeHref = (url?: string) => (url && /^https?:\/\//i.test(url) ? url : undefined) + const renderSocialIcon = (expert: Expert) => (
{expert.telegram && ( - )} {expert.linkedin && ( - )} {expert.twitter && ( - )} {expert.website && ( - { - const id = setInterval(() => setSlideIdx(i => (i + 1) % slides.length), 5000) - return () => clearInterval(id) - }, [slides.length]) - const sectionRef = useRef(null) - const pinRef = useRef(null) - const centerRef = useRef(null) - const overlayRef = useRef(null) + const sectionRef = useRef(null) + const pinRef = useRef(null) + const centerRef = useRef(null) + const overlayRef = useRef(null) const headlineRef = useRef(null) - const hintRef = useRef(null) - const satRefs = useRef<(HTMLDivElement | null)[]>([]) + const hintRef = useRef(null) + const satRefs = useRef<(HTMLDivElement | null)[]>([]) const [isMobile, setIsMobile] = useState(false) useEffect(() => { @@ -91,14 +57,11 @@ export default function HeroSection() { return () => mq.removeEventListener('change', update) }, []) - // GSAP ScrollTrigger scrub timeline — drives the whole de-zoom + scatter off scroll progress. - // Synced to Lenis via the GSAP ticker set up in RootLayout (joinspread.app recipe). useLayoutEffect(() => { const section = sectionRef.current if (!section) return const ctx = gsap.context(() => { - // single scrubbed timeline pinned for the section's scroll length const tl = gsap.timeline({ scrollTrigger: { trigger: section, @@ -108,26 +71,20 @@ export default function HeroSection() { }, }) - // background morph navy → cream tl.fromTo(pinRef.current, { backgroundColor: NAVY }, { backgroundColor: PAPER, ease: 'none' }, 0) - // center image: full-bleed cover (scale 2.55) → centered rounded card tl.fromTo(centerRef.current, { scale: 2.55, borderRadius: 0 }, { scale: 1, borderRadius: 18, ease: 'none' }, 0) - // legibility overlay fades out as the image shrinks tl.fromTo(overlayRef.current, { opacity: 0.42 }, { opacity: 0, ease: 'none', duration: 0.4 }, 0) - // headline lifts + blurs + fades out as the de-zoom begins tl.fromTo(headlineRef.current, { yPercent: 0, opacity: 1, filter: 'blur(0px)' }, { yPercent: -10, opacity: 0, filter: 'blur(8px)', ease: 'none', duration: 0.35 }, 0) - // scroll hint fades immediately tl.fromTo(hintRef.current, { opacity: 1 }, { opacity: 0, ease: 'none', duration: 0.08 }, 0) - // satellites: clustered behind center → fan out to scattered positions with parallax satRefs.current.forEach((el, i) => { if (!el) return const s = SATELLITES[i] @@ -167,7 +124,7 @@ export default function HeroSection() { ) })} - {/* center image — de-zooms from full-bleed cover to a centered card */} + {/* center video — de-zooms from full-bleed cover to a centered card */}
-

+ }} className="max-md:!text-3xl"> {t.title}

{t.desc}

- {/* CTA buttons */} -
- {t.cta1 && ( - - - {t.cta1} - - )} -
- {/* slide dots */} -
- {slides.map((_, i) => ( -
diff --git a/src/pages/Home/sections/SpreadFeatures.tsx b/src/pages/Home/sections/SpreadFeatures.tsx index 4a1f12e..6d095e9 100644 --- a/src/pages/Home/sections/SpreadFeatures.tsx +++ b/src/pages/Home/sections/SpreadFeatures.tsx @@ -1,223 +1,14 @@ -import { useRef, useState, useEffect } from 'react' -import { motion, AnimatePresence, useScroll, useMotionValueEvent } from 'framer-motion' -import { useLang } from '@/context/LangContext' -import { ArrowLeft } from 'lucide-react' -import { Link } from 'react-router-dom' +import { useState, useEffect, useRef } from 'react' +import { motion } from 'framer-motion' -const NAVY = '#032340' const GOLD = '#CD9E53' +const NAVY = '#032340' const EASE = [0.22, 1, 0.36, 1] as const -type Feat = { - img: string - imgSide: 'left' | 'right' - imgPos?: string - fa: { kicker: string; title: string; desc: string; stat: string; statTail: string } - en: { kicker: string; title: string; desc: string; stat: string; statTail: string } -} - -/* Your 3 hero images. As you scroll, they replace each other in place with a fade. */ -const FEATURES: Feat[] = [ - { - img: '/hero%201.webp', imgSide: 'right', imgPos: 'right 35%', - fa: { - kicker: 'اندیشکده فولاد آینده', - title: 'بینشی نو برای توسعه‌ی صنعتی', - desc: '', - stat: '۶ بازار', - statTail: 'رصد ۲۴ ساعته. هشدار در کمتر از ۱۲ ثانیه.', - }, - en: { - kicker: 'Future Steel Institute', - title: 'A New Vision for Industrial Development', - desc: '', - stat: '6 markets', - statTail: 'Scanned 24/7. Alerts in under 12 seconds.', - }, - }, - { - img: '/hero2.webp', imgSide: 'left', imgPos: '100% center', - fa: { - kicker: 'فناوری صنعتی', - title: 'داده، تحلیل، آینده‌نگاری', - desc: 'تصمیم‌سازی هوشمند در جهانی در حال تحول\n+۱۰۰ منبع داده جهانی، رصد ۲۴/۷ بازارها', - stat: '٪۳۸', - statTail: 'میانگین حاشیه سود در هر معامله.', - }, - en: { - kicker: 'Industrial Technology', - title: 'Data, Analysis, Foresight', - desc: 'Smart decision-making in a world in transformation\n100+ global data sources, 24/7 market monitoring', - stat: '38%', - statTail: 'Average margin per deal.', - }, - }, - { - img: '/hero3.webp', imgSide: 'right', imgPos: '100% center', - fa: { - kicker: 'ژئواکونومی', - title: 'آزمون استراتژی در آینده‌ها', - desc: '', - stat: '۲۰ ساعت', - statTail: 'صرفه‌جویی هفتگی نسبت به روش دستی.', - }, - en: { - kicker: 'Geo-Economics', - title: 'Testing Strategy in Futures', - desc: '', - stat: '20 hrs', - statTail: 'Saved per week vs. manual.', - }, - }, -] - -/* fade-up + blur wrapper — fires fast on mount (re-keyed per active slide) */ -function FadeUp({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) { - return ( - - {children} - - ) -} - -const DomeIcon = () => ( - - - - - - - -) - -const GearIcon = () => ( - - - - - - - - -) - -const ChartIcon = () => ( - - - - - - - - -) - -/* the text column for the active slide */ -function TextPanel({ index, feat, lang }: { index: number; feat: Feat; lang: 'fa' | 'en' }) { - const t = feat[lang] - const isSlide2 = index === 1 - const textColor = isSlide2 ? NAVY : GOLD - const descColor = isSlide2 ? 'rgba(3, 35, 64, 0.72)' : 'rgba(255, 255, 255, 0.78)' - - const renderIcon = () => { - if (index === 0) return - if (index === 1) return - return - } - - const buttonText = lang === 'fa' - ? (index === 0 ? 'آشنایی با چشم‌انداز ما' : index === 1 ? 'نبض صنعت' : 'آزمایشگاه آینده') - : (index === 0 ? 'Discover our perspective' : index === 1 ? 'Industry Pulse' : 'The Futures Laboratory') - const buttonHref = index === 0 ? '/about' : index === 1 ? '/pulse' : '/radar' - - return ( -
- -
- {renderIcon()} -
-
- -

- {t.title} -

-
- -

- {t.desc} -

-
- - - - {buttonText} - - -
- ) -} - -/* progress rail */ -function Rail({ active, total }: { active: number; total: number }) { - const inactiveColor = active === 1 ? 'rgba(3, 35, 64, 0.22)' : 'rgba(255, 255, 255, 0.3)' - return ( -
- {Array.from({ length: total }).map((_, i) => ( -
- ))} -
- ) -} - export default function SpreadFeatures() { - const { lang } = useLang() - const containerRef = useRef(null) - const [active, setActive] = useState(0) const [isMobile, setIsMobile] = useState(false) + const [videoSrc, setVideoSrc] = useState(null) + const blobUrl = useRef(null) useEffect(() => { const mq = window.matchMedia('(max-width: 767px)') @@ -227,123 +18,72 @@ export default function SpreadFeatures() { return () => mq.removeEventListener('change', update) }, []) - const { scrollYProgress } = useScroll({ target: containerRef, offset: ['start start', 'end end'] }) - useMotionValueEvent(scrollYProgress, 'change', (v) => { - const next = v < 0.34 ? 0 : v < 0.67 ? 1 : 2 - if (next !== active) setActive(next) - }) + // Fetch video as blob to bypass range-request issues on tunnels/proxies + useEffect(() => { + let cancelled = false + fetch('/hero-bg.mp4') + .then(r => { if (!r.ok) throw new Error('fetch failed'); return r.blob() }) + .then(blob => { + if (cancelled) return + const url = URL.createObjectURL(blob) + blobUrl.current = url + setVideoSrc(url) + }) + .catch(() => { /* keep navy background */ }) + return () => { + cancelled = true + if (blobUrl.current) URL.revokeObjectURL(blobUrl.current) + } + }, []) return ( -
-
+
+ {/* navy fallback — visible until video blob is ready */} +
- {FEATURES.map((f, i) => { - const isActive = i === active - const isTextOnLeft = i === 1 - const leftBg = isTextOnLeft ? GOLD : '#efe9e1' - const rightBg = isTextOnLeft ? '#efe9e1' : NAVY + {/* full-bleed video — src is a local blob URL, no range requests */} + {videoSrc && ( +
) diff --git a/src/pages/Home/sections/VideocastPodcastSection.tsx b/src/pages/Home/sections/VideocastPodcastSection.tsx index b0a1013..43bac69 100644 --- a/src/pages/Home/sections/VideocastPodcastSection.tsx +++ b/src/pages/Home/sections/VideocastPodcastSection.tsx @@ -95,16 +95,47 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
- {/* description only — playlist removed */} -
-
+ {/* active video description */} +
+
🎙

{VIDEOS[activeVideo][lang].title}

-

+

{VIDEOS[activeVideo][lang].body}

+ + {/* video playlist — 3 thumbnail cards */} +
+ {VIDEOS.slice(0, 3).map((v, i) => ( + + ))} +
)} @@ -131,208 +162,77 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
- {/* ── featured player card (light) ── */} + {/* ── featured player card ── */}
- {/* cover + rating container */} -
- -
- ★★★★★ - {pod.rating} + + {/* cover — full height, flush to right edge */} +
+ + {/* rating badge overlaid at bottom of cover */} +
+ ★★★★★ + {pod.rating}
- {/* player body */} + {/* player body — directly beside cover, no gap */}
- {/* Top Row: Buttons on the far left, Episode number on the right */} -
- {/* Download + Bookmark outline circular buttons */} + {/* buttons row + episode number */} +
- -
- - {/* Episode number */} -
- {pod.epNum} -
+
{pod.epNum}
- {/* Title Row */} -
-

- {pod[lang].title} -

+ {/* title */} +

+ {pod[lang].title} +

+ + {/* date */} +
+ + {pod.date}
- {/* Date Row (directly under Title) */} -
-
- - {pod.date} -
-
+
- {/* Separator line */} -
- - {/* Play button + Waveform row */} -
- {/* Play button */} - - - {/* Waveform container */} -
- ۰۲:۳۱ -
- {[ - 4, 6, 8, 10, 7, 5, 8, 12, 15, 18, 14, 10, 8, 12, 16, 20, 22, 17, 12, 9, - 12, 15, 18, 22, 24, 20, 15, 11, 8, 12, 15, 17, 20, 22, 18, 14, 10, 8, 12, 15, - 19, 21, 16, 12, 9, 11, 14, 18, 20, 16, 11, 8, 6, 9, 12, 15, 17, 13, 9, 7, - 10, 13, 16, 18, 14, 10, 8, 11, 14, 16, 13, 9, 7, 10, 13, 16, 18, 14, 10, 7 - ].map((h, i) => ( - +
+ ۰۲:۳۱ +
+ {[4,6,8,10,7,5,8,12,15,18,14,10,8,12,16,20,22,17,12,9,12,15,18,22,24,20,15,11,8,12,15,17,20,22,18,14,10,8,12,15,19,21,16,12,9,11,14,18,20,16,11,8,6,9,12,15,17,13,9,7,10,13,16,18,14,10,8,11,14,16,13,9,7,10,13,16,18,14,10,7].map((h, i) => ( + ))}
- {pod.duration} + {pod.duration}
@@ -353,7 +253,7 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po flexDirection: 'column', gap: 16 }}> - {PODCASTS.filter((_, idx) => idx !== activePod).map((p) => { + {PODCASTS.filter((_, idx) => idx !== activePod).slice(0, 2).map((p) => { const originalIndex = PODCASTS.findIndex(item => item.id === p.id); return ( - ) +/* ══ CSS keyframes injected once ══ */ +const STYLE = ` +@keyframes gradSpin { + 0% { background-position: 0% 50% } + 50% { background-position: 100% 50% } + 100% { background-position: 0% 50% } } +@keyframes borderRotate { + 0% { --angle: 0deg } + 100% { --angle: 360deg } +} +@property --angle { + syntax: ''; + initial-value: 0deg; + inherits: false; +} +@keyframes pulse-bar { + 0%, 100% { transform: scaleY(0.3) } + 50% { transform: scaleY(1) } +} +.cover-border-wrap { + --angle: 0deg; + animation: borderRotate 3s linear infinite; + padding: 3px; + border-radius: 4px; + background: conic-gradient(from var(--angle), #7a4f00, #CD9E53, #f0d080, #fff8e1, #CD9E53, #7a4f00); + box-shadow: 0 0 24px #CD9E5366, 0 0 48px #CD9E5322; +} +` + +/* ══ Real waveform audio player ══ */ +function WavePlayer({ episodeKey }: { episodeKey: string | number }) { + const canvasRef = useRef(null) + const animRef = useRef(0) + const audioCtxRef = useRef(null) + const analyserRef = useRef(null) + const sourcesRef = useRef([]) + const gainRef = useRef(null) + + const [playing, setPlaying] = useState(false) + const [muted, setMuted] = useState(false) + const [progress, setProgress] = useState(0) // 0-1 + const [elapsed, setElapsed] = useState(0) // seconds + const startTimeRef = useRef(0) + const pausedAtRef = useRef(0) + const TOTAL = 180 // demo: 3 min + + /* draw waveform from analyser */ + const draw = useCallback(() => { + const canvas = canvasRef.current + const analyser = analyserRef.current + if (!canvas || !analyser) return + const ctx = canvas.getContext('2d')! + const data = new Uint8Array(analyser.frequencyBinCount) + analyser.getByteFrequencyData(data) + + const W = canvas.width, H = canvas.height + ctx.clearRect(0, 0, W, H) + + const bars = 80 + const step = Math.floor(data.length / bars) + const bw = W / bars + + for (let i = 0; i < bars; i++) { + const val = data[i * step] / 255 + const bh = Math.max(3, val * H * 0.9) + const x = i * bw + const y = (H - bh) / 2 + + // gradient per bar + const g = ctx.createLinearGradient(0, y, 0, y + bh) + g.addColorStop(0, `${GOLD}cc`) + g.addColorStop(0.5, GOLD) + g.addColorStop(1, `${GOLD}44`) + ctx.fillStyle = g + ctx.beginPath() + ctx.roundRect(x + 1, y, bw - 2, bh, 2) + ctx.fill() + } + + animRef.current = requestAnimationFrame(draw) + }, []) + + /* idle waveform (decorative) when paused */ + const drawIdle = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d')! + const W = canvas.width, H = canvas.height + ctx.clearRect(0, 0, W, H) + const bars = 80 + const bw = W / bars + for (let i = 0; i < bars; i++) { + const val = 0.08 + 0.12 * Math.sin(i * 0.4) + const bh = Math.max(3, val * H) + const x = i * bw + const y = (H - bh) / 2 + ctx.fillStyle = 'rgba(205,158,83,0.25)' + ctx.beginPath() + ctx.roundRect(x + 1, y, bw - 2, bh, 2) + ctx.fill() + } + }, []) + + /* stop engine */ + const stop = useCallback(() => { + cancelAnimationFrame(animRef.current) + sourcesRef.current.forEach(o => { try { o.stop(); o.disconnect() } catch { /* already stopped */ } }) + sourcesRef.current = [] + gainRef.current?.disconnect() + gainRef.current = null + analyserRef.current?.disconnect() + analyserRef.current = null + drawIdle() + }, [drawIdle]) + + /* start engine — synthesised audio (no file needed) */ + const start = useCallback(() => { + if (!audioCtxRef.current) audioCtxRef.current = new AudioContext() + const actx = audioCtxRef.current + if (actx.state === 'suspended') actx.resume() + + const analyser = actx.createAnalyser() + analyser.fftSize = 256 + analyserRef.current = analyser + + const gain = actx.createGain() + gain.gain.value = muted ? 0 : 0.04 + gainRef.current = gain + + /* layered oscillators for a richer "voice" look */ + const freqs = [120, 240, 480, 960] + const oscs: OscillatorNode[] = freqs.map(f => { + const osc = actx.createOscillator() + osc.type = 'sine' + osc.frequency.value = f + Math.random() * 40 + osc.connect(gain) + osc.start() + return osc + }) + sourcesRef.current = oscs + + gain.connect(analyser) + analyser.connect(actx.destination) + + startTimeRef.current = actx.currentTime - pausedAtRef.current + draw() + }, [draw, muted]) + + /* toggle play */ + const toggle = () => { + if (playing) { + pausedAtRef.current = (audioCtxRef.current?.currentTime ?? 0) - startTimeRef.current + stop() + setPlaying(false) + } else { + start() + setPlaying(true) + } + } + + /* progress ticker */ + useEffect(() => { + if (!playing) return + const id = setInterval(() => { + const t = (audioCtxRef.current?.currentTime ?? 0) - startTimeRef.current + setElapsed(t) + setProgress(Math.min(t / TOTAL, 1)) + if (t >= TOTAL) { stop(); setPlaying(false); setProgress(1) } + }, 200) + return () => clearInterval(id) + }, [playing, stop]) + + /* reset when episode changes */ + useEffect(() => { + stop() + setPlaying(false) + setProgress(0) + setElapsed(0) + pausedAtRef.current = 0 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [episodeKey]) + + /* mute toggle */ + useEffect(() => { + if (gainRef.current) gainRef.current.gain.value = muted ? 0 : 0.04 + }, [muted]) + + /* initial idle draw */ + useEffect(() => { drawIdle() }, [drawIdle, episodeKey]) + + /* canvas resize */ + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + const ro = new ResizeObserver(() => { + canvas.width = canvas.offsetWidth * devicePixelRatio + canvas.height = canvas.offsetHeight * devicePixelRatio + const ctx = canvas.getContext('2d')! + ctx.scale(devicePixelRatio, devicePixelRatio) + drawIdle() + }) + ro.observe(canvas) + return () => ro.disconnect() + }, [drawIdle]) + + const fmt = (s: number) => { + const m = Math.floor(s / 60), sec = Math.floor(s % 60) + return `${m}:${String(sec).padStart(2, '0')}` + } -/* ─── white book cover ─── */ -function BookCard({ pod, lang }: { pod: typeof PODCASTS[0]; lang: 'fa' | 'en' }) { - const epNum = pod.epNum.replace(/[^0-9۰-۹]/g, '') return ( -
-
{epNum}
-
- +
+ {/* waveform canvas */} +
{ + const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect() + const ratio = (e.clientX - rect.left) / rect.width + pausedAtRef.current = ratio * TOTAL + setProgress(ratio) + setElapsed(ratio * TOTAL) + if (playing) { stop(); start() } + }} + > + + {/* progress overlay */} +
+ {/* playhead */} +
-
-
{pod[lang].title}
-
{pod[lang].guest}
-
-
- + + {/* controls */} +
+ + + {fmt(elapsed)} + + {/* track bar */} +
{ + const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect() + const ratio = (e.clientX - rect.left) / rect.width + pausedAtRef.current = ratio * TOTAL + setProgress(ratio) + setElapsed(ratio * TOTAL) + if (playing) { stop(); start() } + }}> +
+
+
+ + {fmt(TOTAL)} + +
) } -/* ─── video list row ─── */ -function VidRow({ vid, lang }: { vid: typeof VIDEOS[0]; lang: 'fa' | 'en' }) { +/* ─── active glow pill ─── */ +function ActivePill({ active }: { active: boolean }) { + return active ? ( + + ) : null +} + +/* ─── episode sidebar item ─── */ +function EpItem({ pod, active, onClick, lang }: { + pod: typeof PODCASTS[0]; active: boolean; onClick: () => void; lang: 'fa' | 'en' +}) { const isFa = lang === 'fa' return ( -
-
- -
-
-
+
+ + {active && ( +
+
+
+ )}
{vid[lang].title}
-
- {vid.date} - {vid.duration} + transition: 'color .2s', + }}>{pod[lang].title}
+
+ + {pod.epNum} + · + {pod.duration}
-
+ ) } @@ -124,216 +363,506 @@ export default function Podcast() { const { lang } = useLang() const t = T[lang] const isFa = lang === 'fa' - const [active, setActive] = useState(0) + const [active, setActive] = useState(0) + const [activeVid, setActiveVid] = useState(0) + const [vidPlaying, setVidPlaying] = useState(false) const pod = PODCASTS[active] - const epNum = pod.epNum.replace(/[^0-9۰-۹]/g, '') + const vid = VIDEOS[activeVid] return ( -
-
+
+ - {/* ══ PODCAST HERO CARD (dark) ══ */} -
+ {/* ══ PODCAST SECTION ══ */} +
+
- {/* dot pattern */} -
- - {/* COL 1 — episode list */} + {/* label */}
+ + {t.podcast} +
+ + {/* main grid */} +
+ + {/* sidebar */}
- {t.otherEps} - -
-
- {PODCASTS.map((p, i) => ( - setActive(i)} lang={lang} /> - ))} -
-
- - {/* COL 2 — center */} -
- {/* meta */} -
- - {pod.date} - - - #{epNum} +
+
{t.episodes}
+
+
+ {PODCASTS.map((p, i) => ( + setActive(i)} lang={lang} /> + ))} +
- {/* title */} -

{pod[lang].title}

+ {/* hero */} +
- {/* guest */} -
{pod[lang].guest}
+ {/* blurred bg */} +
+
- {/* body excerpt */} -

{pod[lang].body}

+ {/* inner grid: text | cover-image */} +
- {/* listen + duration */} -
- - - {pod.duration} - + {/* ── COVER image column ── */} + {/* outer: spinning gradient border */} +
+ + {/* inner: clips image to rounded rect */} +
+ {pod[lang].title} +
+
+
+ + {/* ── text column ── */} +
+ + {/* ep badge */} +
+ {pod.epNum} + + {pod.date} +
+ + {/* title */} +

{pod[lang].title}

+ + {/* guest */} +
+
+ {pod[lang].guest} +
+ + {/* body */} +

{pod[lang].body}

+ + {/* ── AUDIO PLAYER ── */} +
+ {pod.mediaUrl + ?
+ + {/* bottom: rating only */} +
+ + ★ {pod.rating} + +
+ +
+
- - {/* COL 3 — book cover */} -
- -
+
+ + {/* ══ VIDEOCAST SECTION ══ */} +
+
- {/* ══ VIDEOCAST CARD (light) ══ */} -
{/* header */} -
-
-
- +
+
+
+ + {t.videocast}
- {t.videocast}
- {/* grid: featured (left) + list (right) */} -
+ {/* featured hero video */} +
{ if (vid.mediaUrl && !vidPlaying) setVidPlaying(true) }} + onMouseEnter={e => { + const el = e.currentTarget as HTMLDivElement + const img = el.querySelector('img'); if (img) (img as HTMLElement).style.transform = 'scale(1.04)' + const pb = el.querySelector('.play-btn') as HTMLElement | null; if (pb) pb.style.transform = 'translate(-50%,-50%) scale(1.12)' + }} + onMouseLeave={e => { + const el = e.currentTarget as HTMLDivElement + const img = el.querySelector('img'); if (img) (img as HTMLElement).style.transform = 'scale(1)' + const pb = el.querySelector('.play-btn') as HTMLElement | null; if (pb) pb.style.transform = 'translate(-50%,-50%) scale(1)' + }} + > + {vidPlaying && vid.mediaUrl ? ( +