feat: add Technogen reports section with PDF downloads and CMS management

This commit is contained in:
alireza 2026-08-25 15:39:02 +03:30
parent ae69f310df
commit 2c299776b5
22 changed files with 3256 additions and 3 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

@ -0,0 +1,105 @@
export type TechnogenReport = {
id: string
code?: string
tagFa?: string
tagEn?: string
titleFa: string
titleEn?: string
subtitleFa?: string
subtitleEn?: string
descriptionFa?: string
descriptionEn?: string
dateFa?: string
dateEn?: string
pagesFa?: string
pagesEn?: string
isFeatured?: boolean
coverImage?: string
fileUrl?: string
sortOrder?: number
createdAt?: string
updatedAt?: string
}
export const FALLBACK_TECHNOGEN_REPORTS: TechnogenReport[] = [
{
id: 'tgn-cs-01',
code: 'TGN-CS-01',
tagFa: 'مطالعه تطبیقی',
tagEn: 'Comparative Study',
titleFa: 'پوسکو؛ از فولادساز ملی تا توسعه‌گر فناوری',
titleEn: 'POSCO: From National Steelmaker to Technology Developer',
subtitleFa: 'چگونه یک بنگاه بدون سرمایه، فناوری و تجربه، در پنج دهه به مالک، توسعه‌دهنده و صادرکننده فناوری تبدیل شد',
subtitleEn: 'How a firm with zero capital, technology, or experience became an owner, developer, and exporter of steel technology across five decades.',
descriptionFa: 'تحلیل عمیق ۵ دهه یادگیری فناورانه، سرمایه‌گذاری استراتژیک در تحقیق و توسعه، جهش تکنولوژیک و ساخت اکوسیستم نوآوری با مشارکت دانشگاه پوهانگ (POSTECH) و موسسه RIST.',
descriptionEn: 'In-depth analysis of five decades of technological learning, strategic R&D investment, and innovation ecosystem building.',
dateFa: 'مرداد ۱۴۰۵',
dateEn: 'August 2026',
pagesFa: '۳۰ صفحه',
pagesEn: '30 Pages',
isFeatured: true,
coverImage: '/technogen/technogen_posco_cover.png',
fileUrl: '/technogen/technogen_posco_study.pdf',
sortOrder: 1,
},
{
id: 'tgn-an-01',
code: 'TGN-AN-01',
tagFa: 'یادداشت تحلیلی',
tagEn: 'Analytical Note',
titleFa: 'از تولیدکننده تا صاحب فناوری؛ مسیر جهش صنعت ایران',
titleEn: 'From Manufacturer to Technology Owner: Iran\'s Industrial Leap Pathway',
subtitleFa: 'درس‌های یادگیری صنعتی در کره‌جنوبی و برزیل برای ساخت قابلیت فناورانه در ایران',
subtitleEn: 'Lessons from industrial learning in South Korea and Brazil for building technological capabilities in Iran.',
descriptionFa: 'چرا تمرکز صرف بر ظرفیت تولید بدون ساخت قابلیت درون‌زای طراحی و مهندسی، صنعت را در دام مونتاژ و وابستگی به لایسنسور خارجی نگه می‌دارد.',
descriptionEn: 'Why focusing solely on production volume without indigenous engineering keeps industry trapped in dependency.',
dateFa: 'مرداد ۱۴۰۵',
dateEn: 'August 2026',
pagesFa: '۴ صفحه',
pagesEn: '4 Pages',
isFeatured: false,
coverImage: '/technogen/tgn_an_01_cover.png',
fileUrl: '/technogen/tgn_an_01.pdf',
sortOrder: 2,
},
{
id: 'tgn-an-02',
code: 'TGN-AN-02',
tagFa: 'یادداشت تحلیلی',
tagEn: 'Analytical Note',
titleFa: 'رشد بدون توسعه؛ چرا صنعتی‌شدن بدون فناورشدن به سقف می‌خورد؟',
titleEn: 'Growth Without Development: Why Industrialization Without Technology Hits a Ceiling',
subtitleFa: 'بهره‌وری سقف دارد؛ فناوری ندارد. چرا انباشت قابلیت فناورانه، شرط عبور از سقف رشد صنعتی است.',
subtitleEn: 'Productivity has a ceiling; technology does not. Why technological capability accumulation is essential for surpassing industrial growth limits.',
descriptionFa: 'بررسی منحنی‌های مرز کارایی و تحلیل تفاوت بنیادی میان بهینه‌سازی عملیاتی موجود و جابه‌جایی مرز فناوری از طریق خلق دانش فنی.',
descriptionEn: 'Analyzing efficiency frontiers and the fundamental difference between operational optimization and technological boundary shifts.',
dateFa: 'مرداد ۱۴۰۵',
dateEn: 'August 2026',
pagesFa: '۴ صفحه',
pagesEn: '4 Pages',
isFeatured: false,
coverImage: '/technogen/tgn_an_02_cover.png',
fileUrl: '/technogen/tgn_an_02.pdf',
sortOrder: 3,
},
{
id: 'tgn-an-03',
code: 'TGN-AN-03',
tagFa: 'یادداشت تحلیلی',
tagEn: 'Analytical Note',
titleFa: 'توسعه‌گر فناوری یا مصرف‌کنندهٔ دائمی؟ انتخابی که بنگاه‌های بزرگ باید امروز بکنند',
titleEn: 'Technology Developer or Perpetual Consumer? The Critical Choice for Large Enterprises Today',
subtitleFa: 'درس‌های جهش و میان‌بر فناورانه از بنگاه‌های بزرگ کشورهای در حال توسعه؛ از پوسکو و امبرائر تا سیمکس و بائواستیل',
subtitleEn: 'Technological leapfrogging lessons from large emerging-market enterprises: from POSCO and Embraer to CEMEX and Baosteel.',
descriptionFa: 'تفاوت در مقصد سرمایه‌گذاری: یکی برای «داشتن» فناوری خرج می‌کند و دیگری برای «توانستن» — یعنی جذب، تغییر، طراحی و بازآفرینی فناوری.',
descriptionEn: 'The difference in investment purpose: spending to acquire technology versus spending to build the capability to absorb, modify, design, and recreate it.',
dateFa: 'مرداد ۱۴۰۵',
dateEn: 'August 2026',
pagesFa: '۴ صفحه',
pagesEn: '4 Pages',
isFeatured: false,
coverImage: '/technogen/tgn_an_03_cover.png',
fileUrl: '/technogen/tgn_an_03.pdf',
sortOrder: 4,
},
]

View File

