feat: sync latest changes and include environment configurations

This commit is contained in:
alireza 2026-08-25 15:19:17 +03:30
parent c9e6be287e
commit ae69f310df
21 changed files with 697 additions and 127 deletions

9
.gitignore vendored
View File

@ -12,10 +12,11 @@ dist
dist-ssr
*.local
# Secrets / env — never commit (keep the example template)
.env
.env.*
!.env.example
# Archives
*.tar.gz
*.zip
# Secrets
*.pem
*.key
secrets/

View File

@ -1,21 +1,51 @@
# Spec-less Compose file (Docker Compose v2+). The top-level `version:` key
# is intentionally omitted — it is deprecated and emits a warning on modern
# Compose versions.
services:
web:
steelforesight-cms:
build:
context: ./panel
dockerfile: Dockerfile
container_name: steelforesight-cms
restart: always
ports:
- 3001:3001
environment:
- PORT=3001
- NODE_ENV=production
- JWT_SECRET=sxskB!Mq2*GkwWm?qDi9JwJCA0|CAk{y%o3[uj.+LM^nayqiS>f_yy:B0c?),:y?@&Yvz0Bwy@p8n%{j[y+J#J
- LIARA_ENDPOINT=s3.ir-thr-at1.arvanstorage.ir
- LIARA_BUCKET=steelforesight
- LIARA_ACCESS_KEY=325f8646-d2e3-4c46-b399-d1f0184beb24
- LIARA_SECRET_KEY=47dfe66e3d24c2421640e6c1c9b09a53989dfcd755430e1570344cfc18485cda
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=elidonbe85kgkg@
- MEDIA_BASE=/media
- PUBLIC_ORIGIN=http://94.101.186.198:7860
- PANEL_BASE_URL=http://94.101.186.198:3001
- DATABASE_URL=postgresql://user:d1ph12poyhd1o2pDASd2jp2dnbcba@app-postgres-1:5432/steelforesight
- DATABASE_SSL=disable
- KAVENEGAR_API_KEY=4C535063775043745559427769372B6F592B33524238345A6A494E4F456546417931697934304A734F6F6F3D
- SSO_SECRET=wSoNQxqlABZylZpXfuvUauUtaWXlNiJUxiOB9lBXOj8
- AI_BASE_URL=https://ai.liara.ir/api/68eb653bb55873971e0d46d6/v1
- AI_API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiI2YTQ1MDc1MjViNTk5MGNhZWMzZjY2OWMiLCJ0eXBlIjoiYWlfa2V5IiwiaWF0IjoxNzgyOTA4NzU0fQ.B6snXRTQonKbNEyCi-8YtgKIfkYEaVvkMFgal9S7xNg
- AI_MODEL=google/gemini-3.5-flash
- ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000,http://localhost:3001,http://94.101.186.198:7860,http://94.101.186.198:3001,https://steelforesight.ir,https://api.steelforesight.ir,https://steelforesight.liara.run,https://cms-steelforesight.liara.run
networks:
- sf-network
steelforesight-frontend:
build:
context: ./frontend
dockerfile: Dockerfile
image: andishkade-foolad:latest
container_name: andishkade-foolad
args:
- VITE_PANEL_API=
container_name: steelforesight-frontend
restart: always
ports:
# host:container — HF Spaces also exposes 7860 publicly
- "7860:7860"
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:7860/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
- 7860:7860
depends_on:
- steelforesight-cms
networks:
- sf-network
networks:
sf-network:
driver: bridge

3
frontend/.env Normal file
View File

@ -0,0 +1,3 @@
# Frontend Environment Variables
VITE_PANEL_API=
VITE_CMS_TARGET=http://localhost:3001

View File

@ -33,9 +33,6 @@ server {
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
# add_header directives are not inherited when a location defines one,
# so keep the security policy on cached assets too.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
@ -46,8 +43,7 @@ server {
# Never cache index.html (so deploys take effect immediately)
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: https://cms-steelforesight.liara.run https://api.steelforesight.ir https://*.storage.iran.liara.site https://www.msc.ir https://*.basemaps.cartocdn.com https://www.google.com; media-src 'self' blob: https://cms-steelforesight.liara.run https://api.steelforesight.ir https://*.storage.iran.liara.site; connect-src 'self' https://cms-steelforesight.liara.run https://api.steelforesight.ir; frame-src https://maps.google.com; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: http://* https://* https://cms-steelforesight.liara.run https://api.steelforesight.ir https://*.arvanstorage.ir https://*.storage.iran.liara.site https://www.msc.ir https://*.basemaps.cartocdn.com https://www.google.com; media-src 'self' blob: http://* https://* https://cms-steelforesight.liara.run https://api.steelforesight.ir https://*.arvanstorage.ir https://*.storage.iran.liara.site; connect-src 'self' http://* https://* https://cms-steelforesight.liara.run https://api.steelforesight.ir; frame-src https://maps.google.com; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
@ -60,12 +56,18 @@ server {
return 404;
}
# Same-origin proxy for CMS audio/video files. Keeping Range headers is
# required for seeking and reliable HTMLMediaElement playback.
# Same-origin proxy for CMS API and audio/video files
location /api/ {
proxy_pass http://steelforesight-cms:3001/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /media/ {
proxy_pass https://cms-steelforesight.liara.run;
proxy_ssl_server_name on;
proxy_set_header Host cms-steelforesight.liara.run;
proxy_pass http://steelforesight-cms:3001/media/;
proxy_set_header Host $host;
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_buffering off;
@ -77,8 +79,7 @@ server {
}
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: https://cms-steelforesight.liara.run https://api.steelforesight.ir https://*.storage.iran.liara.site https://www.msc.ir https://*.basemaps.cartocdn.com https://www.google.com; media-src 'self' blob: https://cms-steelforesight.liara.run https://api.steelforesight.ir https://*.storage.iran.liara.site; connect-src 'self' https://cms-steelforesight.liara.run https://api.steelforesight.ir; frame-src https://maps.google.com; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: http://* https://* https://cms-steelforesight.liara.run https://api.steelforesight.ir https://*.arvanstorage.ir https://*.storage.iran.liara.site https://www.msc.ir https://*.basemaps.cartocdn.com https://www.google.com; media-src 'self' blob: http://* https://* https://cms-steelforesight.liara.run https://api.steelforesight.ir https://*.arvanstorage.ir https://*.storage.iran.liara.site; connect-src 'self' http://* https://* https://cms-steelforesight.liara.run https://api.steelforesight.ir; frame-src https://maps.google.com; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

View File

@ -2,7 +2,7 @@
Each radar sub-page (/radar/<slug>) shows the articles whose category == the
sub-menu label. Falls back to an empty list when the panel is offline. */
import type { RadarItem, RadarCategory } from './radar'
import { RADAR_ITEMS as FALLBACK_RADAR_ITEMS, type RadarItem, type RadarCategory } from './radar'
export type RFBlock = { type: 'text' | 'image'; value?: string; url?: string; caption?: string }
@ -23,7 +23,8 @@ export type RadarFutureArticle = {
export const RADAR_FUTURE: RadarFutureArticle[] = []
// same data in the RadarItem shape (for the /radar index + home «رادار آینده» strip)
export const RADAR_FUTURE_ITEMS: RadarItem[] = []
// initialized with full fallback items so the page renders instantly without waiting
export const RADAR_FUTURE_ITEMS: RadarItem[] = [...FALLBACK_RADAR_ITEMS]
const LABEL_TO_ENUM: Record<string, RadarCategory> = {
'بازار و زنجیره فولاد': 'market',
'فناوری و نوآوری': 'tech',
@ -70,6 +71,7 @@ export async function bootstrapRadarFuture(): Promise<void> {
en: { title, date, excerpt },
}
}))
if (RADAR_FUTURE.length > 0) {
RADAR_FUTURE_ITEMS.length = 0
RADAR_FUTURE_ITEMS.push(...RADAR_FUTURE.map((a): RadarItem => {
const body = a.blocks.filter(b => b.type === 'text').map(b => b.value || '').filter(Boolean)
@ -82,5 +84,6 @@ export async function bootstrapRadarFuture(): Promise<void> {
en: { title: a.en.title, date: a.en.date, excerpt: a.en.excerpt, body },
}
}))
} catch { /* panel offline → keep empty */ }
}
} catch { /* panel offline → keep fallback */ }
}

View File

@ -38,28 +38,31 @@ export interface PodcastItem {
export const videos: VideoItem[] = [
{
id: 1,
fa: { title: 'نگاهی به آن سوی مرزهای تولید', guest: 'مهندس سیامک فجری',
id: 'art-video-ep1-steel-future',
fa: { title: 'فولاد در آیینه آینده', guest: 'مهندس حسینی',
body: 'فراتر از داده‌های خام قیمت و تناژ، به سراغ مهندسی تغییر و معماری آینده صنعت می‌رویم.' },
en: { title: 'Beyond the Frontiers of Production', guest: 'Eng. Siamak Fajri',
en: { title: 'Steel in the Mirror of Future', guest: 'Eng. Hosseini',
body: 'Going beyond raw price and tonnage data to the engineering of change.' },
ep: 'EP.12', duration: '۴۲ دقیقه', date: '۲۵ خرداد ۱۴۰۵', thumb: '/news/photo-1504307651254-35680f356dfd.jpg',
ep: 'EP.1', duration: '۴۲ دقیقه', date: '۲۵ خرداد ۱۴۰۵', thumb: '/media/steel_in_mirror_of_future.png',
mediaUrl: '/media/steel_in_mirror_of_future.mp4',
},
{
id: 2,
fa: { title: 'تحلیل بازار جهانی فلزات پایه', guest: 'دکتر نیلوفر احمدی',
id: 'art-video-ep2-ai-steel',
fa: { title: 'هوش مصنوعی در صنعت فولاد', guest: 'دکتر علوی',
body: 'نوسانات قیمت مس، آلومینیوم و روی و تأثیر آن بر زنجیره ارزش فولاد.' },
en: { title: 'Global Base Metals Analysis', guest: 'Dr. Niloofar Ahmadi',
en: { title: 'AI in Steel Industry', guest: 'Dr. Alavi',
body: 'Price volatility in copper, aluminum and zinc and its steel-chain impact.' },
ep: 'EP.11', duration: '۳۸ دقیقه', date: '۱۸ خرداد ۱۴۰۵', thumb: '/news/photo-1558618666-fcd25c85cd64.jpg',
ep: 'EP.2', duration: '۳۸ دقیقه', date: '۱۸ خرداد ۱۴۰۵', thumb: '/media/ai_in_steel_industry.png',
mediaUrl: '/media/ai_in_steel_industry.mp4',
},
{
id: 3,
fa: { title: 'ژئوپلیتیک و صادرات فولاد ایران', guest: 'مهندس فرید تهرانی',
id: 'art-video-ep3-green-hydrogen',
fa: { title: 'هیدروژن سبز', guest: 'مهندس کریمی',
body: 'تأثیر تحولات منطقه‌ای بر بازارهای صادراتی فولاد ایران و فرصت‌های پیش رو.' },
en: { title: 'Geopolitics & Iran Steel Exports', guest: 'Eng. Farid Tehrani',
en: { title: 'Green Hydrogen', guest: 'Eng. Karimi',
body: 'How regional developments shape Iran\'s steel export markets and opportunities ahead.' },
ep: 'EP.10', duration: '۵۱ دقیقه', date: '۱۱ خرداد ۱۴۰۵', thumb: '/news/photo-1504328345606-18bbc8c9d7d1.jpg',
ep: 'EP.3', duration: '۵۱ دقیقه', date: '۱۱ خرداد ۱۴۰۵', thumb: '/media/green_hydrogen.png',
mediaUrl: '/media/green_hydrogen.mp4',
},
{
id: 4,

View File

@ -39,14 +39,24 @@ const bootstraps = [
bootstrapRadarFuture,
]
await Promise.all(
// Mount React immediately for instant 0ms first render
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
</StrictMode>,
)
// Background non-blocking warm-up of remote APIs
Promise.allSettled(
bootstraps.map((bootstrap) =>
Promise.race([
bootstrap().catch(() => {}),
new Promise<void>((resolve) => window.setTimeout(resolve, BOOTSTRAP_TIMEOUT_MS)),
]),
),
)
).catch(() => {})
// PWA: register service worker in production builds only (skip in dev — avoids stale-cache pain)
if ('serviceWorker' in navigator && import.meta.env.PROD) {
@ -57,11 +67,3 @@ if ('serviceWorker' in navigator && import.meta.env.PROD) {
// iOS Safari ignores user-scalable=no — block pinch + double-tap zoom manually
document.addEventListener('gesturestart', (e) => e.preventDefault())
document.addEventListener('dblclick', (e) => e.preventDefault(), { passive: false })
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
</StrictMode>,
)

View File

@ -147,7 +147,7 @@ export default function AtAGlanceSection({ embedded = false }: { embedded?: bool
}, [])
const items = useMemo(
() => sortByPublishedDesc(remoteItems ?? []),
() => sortByPublishedDesc(remoteItems && remoteItems.length > 0 ? remoteItems : FALLBACK_ITEMS),
[remoteItems],
)
const latest = items.slice(0, 5)

View File

@ -1,8 +1,10 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Telescope, Scale, Database, Users, Lightbulb, Network, type LucideIcon } from 'lucide-react'
import { useLang } from '@/context/LangContext'
import { RadarCard } from '@/pages/Radar/Radar'
import { RADAR_FUTURE_ITEMS as RADAR_ITEMS } from '@/content/radarFuture'
import { RADAR_FUTURE_ITEMS, bootstrapRadarFuture } from '@/content/radarFuture'
import { RADAR_ITEMS as FALLBACK_RADAR_ITEMS } from '@/content/radar'
type Area = { Icon: LucideIcon; color: string; title: string; desc: string }
const RESEARCH_AREAS: Record<'fa' | 'en', Area[]> = {
@ -46,6 +48,23 @@ export default function RadarTechSection({ only }: { only?: 'radar' | 'approach'
const t = T[lang]
const showRadar = only !== 'approach'
const showApproach = only !== 'radar'
const [items, setItems] = useState(() =>
RADAR_FUTURE_ITEMS.length > 0 ? [...RADAR_FUTURE_ITEMS] : [...FALLBACK_RADAR_ITEMS]
)
useEffect(() => {
let active = true
bootstrapRadarFuture().finally(() => {
if (!active) return
if (RADAR_FUTURE_ITEMS.length > 0) {
setItems([...RADAR_FUTURE_ITEMS])
}
})
return () => { active = false }
}, [])
const displayItems = items.length > 0 ? items : FALLBACK_RADAR_ITEMS
return (
<section dir={lang === 'fa' ? 'rtl' : 'ltr'} style={{ background: only === 'approach' ? 'var(--paper-2)' : '#fff' }}>
<div className="max-w-7xl mx-auto px-12 max-md:px-5">
@ -63,7 +82,7 @@ export default function RadarTechSection({ only }: { only?: 'radar' | 'approach'
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 22 }} className="max-lg:!grid-cols-2 max-sm:!grid-cols-1">
{RADAR_ITEMS.slice(0, 4).map((item) => (
{displayItems.slice(0, 4).map((item) => (
<RadarCard key={item.id} item={item} lang={lang} />
))}
</div>

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useLang } from '@/context/LangContext'
import { Bookmark, Podcast, Clock, Calendar, Play, ChevronRight } from 'lucide-react'
@ -29,6 +29,7 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
const { member } = useAuth()
const navigate = useNavigate()
const t = T[lang]
const [videos, setVideos] = useState(() => [...VIDEOS])
const [activeVideo, setActiveVideo] = useState(0)
const [activePod, setActivePod] = useState(() => {
const playableIndex = PODCASTS.findIndex(item => Boolean(item.mediaUrl?.trim()))
@ -37,12 +38,16 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
const [podcasts, setPodcasts] = useState(() => [...PODCASTS])
const [bookmarkedIds, setBookmarkedIds] = useState<Set<string>>(new Set())
const [bookmarkBusy, setBookmarkBusy] = useState(false)
const videoRef = useRef<HTMLVideoElement>(null)
const currentVideo = videos[activeVideo] ?? videos[0]
const pod = podcasts[activePod] ?? podcasts[0]
useEffect(() => {
let active = true
bootstrapMedia().then(() => {
if (!active) return
setVideos([...VIDEOS])
setPodcasts([...PODCASTS])
const playableIndex = PODCASTS.findIndex(item => Boolean(item.mediaUrl?.trim()))
setActivePod(playableIndex >= 0 ? playableIndex : 0)
@ -50,6 +55,14 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
return () => { active = false }
}, [])
useEffect(() => {
if (videoRef.current) {
videoRef.current.pause()
videoRef.current.currentTime = 0
videoRef.current.load()
}
}, [activeVideo])
useEffect(() => {
if (!member) { setBookmarkedIds(new Set()); return }
let active = true
@ -63,6 +76,16 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
return () => { active = false }
}, [member])
const toggleVideoPlay = () => {
const v = videoRef.current
if (!v) return
if (v.paused) {
v.play().catch(() => {})
} else {
v.pause()
}
}
const togglePodcastBookmark = async () => {
if (!member) { navigate('/login'); return }
if (!pod || bookmarkBusy) return
@ -130,45 +153,43 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
<div style={{ display: 'flex', gap: 16, alignItems: 'stretch', marginBottom: 32 }} className="max-lg:!flex-col">
<div style={{ flex: '1.2 1 0', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 14 }} className="max-lg:!w-full">
<div style={{ flex: '1 1 0', minWidth: 0, position: 'relative', overflow: 'hidden', background: '#000', borderRadius: 16, minHeight: 0, boxShadow: '0 30px 70px -34px rgba(3,35,64,0.5)' }} className="max-lg:!aspect-video max-lg:!flex-none">
<img src={VIDEOS[activeVideo].thumb} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', opacity: 0.9, position: 'absolute', inset: 0 }} />
{currentVideo?.mediaUrl ? (
<video
ref={videoRef}
src={currentVideo.mediaUrl}
poster={currentVideo.thumb}
controls
playsInline
preload="metadata"
style={{ width: '100%', height: '100%', objectFit: 'contain', background: '#000', display: 'block' }}
/>
) : (
<>
<img src={currentVideo?.thumb} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', opacity: 0.9, position: 'absolute', inset: 0 }} />
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(3,35,64,0.55) 0%, transparent 45%)' }} />
{/* center play */}
<button aria-label="play" style={{
<button
onClick={toggleVideoPlay}
aria-label="play"
style={{
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)',
width: 64, height: 64, borderRadius: '50%', border: '2px solid rgba(255,255,255,0.6)',
background: 'rgba(255,255,255,0.14)', backdropFilter: 'blur(4px)', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
}}
>
<div style={{ width: 0, height: 0, borderTop: '11px solid transparent', borderBottom: '11px solid transparent', borderLeft: '19px solid #fff', marginLeft: 4 }} />
</button>
{/* controls bar */}
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '0 16px 14px', direction: 'ltr' }}>
<div style={{ height: 4, borderRadius: 2, background: 'rgba(255,255,255,0.3)', marginBottom: 12 }}>
<div style={{ width: '22%', height: '100%', borderRadius: 2, background: GOLD, position: 'relative' }}>
<span style={{ position: 'absolute', right: -5, top: '50%', transform: 'translateY(-50%)', width: 11, height: 11, borderRadius: '50%', background: '#fff' }} />
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', color: '#fff' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<span style={{ fontSize: 18 }}></span>
<span style={{ fontSize: 15, opacity: 0.85 }}></span>
<span style={{ fontSize: 15, opacity: 0.85 }}>🔊</span>
<span style={{ fontSize: 11, opacity: 0.8, direction: 'ltr' }}>۰۹:۲۷ / {VIDEOS[activeVideo].duration}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, opacity: 0.85 }}>
<span style={{ fontSize: 14 }}></span>
<span style={{ fontSize: 14 }}></span>
</div>
</div>
</div>
</>
)}
</div>
{/* title — directly below the video, inside the right column */}
<div style={{ direction: lang === 'fa' ? 'rtl' : 'ltr' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span style={{ fontSize: 16 }}>🎙</span>
<h3 style={{ fontSize: 'clamp(15px,1.5vw,20px)', fontWeight: 900, color: NAVY, margin: 0, lineHeight: 1.4 }}>{VIDEOS[activeVideo][lang].title}</h3>
<h3 style={{ fontSize: 'clamp(15px,1.5vw,20px)', fontWeight: 900, color: NAVY, margin: 0, lineHeight: 1.4 }}>{currentVideo?.[lang]?.title}</h3>
</div>
<p style={{ fontSize: 13, lineHeight: 1.9, color: 'rgba(3,35,64,0.7)', margin: 0, textAlign: 'justify', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{VIDEOS[activeVideo][lang].body}</p>
<p style={{ fontSize: 13, lineHeight: 1.9, color: 'rgba(3,35,64,0.7)', margin: 0, textAlign: 'justify', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{currentVideo?.[lang]?.body}</p>
</div>
</div>
@ -176,7 +197,7 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
<div style={{ flex: '1 1 0', minWidth: 0 }} className="max-lg:!w-full">
{/* video playlist — 3 thumbnail cards */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, alignContent: 'start', direction: lang === 'fa' ? 'rtl' : 'ltr' }} className="max-md:!grid-cols-2">
{VIDEOS.slice(0, 3).map((v, i) => (
{videos.slice(0, 3).map((v, i) => (
<button
key={v.id}
onClick={() => setActiveVideo(i)}
@ -196,10 +217,11 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
<div style={{ width: 0, height: 0, borderTop: '7px solid transparent', borderBottom: '7px solid transparent', borderLeft: `12px solid ${NAVY}`, marginLeft: 3 }} />
</div>
</div>
{v.duration ? (
<span style={{ position: 'absolute', bottom: 8, left: 8, fontSize: 10, fontWeight: 800, color: '#fff', background: 'rgba(0,0,0,0.55)', padding: '2px 7px', borderRadius: 4, direction: 'ltr' }}>{v.duration}</span>
) : null}
</div>
<div style={{ padding: '12px 14px', direction: lang === 'fa' ? 'rtl' : 'ltr', textAlign: 'right' }}>
<div style={{ fontSize: 11, color: GOLD, fontWeight: 800, marginBottom: 4 }}>{v.ep}</div>
<div style={{ fontSize: 13, fontWeight: 800, color: NAVY, lineHeight: 1.4 }}>{v[lang].title}</div>
</div>
</button>

View File

@ -5,7 +5,7 @@ import path from 'path'
// hosts allowed through Vite's host check (for tunnels: tunnelmole, localtunnel, cloudflare)
const ALLOWED_TUNNEL_HOSTS = ['.tunnelmole.net', '.loca.lt', '.trycloudflare.com']
const CMS_TARGET = 'https://cms-steelforesight.liara.run'
const CMS_TARGET = process.env.VITE_CMS_TARGET || 'http://localhost:3001'
export default defineConfig({
plugins: [react(), tailwindcss()],

21
panel/.env Normal file
View File

@ -0,0 +1,21 @@
PORT=3001
JWT_SECRET=sxskB!Mq2*GkwWm?qDi9JwJCA0|CAk{y%o3[uj.+LM^nayqiS>f_yy:B0c?),:y?@&Yvz0Bwy@p8n%{j[y+J#J
NODE_ENV=development
LIARA_ENDPOINT=s3.ir-thr-at1.arvanstorage.ir
LIARA_BUCKET=steelforesight
LIARA_ACCESS_KEY=325f8646-d2e3-4c46-b399-d1f0184beb24
LIARA_SECRET_KEY=47dfe66e3d24c2421640e6c1c9b09a53989dfcd755430e1570344cfc18485cda
ADMIN_USERNAME=admin
ADMIN_PASSWORD=elidonbe85kgkg@
MEDIA_BASE=http://localhost:3001
PUBLIC_ORIGIN=http://localhost:3001
KAVENEGAR_API_KEY=4C535063775043745559427769372B6F592B33524238345A6A494E4F456546417931697934304A734F6F6F3D
SSO_SECRET=wSoNQxqlABZylZpXfuvUauUtaWXlNiJUxiOB9lBXOj8
AI_BASE_URL=https://ai.liara.ir/api/68eb653bb55873971e0d46d6/v1
AI_API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiI2YTQ1MDc1MjViNTk5MGNhZWMzZjY2OWMiLCJ0eXBlIjoiYWlfa2V5IiwiaWF0IjoxNzgyOTA4NzU0fQ.B6snXRTQonKbNEyCi-8YtgKIfkYEaVvkMFgal9S7xNg
AI_MODEL=google/gemini-3.5-flash
PANEL_BASE_URL=http://localhost:3001
DATABASE_URL=postgresql://user:d1ph12poyhd1o2pDASd2jp2dnbcba@94.101.186.198:5433/steelforesight
DATABASE_SSL=disable
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000,http://localhost:3001

1
panel/.gitignore vendored
View File

@ -1,5 +1,4 @@
node_modules
.env
data.db
data.db-journal
data.db-shm

13
panel/Dockerfile Normal file
View File

@ -0,0 +1,13 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm config set registry https://package-mirror.liara.ir/repository/npm/ \
&& npm ci --no-audit --no-fund --legacy-peer-deps --maxsockets 2
COPY . .
EXPOSE 3001
CMD ["node", "server.js"]

View File

@ -491,6 +491,25 @@ if (ownerRows.length === 0) {
await pool.query("UPDATE users SET role = 'owner' WHERE id = (SELECT id FROM users ORDER BY id LIMIT 1)");
}
export function normalizeMediaUrl(url) {
if (!url || typeof url !== 'string') return url;
const match = url.match(/^(?:https?:\/\/[^\/]+)?\/(media|uploads)\/(.+)$/i);
if (match) {
return `/${match[1]}/${match[2]}`;
}
return url;
}
function normalizeBlocks(blocks) {
if (!Array.isArray(blocks)) return [];
return blocks.map(b => {
if (b && typeof b === 'object' && b.url) {
return { ...b, url: normalizeMediaUrl(b.url) };
}
return b;
});
}
export function rowToPrice(row) {
if (!row) return null;
return {
@ -621,7 +640,7 @@ export function rowToFactoryReport(row) {
if (!row) return null;
return {
id: row.id,
img: row.img,
img: normalizeMediaUrl(row.img),
tagFa: row.tag_fa,
tagEn: row.tag_en,
titleFa: row.title_fa,
@ -630,7 +649,7 @@ export function rowToFactoryReport(row) {
dateEn: row.date_en,
excerptFa: row.excerpt_fa,
excerptEn: row.excerpt_en,
fileUrl: row.file_url,
fileUrl: normalizeMediaUrl(row.file_url),
scrollDir: row.scroll_dir,
sortOrder: row.sort_order,
createdAt: row.created_at,
@ -643,8 +662,8 @@ export function rowToIntegration(row) {
return {
id: row.id,
name: row.name,
icon: row.icon,
logo: row.logo,
icon: normalizeMediaUrl(row.icon),
logo: normalizeMediaUrl(row.logo),
accent: row.accent,
gridCol: row.grid_col,
gridRow: row.grid_row,
@ -702,7 +721,7 @@ export function rowToVisionItem(row) {
if (!row) return null;
return {
id: row.id,
icon: row.icon,
icon: normalizeMediaUrl(row.icon),
titleFa: row.title_fa,
titleEn: row.title_en,
descFa: row.desc_fa,
@ -732,9 +751,9 @@ export function rowToRadarItem(row) {
return {
id: row.id,
category: row.category,
img: row.img,
img: normalizeMediaUrl(row.img),
authorName: row.author_name,
authorAvatar: row.author_avatar,
authorAvatar: normalizeMediaUrl(row.author_avatar),
titleFa: row.title_fa,
dateFa: row.date_fa,
excerptFa: row.excerpt_fa,
@ -768,14 +787,14 @@ export function rowToRadarPage(row) {
featuredTitleEn: row.featured_title_en,
featuredDateFa: row.featured_date_fa,
featuredDateEn: row.featured_date_en,
featuredImg: row.featured_img,
featuredImg: normalizeMediaUrl(row.featured_img),
bannerKickerFa: row.banner_kicker_fa,
bannerKickerEn: row.banner_kicker_en,
bannerTitleFa: row.banner_title_fa,
bannerTitleEn: row.banner_title_en,
bannerDescFa: row.banner_desc_fa,
bannerDescEn: row.banner_desc_en,
bannerBookImg: row.banner_book_img,
bannerBookImg: normalizeMediaUrl(row.banner_book_img),
sortOrder: row.sort_order,
createdAt: row.created_at,
updatedAt: row.updated_at,
@ -795,7 +814,7 @@ export function rowToBanner(row) {
ctaLabelFa: row.cta_label_fa,
ctaLabelEn: row.cta_label_en,
ctaUrl: row.cta_url,
image: row.image,
image: normalizeMediaUrl(row.image),
position: row.position,
active: !!row.active,
sortOrder: row.sort_order,
@ -821,8 +840,8 @@ export function rowToArticle(row) {
isFree: !!row.is_free,
summary: row.summary,
body: row.body,
coverImage: row.cover_image,
blocks: row.blocks ? JSON.parse(row.blocks) : [],
coverImage: normalizeMediaUrl(row.cover_image),
blocks: normalizeBlocks(row.blocks ? JSON.parse(row.blocks) : []),
sources: row.sources ? JSON.parse(row.sources) : [],
tags: row.tags ? JSON.parse(row.tags) : [],
featured: !!row.featured,

34
panel/reset-password.js Normal file
View File

@ -0,0 +1,34 @@
import 'dotenv/config';
import bcrypt from 'bcryptjs';
import { pool } from './db.js';
async function main() {
try {
const users = await pool.query('SELECT id, username, role FROM users');
console.log('Current users in DB:', users.rows);
const hash = await bcrypt.hash('admin123', 10);
const existing = users.rows.find(u => u.username === 'admin');
if (!existing) {
await pool.query(
'INSERT INTO users (username, password_hash, role) VALUES ($1, $2, $3)',
['admin', hash, 'owner']
);
console.log('Created user "admin" with password "admin123" (role: owner)');
} else {
await pool.query(
'UPDATE users SET password_hash = $1 WHERE username = $2',
[hash, 'admin']
);
console.log('Successfully updated password for user "admin" to "admin123"');
}
} catch (err) {
console.error('Error resetting password:', err);
} finally {
await pool.end();
process.exit(0);
}
}
main();

View File

@ -0,0 +1,55 @@
import pg from 'pg';
const { Pool } = pg;
const liaraPool = new Pool({
connectionString: 'postgresql://root:hePGFWh8f5TAEWCBK0V8XtQh@el-capitan.liara.cloud:32727/postgres',
ssl: false,
});
const vpsPool = new Pool({
connectionString: 'postgresql://user:d1ph12poyhd1o2pDASd2jp2dnbcba@94.101.186.198:5433/steelforesight',
ssl: false,
});
async function migrateDb() {
console.log('Connecting to databases...');
// 1. Get all tables in Liara
const tablesRes = await liaraPool.query(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY table_name;
`);
const tables = tablesRes.rows.map(r => r.table_name);
console.log('Tables to migrate:', tables);
// 2. For each table, get column definitions & create table on VPS if not exists
for (const table of tables) {
console.log(`\n--- Migrating table: ${table} ---`);
// Get column names and types
const colsRes = await liaraPool.query(`
SELECT column_name, data_type, udt_name, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1
ORDER BY ordinal_position;
`, [table]);
// Fetch all rows
const dataRes = await liaraPool.query(`SELECT * FROM "${table}"`);
console.log(`Found ${dataRes.rows.length} rows in ${table}`);
// Drop table on VPS if exists and create fresh
// We can extract table DDL or create from columns
if (dataRes.rows.length > 0) {
const colNames = Object.keys(dataRes.rows[0]);
// Let's create the table with db.js initialization or dynamic columns
// But simpler: let db.js initialize tables on VPS first, or execute DDL
}
}
}
migrateDb().catch(console.error);

View File

@ -0,0 +1,115 @@
import pg from 'pg';
const { Pool } = pg;
const liaraPool = new Pool({
connectionString: 'postgresql://root:hePGFWh8f5TAEWCBK0V8XtQh@el-capitan.liara.cloud:32727/postgres',
ssl: false,
});
const vpsPool = new Pool({
connectionString: 'postgresql://user:d1ph12poyhd1o2pDASd2jp2dnbcba@94.101.186.198:5433/steelforesight',
ssl: false,
});
async function migrateFast() {
console.log('Starting fast batch migration...');
const tablesRes = await liaraPool.query(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY table_name;
`);
const tables = tablesRes.rows.map(r => r.table_name);
for (const table of tables) {
console.log(`\nTable: ${table}`);
// Check if table exists on VPS
const checkTable = await vpsPool.query(`
SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1
`, [table]);
if (checkTable.rows.length === 0) {
const colsRes = await liaraPool.query(`
SELECT column_name, data_type, udt_name, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1
ORDER BY ordinal_position;
`, [table]);
const colDefs = colsRes.rows.map(col => {
let type = col.data_type;
if (col.data_type === 'USER-DEFINED') type = col.udt_name;
if (col.column_name === 'id' && (col.data_type === 'integer' || col.data_type === 'bigint') && col.column_default?.includes('nextval')) {
return `"${col.column_name}" SERIAL PRIMARY KEY`;
}
let def = `"${col.column_name}" ${type}`;
if (col.is_nullable === 'NO') def += ' NOT NULL';
if (col.column_name === 'id' && col.data_type === 'text') def += ' PRIMARY KEY';
return def;
}).join(',\n ');
const createSql = `CREATE TABLE IF NOT EXISTS "${table}" (\n ${colDefs}\n);`;
await vpsPool.query(createSql);
console.log(`Created table ${table}`);
}
const rowsRes = await liaraPool.query(`SELECT * FROM "${table}"`);
console.log(`Copying ${rowsRes.rows.length} rows...`);
if (rowsRes.rows.length > 0) {
await vpsPool.query(`TRUNCATE TABLE "${table}" CASCADE;`);
// Batch insert in chunks of 100 rows
const chunkSize = 100;
for (let i = 0; i < rowsRes.rows.length; i += chunkSize) {
const chunk = rowsRes.rows.slice(i, i + chunkSize);
const keys = Object.keys(chunk[0]);
const colNames = keys.map(k => `"${k}"`).join(', ');
const values = [];
const valuePlaceholders = [];
let paramIdx = 1;
for (const row of chunk) {
const rowPlaceholders = [];
for (const key of keys) {
values.push(row[key]);
rowPlaceholders.push(`$${paramIdx++}`);
}
valuePlaceholders.push(`(${rowPlaceholders.join(', ')})`);
}
const batchSql = `INSERT INTO "${table}" (${colNames}) VALUES ${valuePlaceholders.join(', ')} ON CONFLICT DO NOTHING;`;
await vpsPool.query(batchSql, values);
}
console.log(`✓ Completed table ${table}`);
}
}
// Update sequences
const seqRes = await vpsPool.query(`
SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'public';
`);
for (const seq of seqRes.rows) {
const seqName = seq.sequence_name;
const tabName = seqName.replace(/_id_seq$/, '');
try {
await vpsPool.query(`SELECT setval('${seqName}', COALESCE((SELECT MAX(id) FROM "${tabName}"), 1), true);`);
console.log(`Updated sequence: ${seqName}`);
} catch (e) {
// ignore
}
}
console.log('\nAll PostgreSQL tables & data migrated successfully!');
await liaraPool.end();
await vpsPool.end();
}
migrateFast().catch(err => {
console.error('Migration error:', err);
process.exit(1);
});

View File

@ -0,0 +1,111 @@
import pg from 'pg';
const { Pool } = pg;
const liaraPool = new Pool({
connectionString: 'postgresql://root:hePGFWh8f5TAEWCBK0V8XtQh@el-capitan.liara.cloud:32727/postgres',
ssl: false,
});
const vpsPool = new Pool({
connectionString: 'postgresql://user:d1ph12poyhd1o2pDASd2jp2dnbcba@94.101.186.198:5433/steelforesight',
ssl: false,
});
async function migrate() {
console.log('Connecting to Liara and VPS PostgreSQL...');
// 1. Get list of user tables in Liara
const tablesRes = await liaraPool.query(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY table_name;
`);
const tables = tablesRes.rows.map(r => r.table_name);
console.log('Tables to migrate:', tables);
// Initialize schema on VPS first (we can read db.js DDL or create tables dynamically)
// Let's create tables if not exist using information_schema
for (const table of tables) {
console.log(`\nProcessing schema for: ${table}`);
const colsRes = await liaraPool.query(`
SELECT column_name, data_type, udt_name, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1
ORDER BY ordinal_position;
`, [table]);
// Check if table exists on VPS
const checkTable = await vpsPool.query(`
SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1
`, [table]);
if (checkTable.rows.length === 0) {
// Build CREATE TABLE statement
const colDefs = colsRes.rows.map(col => {
let type = col.data_type;
if (col.data_type === 'USER-DEFINED') type = col.udt_name;
if (col.column_name === 'id' && (col.data_type === 'integer' || col.data_type === 'bigint') && col.column_default?.includes('nextval')) {
return `"${col.column_name}" SERIAL PRIMARY KEY`;
}
let def = `"${col.column_name}" ${type}`;
if (col.is_nullable === 'NO') def += ' NOT NULL';
if (col.column_name === 'id' && col.data_type === 'text') def += ' PRIMARY KEY';
return def;
}).join(',\n ');
const createSql = `CREATE TABLE IF NOT EXISTS "${table}" (\n ${colDefs}\n);`;
console.log(`Creating table ${table}...`);
await vpsPool.query(createSql);
}
// Now copy data
const rowsRes = await liaraPool.query(`SELECT * FROM "${table}"`);
console.log(`Copying ${rowsRes.rows.length} rows for ${table}...`);
if (rowsRes.rows.length > 0) {
// Clear existing rows on VPS to avoid duplicates
await vpsPool.query(`TRUNCATE TABLE "${table}" CASCADE;`);
for (const row of rowsRes.rows) {
const keys = Object.keys(row);
const values = Object.values(row);
const placeholders = keys.map((_, i) => `$${i + 1}`).join(', ');
const colNames = keys.map(k => `"${k}"`).join(', ');
const insertSql = `INSERT INTO "${table}" (${colNames}) VALUES (${placeholders}) ON CONFLICT DO NOTHING;`;
await vpsPool.query(insertSql, values);
}
console.log(`✓ Copied ${rowsRes.rows.length} rows to VPS ${table}`);
}
}
// Update sequences if any
const seqRes = await vpsPool.query(`
SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'public';
`);
for (const seq of seqRes.rows) {
const seqName = seq.sequence_name;
const tabName = seqName.split('_')[0];
try {
await vpsPool.query(`SELECT setval('${seqName}', COALESCE((SELECT MAX(id) FROM "${tabName}"), 1), true);`);
console.log(`Updated sequence: ${seqName}`);
} catch (e) {
// ignore
}
}
console.log('\n========================================');
console.log('✓ PostgreSQL Migration Complete!');
console.log('========================================');
await liaraPool.end();
await vpsPool.end();
}
migrate().catch(err => {
console.error('Migration error:', err);
process.exit(1);
});

View File

@ -159,16 +159,20 @@ app.use(cors({
}));
app.use(cookieParser());
app.use(helmet({
hsts: false,
crossOriginOpenerPolicy: false,
originAgentCluster: false,
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "blob:", ...(s3Host && process.env.LIARA_BUCKET ? [`https://${process.env.LIARA_BUCKET}.${s3Host}`] : [])],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
imgSrc: ["'self'", "data:", "blob:", "*"],
connectSrc: ["'self'", "*"],
fontSrc: ["'self'", "data:", "*"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
upgradeInsecureRequests: null,
},
},
frameguard: { action: 'deny' },

115
panel/transfer.js Normal file
View File

@ -0,0 +1,115 @@
import pg from 'pg';
const { Pool } = pg;
const liaraPool = new Pool({
connectionString: 'postgresql://root:hePGFWh8f5TAEWCBK0V8XtQh@el-capitan.liara.cloud:32727/postgres',
ssl: false,
});
const vpsPool = new Pool({
connectionString: 'postgresql://user:d1ph12poyhd1o2pDASd2jp2dnbcba@94.101.186.198:5433/steelforesight',
ssl: false,
});
async function migrateFast() {
console.log('Starting fast batch migration...');
const tablesRes = await liaraPool.query(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY table_name;
`);
const tables = tablesRes.rows.map(r => r.table_name);
for (const table of tables) {
console.log(`\nTable: ${table}`);
// Check if table exists on VPS
const checkTable = await vpsPool.query(`
SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1
`, [table]);
if (checkTable.rows.length === 0) {
const colsRes = await liaraPool.query(`
SELECT column_name, data_type, udt_name, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1
ORDER BY ordinal_position;
`, [table]);
const colDefs = colsRes.rows.map(col => {
let type = col.data_type;
if (col.data_type === 'USER-DEFINED') type = col.udt_name;
if (col.column_name === 'id' && (col.data_type === 'integer' || col.data_type === 'bigint') && col.column_default?.includes('nextval')) {
return `"${col.column_name}" SERIAL PRIMARY KEY`;
}
let def = `"${col.column_name}" ${type}`;
if (col.is_nullable === 'NO') def += ' NOT NULL';
if (col.column_name === 'id' && col.data_type === 'text') def += ' PRIMARY KEY';
return def;
}).join(',\n ');
const createSql = `CREATE TABLE IF NOT EXISTS "${table}" (\n ${colDefs}\n);`;
await vpsPool.query(createSql);
console.log(`Created table ${table}`);
}
const rowsRes = await liaraPool.query(`SELECT * FROM "${table}"`);
console.log(`Copying ${rowsRes.rows.length} rows...`);
if (rowsRes.rows.length > 0) {
await vpsPool.query(`TRUNCATE TABLE "${table}" CASCADE;`);
// Batch insert in chunks of 100 rows
const chunkSize = 100;
for (let i = 0; i < rowsRes.rows.length; i += chunkSize) {
const chunk = rowsRes.rows.slice(i, i + chunkSize);
const keys = Object.keys(chunk[0]);
const colNames = keys.map(k => `"${k}"`).join(', ');
const values = [];
const valuePlaceholders = [];
let paramIdx = 1;
for (const row of chunk) {
const rowPlaceholders = [];
for (const key of keys) {
values.push(row[key]);
rowPlaceholders.push(`$${paramIdx++}`);
}
valuePlaceholders.push(`(${rowPlaceholders.join(', ')})`);
}
const batchSql = `INSERT INTO "${table}" (${colNames}) VALUES ${valuePlaceholders.join(', ')} ON CONFLICT DO NOTHING;`;
await vpsPool.query(batchSql, values);
}
console.log(`✓ Completed table ${table}`);
}
}
// Update sequences
const seqRes = await vpsPool.query(`
SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'public';
`);
for (const seq of seqRes.rows) {
const seqName = seq.sequence_name;
const tabName = seqName.replace(/_id_seq$/, '');
try {
await vpsPool.query(`SELECT setval('${seqName}', COALESCE((SELECT MAX(id) FROM "${tabName}"), 1), true);`);
console.log(`Updated sequence: ${seqName}`);
} catch (e) {
// ignore
}
}
console.log('\nAll PostgreSQL tables & data migrated successfully!');
await liaraPool.end();
await vpsPool.end();
}
migrateFast().catch(err => {
console.error('Migration error:', err);
process.exit(1);
});