@ -3,6 +3,7 @@ import IntegrationsSection from './sections/IntegrationsSection'
// import InternalNewsSection from './sections/InternalNewsSection' // «تازه‌ترین اخبار صنعت» — disabled, restore anytime
import RadarTechSection from './sections/RadarTechSection'
import AtAGlanceSection from './sections/AtAGlanceSection'
import TechnogenSection from './sections/TechnogenSection'
import FeaturedReportBanner from './sections/FeaturedReportBanner'
import MapEventsSection from './sections/MapEventsSection'
import FactoryReportsScroll from './sections/FactoryReportsScroll'
@ -19,6 +20,7 @@ export default function Home() {
<RadarTechSection only="radar" /> {/* رادار آینده */}
<IntegrationsSection /> {/* آمار و داده — نبض صنعت */}
<AtAGlanceSection /> {/* در یک نگاه */}
<TechnogenSection /> {/* تکنوژن؛ برنامه توسعه‌گر ملی فناوری */}
<MapEventsSection /> {/* دیده‌بان رویدادها */}
<FeaturedReportBanner /> {/* دیپلماسی (گزارش ویژه) */}
<FactoryReportsScroll /> {/* ماهنامه تحلیلی کارخانه */}

View File

@ -0,0 +1,854 @@
import { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Download, FileText, ExternalLink, Sparkles, X, Eye, BookOpen, Layers } from 'lucide-react'
import { useLang } from '@/context/LangContext'
import { FALLBACK_TECHNOGEN_REPORTS, type TechnogenReport } from '@/data/technogen'
const NAVY = '#032340'
const GOLD = '#CD9E53'
const EASE = [0.22, 1, 0.36, 1] as const
export default function TechnogenSection() {
const { lang } = useLang()
const isFa = lang === 'fa'
const [reports, setReports] = useState<TechnogenReport[]>(FALLBACK_TECHNOGEN_REPORTS)
const [selectedReport, setSelectedReport] = useState<TechnogenReport | null>(null)
useEffect(() => {
let unmounted = false
fetch('/api/technogen')
.then((res) => {
if (!res.ok) throw new Error('API error')
return res.json()
})
.then((data: TechnogenReport[]) => {
if (!unmounted && Array.isArray(data) && data.length > 0) {
setReports(data)
}
})
.catch(() => {
// use fallback reports seamlessly
})
return () => {
unmounted = true
}
}, [])
// Featured report (POSCO study) vs analytical notes
const featured = reports.find((r) => r.isFeatured) || reports[0]
const analyticalNotes = reports.filter((r) => r.id !== featured?.id)
const openPreview = (report: TechnogenReport) => {
setSelectedReport(report)
}
const closePreview = () => {
setSelectedReport(null)
}
return (
<section
id="technogen"
dir={isFa ? 'rtl' : 'ltr'}
style={{
background: 'var(--paper)',
borderTop: '1px solid var(--rule-thin)',
borderBottom: '1px solid var(--rule-thin)',
padding: '72px 0 88px',
position: 'relative',
overflow: 'hidden',
}}
>
{/* Background ambient lighting */}
<div
style={{
position: 'absolute',
top: '-15%',
right: isFa ? '-5%' : 'auto',
left: isFa ? 'auto' : '-5%',
width: '550px',
height: '550px',
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(205,158,83,0.08) 0%, rgba(3,35,64,0) 70%)',
pointerEvents: 'none',
}}
/>
<div
style={{
position: 'absolute',
bottom: '-20%',
left: isFa ? '-10%' : 'auto',
right: isFa ? 'auto' : '-10%',
width: '600px',
height: '600px',
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(0,174,239,0.06) 0%, rgba(3,35,64,0) 70%)',
pointerEvents: 'none',
}}
/>
<div className="max-w-7xl mx-auto px-12 max-md:px-5">
{/* ── Section Header ── */}
<div style={{ marginBottom: 40, position: 'relative', zIndex: 1 }}>
<motion.div
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, ease: EASE }}
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, marginBottom: 12 }}
>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '4px 12px',
borderRadius: 20,
background: 'rgba(3,35,64,0.06)',
border: '1px solid rgba(3,35,64,0.12)',
color: NAVY,
fontSize: 12,
fontWeight: 800,
letterSpacing: isFa ? '0' : '1px',
}}
>
<Sparkles size={14} color={GOLD} />
TECHNOGEN
</span>
<span style={{ fontSize: 13, color: GOLD, fontWeight: 700 }}>
{isFa ? 'برنامه توسعه‌گر ملی فناوری' : 'National Technology Developer Program'}
</span>
</motion.div>
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', flexWrap: 'wrap', gap: 16 }}>
<div>
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, ease: EASE, delay: 0.1 }}
style={{
fontSize: 'clamp(24px, 2.8vw, 36px)',
fontWeight: 900,
color: NAVY,
margin: '0 0 8px',
letterSpacing: isFa ? '-0.5px' : '-0.2px',
lineHeight: 1.3,
}}
>
{isFa ? 'تکنوژن؛ برنامه توسعه‌گر ملی فناوری' : 'Technogen: National Technology Developer Program'}
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, ease: EASE, delay: 0.15 }}
style={{
fontSize: 'clamp(14px, 1.2vw, 16px)',
color: 'rgba(3,35,64,0.7)',
margin: 0,
maxWidth: 720,
lineHeight: 1.7,
}}
>
{isFa
? 'گزارش‌های تحلیلی و مطالعات تطبیقی یادگیری صنعتی، انتقال دانش و ساخت قابلیت فناورانه در زنجیره فولاد'
: 'Analytical notes and comparative studies on industrial learning, knowledge absorption, and technological capability building in steel.'}
</motion.p>
</div>
</div>
</div>
{/* ── Flagship Featured Report (POSCO Study) ── */}
{featured && (
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.7, ease: EASE }}
style={{
position: 'relative',
borderRadius: 24,
overflow: 'hidden',
background: `linear-gradient(135deg, ${NAVY} 0%, #062b4f 50%, #041c33 100%)`,
boxShadow: '0 25px 50px -18px rgba(3,35,64,0.35)',
border: '1px solid rgba(255,255,255,0.1)',
marginBottom: 36,
}}
>
{/* Ambient decorative elements */}
<div
style={{
position: 'absolute',
top: 0,
right: 0,
width: '100%',
height: '100%',
backgroundImage: 'radial-gradient(circle at 85% 20%, rgba(205,158,83,0.15) 0%, transparent 50%)',
pointerEvents: 'none',
}}
/>
<div
style={{
display: 'flex',
alignItems: 'center',
minHeight: 380,
padding: '36px 44px',
}}
className="max-lg:!flex-col max-md:!p-6"
>
{/* Cover Column */}
<div
style={{
flex: '0 0 280px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
}}
className="max-lg:!w-full max-lg:!mb-8"
>
<motion.div
whileHover={{ scale: 1.03, rotateY: isFa ? -4 : 4, y: -4 }}
transition={{ duration: 0.4 }}
style={{
position: 'relative',
borderRadius: 12,
boxShadow: '0 20px 40px -10px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.12)',
overflow: 'hidden',
background: NAVY,
maxWidth: 240,
width: '100%',
aspectRatio: '1 / 1.414',
cursor: 'pointer',
}}
onClick={() => openPreview(featured)}
>
<img
src={featured.coverImage}
alt={isFa ? featured.titleFa : (featured.titleEn || featured.titleFa)}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
}}
/>
<div
style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(to top, rgba(3,35,64,0.6) 0%, transparent 40%)',
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'center',
padding: 12,
opacity: 0,
transition: 'opacity 0.25s',
}}
className="hover:!opacity-100"
>
<span
style={{
background: 'rgba(255,255,255,0.95)',
color: NAVY,
fontSize: 12,
fontWeight: 800,
padding: '6px 14px',
borderRadius: 20,
display: 'inline-flex',
alignItems: 'center',
gap: 6,
}}
>
<Eye size={14} />
{isFa ? 'مشاهده گزارش' : 'Preview'}
</span>
</div>
</motion.div>
</div>
{/* Text / Info Column */}
<div
style={{
flex: '1 1 0',
marginInlineStart: 36,
color: '#fff',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
className="max-lg:!m-0 max-lg:!w-full max-lg:!text-center"
>
{/* Meta Badges */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
marginBottom: 14,
flexWrap: 'wrap',
}}
className="max-lg:!justify-center"
>
<span
style={{
background: GOLD,
color: NAVY,
fontSize: 11,
fontWeight: 900,
padding: '4px 10px',
borderRadius: 6,
}}
>
{isFa ? 'مطالعه تطبیقی ویژه' : 'Flagship Case Study'}
</span>
{featured.code && (
<span
style={{
background: 'rgba(255,255,255,0.12)',
color: '#fff',
fontSize: 11,
fontWeight: 800,
padding: '4px 10px',
borderRadius: 6,
letterSpacing: '0.5px',
}}
>
{featured.code}
</span>
)}
<span style={{ fontSize: 13, color: 'rgba(255,255,255,0.6)', fontWeight: 600 }}>
{isFa ? (featured.pagesFa || '۳۰ صفحه') : (featured.pagesEn || '30 Pages')}
</span>
<span style={{ color: 'rgba(255,255,255,0.3)' }}></span>
<span style={{ fontSize: 13, color: 'rgba(255,255,255,0.6)' }}>
{isFa ? (featured.dateFa || 'مرداد ۱۴۰۵') : (featured.dateEn || 'August 2026')}
</span>
</div>
{/* Title */}
<h3
style={{
fontSize: 'clamp(20px, 2.3vw, 28px)',
fontWeight: 900,
lineHeight: 1.4,
margin: '0 0 12px',
color: '#fff',
}}
>
{isFa ? featured.titleFa : (featured.titleEn || featured.titleFa)}
</h3>
{/* Subtitle / Quote */}
{featured.subtitleFa && (
<div
style={{
fontSize: 'clamp(14px, 1.1vw, 16px)',
lineHeight: 1.7,
color: 'rgba(255,255,255,0.85)',
fontWeight: 600,
marginBottom: 14,
borderInlineStart: `3px solid ${GOLD}`,
paddingInlineStart: 14,
}}
className="max-lg:!border-none max-lg:!p-0"
>
{isFa ? featured.subtitleFa : (featured.subtitleEn || featured.subtitleFa)}
</div>
)}
{/* Description */}
{featured.descriptionFa && (
<p
style={{
fontSize: 13,
lineHeight: 1.8,
color: 'rgba(255,255,255,0.65)',
margin: '0 0 24px',
maxWidth: 680,
}}
className="max-lg:!mx-auto"
>
{isFa ? featured.descriptionFa : (featured.descriptionEn || featured.descriptionFa)}
</p>
)}
{/* Action Buttons */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 14,
flexWrap: 'wrap',
}}
className="max-lg:!justify-center"
>
{featured.fileUrl && (
<a
href={featured.fileUrl}
download
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 10,
background: `linear-gradient(135deg, ${GOLD} 0%, #e0b469 100%)`,
color: NAVY,
fontSize: 13,
fontWeight: 900,
padding: '12px 24px',
borderRadius: 12,
textDecoration: 'none',
boxShadow: '0 10px 25px -5px rgba(205,158,83,0.4)',
transition: 'all 0.2s ease',
}}
className="hover:!brightness-105 active:!scale-95"
>
<Download size={17} strokeWidth={2.5} />
<span>{isFa ? 'دانلود متن کامل گزارش (PDF)' : 'Download Full PDF (30 Pages)'}</span>
</a>
)}
<button
onClick={() => openPreview(featured)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
background: 'rgba(255,255,255,0.08)',
border: '1px solid rgba(255,255,255,0.2)',
color: '#fff',
fontSize: 13,
fontWeight: 700,
padding: '12px 20px',
borderRadius: 12,
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
className="hover:!bg-white/15"
>
<BookOpen size={16} />
<span>{isFa ? 'مطالعه و ورق زدن آنلاین' : 'Read Online'}</span>
</button>
</div>
</div>
</div>
</motion.div>
)}
{/* ── Analytical Notes (3-Card Grid) ── */}
<div style={{ marginTop: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Layers size={18} color={NAVY} />
<h4 style={{ fontSize: 18, fontWeight: 900, color: NAVY, margin: 0 }}>
{isFa ? 'یادداشت‌های تحلیلی تکنوژن' : 'Technogen Analytical Notes'}
</h4>
</div>
<span style={{ fontSize: 12, color: 'rgba(3,35,64,0.6)', fontWeight: 600 }}>
{isFa ? `${analyticalNotes.length} شماره منتشر شده` : `${analyticalNotes.length} Published Notes`}
</span>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: 20,
}}
className="max-lg:!grid-cols-2 max-md:!grid-cols-1"
>
{analyticalNotes.map((note, idx) => (
<motion.article
key={note.id}
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, ease: EASE, delay: idx * 0.1 }}
whileHover={{ y: -6 }}
style={{
background: '#FFFFFF',
borderRadius: 20,
border: '1px solid rgba(3,35,64,0.08)',
boxShadow: '0 8px 24px -6px rgba(3,35,64,0.06)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
transition: 'box-shadow 0.3s ease',
}}
className="hover:!shadow-xl"
>
{/* Card Top / Cover & Tag */}
<div
style={{
position: 'relative',
background: `linear-gradient(135deg, ${NAVY} 0%, #0a2f52 100%)`,
padding: '24px 20px 20px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
cursor: 'pointer',
}}
onClick={() => openPreview(note)}
>
<div
style={{
position: 'absolute',
top: 14,
right: isFa ? 14 : 'auto',
left: isFa ? 'auto' : 14,
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
{note.code && (
<span
style={{
background: 'rgba(255,255,255,0.15)',
color: '#fff',
fontSize: 10,
fontWeight: 800,
padding: '3px 8px',
borderRadius: 4,
letterSpacing: '0.5px',
}}
>
{note.code}
</span>
)}
<span
style={{
background: 'rgba(205,158,83,0.25)',
color: GOLD,
fontSize: 10,
fontWeight: 800,
padding: '3px 8px',
borderRadius: 4,
}}
>
{isFa ? (note.tagFa || 'یادداشت تحلیلی') : (note.tagEn || 'Analytical Note')}
</span>
</div>
{/* Thumbnail Cover Image */}
<div
style={{
width: 140,
aspectRatio: '1 / 1.414',
borderRadius: 8,
overflow: 'hidden',
marginTop: 18,
boxShadow: '0 12px 24px -4px rgba(0,0,0,0.5)',
border: '1px solid rgba(255,255,255,0.15)',
background: NAVY,
}}
>
<img
src={note.coverImage}
alt={isFa ? note.titleFa : (note.titleEn || note.titleFa)}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</div>
</div>
{/* Card Body */}
<div
style={{
padding: '20px 22px',
display: 'flex',
flexDirection: 'column',
flex: '1 1 auto',
}}
>
<div
style={{
fontSize: 11,
color: 'rgba(3,35,64,0.55)',
fontWeight: 700,
marginBottom: 8,
display: 'flex',
justifyContent: 'space-between',
}}
>
<span>{isFa ? (note.dateFa || 'مرداد ۱۴۰۵') : (note.dateEn || 'August 2026')}</span>
<span>{isFa ? (note.pagesFa || '۴ صفحه') : (note.pagesEn || '4 Pages')}</span>
</div>
<h4
style={{
fontSize: 16,
fontWeight: 900,
color: NAVY,
lineHeight: 1.45,
margin: '0 0 8px',
minHeight: 46,
}}
>
{isFa ? note.titleFa : (note.titleEn || note.titleFa)}
</h4>
{note.subtitleFa && (
<p
style={{
fontSize: 12,
lineHeight: 1.7,
color: 'rgba(3,35,64,0.7)',
margin: '0 0 16px',
flex: '1 1 auto',
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{isFa ? note.subtitleFa : (note.subtitleEn || note.subtitleFa)}
</p>
)}
{/* Actions */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
paddingTop: 12,
borderTop: '1px solid rgba(3,35,64,0.06)',
marginTop: 'auto',
}}
>
{note.fileUrl && (
<a
href={note.fileUrl}
download
target="_blank"
rel="noopener noreferrer"
style={{
flex: '1 1 0',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
background: 'rgba(3,35,64,0.06)',
color: NAVY,
fontSize: 12,
fontWeight: 800,
padding: '8px 12px',
borderRadius: 8,
textDecoration: 'none',
transition: 'all 0.2s',
}}
className="hover:!bg-navy hover:!text-white"
>
<Download size={14} />
<span>{isFa ? 'دانلود PDF' : 'Download'}</span>
</a>
)}
<button
onClick={() => openPreview(note)}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
background: 'transparent',
border: '1px solid rgba(3,35,64,0.15)',
color: NAVY,
fontSize: 12,
fontWeight: 700,
padding: '8px 12px',
borderRadius: 8,
cursor: 'pointer',
transition: 'all 0.2s',
}}
className="hover:!border-navy"
>
<Eye size={14} />
<span>{isFa ? 'مشاهده' : 'View'}</span>
</button>
</div>
</div>
</motion.article>
))}
</div>
</div>
</div>
{/* ── Document Reader / Preview Modal ── */}
<AnimatePresence>
{selectedReport && (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 9999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '24px 16px',
}}
>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{
position: 'absolute',
inset: 0,
background: 'rgba(3, 20, 36, 0.85)',
backdropFilter: 'blur(8px)',
}}
onClick={closePreview}
/>
{/* Modal Window */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.3, ease: EASE }}
style={{
position: 'relative',
width: '100%',
maxWidth: 1000,
height: '90vh',
background: '#FFFFFF',
borderRadius: 24,
boxShadow: '0 30px 90px rgba(0,0,0,0.5)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
zIndex: 1,
}}
>
{/* Modal Top Header */}
<div
style={{
padding: '16px 24px',
background: NAVY,
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
borderBottom: '1px solid rgba(255,255,255,0.1)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0 }}>
<FileText size={20} color={GOLD} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 11, color: GOLD, fontWeight: 700 }}>
{selectedReport.code} · {isFa ? selectedReport.tagFa : (selectedReport.tagEn || selectedReport.tagFa)}
</div>
<div
style={{
fontSize: 15,
fontWeight: 900,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{isFa ? selectedReport.titleFa : (selectedReport.titleEn || selectedReport.titleFa)}
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexShrink: 0 }}>
{selectedReport.fileUrl && (
<a
href={selectedReport.fileUrl}
download
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
background: GOLD,
color: NAVY,
fontSize: 12,
fontWeight: 800,
padding: '6px 14px',
borderRadius: 8,
textDecoration: 'none',
}}
>
<Download size={14} />
<span>{isFa ? 'دانلود فایل' : 'Download'}</span>
</a>
)}
{selectedReport.fileUrl && (
<a
href={selectedReport.fileUrl}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
borderRadius: 8,
background: 'rgba(255,255,255,0.1)',
color: '#fff',
textDecoration: 'none',
}}
title={isFa ? 'باز کردن در پنجره جدید' : 'Open in new tab'}
>
<ExternalLink size={15} />
</a>
)}
<button
onClick={closePreview}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
borderRadius: 8,
background: 'rgba(255,255,255,0.1)',
border: 'none',
color: '#fff',
cursor: 'pointer',
}}
title={isFa ? 'بستن' : 'Close'}
>
<X size={18} />
</button>
</div>
</div>
{/* Modal Viewer Body */}
<div style={{ flex: '1 1 0', position: 'relative', background: '#e5e7eb' }}>
{selectedReport.fileUrl ? (
<iframe
src={`${selectedReport.fileUrl}#toolbar=1&navpanes=0`}
style={{
width: '100%',
height: '100%',
border: 'none',
}}
title={selectedReport.titleFa}
/>
) : (
<div style={{ display: 'grid', placeItems: 'center', height: '100%', color: '#6b7280' }}>
{isFa ? 'فایل PDF در دسترس نیست.' : 'PDF file not available.'}
</div>
)}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</section>
)
}

View File

@ -333,6 +333,30 @@ await pool.query(`
);
CREATE INDEX IF NOT EXISTS idx_factory_reports_sort ON factory_reports(sort_order, created_at DESC);
CREATE TABLE IF NOT EXISTS technogen_reports (
id TEXT PRIMARY KEY,
code TEXT,
tag_fa TEXT,
tag_en TEXT,
title_fa TEXT NOT NULL,
title_en TEXT,
subtitle_fa TEXT,
subtitle_en TEXT,
description_fa TEXT,
description_en TEXT,
date_fa TEXT,
date_en TEXT,
pages_fa TEXT,
pages_en TEXT,
is_featured INTEGER DEFAULT 0,
cover_image TEXT,
file_url TEXT,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_technogen_sort ON technogen_reports(is_featured DESC, sort_order ASC, created_at DESC);
CREATE TABLE IF NOT EXISTS integrations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
@ -491,6 +515,108 @@ if (ownerRows.length === 0) {
await pool.query("UPDATE users SET role = 'owner' WHERE id = (SELECT id FROM users ORDER BY id LIMIT 1)");
}
// seed technogen reports if table is empty
const { rows: techRows } = await pool.query("SELECT COUNT(*)::int AS cnt FROM technogen_reports");
if (techRows[0]?.cnt === 0) {
const INITIAL_TECHNOGEN_REPORTS = [
{
id: 'tgn-cs-01',
code: 'TGN-CS-01',
tag_fa: 'مطالعه تطبیقی',
tag_en: 'Comparative Study',
title_fa: 'پوسکو؛ از فولادساز ملی تا توسعه‌گر فناوری',
title_en: 'POSCO: From National Steelmaker to Technology Developer',
subtitle_fa: 'چگونه یک بنگاه بدون سرمایه، فناوری و تجربه، در پنج دهه به مالک، توسعه‌دهنده و صادرکننده فناوری تبدیل شد',
subtitle_en: 'How a firm with zero capital, technology, or experience became an owner, developer, and exporter of steel technology across five decades.',
description_fa: 'تحلیل عمیق ۵ دهه یادگیری فناورانه، سرمایه‌گذاری استراتژیک در تحقیق و توسعه، جهش تکنولوژیک و ساخت اکوسیستم نوآوری با مشارکت دانشگاه پوهانگ (POSTECH) و موسسه RIST.',
description_en: 'In-depth analysis of five decades of technological learning, strategic R&D investment, and innovation ecosystem building.',
date_fa: 'مرداد ۱۴۰۵',
date_en: 'August 2026',
pages_fa: '۳۰ صفحه',
pages_en: '30 Pages',
is_featured: 1,
cover_image: '/technogen/technogen_posco_cover.png',
file_url: '/technogen/technogen_posco_study.pdf',
sort_order: 1,
},
{
id: 'tgn-an-01',
code: 'TGN-AN-01',
tag_fa: 'یادداشت تحلیلی',
tag_en: 'Analytical Note',
title_fa: 'از تولیدکننده تا صاحب فناوری؛ مسیر جهش صنعت ایران',
title_en: 'From Manufacturer to Technology Owner: Iran\'s Industrial Leap Pathway',
subtitle_fa: 'درس‌های یادگیری صنعتی در کره‌جنوبی و برزیل برای ساخت قابلیت فناورانه در ایران',
subtitle_en: 'Lessons from industrial learning in South Korea and Brazil for building technological capabilities in Iran.',
description_fa: 'چرا تمرکز صرف بر ظرفیت تولید بدون ساخت قابلیت درون‌زای طراحی و مهندسی، صنعت را در دام مونتاژ و وابستگی به لایسنسور خارجی نگه می‌دارد.',
description_en: 'Why focusing solely on production volume without indigenous engineering keeps industry trapped in dependency.',
date_fa: 'مرداد ۱۴۰۵',
date_en: 'August 2026',
pages_fa: '۴ صفحه',
pages_en: '4 Pages',
is_featured: 0,
cover_image: '/technogen/tgn_an_01_cover.png',
file_url: '/technogen/tgn_an_01.pdf',
sort_order: 2,
},
{
id: 'tgn-an-02',
code: 'TGN-AN-02',
tag_fa: 'یادداشت تحلیلی',
tag_en: 'Analytical Note',
title_fa: 'رشد بدون توسعه؛ چرا صنعتی‌شدن بدون فناورشدن به سقف می‌خورد؟',
title_en: 'Growth Without Development: Why Industrialization Without Technology Hits a Ceiling',
subtitle_fa: 'بهره‌وری سقف دارد؛ فناوری ندارد. چرا انباشت قابلیت فناورانه، شرط عبور از سقف رشد صنعتی است.',
subtitle_en: 'Productivity has a ceiling; technology does not. Why technological capability accumulation is essential for surpassing industrial growth limits.',
description_fa: 'بررسی منحنی‌های مرز کارایی و تحلیل تفاوت بنیادی میان بهینه‌سازی عملیاتی موجود و جابه‌جایی مرز فناوری از طریق خلق دانش فنی.',
description_en: 'Analyzing efficiency frontiers and the fundamental difference between operational optimization and technological boundary shifts.',
date_fa: 'مرداد ۱۴۰۵',
date_en: 'August 2026',
pages_fa: '۴ صفحه',
pages_en: '4 Pages',
is_featured: 0,
cover_image: '/technogen/tgn_an_02_cover.png',
file_url: '/technogen/tgn_an_02.pdf',
sort_order: 3,
},
{
id: 'tgn-an-03',
code: 'TGN-AN-03',
tag_fa: 'یادداشت تحلیلی',
tag_en: 'Analytical Note',
title_fa: 'توسعه‌گر فناوری یا مصرف‌کنندهٔ دائمی؟ انتخابی که بنگاه‌های بزرگ باید امروز بکنند',
title_en: 'Technology Developer or Perpetual Consumer? The Critical Choice for Large Enterprises Today',
subtitle_fa: 'درس‌های جهش و میان‌بر فناورانه از بنگاه‌های بزرگ کشورهای در حال توسعه؛ از پوسکو و امبرائر تا سیمکس و بائواستیل',
subtitle_en: 'Technological leapfrogging lessons from large emerging-market enterprises: from POSCO and Embraer to CEMEX and Baosteel.',
description_fa: 'تفاوت در مقصد سرمایه‌گذاری: یکی برای «داشتن» فناوری خرج می‌کند و دیگری برای «توانستن» — یعنی جذب، تغییر، طراحی و بازآفرینی فناوری.',
description_en: 'The difference in investment purpose: spending to acquire technology versus spending to build the capability to absorb, modify, design, and recreate it.',
date_fa: 'مرداد ۱۴۰۵',
date_en: 'August 2026',
pages_fa: '۴ صفحه',
pages_en: '4 Pages',
is_featured: 0,
cover_image: '/technogen/tgn_an_03_cover.png',
file_url: '/technogen/tgn_an_03.pdf',
sort_order: 4,
},
];
for (const item of INITIAL_TECHNOGEN_REPORTS) {
await pool.query(`
INSERT INTO technogen_reports (
id, code, tag_fa, tag_en, title_fa, title_en, subtitle_fa, subtitle_en,
description_fa, description_en, date_fa, date_en, pages_fa, pages_en,
is_featured, cover_image, file_url, sort_order
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
ON CONFLICT (id) DO NOTHING
`, [
item.id, item.code, item.tag_fa, item.tag_en, item.title_fa, item.title_en,
item.subtitle_fa, item.subtitle_en, item.description_fa, item.description_en,
item.date_fa, item.date_en, item.pages_fa, item.pages_en,
item.is_featured, item.cover_image, item.file_url, item.sort_order,
]);
}
}
export function normalizeMediaUrl(url) {
if (!url || typeof url !== 'string') return url;
const match = url.match(/^(?:https?:\/\/[^\/]+)?\/(media|uploads)\/(.+)$/i);
@ -850,3 +976,30 @@ export function rowToArticle(row) {
updatedAt: row.updated_at,
};
}
export function rowToTechnogenReport(row) {
if (!row) return null;
return {
id: row.id,
code: row.code,
tagFa: row.tag_fa,
tagEn: row.tag_en,
titleFa: row.title_fa,
titleEn: row.title_en,
subtitleFa: row.subtitle_fa,
subtitleEn: row.subtitle_en,
descriptionFa: row.description_fa,
descriptionEn: row.description_en,
dateFa: row.date_fa,
dateEn: row.date_en,
pagesFa: row.pages_fa,
pagesEn: row.pages_en,
isFeatured: !!row.is_featured,
coverImage: normalizeMediaUrl(row.cover_image),
fileUrl: normalizeMediaUrl(row.file_url),
sortOrder: row.sort_order,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}

View File

@ -2,11 +2,11 @@ const $ = (sel, root = document) => root.querySelector(sel);
const root = $('#app');
const TAB_GROUPS = [
{ label: 'محتوا', tabs: ['glance', 'radarFuture', 'factoryReports', 'media', 'events', 'banners'] },
{ label: 'محتوا', tabs: ['glance', 'radarFuture', 'technogen', 'factoryReports', 'media', 'events', 'banners'] },
{ label: 'کاربران', tabs: ['members', 'newsletter'] },
];
const TAB_LABELS = {
glance: 'در یک نگاه', radarFuture: 'رادار آینده', factoryReports: 'ماهنامه کارخانه', media: 'ویدیوکست و پادکست',
glance: 'در یک نگاه', radarFuture: 'رادار آینده', technogen: 'تکنوژن', factoryReports: 'ماهنامه کارخانه', media: 'ویدیوکست و پادکست',
events: 'رویدادها', banners: 'بنرها',
members: 'کاربران سایت', newsletter: 'خبرنامه',
};
@ -184,6 +184,7 @@ function wireTabs() {
const t = btn.dataset.tab;
if (t === 'glance') renderRadarList();
else if (t === 'radarFuture') renderRadarFutureList();
else if (t === 'technogen') renderTechnogenList();
else if (t === 'factoryReports') renderFactoryReportsList();
else if (t === 'media') renderMediaList();
else if (t === 'events') renderEventsList();
@ -2052,6 +2053,167 @@ function renderFactoryReportEditor(report) {
});
}
// ═══════════════════════════════════════════════
// TECHNOGEN (تکنوژن؛ برنامه توسعه‌گر ملی فناوری)
// ═══════════════════════════════════════════════
async function renderTechnogenList() {
root.innerHTML = `
${topbarHtml('technogen', '+ گزارش جدید تکنوژن')}
<main class="page">
<h2>تکنوژن؛ برنامه توسعهگر ملی فناوری</h2>
<p class="muted" style="margin:-4px 0 12px">مدیریت گزارشها، مطالعات تطبیقی و یادداشتهای تحلیلی تکنوژن همراه با فایلهای PDF.</p>
<div id="list" class="list"><p class="muted">در حال بارگذاری</p></div>
</main>`;
wireTabs();
$('#newBtn').addEventListener('click', () => renderTechnogenEditor(null));
try {
const items = await api('/api/technogen?limit=100');
if (!items.length) { $('#list').innerHTML = `<p class="muted">هنوز گزارشی ثبت نشده است.</p>`; return; }
$('#list').innerHTML = items.map(r => `
<article class="row" data-id="${escapeAttr(r.id)}">
<div class="row-cover" style="background-image:url('${escapeAttr(r.coverImage||'')}');background-size:contain;background-repeat:no-repeat;background-position:center;background-color:#032340;border-radius:6px;width:70px;height:90px;flex-shrink:0;"></div>
<div class="row-main" style="margin-inline-start:12px">
<div class="row-meta">
${r.isFeatured ? '<span class="pill" style="background:#CD9E53;color:#032340;font-weight:bold">گزارش شاخص</span>' : ''}
${r.code ? `<span class="pill" style="direction:ltr">${escapeHtml(r.code)}</span>` : ''}
${r.tagFa ? `<span class="pill">${escapeHtml(r.tagFa)}</span>` : ''}
${r.pagesFa ? `<span class="muted">${escapeHtml(r.pagesFa)}</span>` : ''}
${r.dateFa ? `<span class="muted">${escapeHtml(r.dateFa)}</span>` : ''}
</div>
<h3 style="margin:4px 0 2px">${escapeHtml(r.titleFa)}</h3>
${r.subtitleFa ? `<p class="muted clamp-2" style="font-size:13px">${escapeHtml(r.subtitleFa)}</p>` : ''}
${r.fileUrl ? `<div style="margin-top:4px"><a href="${escapeAttr(r.fileUrl)}" target="_blank" rel="noopener noreferrer" style="font-size:12px;color:#00AEEF;text-decoration:none">📄 دانلود / مشاهده فایل PDF</a></div>` : ''}
</div>
<div class="row-actions">
<button data-action="edit">ویرایش</button>
<button data-action="delete" class="danger">حذف</button>
</div>
</article>`).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') renderTechnogenEditor(await api(`/api/technogen/${id}`));
else if (btn.dataset.action === 'delete') {
if (!confirm('این گزارش تکنوژن حذف شود؟')) return;
await api(`/api/technogen/${id}`, { method: 'DELETE' });
renderTechnogenList();
}
});
} catch (err) { $('#list').innerHTML = `<p class="error">خطا: ${escapeHtml(err.message)}</p>`; }
}
function renderTechnogenEditor(report) {
const r = report || {
id: '', code: 'TGN-AN-0' + (Math.floor(Math.random() * 90) + 10),
tagFa: 'یادداشت تحلیلی', tagEn: 'Analytical Note',
titleFa: '', titleEn: '',
subtitleFa: '', subtitleEn: '',
descriptionFa: '', descriptionEn: '',
dateFa: 'مرداد ۱۴۰۵', dateEn: 'August 2026',
pagesFa: '۴ صفحه', pagesEn: '4 Pages',
isFeatured: false, coverImage: '', fileUrl: '', sortOrder: 0
};
const isEdit = !!report;
root.innerHTML = `
<header class="topbar">
<div class="brand">${isEdit ? 'ویرایش گزارش تکنوژن' : 'گزارش جدید تکنوژن'}</div>
<div class="actions"><button id="backBtn" class="ghost"> بازگشت</button></div>
</header>
<main class="page editor">
<form id="editorForm" class="grid">
<label>کد سند (مثلاً TGN-CS-01 یا TGN-AN-01) <input name="code" value="${escapeAttr(r.code||'')}" required style="direction:ltr" /></label>
<label class="checkbox" style="align-self:flex-end;padding-bottom:10px">
<input type="checkbox" name="isFeatured" ${r.isFeatured ? 'checked' : ''} />
<span> گزارش شاخص و ویژه (مطالعه تطبیقی اصلی)</span>
</label>
<label>عنوان فارسی <input name="titleFa" value="${escapeAttr(r.titleFa||'')}" required placeholder="عنوان گزارش تکنوژن…" /></label>
<label>عنوان انگلیسی <input name="titleEn" value="${escapeAttr(r.titleEn||'')}" placeholder="English Title…" style="direction:ltr" /></label>
<label>برچسب فارسی <input name="tagFa" value="${escapeAttr(r.tagFa||'')}" placeholder="مطالعه تطبیقی یا یادداشت تحلیلی" /></label>
<label>برچسب انگلیسی <input name="tagEn" value="${escapeAttr(r.tagEn||'')}" placeholder="Comparative Study / Analytical Note" style="direction:ltr" /></label>
<label>تاریخ فارسی <input name="dateFa" value="${escapeAttr(r.dateFa||'')}" placeholder="مرداد ۱۴۰۵" /></label>
<label>تاریخ انگلیسی <input name="dateEn" value="${escapeAttr(r.dateEn||'')}" placeholder="August 2026" style="direction:ltr" /></label>
<label>تعداد صفحات فارسی <input name="pagesFa" value="${escapeAttr(r.pagesFa||'')}" placeholder="۳۰ صفحه" /></label>
<label>تعداد صفحات انگلیسی <input name="pagesEn" value="${escapeAttr(r.pagesEn||'')}" placeholder="30 Pages" style="direction:ltr" /></label>
<label>ترتیب نمایش <input name="sortOrder" type="number" value="${r.sortOrder||0}" /></label>
<div></div>
<div class="col span-2">
<label style="display:block;margin-bottom:6px">تصویر کاور گزارش</label>
<input type="file" id="technogenCoverFile" accept="image/*" style="display:block;margin-bottom:8px" />
<input type="text" name="coverImage" value="${escapeAttr(r.coverImage||'')}" placeholder="آدرس تصویر (یا از دکمه بالا آپلود کنید)" style="width:100%" />
<img id="technogenCoverPreview" alt="" src="${escapeAttr(r.coverImage||'')}" style="${r.coverImage?'':'display:none'};max-width:220px;max-height:160px;border-radius:6px;margin-top:8px;border:1px solid #ccc;background:#032340;padding:2px" />
</div>
<div class="col span-2">
<label style="display:block;margin-bottom:6px">فایل گزارش (PDF) آپلود مستقیم به استوریج</label>
<input type="file" id="technogenPdfFile" accept="application/pdf" style="display:block;margin-bottom:8px" />
<input type="text" name="fileUrl" value="${escapeAttr(r.fileUrl||'')}" placeholder="آدرس فایل PDF (یا از دکمه بالا آپلود کنید)" style="width:100%;direction:ltr" />
<span id="technogenPdfStatus" class="muted" style="display:block;font-size:12px;margin-top:6px"></span>
</div>
<div class="col span-2"><label>زیرعنوان / پیام محوری (فارسی) <textarea name="subtitleFa" rows="3" placeholder="توضیح یا شعار محوری روی جلد…">${escapeHtml(r.subtitleFa||'')}</textarea></label></div>
<div class="col span-2"><label>زیرعنوان / پیام محوری (انگلیسی) <textarea name="subtitleEn" rows="3" style="direction:ltr">${escapeHtml(r.subtitleEn||'')}</textarea></label></div>
<div class="col span-2"><label>شرح تفصیلی یا نکات کلیدی (فارسی) <textarea name="descriptionFa" rows="4">${escapeHtml(r.descriptionFa||'')}</textarea></label></div>
<div class="col span-2"><label>شرح تفصیلی یا نکات کلیدی (انگلیسی) <textarea name="descriptionEn" rows="4" style="direction:ltr">${escapeHtml(r.descriptionEn||'')}</textarea></label></div>
<div class="col span-2 row-end"><button type="submit" class="primary">${isEdit ? 'ذخیره تغییرات' : 'ایجاد گزارش'}</button></div>
</form>
</main>`;
$('#backBtn').addEventListener('click', () => renderTechnogenList());
document.getElementById('technogenCoverFile').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('technogenCoverPreview');
if (prev) { prev.src = url; prev.style.display = ''; }
} catch (err) { alert('آپلود تصویر ناموفق: ' + err.message); }
});
document.getElementById('technogenPdfFile').addEventListener('change', async e => {
const file = e.target.files?.[0]; if (!file) return;
const status = document.getElementById('technogenPdfStatus');
if (status) status.textContent = 'در حال آپلود فایل PDF…';
const fd = new FormData(); fd.append('file', file);
try {
const { url } = await api('/api/uploads/raw', { method:'POST', body:fd, isForm:true });
$('input[name="fileUrl"]').value = url;
if (status) status.textContent = '✓ فایل PDF با موفقیت آپلود شد: ' + url;
} catch (err) { if (status) status.textContent = ''; alert('آپلود PDF ناموفق: ' + err.message); }
});
$('#editorForm').addEventListener('submit', async ev => {
ev.preventDefault();
const fd = new FormData(ev.target);
const payload = {
code: fd.get('code') || null,
isFeatured: fd.get('isFeatured') === 'on',
titleFa: fd.get('titleFa'), titleEn: fd.get('titleEn') || null,
tagFa: fd.get('tagFa') || null, tagEn: fd.get('tagEn') || null,
dateFa: fd.get('dateFa') || null, dateEn: fd.get('dateEn') || null,
pagesFa: fd.get('pagesFa') || null, pagesEn: fd.get('pagesEn') || null,
coverImage: fd.get('coverImage') || null,
fileUrl: fd.get('fileUrl') || null,
subtitleFa: fd.get('subtitleFa') || null, subtitleEn: fd.get('subtitleEn') || null,
descriptionFa: fd.get('descriptionFa') || null, descriptionEn: fd.get('descriptionEn') || null,
sortOrder: Number(fd.get('sortOrder') || 0),
};
try {
if (isEdit) await api(`/api/technogen/${report.id}`, { method:'PUT', body:payload });
else await api('/api/technogen', { method:'POST', body:payload });
renderTechnogenList();
} catch (err) { alert('ذخیره ناموفق: ' + err.message); }
});
}
// ═══════════════════════════════════════════════
// INTEGRATIONS (منابع داده)
// ═══════════════════════════════════════════════

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

@ -16,7 +16,7 @@ import fs from 'node:fs';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import { randomInt, createHmac, randomUUID } from 'node:crypto';
import { db, rowToArticle, rowToRiskSignal, rowToPrice, rowToEvent, rowToTeamMember, rowToPlan, rowToBanner, rowToRadarItem, rowToRadarPage, rowToFactoryReport, rowToIntegration, rowToInstituteStat, rowToMarketPrice, rowToMarketChartPoint, rowToVisionItem, rowToAdvisoryMember } from './db.js';
import { db, rowToArticle, rowToRiskSignal, rowToPrice, rowToEvent, rowToTeamMember, rowToPlan, rowToBanner, rowToRadarItem, rowToRadarPage, rowToFactoryReport, rowToTechnogenReport, rowToIntegration, rowToInstituteStat, rowToMarketPrice, rowToMarketChartPoint, rowToVisionItem, rowToAdvisoryMember } from './db.js';
import { getJob, listJobs, runGenerator } from './content-generator.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@ -1134,6 +1134,81 @@ app.delete('/api/factory-reports/:id', authRequired, async (req, res) => {
res.status(204).end();
});
// ---------- technogen reports ----------
const TECHNOGEN_FIELDS = `id, code, tag_fa, tag_en, title_fa, title_en, subtitle_fa, subtitle_en,
description_fa, description_en, date_fa, date_en, pages_fa, pages_en,
is_featured, cover_image, file_url, sort_order, created_at, updated_at`;
app.get('/api/technogen', async (req, res) => {
const limit = Math.min(Number(req.query.limit) || 50, 200);
const rows = await db.prepare(`SELECT ${TECHNOGEN_FIELDS} FROM technogen_reports ORDER BY is_featured DESC, sort_order ASC, created_at DESC LIMIT ?`).all(limit);
res.json(rows.map(rowToTechnogenReport));
});
app.get('/api/technogen/:id', async (req, res) => {
const row = await db.prepare(`SELECT ${TECHNOGEN_FIELDS} FROM technogen_reports WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToTechnogenReport(row));
});
function normalizeTechnogenBody(b) {
return {
code: b.code ? String(b.code).trim() : null,
tag_fa: b.tagFa || null,
tag_en: b.tagEn || null,
title_fa: String(b.titleFa || '').trim(),
title_en: b.titleEn || null,
subtitle_fa: b.subtitleFa || null,
subtitle_en: b.subtitleEn || null,
description_fa: b.descriptionFa || null,
description_en: b.descriptionEn || null,
date_fa: b.dateFa || null,
date_en: b.dateEn || null,
pages_fa: b.pagesFa || null,
pages_en: b.pagesEn || null,
is_featured: b.isFeatured ? 1 : 0,
cover_image: b.coverImage || null,
file_url: b.fileUrl || null,
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/technogen', authRequired, async (req, res) => {
const r = normalizeTechnogenBody(req.body || {});
if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' });
const id = req.body.id ? String(req.body.id).trim().toLowerCase() : nanoid(14);
await db.prepare(`INSERT INTO technogen_reports (
id, code, tag_fa, tag_en, title_fa, title_en, subtitle_fa, subtitle_en,
description_fa, description_en, date_fa, date_en, pages_fa, pages_en,
is_featured, cover_image, file_url, sort_order
) VALUES (
@id, @code, @tag_fa, @tag_en, @title_fa, @title_en, @subtitle_fa, @subtitle_en,
@description_fa, @description_en, @date_fa, @date_en, @pages_fa, @pages_en,
@is_featured, @cover_image, @file_url, @sort_order
)`).run({ id, ...r });
res.status(201).json(rowToTechnogenReport(await db.prepare(`SELECT ${TECHNOGEN_FIELDS} FROM technogen_reports WHERE id = ?`).get(id)));
});
app.put('/api/technogen/:id', authRequired, async (req, res) => {
if (!await db.prepare('SELECT id FROM technogen_reports WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
const r = normalizeTechnogenBody(req.body || {});
if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' });
await db.prepare(`UPDATE technogen_reports SET
code=@code, tag_fa=@tag_fa, tag_en=@tag_en, title_fa=@title_fa, title_en=@title_en,
subtitle_fa=@subtitle_fa, subtitle_en=@subtitle_en, description_fa=@description_fa, description_en=@description_en,
date_fa=@date_fa, date_en=@date_en, pages_fa=@pages_fa, pages_en=@pages_en,
is_featured=@is_featured, cover_image=@cover_image, file_url=@file_url,
sort_order=@sort_order, updated_at=now() WHERE id=@id
`).run({ id: req.params.id, ...r });
res.json(rowToTechnogenReport(await db.prepare(`SELECT ${TECHNOGEN_FIELDS} FROM technogen_reports WHERE id = ?`).get(req.params.id)));
});
app.delete('/api/technogen/:id', authRequired, async (req, res) => {
const info = await db.prepare('DELETE FROM technogen_reports WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- integrations ----------
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'];