feat: update and harden steel foresight
This commit is contained in:
parent
03405e3f5d
commit
c9e6be287e
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Product
|
||||||
|
|
||||||
|
## Register
|
||||||
|
|
||||||
|
product
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
Steel-industry analysts, managers, and institute members who need fast, credible market and risk intelligence. They use the frontend as a Persian-first research portal and the dashboard as a scanning surface for world, industry, pricing, competitor, and strategic signals.
|
||||||
|
|
||||||
|
## Product Purpose
|
||||||
|
|
||||||
|
The product helps users monitor steel-sector change, understand risks and opportunities, and move from broad market signals to sharper decisions. Success means the interface feels trustworthy, dense enough for expert work, and easy to scan without decorative friction.
|
||||||
|
|
||||||
|
## Brand Personality
|
||||||
|
|
||||||
|
Analytical, authoritative, restrained. The experience should feel like a serious research desk: calm, precise, and current.
|
||||||
|
|
||||||
|
## Anti-references
|
||||||
|
|
||||||
|
Avoid generic SaaS dashboards, decorative card piles, overdone glass effects, playful motion, weak gray text, and controls that look novel but are hard to understand. Avoid making data-heavy surfaces feel like a marketing landing page.
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
Prioritize scanning over spectacle.
|
||||||
|
|
||||||
|
Use brand color to clarify state, not decorate inactive UI.
|
||||||
|
|
||||||
|
Keep Persian RTL reading comfortable with strong hierarchy and stable spacing.
|
||||||
|
|
||||||
|
Make interaction states obvious: active, hover, focus, disabled, and reduced-motion behavior should all be intentional.
|
||||||
|
|
||||||
|
Preserve expert trust through restraint, consistency, and clean data presentation.
|
||||||
|
|
||||||
|
## Accessibility & Inclusion
|
||||||
|
|
||||||
|
Target WCAG AA contrast for text and controls. Respect reduced motion. Do not rely on color alone for active state. Preserve keyboard-accessible controls and visible focus states.
|
||||||
|
|
@ -33,22 +33,53 @@ server {
|
||||||
location /assets/ {
|
location /assets/ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
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;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Never cache index.html (so deploys take effect immediately)
|
# Never cache index.html (so deploys take effect immediately)
|
||||||
location = /index.html {
|
location = /index.html {
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
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 X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Never let the SPA fallback turn hidden files such as /.env into a 200.
|
||||||
|
location ~ /\.(?!well-known(?:/|$)) {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Same-origin proxy for CMS audio/video files. Keeping Range headers is
|
||||||
|
# required for seeking and reliable HTMLMediaElement playback.
|
||||||
|
location /media/ {
|
||||||
|
proxy_pass https://cms-steelforesight.liara.run;
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
proxy_set_header Host cms-steelforesight.liara.run;
|
||||||
|
proxy_set_header Range $http_range;
|
||||||
|
proxy_set_header If-Range $http_if_range;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
# SPA history-fallback
|
# SPA history-fallback
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Security headers
|
# Security headers
|
||||||
add_header X-Frame-Options "SAMEORIGIN" 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 X-Frame-Options "DENY" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|
|
||||||
BIN
frontend/public/hero-bg.mp4 (Stored with Git LFS)
BIN
frontend/public/hero-bg.mp4 (Stored with Git LFS)
Binary file not shown.
|
|
@ -10,7 +10,25 @@ function scrollToTop() {
|
||||||
/* curved top edge with a centered scroll-to-top button (matches the Figma) */
|
/* curved top edge with a centered scroll-to-top button (matches the Figma) */
|
||||||
function FooterCurve() {
|
function FooterCurve() {
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', height: 64, lineHeight: 0, background: 'var(--paper, #fff)' }}>
|
<div className="footer-curve" style={{ position: 'relative', height: 64, lineHeight: 0, background: 'var(--paper, #fff)' }}>
|
||||||
|
<style>{`
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.footer-curve {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 76px !important;
|
||||||
|
background: linear-gradient(to bottom, var(--paper, #fff) 0 48px, #032340 48px 100%) !important;
|
||||||
|
}
|
||||||
|
.footer-curve > svg { display: none !important; }
|
||||||
|
.footer-scroll-top {
|
||||||
|
top: 5px !important;
|
||||||
|
left: 50% !important;
|
||||||
|
width: 44px !important;
|
||||||
|
height: 44px !important;
|
||||||
|
border: 3px solid var(--paper, #fff) !important;
|
||||||
|
box-shadow: 0 7px 20px rgba(3,35,64,.22) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
<svg viewBox="0 0 1440 64" preserveAspectRatio="none" width="100%" height="64" style={{ display: 'block' }}>
|
<svg viewBox="0 0 1440 64" preserveAspectRatio="none" width="100%" height="64" style={{ display: 'block' }}>
|
||||||
<path
|
<path
|
||||||
d="M0,0 L132,0 C192,0 206,52 240,52 C274,52 288,0 348,0 L1440,0 L1440,64 L0,64 Z"
|
d="M0,0 L132,0 C192,0 206,52 240,52 C274,52 288,0 348,0 L1440,0 L1440,64 L0,64 Z"
|
||||||
|
|
@ -19,6 +37,7 @@ function FooterCurve() {
|
||||||
</svg>
|
</svg>
|
||||||
{/* scroll-to-top button sitting in the dip (left side) */}
|
{/* scroll-to-top button sitting in the dip (left side) */}
|
||||||
<button
|
<button
|
||||||
|
className="footer-scroll-top"
|
||||||
onClick={scrollToTop}
|
onClick={scrollToTop}
|
||||||
aria-label="بازگشت به بالا"
|
aria-label="بازگشت به بالا"
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import Contact from '@/pages/Contact/Contact'
|
||||||
import About from '@/pages/About/About'
|
import About from '@/pages/About/About'
|
||||||
import AuthPage from '@/pages/Auth/AuthPage'
|
import AuthPage from '@/pages/Auth/AuthPage'
|
||||||
import ProfilePage from '@/pages/Profile/ProfilePage'
|
import ProfilePage from '@/pages/Profile/ProfilePage'
|
||||||
import Podcast from '@/pages/Podcast/Podcast'
|
import Podcast from '@/pages/Podcast/PodcastFigma'
|
||||||
import SpecializedMeetings from '@/pages/SpecializedMeetings/SpecializedMeetings'
|
import SpecializedMeetings from '@/pages/SpecializedMeetings/SpecializedMeetings'
|
||||||
import SteelDashboard from '@/pages/SteelDashboard/SteelDashboard'
|
import SteelDashboard from '@/pages/SteelDashboard/SteelDashboard'
|
||||||
import SearchResults from '@/pages/Search/SearchResults'
|
import SearchResults from '@/pages/Search/SearchResults'
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'
|
||||||
|
import { Pause, Play, Volume2, VolumeX } from 'lucide-react'
|
||||||
|
|
||||||
|
const NAVY = '#032340'
|
||||||
|
const GOLD = '#CD9E53'
|
||||||
|
|
||||||
|
function createWaveform(seedValue: string | number, count = 72) {
|
||||||
|
let seed = String(seedValue).split('').reduce((sum, char) => sum + char.charCodeAt(0), 17)
|
||||||
|
return Array.from({ length: count }, (_, index) => {
|
||||||
|
seed = (seed * 9301 + 49297) % 233280
|
||||||
|
const random = seed / 233280
|
||||||
|
const envelope = 0.48 + 0.27 * Math.sin(index * 0.31) + 0.15 * Math.sin(index * 0.83)
|
||||||
|
return Math.max(0.18, Math.min(0.96, envelope + random * 0.3))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(seconds: number) {
|
||||||
|
if (!Number.isFinite(seconds) || seconds < 0) return '0:00'
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
return `${minutes}:${String(Math.floor(seconds % 60)).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaWavePlayerProps = {
|
||||||
|
src: string
|
||||||
|
episodeKey: string | number
|
||||||
|
tone?: 'light' | 'dark'
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MediaWavePlayer({ src, episodeKey, tone = 'dark', compact = false }: MediaWavePlayerProps) {
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null)
|
||||||
|
const [playing, setPlaying] = useState(false)
|
||||||
|
const [muted, setMuted] = useState(false)
|
||||||
|
const [current, setCurrent] = useState(0)
|
||||||
|
const [duration, setDuration] = useState(0)
|
||||||
|
const [failed, setFailed] = useState(false)
|
||||||
|
const bars = useMemo(() => createWaveform(episodeKey), [episodeKey])
|
||||||
|
const progress = duration > 0 ? Math.min(current / duration, 1) : 0
|
||||||
|
const dark = tone === 'dark'
|
||||||
|
const ink = dark ? '#fff' : NAVY
|
||||||
|
const mutedInk = dark ? 'rgba(255,255,255,0.52)' : 'rgba(3,35,64,0.56)'
|
||||||
|
const idleBar = dark ? 'rgba(255,255,255,0.18)' : 'rgba(3,35,64,0.16)'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio) return
|
||||||
|
audio.pause()
|
||||||
|
audio.currentTime = 0
|
||||||
|
audio.muted = false
|
||||||
|
setPlaying(false)
|
||||||
|
setMuted(false)
|
||||||
|
setCurrent(0)
|
||||||
|
setDuration(0)
|
||||||
|
setFailed(false)
|
||||||
|
audio.load()
|
||||||
|
}, [src, episodeKey])
|
||||||
|
|
||||||
|
const togglePlayback = async () => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio || failed) return
|
||||||
|
if (!audio.paused) {
|
||||||
|
audio.pause()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await audio.play()
|
||||||
|
} catch {
|
||||||
|
setFailed(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seekToRatio = (ratio: number) => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio || !duration) return
|
||||||
|
audio.currentTime = Math.max(0, Math.min(1, ratio)) * duration
|
||||||
|
setCurrent(audio.currentTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
const seekFromPointer = (event: MouseEvent<HTMLDivElement>) => {
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect()
|
||||||
|
seekToRatio((event.clientX - rect.left) / rect.width)
|
||||||
|
}
|
||||||
|
|
||||||
|
const seekFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio || !duration || !['ArrowRight', 'ArrowLeft'].includes(event.key)) return
|
||||||
|
event.preventDefault()
|
||||||
|
const delta = event.key === 'ArrowRight' ? 5 : -5
|
||||||
|
audio.currentTime = Math.max(0, Math.min(duration, audio.currentTime + delta))
|
||||||
|
setCurrent(audio.currentTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio) return
|
||||||
|
audio.muted = !audio.muted
|
||||||
|
setMuted(audio.muted)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '100%', minWidth: 0, display: 'flex', gap: compact ? 12 : 10,
|
||||||
|
flexDirection: compact ? 'row-reverse' : 'column',
|
||||||
|
alignItems: compact ? 'center' : undefined,
|
||||||
|
direction: compact ? 'ltr' : undefined,
|
||||||
|
}}>
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={src}
|
||||||
|
preload="metadata"
|
||||||
|
onLoadedMetadata={(event) => setDuration(Number.isFinite(event.currentTarget.duration) ? event.currentTarget.duration : 0)}
|
||||||
|
onDurationChange={(event) => setDuration(Number.isFinite(event.currentTarget.duration) ? event.currentTarget.duration : 0)}
|
||||||
|
onTimeUpdate={(event) => setCurrent(event.currentTarget.currentTime)}
|
||||||
|
onPlay={() => setPlaying(true)}
|
||||||
|
onPause={() => setPlaying(false)}
|
||||||
|
onEnded={() => { setPlaying(false); setCurrent(0) }}
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="slider"
|
||||||
|
aria-label="موقعیت پخش پادکست"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={Math.max(0, Math.round(duration))}
|
||||||
|
aria-valuenow={Math.round(current)}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={seekFromPointer}
|
||||||
|
onKeyDown={seekFromKeyboard}
|
||||||
|
style={{
|
||||||
|
height: compact ? 58 : 48,
|
||||||
|
flex: compact ? 1 : undefined,
|
||||||
|
minWidth: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
direction: 'ltr',
|
||||||
|
cursor: duration ? 'pointer' : 'default',
|
||||||
|
overflow: 'hidden',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bars.map((height, index) => {
|
||||||
|
const played = (index + 0.5) / bars.length <= progress
|
||||||
|
return (
|
||||||
|
<span key={index} style={{
|
||||||
|
flex: '1 1 0',
|
||||||
|
minWidth: 1,
|
||||||
|
maxWidth: compact ? 'none' : 6,
|
||||||
|
height: `${Math.round(height * (compact ? 52 : 42))}px`,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: played ? GOLD : idleBar,
|
||||||
|
transition: 'background-color 140ms ease',
|
||||||
|
}} />
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, direction: 'ltr', flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={togglePlayback}
|
||||||
|
disabled={failed}
|
||||||
|
aria-label={playing ? 'توقف پادکست' : 'پخش پادکست'}
|
||||||
|
style={{
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: failed ? idleBar : GOLD,
|
||||||
|
color: dark ? NAVY : '#fff',
|
||||||
|
cursor: failed ? 'not-allowed' : 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{playing
|
||||||
|
? <Pause size={17} fill="currentColor" />
|
||||||
|
: <Play size={17} fill="currentColor" style={{ marginLeft: 2 }} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!compact && (
|
||||||
|
<>
|
||||||
|
<span style={{ minWidth: 36, color: ink, fontSize: 11, fontWeight: 800 }}>{formatTime(current)}</span>
|
||||||
|
<div style={{ flex: 1, height: 3, borderRadius: 999, background: idleBar, overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${progress * 100}%`, height: '100%', background: GOLD, transition: 'width 120ms linear' }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ minWidth: 36, textAlign: 'right', color: mutedInk, fontSize: 11, fontWeight: 700 }}>{formatTime(duration)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleMute}
|
||||||
|
aria-label={muted ? 'فعال کردن صدا' : 'قطع صدا'}
|
||||||
|
style={{ border: 0, background: 'transparent', padding: 5, color: mutedInk, cursor: 'pointer', display: 'flex' }}
|
||||||
|
>
|
||||||
|
{muted ? <VolumeX size={17} /> : <Volume2 size={17} />}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{failed && (
|
||||||
|
<div style={{ color: dark ? '#fca5a5' : '#b42318', fontSize: 11, textAlign: 'right' }}>
|
||||||
|
فایل صوتی قابل پخش نیست. آدرس فایل یا فرمت آن را بررسی کنید.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,17 @@ const STRENGTH_FA: Record<string, string> = { weak: 'ضعیف', moderate: 'مت
|
||||||
type Strength = 'none' | 'weak' | 'moderate' | 'strong'
|
type Strength = 'none' | 'weak' | 'moderate' | 'strong'
|
||||||
const Strength = { None: 'none' as const, Weak: 'weak' as const, Moderate: 'moderate' as const, Strong: 'strong' as const }
|
const Strength = { None: 'none' as const, Weak: 'weak' as const, Moderate: 'moderate' as const, Strong: 'strong' as const }
|
||||||
|
|
||||||
interface FinancialScoreProps { title: string; description: string; initialScore?: number }
|
interface FinancialScoreDetailSource { label: string; url: string; note: string }
|
||||||
|
interface FinancialScoreProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
initialScore?: number;
|
||||||
|
meaning?: string;
|
||||||
|
basis?: string[];
|
||||||
|
implication?: string;
|
||||||
|
actions?: string[];
|
||||||
|
sources?: FinancialScoreDetailSource[];
|
||||||
|
}
|
||||||
interface FinancialScoreButtonProps { children?: React.ReactNode; isOutlined?: boolean; onClick?: () => void }
|
interface FinancialScoreButtonProps { children?: React.ReactNode; isOutlined?: boolean; onClick?: () => void }
|
||||||
interface FinancialScoreCardProps { children?: React.ReactNode }
|
interface FinancialScoreCardProps { children?: React.ReactNode }
|
||||||
interface FinancialScoreDisplayProps { value: Score; max: number }
|
interface FinancialScoreDisplayProps { value: Score; max: number }
|
||||||
|
|
@ -176,11 +186,13 @@ function FinancialScoreHeader({ title, strength }: FinancialScoreHeaderProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FinancialScore({ title, description, initialScore }: FinancialScoreProps) {
|
function FinancialScore({ title, description, initialScore, meaning, basis, implication, actions, sources }: FinancialScoreProps) {
|
||||||
const [score, setScore] = useState<Score>(initialScore ?? null)
|
const [score, setScore] = useState<Score>(initialScore ?? null)
|
||||||
|
const [showDetails, setShowDetails] = useState(false)
|
||||||
const hasScore = score !== null
|
const hasScore = score !== null
|
||||||
const max = 100
|
const max = 100
|
||||||
const strength = Utils.getStrength(score, max)
|
const strength = Utils.getStrength(score, max)
|
||||||
|
const hasDetails = Boolean(meaning || basis?.length || implication || actions?.length || sources?.length)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FinancialScoreCard>
|
<FinancialScoreCard>
|
||||||
|
|
@ -190,9 +202,70 @@ function FinancialScore({ title, description, initialScore }: FinancialScoreProp
|
||||||
<FinancialScoreDisplay value={score} max={max} />
|
<FinancialScoreDisplay value={score} max={max} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-center text-xs leading-relaxed mb-5 min-h-[3.5rem]">{description}</p>
|
<p className="text-muted-foreground text-center text-xs leading-relaxed mb-5 min-h-[3.5rem]">{description}</p>
|
||||||
<FinancialScoreButton isOutlined={hasScore} onClick={() => !hasScore && setScore(Utils.randomInt(0, max))}>
|
<FinancialScoreButton
|
||||||
{hasScore ? 'جزئیات بیشتر' : 'محاسبه امتیاز'}
|
isOutlined={hasScore}
|
||||||
|
onClick={() => {
|
||||||
|
if (!hasScore) setScore(Utils.randomInt(0, max))
|
||||||
|
if (hasDetails) setShowDetails((value) => !value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasScore ? (showDetails ? 'بستن جزئیات' : 'جزئیات بیشتر') : 'محاسبه امتیاز'}
|
||||||
</FinancialScoreButton>
|
</FinancialScoreButton>
|
||||||
|
{showDetails && hasDetails && (
|
||||||
|
<div className="mt-5 rounded-xl border border-slate-200 bg-slate-50 p-4 text-right animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||||
|
{meaning && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="mb-1 text-[11px] font-black" style={{ color: '#032340' }}>این شاخص چه چیزی را نشان میدهد؟</div>
|
||||||
|
<p className="text-[11px] leading-7 text-slate-700">{meaning}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{basis && basis.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="mb-2 text-[11px] font-black" style={{ color: '#032340' }}>دادههای مبنا</div>
|
||||||
|
<ul className="space-y-2 pr-4 text-[11px] leading-7 text-slate-700">
|
||||||
|
{basis.map((item) => <li key={item} className="list-disc">{item}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{implication && (
|
||||||
|
<div className="mb-4 rounded-lg border border-blue-100 bg-white p-3">
|
||||||
|
<div className="mb-1 text-[11px] font-black text-blue-800">برداشت برای فولاد مبارکه</div>
|
||||||
|
<p className="text-[11px] leading-7 text-slate-800">{implication}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actions && actions.length > 0 && (
|
||||||
|
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 p-3">
|
||||||
|
<div className="mb-2 text-[11px] font-black text-amber-800">نیاز به اقدام</div>
|
||||||
|
<ul className="space-y-2 pr-4 text-[11px] leading-7 text-slate-800">
|
||||||
|
{actions.map((item) => <li key={item} className="list-disc">{item}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sources && sources.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-[11px] font-black" style={{ color: '#032340' }}>منابع و گزارشها</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{sources.map((source) => (
|
||||||
|
<a
|
||||||
|
key={source.url}
|
||||||
|
href={source.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-lg border border-slate-200 bg-white px-3 py-2 no-underline transition-colors hover:border-blue-300"
|
||||||
|
>
|
||||||
|
<div className="text-[11px] font-bold leading-6 text-blue-800">{source.label}</div>
|
||||||
|
<div className="text-[10px] leading-6 text-slate-500">{source.note}</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</FinancialScoreCard>
|
</FinancialScoreCard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,12 @@ interface WordProps {
|
||||||
const Word: React.FC<WordProps> = ({ children, progress, range }) => {
|
const Word: React.FC<WordProps> = ({ children, progress, range }) => {
|
||||||
const opacity = useTransform(progress, range, [0, 1])
|
const opacity = useTransform(progress, range, [0, 1])
|
||||||
return (
|
return (
|
||||||
<span style={{ position: 'relative', marginInlineEnd: '0.28em' }}>
|
<>
|
||||||
<span style={{ position: 'absolute', inset: 0, opacity: 0.18 }}>{children}</span>
|
<span style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
<motion.span style={{ opacity }}>{children}</motion.span>
|
<span style={{ position: 'absolute', inset: 0, opacity: 0.18 }}>{children}</span>
|
||||||
</span>
|
<motion.span style={{ opacity }}>{children}</motion.span>
|
||||||
|
</span>{' '}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,7 +47,7 @@ export const MagicText: React.FC<MagicTextProps> = ({ text, dir = 'rtl', style,
|
||||||
ref={container}
|
ref={container}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
className={className}
|
className={className}
|
||||||
style={{ display: 'flex', flexWrap: 'wrap', textAlign: 'justify', ...style }}
|
style={{ display: 'block', width: '100%', textAlign: 'justify', ...style }}
|
||||||
>
|
>
|
||||||
{words.map((word, i) => {
|
{words.map((word, i) => {
|
||||||
const start = i / words.length
|
const start = i / words.length
|
||||||
|
|
@ -92,7 +94,7 @@ export const MagicTextGroup: React.FC<MagicTextGroupProps> = ({ paragraphs, dir
|
||||||
key={pi}
|
key={pi}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
className={className}
|
className={className}
|
||||||
style={{ display: 'flex', flexWrap: 'wrap', textAlign: 'justify', ...style }}
|
style={{ display: 'block', width: '100%', textAlign: 'justify', ...style }}
|
||||||
>
|
>
|
||||||
{words.map((word, wi) => {
|
{words.map((word, wi) => {
|
||||||
const start = globalIndex / totalWords
|
const start = globalIndex / totalWords
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ export type RFBlock = { type: 'text' | 'image'; value?: string; url?: string; ca
|
||||||
export type RadarFutureArticle = {
|
export type RadarFutureArticle = {
|
||||||
id: string
|
id: string
|
||||||
category: string // one of the 5 sub-menu labels
|
category: string // one of the 5 sub-menu labels
|
||||||
|
featured: boolean
|
||||||
|
createdAt: string
|
||||||
img: string
|
img: string
|
||||||
tags: string[]
|
tags: string[]
|
||||||
sources: { title: string; url: string }[]
|
sources: { title: string; url: string }[]
|
||||||
|
|
@ -37,7 +39,9 @@ export const RADAR_SLUG_LABEL: Record<string, string> = {
|
||||||
technology: 'فناوری و نوآوری',
|
technology: 'فناوری و نوآوری',
|
||||||
}
|
}
|
||||||
|
|
||||||
const PANEL_API = (import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || ''
|
const PANEL_API = import.meta.env.DEV
|
||||||
|
? ''
|
||||||
|
: ((import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || '')
|
||||||
|
|
||||||
export async function bootstrapRadarFuture(): Promise<void> {
|
export async function bootstrapRadarFuture(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -55,6 +59,8 @@ export async function bootstrapRadarFuture(): Promise<void> {
|
||||||
return {
|
return {
|
||||||
id: String(a.id),
|
id: String(a.id),
|
||||||
category: String(a.category ?? ''),
|
category: String(a.category ?? ''),
|
||||||
|
featured: Boolean(a.featured),
|
||||||
|
createdAt: String(a.createdAt ?? ''),
|
||||||
img: cover,
|
img: cover,
|
||||||
tags: Array.isArray(a.tags) ? (a.tags as string[]) : [],
|
tags: Array.isArray(a.tags) ? (a.tags as string[]) : [],
|
||||||
sources: Array.isArray(a.sources) ? (a.sources as { title: string; url: string }[]) : [],
|
sources: Array.isArray(a.sources) ? (a.sources as { title: string; url: string }[]) : [],
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ export interface Event {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
date: number;
|
date: number;
|
||||||
|
dateEnd?: number;
|
||||||
month: string;
|
month: string;
|
||||||
year: number;
|
year: number;
|
||||||
type: EventType;
|
type: EventType;
|
||||||
|
|
@ -14,6 +15,45 @@ export interface Event {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const events: Event[] = [
|
export const events: Event[] = [
|
||||||
|
{
|
||||||
|
id: 'smu-steel-summit-2026',
|
||||||
|
title: 'اجلاس فولاد SMU ۲۰۲۶',
|
||||||
|
date: 2,
|
||||||
|
dateEnd: 4,
|
||||||
|
month: 'شهریور',
|
||||||
|
year: 1405,
|
||||||
|
type: 'conference',
|
||||||
|
location: 'مرکز همایشهای بینالمللی جورجیا، آتلانتا، ایالات متحده',
|
||||||
|
city: 'آتلانتا',
|
||||||
|
registrationOpen: true,
|
||||||
|
description: 'گردهمایی مدیران بازار فولاد آمریکای شمالی با تمرکز بر قیمت، عرضه، تقاضا، تجارت و فناوریهای مؤثر بر زنجیره فولاد.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'china-international-steel-congress-2026',
|
||||||
|
title: 'پانزدهمین کنگره بینالمللی فولاد چین',
|
||||||
|
date: 28,
|
||||||
|
dateEnd: 29,
|
||||||
|
month: 'شهریور',
|
||||||
|
year: 1405,
|
||||||
|
type: 'conference',
|
||||||
|
location: 'شانگهای، چین',
|
||||||
|
city: 'شانگهای',
|
||||||
|
registrationOpen: true,
|
||||||
|
description: 'کنگره صنعت فولاد چین با محورهای ارتقای کیفیت، تحول دیجیتال، هوشمندسازی، توسعه کمکربن و همکاریهای بینالمللی.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'future-green-steel-2026',
|
||||||
|
title: 'اجلاس آینده فولاد سبز ۲۰۲۶',
|
||||||
|
date: 6,
|
||||||
|
dateEnd: 7,
|
||||||
|
month: 'آبان',
|
||||||
|
year: 1405,
|
||||||
|
type: 'conference',
|
||||||
|
location: 'لندن، بریتانیا',
|
||||||
|
city: 'لندن',
|
||||||
|
registrationOpen: true,
|
||||||
|
description: 'اجلاس مدیران صنعت فولاد، انرژی و سرمایهگذاری برای بررسی تولید فولاد کمکربن، هیدروژن، استانداردها و تأمین مالی پروژهها.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'iran-steel-congress-1403',
|
id: 'iran-steel-congress-1403',
|
||||||
title: 'کنگره بینالمللی فولاد ایران ۱۴۰۳',
|
title: 'کنگره بینالمللی فولاد ایران ۱۴۰۳',
|
||||||
|
|
@ -96,6 +136,31 @@ export const events: Event[] = [
|
||||||
|
|
||||||
const PANEL_API = (import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || ''
|
const PANEL_API = (import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || ''
|
||||||
|
|
||||||
|
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹'
|
||||||
|
const AR_DIGITS = '٠١٢٣٤٥٦٧٨٩'
|
||||||
|
|
||||||
|
const normalizeDigits = (value: unknown) => String(value ?? '').replace(/[۰-۹٠-٩]/g, (digit) => {
|
||||||
|
const faIndex = FA_DIGITS.indexOf(digit)
|
||||||
|
if (faIndex >= 0) return String(faIndex)
|
||||||
|
const arIndex = AR_DIGITS.indexOf(digit)
|
||||||
|
return arIndex >= 0 ? String(arIndex) : digit
|
||||||
|
})
|
||||||
|
|
||||||
|
const parseEventRange = (value: unknown): [number, number | undefined] => {
|
||||||
|
const candidates = normalizeDigits(value)
|
||||||
|
.match(/\d+/g)
|
||||||
|
?.map(Number)
|
||||||
|
.filter((number) => number >= 1 && number <= 31)
|
||||||
|
const start = candidates?.[0] ?? 1
|
||||||
|
const end = candidates && candidates.length > 1 ? candidates.at(-1) : undefined
|
||||||
|
return [start, end && end >= start ? end : undefined]
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseEventYear = (value: unknown) => {
|
||||||
|
const year = Number(normalizeDigits(value).match(/\d{4}/)?.[0])
|
||||||
|
return Number.isFinite(year) ? year : 1405
|
||||||
|
}
|
||||||
|
|
||||||
export async function bootstrapEvents(): Promise<void> {
|
export async function bootstrapEvents(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${PANEL_API}/api/events?limit=200`)
|
const res = await fetch(`${PANEL_API}/api/events?limit=200`)
|
||||||
|
|
@ -103,11 +168,14 @@ export async function bootstrapEvents(): Promise<void> {
|
||||||
const fetched = await res.json()
|
const fetched = await res.json()
|
||||||
if (!Array.isArray(fetched) || fetched.length === 0) return
|
if (!Array.isArray(fetched) || fetched.length === 0) return
|
||||||
events.length = 0
|
events.length = 0
|
||||||
events.push(...fetched.map((e: Record<string, unknown>) => ({
|
events.push(...fetched.map((e: Record<string, unknown>) => {
|
||||||
id: String(e.id), title: String(e.titleFa ?? ''), date: e.dateFa as number,
|
const [date, dateEnd] = parseEventRange(e.dateFa)
|
||||||
month: String(e.monthFa ?? ''), year: e.year as number, type: e.type as EventType,
|
return {
|
||||||
location: String(e.locationFa ?? ''), city: String(e.cityFa ?? ''),
|
id: String(e.id), title: String(e.titleFa ?? ''), date, dateEnd,
|
||||||
registrationOpen: Boolean(e.registrationOpen), description: String(e.descriptionFa ?? ''),
|
month: String(e.monthFa ?? ''), year: parseEventYear(e.year), type: e.type as EventType,
|
||||||
})))
|
location: String(e.locationFa ?? ''), city: String(e.cityFa ?? ''),
|
||||||
|
registrationOpen: Boolean(e.registrationOpen), description: String(e.descriptionFa ?? ''),
|
||||||
|
}
|
||||||
|
}))
|
||||||
} catch { /* panel offline → keep static fallback */ }
|
} catch { /* panel offline → keep static fallback */ }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export interface VideoItem {
|
||||||
date: string;
|
date: string;
|
||||||
thumb: string;
|
thumb: string;
|
||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PodcastItem {
|
export interface PodcastItem {
|
||||||
|
|
@ -32,6 +33,7 @@ export interface PodcastItem {
|
||||||
fa: MediaLangText & { ep: string };
|
fa: MediaLangText & { ep: string };
|
||||||
en: MediaLangText & { ep: string };
|
en: MediaLangText & { ep: string };
|
||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const videos: VideoItem[] = [
|
export const videos: VideoItem[] = [
|
||||||
|
|
@ -133,6 +135,7 @@ export const podcasts: PodcastItem[] = [
|
||||||
body: 'کاربردهای عملی یادگیری ماشین در بهینهسازی فرآیند ذوب، کنترل کیفیت و نگهداری پیشگیرانه.' },
|
body: 'کاربردهای عملی یادگیری ماشین در بهینهسازی فرآیند ذوب، کنترل کیفیت و نگهداری پیشگیرانه.' },
|
||||||
en: { title: 'AI on the Steel Production Line', ep: 'Episode 72', guest: 'Dr. Arash Karimi',
|
en: { title: 'AI on the Steel Production Line', ep: 'Episode 72', guest: 'Dr. Arash Karimi',
|
||||||
body: 'Practical applications of machine learning in melt optimization, quality control and predictive maintenance.' },
|
body: 'Practical applications of machine learning in melt optimization, quality control and predictive maintenance.' },
|
||||||
|
mediaUrl: '/media/8ldX5dfRQsE0.mp3',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -151,7 +154,8 @@ function articleToVideo(a: Record<string, unknown>, i: number): VideoItem {
|
||||||
duration: '',
|
duration: '',
|
||||||
date: String(a.publishDate ?? ''),
|
date: String(a.publishDate ?? ''),
|
||||||
thumb: String(a.coverImage ?? '') || '/news/photo-1504307651254-35680f356dfd.jpg',
|
thumb: String(a.coverImage ?? '') || '/news/photo-1504307651254-35680f356dfd.jpg',
|
||||||
mediaUrl: String(a.body ?? ''), // panel stores the media URL in the article body field
|
mediaUrl: proxiedMediaUrl(a.body), // panel stores the media URL in the article body field
|
||||||
|
tags: Array.isArray(a.tags) ? a.tags.map(String).filter(Boolean) : [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,20 +163,36 @@ function articleToPodcast(a: Record<string, unknown>, i: number): PodcastItem {
|
||||||
const title = String(a.title ?? '')
|
const title = String(a.title ?? '')
|
||||||
const guest = String(a.author ?? '')
|
const guest = String(a.author ?? '')
|
||||||
const body = String(a.summary ?? '')
|
const body = String(a.summary ?? '')
|
||||||
const ep = `قسمت ${i + 1}`
|
const episodeNumber = String(a.episodeNumber ?? '').trim() || String(i + 1)
|
||||||
|
const cleanEpisodeNumber = episodeNumber.replace(/^(اپیزود|قسمت|episode|ep\.?)[\s:#-]*/i, '').trim() || String(i + 1)
|
||||||
|
const ep = `قسمت ${cleanEpisodeNumber}`
|
||||||
return {
|
return {
|
||||||
id: String(a.id ?? i),
|
id: String(a.id ?? i),
|
||||||
epNum: `اپیزود ${i + 1}`,
|
epNum: `اپیزود ${cleanEpisodeNumber}`,
|
||||||
date: String(a.publishDate ?? ''),
|
date: String(a.publishDate ?? ''),
|
||||||
rating: '۵/۵',
|
rating: '۵/۵',
|
||||||
duration: '',
|
duration: '',
|
||||||
cover: String(a.coverImage ?? '') || '/news/photo-1540575467063-178a50c2df87.jpg',
|
cover: String(a.coverImage ?? '') || '/news/photo-1540575467063-178a50c2df87.jpg',
|
||||||
fa: { title, guest, body, ep },
|
fa: { title, guest, body, ep },
|
||||||
en: { title, guest, body, ep },
|
en: { title, guest, body, ep },
|
||||||
mediaUrl: String(a.body ?? ''), // panel stores the media URL in the article body field
|
mediaUrl: proxiedMediaUrl(a.body), // panel stores the media URL in the article body field
|
||||||
|
tags: Array.isArray(a.tags) ? a.tags.map(String).filter(Boolean) : [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function proxiedMediaUrl(value: unknown) {
|
||||||
|
const raw = String(value ?? '').trim()
|
||||||
|
if (!raw) return ''
|
||||||
|
try {
|
||||||
|
const url = new URL(raw)
|
||||||
|
const mediaHosts = new Set(['cms-steelforesight.liara.run', 'api.steelforesight.ir'])
|
||||||
|
if (mediaHosts.has(url.hostname) && url.pathname.startsWith('/media/')) {
|
||||||
|
return `${url.pathname}${url.search}`
|
||||||
|
}
|
||||||
|
} catch { /* keep non-URL and relative values unchanged */ }
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
const PANEL_API = (import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || ''
|
const PANEL_API = (import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || ''
|
||||||
|
|
||||||
export async function bootstrapMedia(): Promise<void> {
|
export async function bootstrapMedia(): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -465,3 +465,238 @@ body {
|
||||||
PRINT
|
PRINT
|
||||||
═══════════════════════════════════════════════ */
|
═══════════════════════════════════════════════ */
|
||||||
@media print { .no-print { display: none !important; } }
|
@media print { .no-print { display: none !important; } }
|
||||||
|
|
||||||
|
.steel-switcher {
|
||||||
|
--steel-glass: #7f909c;
|
||||||
|
--steel-ink: #eef6ff;
|
||||||
|
--steel-muted: rgba(238, 246, 255, 0.68);
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
right: 16px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 64px;
|
||||||
|
min-height: 132px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 9px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 26px;
|
||||||
|
isolation: isolate;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 28% 12%, rgba(255,255,255,0.44), transparent 28%),
|
||||||
|
linear-gradient(150deg, rgba(255,255,255,0.24), rgba(126,145,158,0.58) 38%, rgba(70,86,99,0.68));
|
||||||
|
color: var(--steel-muted);
|
||||||
|
backdrop-filter: blur(18px) saturate(165%) contrast(1.08);
|
||||||
|
-webkit-backdrop-filter: blur(18px) saturate(165%) contrast(1.08);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(255,255,255,0.30),
|
||||||
|
inset 5px 7px 10px -8px rgba(255,255,255,0.92),
|
||||||
|
inset -7px -12px 18px -14px rgba(3,35,64,0.78),
|
||||||
|
inset 0 -1px 8px rgba(255,255,255,0.16),
|
||||||
|
0 18px 45px rgba(3,35,64,0.26),
|
||||||
|
0 4px 14px rgba(3,35,64,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steel-switcher::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 1px;
|
||||||
|
z-index: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(255,255,255,0.42), rgba(255,255,255,0.07) 24%, transparent 48%),
|
||||||
|
radial-gradient(circle at 18% 16%, rgba(255,255,255,0.72), transparent 16%),
|
||||||
|
radial-gradient(circle at 85% 84%, rgba(3,35,64,0.22), transparent 32%);
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steel-switcher__legend {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
clip-path: inset(100%);
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steel-switcher__input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
clip-path: inset(100%);
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steel-switcher__option {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 46px;
|
||||||
|
height: 52px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 16px;
|
||||||
|
color: var(--steel-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 180ms ease, transform 180ms ease, background-color 180ms ease;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255,255,255,0.18),
|
||||||
|
inset 0 -1px 4px rgba(3,35,64,0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steel-switcher__icon {
|
||||||
|
display: block;
|
||||||
|
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steel-switcher__option:hover {
|
||||||
|
color: var(--steel-ink);
|
||||||
|
background: rgba(255,255,255,0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steel-switcher__option:hover .steel-switcher__icon {
|
||||||
|
transform: scale(1.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steel-switcher__option:has(input:checked) {
|
||||||
|
color: #fff;
|
||||||
|
cursor: default;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steel-switcher__option:has(input:focus-visible) {
|
||||||
|
outline: 2px solid rgba(255,255,255,0.9);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steel-switcher::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 9px;
|
||||||
|
width: 46px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 15px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 32% 18%, rgba(255,255,255,0.38), transparent 28%),
|
||||||
|
linear-gradient(180deg, color-mix(in srgb, var(--steel-active, #1a6bcf) 92%, #ffffff 8%), color-mix(in srgb, var(--steel-active, #1a6bcf) 86%, #032340 14%));
|
||||||
|
z-index: 0;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(255,255,255,0.24),
|
||||||
|
inset 3px 3px 4px -3px rgba(255,255,255,0.85),
|
||||||
|
inset -3px -8px 8px -8px rgba(3,35,64,0.8),
|
||||||
|
0 8px 18px color-mix(in srgb, var(--steel-active, #1a6bcf) 44%, transparent),
|
||||||
|
0 2px 4px rgba(3,35,64,0.14);
|
||||||
|
transition:
|
||||||
|
translate 420ms cubic-bezier(0.19, 1, 0.22, 1),
|
||||||
|
background 260ms ease,
|
||||||
|
box-shadow 260ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steel-switcher:has(input[c-option="1"]:checked)::after {
|
||||||
|
translate: 0 0;
|
||||||
|
transform-origin: bottom;
|
||||||
|
animation: steelToggle 360ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steel-switcher:has(input[c-option="2"]:checked)::after {
|
||||||
|
translate: 0 62px;
|
||||||
|
transform-origin: top;
|
||||||
|
animation: steelToggle 420ms cubic-bezier(0.19, 1, 0.22, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steel-switcher[c-previous="1"]:has(input[c-option="2"]:checked)::after {
|
||||||
|
transform-origin: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steel-switcher[c-previous="2"]:has(input[c-option="1"]:checked)::after {
|
||||||
|
transform-origin: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes steelToggle {
|
||||||
|
0% { scale: 1 1; }
|
||||||
|
45% { scale: 0.96 1.22; }
|
||||||
|
100% { scale: 1 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.steel-switcher,
|
||||||
|
.steel-switcher::after,
|
||||||
|
.steel-switcher__option,
|
||||||
|
.steel-switcher__icon {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.steel-dashboard-page {
|
||||||
|
padding-bottom: calc(88px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.steel-switcher {
|
||||||
|
right: 50%;
|
||||||
|
top: auto;
|
||||||
|
bottom: max(14px, env(safe-area-inset-bottom));
|
||||||
|
transform: translateX(50%);
|
||||||
|
width: 132px;
|
||||||
|
min-height: 64px;
|
||||||
|
height: 64px;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steel-switcher__option {
|
||||||
|
width: 52px;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steel-switcher::after {
|
||||||
|
left: 9px;
|
||||||
|
top: 9px;
|
||||||
|
width: 52px;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steel-switcher:has(input[c-option="1"]:checked)::after {
|
||||||
|
translate: 0 0;
|
||||||
|
transform-origin: right;
|
||||||
|
animation: steelToggleHorizontal 360ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steel-switcher:has(input[c-option="2"]:checked)::after {
|
||||||
|
translate: 62px 0;
|
||||||
|
transform-origin: left;
|
||||||
|
animation: steelToggleHorizontal 420ms cubic-bezier(0.19, 1, 0.22, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steel-switcher[c-previous="1"]:has(input[c-option="2"]:checked)::after {
|
||||||
|
transform-origin: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steel-switcher[c-previous="2"]:has(input[c-option="1"]:checked)::after {
|
||||||
|
transform-origin: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes steelToggleHorizontal {
|
||||||
|
0% { scale: 1 1; }
|
||||||
|
45% { scale: 1.18 0.96; }
|
||||||
|
100% { scale: 1 1; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,33 @@ import { bootstrapMedia } from './data/media'
|
||||||
import { bootstrapPosts } from './content/posts'
|
import { bootstrapPosts } from './content/posts'
|
||||||
import { bootstrapRadarFuture } from './content/radarFuture'
|
import { bootstrapRadarFuture } from './content/radarFuture'
|
||||||
|
|
||||||
await Promise.all([
|
const BOOTSTRAP_TIMEOUT_MS = 3500
|
||||||
bootstrapReports(), bootstrapEvents(), bootstrapTeam(), bootstrapRadar(), bootstrapRadarPages(), bootstrapBanners(),
|
const bootstraps = [
|
||||||
bootstrapPlans(), bootstrapRisks(), bootstrapMarket(),
|
bootstrapReports,
|
||||||
bootstrapFactoryReports(), bootstrapIntegrations(), bootstrapAbout(), bootstrapMedia(), bootstrapPosts(), bootstrapRadarFuture(),
|
bootstrapEvents,
|
||||||
])
|
bootstrapTeam,
|
||||||
|
bootstrapRadar,
|
||||||
|
bootstrapRadarPages,
|
||||||
|
bootstrapBanners,
|
||||||
|
bootstrapPlans,
|
||||||
|
bootstrapRisks,
|
||||||
|
bootstrapMarket,
|
||||||
|
bootstrapFactoryReports,
|
||||||
|
bootstrapIntegrations,
|
||||||
|
bootstrapAbout,
|
||||||
|
bootstrapMedia,
|
||||||
|
bootstrapPosts,
|
||||||
|
bootstrapRadarFuture,
|
||||||
|
]
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
bootstraps.map((bootstrap) =>
|
||||||
|
Promise.race([
|
||||||
|
bootstrap().catch(() => {}),
|
||||||
|
new Promise<void>((resolve) => window.setTimeout(resolve, BOOTSTRAP_TIMEOUT_MS)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
// PWA: register service worker in production builds only (skip in dev — avoids stale-cache pain)
|
// PWA: register service worker in production builds only (skip in dev — avoids stale-cache pain)
|
||||||
if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
||||||
|
|
@ -42,4 +64,4 @@ createRoot(document.getElementById('root')!).render(
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useEffect, useState, type CSSProperties } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
import { Telescope, Lightbulb, BarChart3, Leaf, Quote, Mail, Target, Link2, Gavel, ListChecks, Compass, ShieldCheck, type LucideIcon } from 'lucide-react'
|
import { Telescope, Lightbulb, BarChart3, Leaf, Quote, Mail, Target, Link2, Gavel, ListChecks, Compass, ShieldCheck, type LucideIcon } from 'lucide-react'
|
||||||
|
import RadarTechSection from '@/pages/Home/sections/RadarTechSection'
|
||||||
|
|
||||||
const NAVY = '#032340'
|
const NAVY = '#032340'
|
||||||
const GOLD = '#CD9E53'
|
const GOLD = '#CD9E53'
|
||||||
|
|
@ -281,6 +282,9 @@ export default function About() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* ── THINKING FRAMEWORK ── */}
|
||||||
|
<RadarTechSection only="approach" />
|
||||||
|
|
||||||
{/* ── POLICY COUNCIL ── (hidden for now: whole section blurred + non-interactive) */}
|
{/* ── POLICY COUNCIL ── (hidden for now: whole section blurred + non-interactive) */}
|
||||||
<section className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingBottom: 56, filter: 'blur(18px)', userSelect: 'none', pointerEvents: 'none' }} aria-hidden="true">
|
<section className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingBottom: 56, filter: 'blur(18px)', userSelect: 'none', pointerEvents: 'none' }} aria-hidden="true">
|
||||||
<SectionLabel text={t.councilTitle} />
|
<SectionLabel text={t.councilTitle} />
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { X, Globe, Calendar, LayoutGrid, Newspaper, ChevronDown } from 'lucide-react'
|
import { X, Globe, Calendar, LayoutGrid, Newspaper, ChevronDown } from 'lucide-react'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
import FeaturedReportBanner from '../Home/sections/FeaturedReportBanner'
|
import FeaturedReportBanner from '../Home/sections/FeaturedReportBanner'
|
||||||
|
import { sortByPublishedDesc } from '@/utils/dateSort'
|
||||||
|
import { resolveSourceUrl } from '@/utils/sourceLinks'
|
||||||
|
|
||||||
const NAVY = '#032340'
|
const NAVY = '#032340'
|
||||||
const GOLD = '#CD9E53'
|
const GOLD = '#CD9E53'
|
||||||
|
|
@ -19,7 +21,9 @@ type Card = {
|
||||||
analysisFa: string; analysisEn: string
|
analysisFa: string; analysisEn: string
|
||||||
tags: string[]
|
tags: string[]
|
||||||
source: string
|
source: string
|
||||||
|
sourceUrl?: string
|
||||||
dateFa: string; dateEn: string
|
dateFa: string; dateEn: string
|
||||||
|
createdAt?: string; updatedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── data ─── */
|
/* ─── data ─── */
|
||||||
|
|
@ -287,7 +291,7 @@ function CardModal({ card, lang, onClose, onTagClick }: { card: Card; lang: 'fa'
|
||||||
<LayoutGrid size={16} color="#fff" strokeWidth={1.6} />
|
<LayoutGrid size={16} color="#fff" strokeWidth={1.6} />
|
||||||
</span>
|
</span>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
|
||||||
<span style={metaItem}><span>{card.source}</span><Globe size={18} color={GOLD} strokeWidth={1.6} /></span>
|
{card.source && <SourceLink source={card.source} sourceUrl={card.sourceUrl} style={metaItem} iconSize={18} iconFirst={false} />}
|
||||||
<span style={metaItem}><span>{isFa ? card.dateFa : card.dateEn}</span><Calendar size={18} color={GOLD} strokeWidth={1.6} /></span>
|
<span style={metaItem}><span>{isFa ? card.dateFa : card.dateEn}</span><Calendar size={18} color={GOLD} strokeWidth={1.6} /></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -331,16 +335,53 @@ function CardModal({ card, lang, onClose, onTagClick }: { card: Card; lang: 'fa'
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── source + date meta row (Figma: «Bloomberg» globe + date calendar, #606060 / 12px) ─── */
|
/* ─── source + date meta row (Figma: «Bloomberg» globe + date calendar, #606060 / 12px) ─── */
|
||||||
function Meta({ source, date, light }: { source?: string; date: string; light?: boolean }) {
|
function Meta({ source, sourceUrl, date, light }: { source?: string; sourceUrl?: string; date: string; light?: boolean }) {
|
||||||
const item = { fontSize: 12, color: light ? 'rgba(255,255,255,0.85)' : '#606060', fontWeight: 400, display: 'inline-flex', alignItems: 'center', gap: 5 } as const
|
const item = { fontSize: 12, color: light ? 'rgba(255,255,255,0.85)' : '#606060', fontWeight: 400, display: 'inline-flex', alignItems: 'center', gap: 5 } as const
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', gap: 18, direction: 'rtl', alignItems: 'center', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 18, direction: 'rtl', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
{source && <span style={item}><Globe size={13} color={GOLD} />{source}</span>}
|
{source && <SourceLink source={source} sourceUrl={sourceUrl} style={item} iconSize={13} />}
|
||||||
<span style={item}><Calendar size={13} color={GOLD} />{date}</span>
|
<span style={item}><Calendar size={13} color={GOLD} />{date}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SourceLink({
|
||||||
|
source,
|
||||||
|
sourceUrl,
|
||||||
|
style,
|
||||||
|
iconSize = 13,
|
||||||
|
iconFirst = true,
|
||||||
|
}: {
|
||||||
|
source: string
|
||||||
|
sourceUrl?: string
|
||||||
|
style: CSSProperties
|
||||||
|
iconSize?: number
|
||||||
|
iconFirst?: boolean
|
||||||
|
}) {
|
||||||
|
const href = resolveSourceUrl(source, sourceUrl)
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{iconFirst && <Globe size={iconSize} color={GOLD} />}
|
||||||
|
<span>{source}</span>
|
||||||
|
{!iconFirst && <Globe size={iconSize} color={GOLD} strokeWidth={1.6} />}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!href) return <span style={style}>{content}</span>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target={href.startsWith('http') ? '_blank' : undefined}
|
||||||
|
rel={href.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||||
|
style={{ ...style, textDecoration: 'underline', textUnderlineOffset: 3, cursor: 'pointer' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── category badge — Figma «Link» pill: gold #CD9E53, white text 14/500, element icon, r8 ─── */
|
/* ─── category badge — Figma «Link» pill: gold #CD9E53, white text 14/500, element icon, r8 ─── */
|
||||||
function Badge({ label }: { label: string }) {
|
function Badge({ label }: { label: string }) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -383,7 +424,9 @@ export default function AtAGlance() {
|
||||||
|
|
||||||
// load «در یک نگاه» cards from the panel (radar_items); fall back to the static list
|
// load «در یک نگاه» cards from the panel (radar_items); fall back to the static list
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const PANEL_API = (import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || ''
|
const PANEL_API = import.meta.env.DEV
|
||||||
|
? ''
|
||||||
|
: ((import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || '')
|
||||||
let alive = true
|
let alive = true
|
||||||
fetch(`${PANEL_API}/api/radar?limit=100`)
|
fetch(`${PANEL_API}/api/radar?limit=100`)
|
||||||
.then(r => (r.ok ? r.json() : null))
|
.then(r => (r.ok ? r.json() : null))
|
||||||
|
|
@ -400,16 +443,18 @@ export default function AtAGlance() {
|
||||||
analysisFa: String(r.analysisFa ?? ''), analysisEn: String(r.analysisEn || r.analysisFa || ''),
|
analysisFa: String(r.analysisFa ?? ''), analysisEn: String(r.analysisEn || r.analysisFa || ''),
|
||||||
tags: Array.isArray(r.tags) ? (r.tags as string[]) : [],
|
tags: Array.isArray(r.tags) ? (r.tags as string[]) : [],
|
||||||
source: String(r.source ?? ''),
|
source: String(r.source ?? ''),
|
||||||
|
sourceUrl: resolveSourceUrl(String(r.source ?? ''), String(r.sourceUrl ?? r.source_url ?? r.url ?? r.link ?? '')),
|
||||||
dateFa: String(r.dateFa ?? ''), dateEn: String(r.dateEn || r.dateFa || ''),
|
dateFa: String(r.dateFa ?? ''), dateEn: String(r.dateEn || r.dateFa || ''),
|
||||||
|
createdAt: String(r.createdAt ?? ''), updatedAt: String(r.updatedAt ?? ''),
|
||||||
})))
|
})))
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
return () => { alive = false }
|
return () => { alive = false }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const filteredCards = tagFilter
|
const filteredCards = sortByPublishedDesc(tagFilter
|
||||||
? cards.filter(c => c.tags.includes(tagFilter))
|
? cards.filter(c => c.tags.includes(tagFilter))
|
||||||
: activeTab === 'all' ? cards : cards.filter(c => c.tab === activeTab)
|
: activeTab === 'all' ? cards : cards.filter(c => c.tab === activeTab))
|
||||||
const hero = filteredCards[0]
|
const hero = filteredCards[0]
|
||||||
const smallItems = filteredCards.slice(1, 4)
|
const smallItems = filteredCards.slice(1, 4)
|
||||||
const listCards = filteredCards.slice(1)
|
const listCards = filteredCards.slice(1)
|
||||||
|
|
@ -473,7 +518,7 @@ export default function AtAGlance() {
|
||||||
<Badge label={catLabel(hero.tab, isFa)} />
|
<Badge label={catLabel(hero.tab, isFa)} />
|
||||||
<h2 style={{ fontSize: 'clamp(18px,2vw,26px)', fontWeight: 700, color: '#fff', lineHeight: 1.6, margin: '14px 0 10px' }}>{isFa ? hero.titleFa : hero.titleEn}</h2>
|
<h2 style={{ fontSize: 'clamp(18px,2vw,26px)', fontWeight: 700, color: '#fff', lineHeight: 1.6, margin: '14px 0 10px' }}>{isFa ? hero.titleFa : hero.titleEn}</h2>
|
||||||
<p style={{ fontSize: 13.5, color: 'rgba(255,255,255,0.82)', lineHeight: 1.9, margin: 0, maxWidth: 580, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{(isFa ? hero.bodyFa : hero.bodyEn)[0] || ''}</p>
|
<p style={{ fontSize: 13.5, color: 'rgba(255,255,255,0.82)', lineHeight: 1.9, margin: 0, maxWidth: 580, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{(isFa ? hero.bodyFa : hero.bodyEn)[0] || ''}</p>
|
||||||
<div style={{ marginTop: 14 }}><Meta source={hero.source} date={isFa ? hero.dateFa : hero.dateEn} light /></div>
|
<div style={{ marginTop: 14 }}><Meta source={hero.source} sourceUrl={hero.sourceUrl} date={isFa ? hero.dateFa : hero.dateEn} light /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -483,7 +528,7 @@ export default function AtAGlance() {
|
||||||
<img src={card.img} alt="" style={{ width: 96, height: 96, objectFit: 'cover', flexShrink: 0, borderRadius: 12 }} />
|
<img src={card.img} alt="" style={{ width: 96, height: 96, objectFit: 'cover', flexShrink: 0, borderRadius: 12 }} />
|
||||||
<div style={{ textAlign: isFa ? 'right' : 'left', flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div style={{ textAlign: isFa ? 'right' : 'left', flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
<div><Badge label={catLabel(card.tab, isFa)} /></div>
|
<div><Badge label={catLabel(card.tab, isFa)} /></div>
|
||||||
<Meta source={card.source} date={isFa ? card.dateFa : card.dateEn} />
|
<Meta source={card.source} sourceUrl={card.sourceUrl} date={isFa ? card.dateFa : card.dateEn} />
|
||||||
<div style={{ fontSize: 14, fontWeight: 700, color: '#262626', lineHeight: 1.7, margin: 0, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{isFa ? card.titleFa : card.titleEn}</div>
|
<div style={{ fontSize: 14, fontWeight: 700, color: '#262626', lineHeight: 1.7, margin: 0, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{isFa ? card.titleFa : card.titleEn}</div>
|
||||||
<TagRow tags={card.tags} onTagClick={handleTagClick} />
|
<TagRow tags={card.tags} onTagClick={handleTagClick} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -520,7 +565,7 @@ export default function AtAGlance() {
|
||||||
<div style={{ padding: '8px 12px', textAlign: isFa ? 'right' : 'left', flex: 1, minWidth: 0 }}>
|
<div style={{ padding: '8px 12px', textAlign: isFa ? 'right' : 'left', flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, marginBottom: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, marginBottom: 10 }}>
|
||||||
<Badge label={catLabel(card.tab, isFa)} />
|
<Badge label={catLabel(card.tab, isFa)} />
|
||||||
<Meta source={card.source} date={isFa ? card.dateFa : card.dateEn} />
|
<Meta source={card.source} sourceUrl={card.sourceUrl} date={isFa ? card.dateFa : card.dateEn} />
|
||||||
</div>
|
</div>
|
||||||
<h3 style={{ fontSize: 16, fontWeight: 700, color: '#262626', lineHeight: 1.8, margin: '0 0 8px' }}>{isFa ? card.titleFa : card.titleEn}</h3>
|
<h3 style={{ fontSize: 16, fontWeight: 700, color: '#262626', lineHeight: 1.8, margin: '0 0 8px' }}>{isFa ? card.titleFa : card.titleEn}</h3>
|
||||||
<p style={{ fontSize: 13, color: '#606060', lineHeight: 1.9, margin: 0, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{(isFa ? card.bodyFa : card.bodyEn)[0] || ''}</p>
|
<p style={{ fontSize: 13, color: '#606060', lineHeight: 1.9, margin: 0, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{(isFa ? card.bodyFa : card.bodyEn)[0] || ''}</p>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { ExternalLink, ChevronLeft, ChevronRight } from 'lucide-react'
|
import { ExternalLink, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
import { events } from '@/data/events'
|
import { bootstrapEvents, events } from '@/data/events'
|
||||||
|
|
||||||
const NAVY = '#032340'
|
const NAVY = '#032340'
|
||||||
const GOLD = '#CD9E53'
|
const GOLD = '#CD9E53'
|
||||||
|
|
@ -15,6 +15,9 @@ const TYPE_LABEL: Record<string, { fa: string; en: string; color: string; bg: st
|
||||||
}
|
}
|
||||||
|
|
||||||
const EVENT_IMGS: Record<string, string> = {
|
const EVENT_IMGS: Record<string, string> = {
|
||||||
|
'smu-steel-summit-2026': '/news/photo-1566873535350-6586b0b7a1f2.jpg',
|
||||||
|
'china-international-steel-congress-2026': '/news/photo-1504307651254-35680f356dfd.jpg',
|
||||||
|
'future-green-steel-2026': '/news/photo-1611273426858-450d8e3c9fce.jpg',
|
||||||
'iran-steel-congress-1403': '/news/photo-1611273426858-450d8e3c9fce.jpg',
|
'iran-steel-congress-1403': '/news/photo-1611273426858-450d8e3c9fce.jpg',
|
||||||
'middle-east-steel-metals-2024': '/news/photo-1570168007204-dfb528c6958f.jpg',
|
'middle-east-steel-metals-2024': '/news/photo-1570168007204-dfb528c6958f.jpg',
|
||||||
'steel-policy-seminar-1404': '/news/photo-1567789884554-0b844b597180.jpg',
|
'steel-policy-seminar-1404': '/news/photo-1567789884554-0b844b597180.jpg',
|
||||||
|
|
@ -24,10 +27,9 @@ const EVENT_IMGS: Record<string, string> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Countdown ─────────────────────────────────────────── */
|
/* ── Countdown ─────────────────────────────────────────── */
|
||||||
const HERO_TARGET = new Date('2026-10-16T09:00:00')
|
function useCountdown(targetTime: number) {
|
||||||
function useCountdown(target: Date) {
|
|
||||||
const calc = () => {
|
const calc = () => {
|
||||||
const diff = Math.max(0, target.getTime() - Date.now())
|
const diff = Math.max(0, targetTime - Date.now())
|
||||||
return {
|
return {
|
||||||
days: Math.floor(diff / 86400000),
|
days: Math.floor(diff / 86400000),
|
||||||
hours: Math.floor((diff % 86400000) / 3600000),
|
hours: Math.floor((diff % 86400000) / 3600000),
|
||||||
|
|
@ -36,7 +38,11 @@ function useCountdown(target: Date) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const [t, setT] = useState(calc)
|
const [t, setT] = useState(calc)
|
||||||
useEffect(() => { const id = setInterval(() => setT(calc()), 1000); return () => clearInterval(id) }, [])
|
useEffect(() => {
|
||||||
|
setT(calc())
|
||||||
|
const id = setInterval(() => setT(calc()), 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [targetTime])
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,7 +51,81 @@ const WEEKDAYS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سهشنبه',
|
||||||
const J_MONTHS = ['فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور','مهر','آبان','آذر','دی','بهمن','اسفند']
|
const J_MONTHS = ['فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور','مهر','آبان','آذر','دی','بهمن','اسفند']
|
||||||
const faNum = (n: number) => n.toLocaleString('fa-IR', { useGrouping: false })
|
const faNum = (n: number) => n.toLocaleString('fa-IR', { useGrouping: false })
|
||||||
|
|
||||||
|
type CalendarEventDate = { day: number; month: number; year: number }
|
||||||
|
|
||||||
|
function jalaliMonthNumber(value: string) {
|
||||||
|
const normalized = String(value ?? '')
|
||||||
|
.replace(/ماه/g, '')
|
||||||
|
.replace(/[\s\u200c]/g, '')
|
||||||
|
.replace(/ي/g, 'ی')
|
||||||
|
.replace(/ك/g, 'ک')
|
||||||
|
return J_MONTHS.findIndex(month => month === normalized) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarDateKey(date: CalendarEventDate) {
|
||||||
|
return date.year * 10000 + date.month * 100 + date.day
|
||||||
|
}
|
||||||
|
|
||||||
|
function gregorianDateForJalali(target: CalendarEventDate) {
|
||||||
|
const cursor = new Date(target.year + 621, 2, 15, 12)
|
||||||
|
for (let index = 0; index < 380; index += 1) {
|
||||||
|
const current = jParts(cursor)
|
||||||
|
if (current.day === target.day && current.month === target.month && current.year === target.year) {
|
||||||
|
return new Date(cursor)
|
||||||
|
}
|
||||||
|
cursor.setDate(cursor.getDate() + 1)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialCalendarAnchor(sourceEvents = events) {
|
||||||
|
const today = new Date()
|
||||||
|
const todayJ = jParts(today)
|
||||||
|
const todayKey = calendarDateKey(todayJ)
|
||||||
|
const nearestHomeEvent = sourceEvents
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(event => ({
|
||||||
|
day: Number(event.date),
|
||||||
|
month: jalaliMonthNumber(event.month),
|
||||||
|
year: Number(event.year),
|
||||||
|
}))
|
||||||
|
.filter(date => date.day >= 1 && date.month >= 1 && date.year > 0 && calendarDateKey(date) >= todayKey)
|
||||||
|
.sort((a, b) => calendarDateKey(a) - calendarDateKey(b))[0]
|
||||||
|
|
||||||
|
return nearestHomeEvent ? gregorianDateForJalali(nearestHomeEvent) ?? today : today
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestUpcomingEvent(sourceEvents: typeof events) {
|
||||||
|
const todayJ = jParts(new Date())
|
||||||
|
const todayKey = calendarDateKey(todayJ)
|
||||||
|
return [...sourceEvents]
|
||||||
|
.filter(event => {
|
||||||
|
const date = { day: Number(event.date), month: jalaliMonthNumber(event.month), year: Number(event.year) }
|
||||||
|
return date.day >= 1 && date.month >= 1 && date.year > 0 && calendarDateKey(date) >= todayKey
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aKey = calendarDateKey({ day: Number(a.date), month: jalaliMonthNumber(a.month), year: Number(a.year) })
|
||||||
|
const bKey = calendarDateKey({ day: Number(b.date), month: jalaliMonthNumber(b.month), year: Number(b.year) })
|
||||||
|
return aKey - bKey
|
||||||
|
})[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventStartTimestamp(event: (typeof events)[number] | undefined) {
|
||||||
|
if (!event) return Date.now()
|
||||||
|
const date = gregorianDateForJalali({
|
||||||
|
day: Number(event.date),
|
||||||
|
month: jalaliMonthNumber(event.month),
|
||||||
|
year: Number(event.year),
|
||||||
|
})
|
||||||
|
if (!date) return Date.now()
|
||||||
|
date.setHours(9, 0, 0, 0)
|
||||||
|
return date.getTime()
|
||||||
|
}
|
||||||
|
|
||||||
function jParts(d: Date) {
|
function jParts(d: Date) {
|
||||||
|
if (!(d instanceof Date) || !Number.isFinite(d.getTime())) {
|
||||||
|
return { day: 0, month: 0, year: 0 }
|
||||||
|
}
|
||||||
const p = new Intl.DateTimeFormat('en-US-u-ca-persian', { day:'numeric', month:'numeric', year:'numeric' }).formatToParts(d)
|
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)
|
const get = (t: string) => Number(p.find(x => x.type === t)?.value)
|
||||||
return { day: get('day'), month: get('month'), year: get('year') }
|
return { day: get('day'), month: get('month'), year: get('year') }
|
||||||
|
|
@ -67,7 +147,7 @@ function buildMonth(anchor: Date) {
|
||||||
|
|
||||||
function MiniCalendar({ anchor, onNav, eventDates, onSelect }: {
|
function MiniCalendar({ anchor, onNav, eventDates, onSelect }: {
|
||||||
anchor: Date; onNav: (d: 1|-1) => void
|
anchor: Date; onNav: (d: 1|-1) => void
|
||||||
eventDates: Date[]; onSelect: (d: Date) => void
|
eventDates: CalendarEventDate[]; onSelect: (d: Date) => void
|
||||||
}) {
|
}) {
|
||||||
const { days, firstDow, month, year } = buildMonth(anchor)
|
const { days, firstDow, month, year } = buildMonth(anchor)
|
||||||
const today = new Date(); const todayJ = jParts(today)
|
const today = new Date(); const todayJ = jParts(today)
|
||||||
|
|
@ -79,23 +159,23 @@ function MiniCalendar({ anchor, onNav, eventDates, onSelect }: {
|
||||||
padding: 12, height: '100%', boxSizing: 'border-box',
|
padding: 12, height: '100%', boxSizing: 'border-box',
|
||||||
}}>
|
}}>
|
||||||
{/* تقویم label row */}
|
{/* تقویم label row */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', padding: '0 8px' }}>
|
<div style={{ width: '100%', padding: '0 8px', boxSizing: 'border-box' }}>
|
||||||
<span style={{ fontSize: 18, fontWeight: 500, color: '#262626', textAlign: 'right' }}>تقویم</span>
|
<span style={{ display: 'block', width: '100%', fontSize: 18, fontWeight: 700, color: '#262626', textAlign: 'right' }}>تقویم</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* month nav */}
|
{/* month nav */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 8px', direction: 'rtl' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 8px', direction: 'ltr' }}>
|
||||||
<button onClick={() => onNav(1)} style={{
|
<button type="button" aria-label="ماه بعد" title="ماه بعد" onClick={() => onNav(1)} style={{
|
||||||
width: 24, height: 24, borderRadius: 24, border: 'none',
|
width: 24, height: 24, borderRadius: 24, border: 'none',
|
||||||
background: '#FBEED9', cursor: 'pointer',
|
background: '#FBEED9', cursor: 'pointer',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
<ChevronRight size={12} color={GOLD} />
|
<ChevronRight size={12} color={GOLD} />
|
||||||
</button>
|
</button>
|
||||||
<span style={{ fontSize: 18, fontWeight: 500, color: '#262626', textAlign: 'center', flex: 1 }}>
|
<span dir="rtl" style={{ fontSize: 18, fontWeight: 500, color: '#262626', textAlign: 'center', flex: 1 }}>
|
||||||
{J_MONTHS[month - 1]}ماه {faNum(year)}
|
{J_MONTHS[month - 1]} ماه {faNum(year)}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={() => onNav(-1)} style={{
|
<button type="button" aria-label="ماه قبل" title="ماه قبل" onClick={() => onNav(-1)} style={{
|
||||||
width: 24, height: 24, borderRadius: 24, border: 'none',
|
width: 24, height: 24, borderRadius: 24, border: 'none',
|
||||||
background: '#FBEED9', cursor: 'pointer',
|
background: '#FBEED9', cursor: 'pointer',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||||
|
|
@ -116,27 +196,33 @@ function MiniCalendar({ anchor, onNav, eventDates, onSelect }: {
|
||||||
|
|
||||||
{/* day cells — bg #F7F7F7 */}
|
{/* day cells — bg #F7F7F7 */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', direction: 'rtl',
|
display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', direction: 'rtl',
|
||||||
background: '#F7F7F7', borderRadius: 16, padding: 4, gap: 3, flex: 1,
|
background: '#F7F7F7', borderRadius: 16, padding: 8, gap: 4,
|
||||||
|
alignContent: 'start', width: '100%', boxSizing: 'border-box', overflow: 'hidden',
|
||||||
}}>
|
}}>
|
||||||
{Array.from({ length: firstDow }, (_, i) => <div key={`e${i}`} />)}
|
{Array.from({ length: firstDow }, (_, i) => <div key={`e${i}`} />)}
|
||||||
{days.map(d => {
|
{days.map(d => {
|
||||||
const jp = jParts(d)
|
const jp = jParts(d)
|
||||||
const isToday = jp.day === todayJ.day && jp.month === todayJ.month && jp.year === todayJ.year
|
const isToday = jp.day === todayJ.day && jp.month === todayJ.month && jp.year === todayJ.year
|
||||||
const hasEv = eventDates.some(ed => {
|
const hasEv = eventDates.some(ed => ed.day === jp.day && ed.month === jp.month && ed.year === jp.year)
|
||||||
const ej = jParts(ed); return ej.day === jp.day && ej.month === jp.month && ej.year === jp.year
|
|
||||||
})
|
|
||||||
return (
|
return (
|
||||||
<button key={jp.day} onClick={() => onSelect(d)} style={{
|
<button key={jp.day} onClick={() => onSelect(d)} style={{
|
||||||
aspectRatio: '1', border: hasEv && !isToday ? `1px solid ${GOLD}` : 'none',
|
aspectRatio: '1', border: hasEv && !isToday ? `1px solid ${GOLD}` : 'none',
|
||||||
|
width: '100%', minWidth: 0, padding: 0,
|
||||||
borderRadius: isToday ? 99 : hasEv ? 60 : '50%',
|
borderRadius: isToday ? 99 : hasEv ? 60 : '50%',
|
||||||
fontSize: isToday ? 14 : 12, fontWeight: 500,
|
fontSize: isToday ? 14 : 12, fontWeight: hasEv || isToday ? 800 : 500,
|
||||||
background: isToday ? GOLD : '#fff',
|
background: isToday ? GOLD : hasEv ? '#FFF6E6' : '#fff',
|
||||||
color: isToday ? '#fff' : '#888888',
|
color: isToday ? '#fff' : hasEv ? NAVY : '#888888',
|
||||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
transition: 'background .15s',
|
transition: 'background .15s', position: 'relative',
|
||||||
}}>
|
}}>
|
||||||
{faNum(jp.day)}
|
{faNum(jp.day)}
|
||||||
|
{hasEv && (
|
||||||
|
<span aria-hidden style={{
|
||||||
|
position: 'absolute', bottom: '12%', left: '50%', transform: 'translateX(-50%)',
|
||||||
|
width: 4, height: 4, borderRadius: '50%', background: isToday ? '#fff' : GOLD,
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
@ -164,14 +250,32 @@ function CBox({ v, label }: { v: number; label: string }) {
|
||||||
export default function Events() {
|
export default function Events() {
|
||||||
const { lang } = useLang()
|
const { lang } = useLang()
|
||||||
const isFa = lang === 'fa'
|
const isFa = lang === 'fa'
|
||||||
const cd = useCountdown(HERO_TARGET)
|
|
||||||
|
|
||||||
const [calAnchor, setCalAnchor] = useState(new Date())
|
const [calAnchor, setCalAnchor] = useState(initialCalendarAnchor)
|
||||||
|
const [eventList, setEventList] = useState(() => [...events])
|
||||||
const [selEvt, setSelEvt] = useState(events[0])
|
const [selEvt, setSelEvt] = useState(events[0])
|
||||||
const [showAll, setShowAll] = useState(false)
|
const [showAll, setShowAll] = useState(false)
|
||||||
|
const nextEvent = nearestUpcomingEvent(eventList) ?? eventList[0]
|
||||||
|
const nextEventTarget = eventStartTimestamp(nextEvent)
|
||||||
|
const cd = useCountdown(nextEventTarget)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
bootstrapEvents().then(() => {
|
||||||
|
if (!active) return
|
||||||
|
const latestEvents = [...events]
|
||||||
|
setEventList(latestEvents)
|
||||||
|
if (latestEvents[0]) setSelEvt(latestEvents[0])
|
||||||
|
setCalAnchor(initialCalendarAnchor(latestEvents))
|
||||||
|
})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleCalNav = (dir: 1 | -1) => {
|
const handleCalNav = (dir: 1 | -1) => {
|
||||||
const n = new Date(calAnchor); n.setDate(n.getDate() + dir * 30); setCalAnchor(n)
|
const firstDay = new Date(calAnchor)
|
||||||
|
while (jParts(firstDay).day !== 1) firstDay.setDate(firstDay.getDate() - 1)
|
||||||
|
firstDay.setDate(firstDay.getDate() + (dir === 1 ? 32 : -1))
|
||||||
|
setCalAnchor(firstDay)
|
||||||
}
|
}
|
||||||
|
|
||||||
// scroll to the calendar when arriving via the «تقویم اندیشکده» nav link (/events#calendar)
|
// scroll to the calendar when arriving via the «تقویم اندیشکده» nav link (/events#calendar)
|
||||||
|
|
@ -184,19 +288,20 @@ export default function Events() {
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [hash])
|
}, [hash])
|
||||||
|
|
||||||
// approximate Gregorian marker dates for each event
|
const eventDates = eventList
|
||||||
const MONTH_OFF: Record<string, number> = {
|
.flatMap(ev => {
|
||||||
فروردین:3, اردیبهشت:4, خرداد:5, تیر:6, مرداد:7, شهریور:8,
|
const start = Number(ev.date)
|
||||||
مهر:9, آبان:10, آذر:11, دی:12, بهمن:1, اسفند:2,
|
const end = Number(ev.dateEnd ?? ev.date)
|
||||||
}
|
return Array.from({ length: Math.max(1, end - start + 1) }, (_, index) => ({
|
||||||
const eventDates = events.map(ev => {
|
day: start + index,
|
||||||
const m = MONTH_OFF[ev.month] ?? 3
|
month: jalaliMonthNumber(ev.month),
|
||||||
const yr = m < 3 ? 2026 : 2025
|
year: Number(ev.year),
|
||||||
return new Date(yr, m - 1, ev.date)
|
}))
|
||||||
})
|
})
|
||||||
|
.filter(date => date.day >= 1 && date.day <= 31 && date.month >= 1 && date.year > 0)
|
||||||
|
|
||||||
const featured = events[0]
|
const featured = eventList[0]
|
||||||
const others = events.slice(1)
|
const others = eventList.slice(1)
|
||||||
const visibleOthers = showAll ? others : others.slice(0, 4)
|
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 selType = TYPE_LABEL[selEvt.type] ?? { fa: selEvt.type, en: selEvt.type, color: GOLD, bg: '#f5f5f5' }
|
||||||
|
|
@ -217,14 +322,14 @@ export default function Events() {
|
||||||
{/* text + countdown — right side, vertically centered */}
|
{/* text + countdown — right side, vertically centered */}
|
||||||
<div style={{ position: 'absolute', top: 0, right: 0, bottom: 0, width: '52%', zIndex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '48px 5vw 48px 24px', direction: 'rtl' }}>
|
<div style={{ position: 'absolute', top: 0, right: 0, bottom: 0, width: '52%', zIndex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '48px 5vw 48px 24px', direction: 'rtl' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 14 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 14 }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 700, color: GOLD }}>استکهلم، سوئد</span>
|
<span style={{ fontSize: 13, fontWeight: 700, color: GOLD }}>{nextEvent.location || nextEvent.city}</span>
|
||||||
<img src="/icons/vuesax/linear/location.svg" alt="" style={{ width: 16, height: 16, filter: 'invert(73%) sepia(50%) saturate(500%) hue-rotate(5deg)' }} />
|
<img src="/icons/vuesax/linear/location.svg" alt="" style={{ width: 16, height: 16, filter: 'invert(73%) sepia(50%) saturate(500%) hue-rotate(5deg)' }} />
|
||||||
</div>
|
</div>
|
||||||
<h1 style={{ fontSize: 'clamp(22px,3vw,42px)', fontWeight: 900, color: GOLD, margin: '0 0 14px', lineHeight: 1.2 }}>
|
<h1 style={{ fontSize: 'clamp(22px,3vw,42px)', fontWeight: 900, color: GOLD, margin: '0 0 14px', lineHeight: 1.2 }}>
|
||||||
اجلاس جهانی فولاد سبز
|
{nextEvent.title}
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ fontSize: 14, lineHeight: 2, color: 'rgba(255,255,255,0.72)', margin: '0 0 24px' }}>
|
<p style={{ fontSize: 14, lineHeight: 2, color: 'rgba(255,255,255,0.72)', margin: '0 0 24px' }}>
|
||||||
مرجع نهایی برای تعیین استانداردهای کربنزدایی در زنجیره ارزش فولاد با حضور وزرای انرژی اتحادیه اروپا و مدیران ارشد ۵۰ شرکت برتر.
|
{nextEvent.description}
|
||||||
</p>
|
</p>
|
||||||
{/* countdown — inline below description */}
|
{/* countdown — inline below description */}
|
||||||
<div style={{ display: 'flex', gap: 12, marginBottom: 24, direction: 'ltr', justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: 12, marginBottom: 24, direction: 'ltr', justifyContent: 'flex-end' }}>
|
||||||
|
|
@ -234,7 +339,15 @@ export default function Events() {
|
||||||
<CBox v={cd.secs} label={isFa ? 'ثانیه' : 'Sec'} />
|
<CBox v={cd.secs} label={isFa ? 'ثانیه' : 'Sec'} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<a href="#" style={{
|
<a href="#calendar" onClick={() => {
|
||||||
|
setSelEvt(nextEvent)
|
||||||
|
const eventDate = gregorianDateForJalali({
|
||||||
|
day: Number(nextEvent.date),
|
||||||
|
month: jalaliMonthNumber(nextEvent.month),
|
||||||
|
year: Number(nextEvent.year),
|
||||||
|
})
|
||||||
|
if (eventDate) setCalAnchor(eventDate)
|
||||||
|
}} style={{
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 8, direction: 'ltr',
|
display: 'inline-flex', alignItems: 'center', gap: 8, direction: 'ltr',
|
||||||
background: GOLD, color: NAVY, borderRadius: 10, padding: '12px 28px',
|
background: GOLD, color: NAVY, borderRadius: 10, padding: '12px 28px',
|
||||||
fontSize: 14, fontWeight: 800, textDecoration: 'none',
|
fontSize: 14, fontWeight: 800, textDecoration: 'none',
|
||||||
|
|
@ -259,11 +372,11 @@ export default function Events() {
|
||||||
<MiniCalendar anchor={calAnchor} onNav={handleCalNav} eventDates={eventDates}
|
<MiniCalendar anchor={calAnchor} onNav={handleCalNav} eventDates={eventDates}
|
||||||
onSelect={d => {
|
onSelect={d => {
|
||||||
const jp = jParts(d)
|
const jp = jParts(d)
|
||||||
const hit = events.find(ev => {
|
const hit = eventList.find(ev => {
|
||||||
const m = MONTH_OFF[ev.month] ?? 3
|
return jp.day >= Number(ev.date)
|
||||||
const yr = m < 3 ? 2026 : 2025
|
&& jp.day <= Number(ev.dateEnd ?? ev.date)
|
||||||
const ed = new Date(yr, m - 1, ev.date)
|
&& jalaliMonthNumber(ev.month) === jp.month
|
||||||
return jParts(ed).day === jp.day && jParts(ed).month === jp.month
|
&& Number(ev.year) === jp.year
|
||||||
})
|
})
|
||||||
if (hit) setSelEvt(hit)
|
if (hit) setSelEvt(hit)
|
||||||
}}
|
}}
|
||||||
|
|
@ -275,16 +388,6 @@ export default function Events() {
|
||||||
background: '#fff', border: '1px solid #E7E7E7', borderRadius: 16,
|
background: '#fff', border: '1px solid #E7E7E7', borderRadius: 16,
|
||||||
padding: 20, display: 'flex', flexDirection: 'column', gap: 20,
|
padding: 20, display: 'flex', flexDirection: 'column', gap: 20,
|
||||||
}}>
|
}}>
|
||||||
{/* hint text — styled box (Figma: bg #F8F9FA, border gold-10%, radius 16, center) */}
|
|
||||||
<div style={{
|
|
||||||
background: '#F8F9FA', border: '1px solid rgba(115,92,0,0.2)',
|
|
||||||
borderRadius: 16, padding: '8px 24px', textAlign: 'center',
|
|
||||||
}}>
|
|
||||||
<span style={{ fontSize: 16, fontWeight: 500, color: '#4D4635', direction: 'rtl' }}>
|
|
||||||
"برای مشاهده جزئیات هر رویداد، روی تاریخ مورد نظر در تقویم کلیک کنید."
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* content row: text LEFT (flex 1) | image RIGHT (251px) — forced LTR */}
|
{/* content row: text LEFT (flex 1) | image RIGHT (251px) — forced LTR */}
|
||||||
<div style={{ display: 'flex', gap: 18, direction: 'ltr', flex: 1, minHeight: 0 }}>
|
<div style={{ display: 'flex', gap: 18, direction: 'ltr', flex: 1, minHeight: 0 }}>
|
||||||
{/* text section */}
|
{/* text section */}
|
||||||
|
|
@ -297,7 +400,9 @@ export default function Events() {
|
||||||
</span>
|
</span>
|
||||||
{/* date */}
|
{/* date */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<span style={{ fontSize: 12, color: '#262626', direction: 'rtl' }}>{selEvt.date} {selEvt.month} {selEvt.year}</span>
|
<span style={{ fontSize: 12, color: '#262626', direction: 'rtl' }}>
|
||||||
|
{selEvt.dateEnd && selEvt.dateEnd !== selEvt.date ? `${selEvt.date} تا ${selEvt.dateEnd}` : selEvt.date} {selEvt.month} {selEvt.year}
|
||||||
|
</span>
|
||||||
<img src="/icons/vuesax/linear/calendar-tick.svg" alt="" style={{ width: 22, height: 22, filter: 'invert(40%) sepia(90%) saturate(400%) hue-rotate(190deg)' }} />
|
<img src="/icons/vuesax/linear/calendar-tick.svg" alt="" style={{ width: 22, height: 22, filter: 'invert(40%) sepia(90%) saturate(400%) hue-rotate(190deg)' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -332,9 +437,9 @@ export default function Events() {
|
||||||
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 56 }}>
|
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 56 }}>
|
||||||
{/* heading — gold right border (Figma: border-right 5px solid #CD9E53) */}
|
{/* heading — gold right border (Figma: border-right 5px solid #CD9E53) */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'flex-end',
|
display: 'flex', alignItems: 'center', justifyContent: 'flex-start',
|
||||||
borderRight: `5px solid ${GOLD}`, paddingRight: 10, marginBottom: 24,
|
borderInlineStart: `5px solid ${GOLD}`, paddingInlineStart: 10, marginBottom: 24,
|
||||||
direction: 'rtl',
|
direction: isFa ? 'rtl' : 'ltr', width: '100%',
|
||||||
}}>
|
}}>
|
||||||
<h2 style={{ fontSize: 24, fontWeight: 500, color: NAVY, margin: 0, lineHeight: '41px' }}>
|
<h2 style={{ fontSize: 24, fontWeight: 500, color: NAVY, margin: 0, lineHeight: '41px' }}>
|
||||||
{isFa ? 'رویداد برجسته پیش رو' : 'Featured Upcoming Event'}
|
{isFa ? 'رویداد برجسته پیش رو' : 'Featured Upcoming Event'}
|
||||||
|
|
@ -371,7 +476,7 @@ export default function Events() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* NAVY PANEL */}
|
{/* NAVY PANEL */}
|
||||||
<div style={{ background: NAVY, padding: '0 36px', display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'center', gap: 10, direction: isFa ? 'rtl' : 'ltr' }}>
|
<div style={{ background: NAVY, padding: '24px 36px 32px', display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'center', gap: 10, direction: isFa ? 'rtl' : 'ltr', boxSizing: 'border-box' }}>
|
||||||
{/* gold decorative bar */}
|
{/* gold decorative bar */}
|
||||||
<div style={{ width: 108, height: 14, background: GOLD, borderRadius: 7, alignSelf: 'flex-end' }} />
|
<div style={{ width: 108, height: 14, background: GOLD, borderRadius: 7, alignSelf: 'flex-end' }} />
|
||||||
|
|
||||||
|
|
@ -417,7 +522,7 @@ export default function Events() {
|
||||||
═══════════════════════════════════════════════ */}
|
═══════════════════════════════════════════════ */}
|
||||||
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 56, paddingBottom: 80 }}>
|
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 56, paddingBottom: 80 }}>
|
||||||
{/* heading — gold right border */}
|
{/* heading — gold right border */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', borderRight: `5px solid ${GOLD}`, paddingRight: 10, marginBottom: 24, direction: 'rtl' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-start', borderInlineStart: `5px solid ${GOLD}`, paddingInlineStart: 10, marginBottom: 24, direction: isFa ? 'rtl' : 'ltr', width: '100%' }}>
|
||||||
<h2 style={{ fontSize: 24, fontWeight: 500, color: NAVY, margin: 0, lineHeight: '41px' }}>
|
<h2 style={{ fontSize: 24, fontWeight: 500, color: NAVY, margin: 0, lineHeight: '41px' }}>
|
||||||
{isFa ? 'سایر رویدادها' : 'Other Events'}
|
{isFa ? 'سایر رویدادها' : 'Other Events'}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ export default function Home() {
|
||||||
<div dir={lang === 'fa' ? 'rtl' : 'ltr'}>
|
<div dir={lang === 'fa' ? 'rtl' : 'ltr'}>
|
||||||
<SpreadFeatures /> {/* هیرو */}
|
<SpreadFeatures /> {/* هیرو */}
|
||||||
<RadarTechSection only="radar" /> {/* رادار آینده */}
|
<RadarTechSection only="radar" /> {/* رادار آینده */}
|
||||||
<RadarTechSection only="approach" /> {/* چارچوب اندیشه ما */}
|
|
||||||
<IntegrationsSection /> {/* آمار و داده — نبض صنعت */}
|
<IntegrationsSection /> {/* آمار و داده — نبض صنعت */}
|
||||||
<AtAGlanceSection /> {/* در یک نگاه */}
|
<AtAGlanceSection /> {/* در یک نگاه */}
|
||||||
<MapEventsSection /> {/* دیدهبان رویدادها */}
|
<MapEventsSection /> {/* دیدهبان رویدادها */}
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,175 @@
|
||||||
import { useState } from 'react'
|
import { useEffect, useMemo, useState, type CSSProperties } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
|
import { sortByPublishedDesc } from '@/utils/dateSort'
|
||||||
|
import { resolveSourceUrl } from '@/utils/sourceLinks'
|
||||||
|
|
||||||
const NAVY = '#032340'
|
const NAVY = '#032340'
|
||||||
const GOLD = '#CD9E53'
|
const GOLD = '#CD9E53'
|
||||||
|
|
||||||
type Glance = {
|
type Glance = {
|
||||||
|
id: string
|
||||||
source: string
|
source: string
|
||||||
date: string; dateEn: string
|
sourceUrl?: string
|
||||||
title: string; titleEn: string
|
date: string
|
||||||
desc: string; descEn: string
|
dateFa: string
|
||||||
|
dateEn: string
|
||||||
|
dateIso: string
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
title: string
|
||||||
|
titleEn: string
|
||||||
|
desc: string
|
||||||
|
descEn: string
|
||||||
img: string
|
img: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const ITEMS: Glance[] = [
|
export const FALLBACK_ITEMS: Glance[] = [
|
||||||
{
|
{
|
||||||
|
id: 'h2-green-steel',
|
||||||
source: 'Financial Times',
|
source: 'Financial Times',
|
||||||
date: '۱۴ اردیبهشت ۱۴۰۵', dateEn: 'May 4, 2026',
|
date: '۱۴ اردیبهشت ۱۴۰۵',
|
||||||
|
dateFa: '۱۴ اردیبهشت ۱۴۰۵',
|
||||||
|
dateEn: 'May 4, 2026',
|
||||||
|
dateIso: '2026-05-04',
|
||||||
title: 'شوک هیدروژنی به بازار جهانی؛ آغاز رسمی عصر فولادسازی بدون کربن',
|
title: 'شوک هیدروژنی به بازار جهانی؛ آغاز رسمی عصر فولادسازی بدون کربن',
|
||||||
titleEn: 'Hydrogen Shock to Global Markets; the Carbon-Free Steel Era Begins',
|
titleEn: 'Hydrogen Shock to Global Markets; the Carbon-Free Steel Era Begins',
|
||||||
desc: 'پروژه عظیم «فولاد سبز H2» در سوئد با جذب بیسابقه ۶.۵ میلیارد یورو سرمایه، رسماً وارد فاز اجرایی شد تا نخستین کارخانه فولادسازی در مقیاس تجاری بر پایه هیدروژن سبز (بدون نیاز به زغالسنگ) در جهان متولد شود. این تأسیسات با هدف کاهش ۹۵ درصدی انتشار CO₂، قواعد رقابت در زنجیره ارزش اروپا را برای همیشه بازتعریف میکند.',
|
desc: 'پروژه فولاد سبز مبتنی بر هیدروژن در اروپا، رقابت را از قیمت صرف به شدت کربن، دسترسی به انرژی پاک و قابلیت اخذ پریمیوم سبز منتقل میکند. برای فولاد مبارکه، پیام اصلی روشن است: بدون مسیر معتبر کاهش کربن، بخشی از بازارهای صادراتی آینده گرانتر یا محدودتر میشوند.',
|
||||||
descEn: 'Sweden\'s massive "Green Steel H2" project, having raised an unprecedented €6.5bn, has officially entered its execution phase to birth the world\'s first commercial-scale steel plant powered by green hydrogen — redefining Europe\'s value-chain competition for good.',
|
descEn: 'Hydrogen-based green steel is shifting competition from price alone toward carbon intensity, clean-energy access and green premium capture.',
|
||||||
img: '/news/photo-1504307651254-35680f356dfd.jpg',
|
img: '/news/photo-1504307651254-35680f356dfd.jpg',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: 'Reuters',
|
id: 'eu-cbam',
|
||||||
date: '۱۲ اردیبهشت ۱۴۰۵', dateEn: 'May 2, 2026',
|
source: 'European Commission / Reuters',
|
||||||
title: 'مکانیسم تنظیم مرزهای کربنی اروپا وارد فاز اجرای کامل شد',
|
date: '۱۲ اردیبهشت ۱۴۰۵',
|
||||||
titleEn: 'EU Carbon Border Mechanism Enters Full Enforcement',
|
dateFa: '۱۲ اردیبهشت ۱۴۰۵',
|
||||||
desc: 'با اجرای کامل CBAM، صادرکنندگان فولاد به اتحادیه اروپا اکنون موظفاند هزینه کامل کربن نهفته در محصولات خود را بپردازند. این تحول، فشار بیسابقهای بر تولیدکنندگان مبتنی بر کوره بلند وارد کرده و مزیت رقابتی را به سمت فولاد کمکربن سوق میدهد.',
|
dateEn: 'May 2, 2026',
|
||||||
descEn: 'With CBAM now fully enforced, steel exporters to the EU must pay the full cost of embedded carbon — placing unprecedented pressure on blast-furnace producers and shifting the competitive edge toward low-carbon steel.',
|
dateIso: '2026-05-02',
|
||||||
|
title: 'CBAM اروپا؛ کربن به متغیر مستقیم قیمت صادرات فولاد تبدیل میشود',
|
||||||
|
titleEn: 'EU CBAM Turns Carbon into a Direct Steel Export Cost',
|
||||||
|
desc: 'ورود CBAM به فاز مالی، مزیت تولیدکنندگانی را که داده انتشار شفاف، برق کمکربن و مسیر کاهش CO₂ دارند تقویت میکند. برای صادرکنندگان فولاد، موضوع فقط گزارشدهی نیست؛ اثر مستقیم روی قیمت تمامشده، قراردادهای بلندمدت و پذیرش مشتری اروپایی دارد.',
|
||||||
|
descEn: 'CBAM strengthens producers with transparent emissions data, low-carbon electricity and credible decarbonization roadmaps.',
|
||||||
img: '/news/photo-1581092160607-ee22731c9b4e.jpg',
|
img: '/news/photo-1581092160607-ee22731c9b4e.jpg',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: 'Bloomberg',
|
id: 'ai-data-centers',
|
||||||
date: '۳۰ فروردین ۱۴۰۵', dateEn: 'Apr 19, 2026',
|
source: 'Bloomberg / worldsteel',
|
||||||
title: 'هوش مصنوعی، تقاضای جهانی فولاد مراکز داده را بازتعریف میکند',
|
date: '۱۰ اردیبهشت ۱۴۰۵',
|
||||||
titleEn: 'AI Is Rewriting Global Steel Demand for Data Centers',
|
dateFa: '۱۰ اردیبهشت ۱۴۰۵',
|
||||||
desc: 'موج ساخت مراکز داده هوش مصنوعی، تقاضای جدیدی برای مقاطع ساختمانی و فولاد ویژه ایجاد کرده است. تحلیلگران پیشبینی میکنند این روند تا پایان دهه، یکی از پیشرانهای اصلی رشد مصرف فولاد در اقتصادهای پیشرفته باشد.',
|
dateEn: 'Apr 30, 2026',
|
||||||
descEn: 'The AI data-center construction wave is creating fresh demand for structural sections and specialty steel — projected to be a leading driver of steel consumption growth in advanced economies through the decade.',
|
dateIso: '2026-04-30',
|
||||||
|
title: 'مراکز داده هوش مصنوعی، الگوی تقاضای فولاد ویژه را تغییر میدهند',
|
||||||
|
titleEn: 'AI Data Centers Reshape Specialty Steel Demand',
|
||||||
|
desc: 'رشد سرمایهگذاری در مراکز داده، تقاضا برای مقاطع سازهای، ورقهای باکیفیت، تجهیزات سرمایشی و فولادهای مهندسی را بالا میبرد. فرصت برای فولاد مبارکه در محصولاتی است که کیفیت، تحویل قابل اتکا و استاندارد پروژهای بالاتری میخواهند.',
|
||||||
|
descEn: 'AI data-center construction lifts demand for structural sections, high-quality flat steel and engineered steel applications.',
|
||||||
img: '/news/photo-1518770660439-4636190af475.jpg',
|
img: '/news/photo-1518770660439-4636190af475.jpg',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'middle-east-logistics',
|
||||||
|
source: 'MSC / AP / Al Jazeera',
|
||||||
|
date: '۶ اردیبهشت ۱۴۰۵',
|
||||||
|
dateFa: '۶ اردیبهشت ۱۴۰۵',
|
||||||
|
dateEn: 'Apr 26, 2026',
|
||||||
|
dateIso: '2026-04-26',
|
||||||
|
title: 'ریسک مسیرهای دریایی، هزینه صادرات منطقهای فولاد را بالا نگه میدارد',
|
||||||
|
titleEn: 'Maritime Risk Keeps Regional Steel Export Costs Elevated',
|
||||||
|
desc: 'اختلال در مسیرهای دریایی و افزایش حق بیمه حمل، مزیت بازارهای نزدیک را پررنگتر میکند. برای فولاد مبارکه، مدیریت بازارهای عراق، CIS و خلیج فارس باید همزمان با سناریوی حمل جایگزین و قراردادهای منعطف انجام شود.',
|
||||||
|
descEn: 'Shipping disruption and higher insurance costs increase the value of nearby export markets and flexible logistics planning.',
|
||||||
|
img: '/news/photo-1540575467063-178a50c2df87.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'energy-security',
|
||||||
|
source: 'IEA / گزارشهای صنعت فولاد ایران',
|
||||||
|
date: '۲ اردیبهشت ۱۴۰۵',
|
||||||
|
dateFa: '۲ اردیبهشت ۱۴۰۵',
|
||||||
|
dateEn: 'Apr 22, 2026',
|
||||||
|
dateIso: '2026-04-22',
|
||||||
|
title: 'امنیت انرژی، سقف عملیاتی تولید فولاد ایران را تعیین میکند',
|
||||||
|
titleEn: 'Energy Security Sets Iran Steel’s Operating Ceiling',
|
||||||
|
desc: 'ناترازی گاز و برق، دیگر فقط مسئله هزینه نیست؛ ریسک توقف تولید، کاهش قابلیت تحویل و افت اعتماد مشتری را ایجاد میکند. اولویت عملیاتی، قراردادهای انرژی پایدار، بازیافت حرارت و سرمایهگذاری در برق اختصاصی است.',
|
||||||
|
descEn: 'Gas and power imbalance is now an availability risk, not only a cost issue, affecting delivery reliability and customer confidence.',
|
||||||
|
img: '/news/photo-1466611653911-95081537e5b7.jpg',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const normalizePanelItem = (row: Record<string, unknown>, index: number): Glance => {
|
||||||
|
const bodyFa = String(row.bodyFa ?? row.descFa ?? row.descriptionFa ?? '').trim()
|
||||||
|
const bodyEn = String(row.bodyEn ?? row.descEn ?? row.descriptionEn ?? bodyFa).trim()
|
||||||
|
const dateEn = String(row.dateEn ?? row.dateIso ?? row.createdAt ?? row.updatedAt ?? '').trim()
|
||||||
|
const dateFa = String(row.dateFa ?? row.date ?? dateEn).trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(row.id ?? `panel-${index}`),
|
||||||
|
source: String(row.source ?? ''),
|
||||||
|
sourceUrl: resolveSourceUrl(String(row.source ?? ''), String(row.sourceUrl ?? row.source_url ?? row.url ?? row.link ?? '')),
|
||||||
|
date: dateFa,
|
||||||
|
dateFa,
|
||||||
|
dateEn,
|
||||||
|
dateIso: String(row.dateIso ?? row.createdAt ?? row.updatedAt ?? dateEn),
|
||||||
|
createdAt: String(row.createdAt ?? ''),
|
||||||
|
updatedAt: String(row.updatedAt ?? ''),
|
||||||
|
title: String(row.titleFa ?? row.title ?? ''),
|
||||||
|
titleEn: String(row.titleEn ?? row.titleFa ?? row.title ?? ''),
|
||||||
|
desc: bodyFa.split(/\n{2,}/)[0] || bodyFa,
|
||||||
|
descEn: bodyEn.split(/\n{2,}/)[0] || bodyEn,
|
||||||
|
img: String(row.img ?? row.image ?? '') || '/news/photo-1504307651254-35680f356dfd.jpg',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function AtAGlanceSection({ embedded = false }: { embedded?: boolean }) {
|
export default function AtAGlanceSection({ embedded = false }: { embedded?: boolean }) {
|
||||||
const { lang } = useLang()
|
const { lang } = useLang()
|
||||||
const isFa = lang === 'fa'
|
const isFa = lang === 'fa'
|
||||||
|
const [remoteItems, setRemoteItems] = useState<Glance[] | null>(null)
|
||||||
const [i, setI] = useState(0)
|
const [i, setI] = useState(0)
|
||||||
const [dir, setDir] = useState<1 | -1>(1)
|
const [dir, setDir] = useState<1 | -1>(1)
|
||||||
const it = ITEMS[i]
|
|
||||||
|
useEffect(() => {
|
||||||
|
const PANEL_API = import.meta.env.DEV
|
||||||
|
? ''
|
||||||
|
: ((import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || '')
|
||||||
|
let alive = true
|
||||||
|
|
||||||
|
fetch(`${PANEL_API}/api/radar?limit=30`)
|
||||||
|
.then(r => (r.ok ? r.json() : null))
|
||||||
|
.then((rows) => {
|
||||||
|
if (!alive || !Array.isArray(rows) || rows.length === 0) return
|
||||||
|
const mapped = rows
|
||||||
|
.map(normalizePanelItem)
|
||||||
|
.filter(item => item.title && item.desc)
|
||||||
|
if (mapped.length) setRemoteItems(mapped)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to load homepage at-a-glance items', error)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => { alive = false }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const items = useMemo(
|
||||||
|
() => sortByPublishedDesc(remoteItems ?? []),
|
||||||
|
[remoteItems],
|
||||||
|
)
|
||||||
|
const latest = items.slice(0, 5)
|
||||||
|
const it = latest[i] ?? latest[0]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setI(0)
|
||||||
|
}, [items])
|
||||||
|
|
||||||
const go = (d: 1 | -1) => {
|
const go = (d: 1 | -1) => {
|
||||||
setDir(d)
|
setDir(d)
|
||||||
setI((prev) => (prev + d + ITEMS.length) % ITEMS.length)
|
setI((prev) => (prev + d + latest.length) % latest.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!it) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id="at-a-glance"
|
||||||
|
aria-busy="true"
|
||||||
|
style={{ background: '#faf7f4', padding: '52px 0', minHeight: 500, scrollMarginTop: '90px' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const T = {
|
const T = {
|
||||||
|
|
@ -80,8 +196,8 @@ export default function AtAGlanceSection({ embedded = false }: { embedded?: bool
|
||||||
padding: '8px 16px', border: `1.5px solid ${GOLD}`, borderRadius: 8,
|
padding: '8px 16px', border: `1.5px solid ${GOLD}`, borderRadius: 8,
|
||||||
transition: 'background 160ms, color 160ms', whiteSpace: 'nowrap',
|
transition: 'background 160ms, color 160ms', whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = GOLD; (e.currentTarget as HTMLElement).style.color = '#fff' }}
|
onMouseEnter={(e) => { e.currentTarget.style.background = GOLD; e.currentTarget.style.color = '#fff' }}
|
||||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = 'transparent'; (e.currentTarget as HTMLElement).style.color = GOLD }}
|
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = GOLD }}
|
||||||
>
|
>
|
||||||
{isFa ? 'مشاهده بیشتر' : 'See more'}
|
{isFa ? 'مشاهده بیشتر' : 'See more'}
|
||||||
<span>{isFa ? '←' : '→'}</span>
|
<span>{isFa ? '←' : '→'}</span>
|
||||||
|
|
@ -93,7 +209,6 @@ export default function AtAGlanceSection({ embedded = false }: { embedded?: bool
|
||||||
style={{ display: 'flex', flexDirection: isFa ? 'row' : 'row-reverse', alignItems: 'center', gap: 0, minHeight: 380, direction: 'ltr' }}
|
style={{ display: 'flex', flexDirection: isFa ? 'row' : 'row-reverse', alignItems: 'center', gap: 0, minHeight: 380, direction: 'ltr' }}
|
||||||
className="max-lg:!flex-col max-lg:!gap-8"
|
className="max-lg:!flex-col max-lg:!gap-8"
|
||||||
>
|
>
|
||||||
{/* ── IMAGE side ── */}
|
|
||||||
<div style={{ width: '35%', position: 'relative', flexShrink: 0 }} className="max-lg:!w-full max-lg:!max-w-[400px]">
|
<div style={{ width: '35%', position: 'relative', flexShrink: 0 }} className="max-lg:!w-full max-lg:!max-w-[400px]">
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', inset: 0,
|
position: 'absolute', inset: 0,
|
||||||
|
|
@ -107,7 +222,7 @@ export default function AtAGlanceSection({ embedded = false }: { embedded?: bool
|
||||||
>
|
>
|
||||||
<AnimatePresence mode="wait" custom={dir}>
|
<AnimatePresence mode="wait" custom={dir}>
|
||||||
<motion.img
|
<motion.img
|
||||||
key={i}
|
key={it.id}
|
||||||
src={it.img}
|
src={it.img}
|
||||||
alt=""
|
alt=""
|
||||||
initial={{ opacity: 0, scale: 1.06 }}
|
initial={{ opacity: 0, scale: 1.06 }}
|
||||||
|
|
@ -120,7 +235,6 @@ export default function AtAGlanceSection({ embedded = false }: { embedded?: bool
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── TEXT card side ── */}
|
|
||||||
<div style={{ flex: 1, position: 'relative', zIndex: 2 }} className="max-lg:!w-full">
|
<div style={{ flex: 1, position: 'relative', zIndex: 2 }} className="max-lg:!w-full">
|
||||||
<div style={{
|
<div style={{
|
||||||
background: '#fff', borderRadius: 6,
|
background: '#fff', borderRadius: 6,
|
||||||
|
|
@ -133,7 +247,7 @@ export default function AtAGlanceSection({ embedded = false }: { embedded?: bool
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={i}
|
key={it.id}
|
||||||
initial={{ opacity: 0, x: dir * 24 }}
|
initial={{ opacity: 0, x: dir * 24 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
exit={{ opacity: 0, x: dir * -16 }}
|
exit={{ opacity: 0, x: dir * -16 }}
|
||||||
|
|
@ -148,12 +262,41 @@ export default function AtAGlanceSection({ embedded = false }: { embedded?: bool
|
||||||
<CalendarIcon />
|
<CalendarIcon />
|
||||||
{isFa ? it.date : it.dateEn}
|
{isFa ? it.date : it.dateEn}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 14, fontWeight: 700, color: GOLD, letterSpacing: '0.3px' }}>{it.source}</span>
|
{it.source && <SourceLink source={it.source} sourceUrl={it.sourceUrl} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style={{ fontSize: 'clamp(13px, 1.05vw, 15px)', lineHeight: 2.1, color: 'rgba(7,29,73,0.72)', margin: 0, textAlign: 'justify' }}>
|
<p style={{
|
||||||
|
fontSize: 'clamp(13px, 1.05vw, 15px)',
|
||||||
|
lineHeight: 2.1,
|
||||||
|
color: 'rgba(7,29,73,0.72)',
|
||||||
|
margin: 0,
|
||||||
|
textAlign: 'justify',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
WebkitLineClamp: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
{isFa ? it.desc : it.descEn}
|
{isFa ? it.desc : it.descEn}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/at-a-glance"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: 6,
|
||||||
|
width: '100%',
|
||||||
|
marginTop: 14,
|
||||||
|
color: GOLD,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 800,
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isFa ? 'مطالعه بیشتر' : 'Read more'}
|
||||||
|
<span aria-hidden="true">{isFa ? '←' : '→'}</span>
|
||||||
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
|
@ -169,6 +312,33 @@ export default function AtAGlanceSection({ embedded = false }: { embedded?: bool
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SourceLink({ source, sourceUrl }: { source: string; sourceUrl?: string }) {
|
||||||
|
const href = resolveSourceUrl(source, sourceUrl)
|
||||||
|
const style: CSSProperties = {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: GOLD,
|
||||||
|
letterSpacing: '0.3px',
|
||||||
|
textDecoration: href ? 'underline' : 'none',
|
||||||
|
textUnderlineOffset: 3,
|
||||||
|
cursor: href ? 'pointer' : 'default',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!href) return <span style={style}>{source}</span>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target={href.startsWith('http') ? '_blank' : undefined}
|
||||||
|
rel={href.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||||
|
style={style}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{source}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function NavBtn({ children, onClick, aria }: { children: React.ReactNode; onClick: () => void; aria: string }) {
|
function NavBtn({ children, onClick, aria }: { children: React.ReactNode; onClick: () => void; aria: string }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
@ -181,8 +351,8 @@ function NavBtn({ children, onClick, aria }: { children: React.ReactNode; onClic
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
fontFamily: 'inherit', transition: 'background 180ms, color 180ms',
|
fontFamily: 'inherit', transition: 'background 180ms, color 180ms',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = GOLD; (e.currentTarget as HTMLElement).style.color = '#fff' }}
|
onMouseEnter={(e) => { e.currentTarget.style.background = GOLD; e.currentTarget.style.color = '#fff' }}
|
||||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = '#fff'; (e.currentTarget as HTMLElement).style.color = GOLD }}
|
onMouseLeave={(e) => { e.currentTarget.style.background = '#fff'; e.currentTarget.style.color = GOLD }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
import { Download, BookOpen, Podcast, Clock, Calendar, Play, ChevronRight } from 'lucide-react'
|
import { Bookmark, Podcast, Clock, Calendar, Play, ChevronRight } from 'lucide-react'
|
||||||
import { videos as VIDEOS, podcasts as PODCASTS } from '@/data/media'
|
import { bootstrapMedia, videos as VIDEOS, podcasts as PODCASTS } from '@/data/media'
|
||||||
|
import MediaWavePlayer from '@/components/MediaWavePlayer'
|
||||||
|
import { authFetch, useAuth } from '@/context/AuthContext'
|
||||||
|
|
||||||
const NAVY = '#032340'
|
const NAVY = '#032340'
|
||||||
const GOLD = '#CD9E53'
|
const GOLD = '#CD9E53'
|
||||||
|
|
@ -23,10 +26,73 @@ const T = {
|
||||||
|
|
||||||
export default function VideocastPodcastSection({ only }: { only?: 'video' | 'pod' }) {
|
export default function VideocastPodcastSection({ only }: { only?: 'video' | 'pod' }) {
|
||||||
const { lang } = useLang()
|
const { lang } = useLang()
|
||||||
|
const { member } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
const t = T[lang]
|
const t = T[lang]
|
||||||
const [activeVideo, setActiveVideo] = useState(0)
|
const [activeVideo, setActiveVideo] = useState(0)
|
||||||
const [activePod, setActivePod] = useState(0)
|
const [activePod, setActivePod] = useState(() => {
|
||||||
const pod = PODCASTS[activePod]
|
const playableIndex = PODCASTS.findIndex(item => Boolean(item.mediaUrl?.trim()))
|
||||||
|
return playableIndex >= 0 ? playableIndex : 0
|
||||||
|
})
|
||||||
|
const [podcasts, setPodcasts] = useState(() => [...PODCASTS])
|
||||||
|
const [bookmarkedIds, setBookmarkedIds] = useState<Set<string>>(new Set())
|
||||||
|
const [bookmarkBusy, setBookmarkBusy] = useState(false)
|
||||||
|
const pod = podcasts[activePod] ?? podcasts[0]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
bootstrapMedia().then(() => {
|
||||||
|
if (!active) return
|
||||||
|
setPodcasts([...PODCASTS])
|
||||||
|
const playableIndex = PODCASTS.findIndex(item => Boolean(item.mediaUrl?.trim()))
|
||||||
|
setActivePod(playableIndex >= 0 ? playableIndex : 0)
|
||||||
|
})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!member) { setBookmarkedIds(new Set()); return }
|
||||||
|
let active = true
|
||||||
|
authFetch('/api/members/me/bookmarks')
|
||||||
|
.then(async response => response.ok ? response.json() : [])
|
||||||
|
.then(items => {
|
||||||
|
if (!active || !Array.isArray(items)) return
|
||||||
|
setBookmarkedIds(new Set(items.filter(item => item.contentType === 'podcast').map(item => String(item.contentId))))
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [member])
|
||||||
|
|
||||||
|
const togglePodcastBookmark = async () => {
|
||||||
|
if (!member) { navigate('/login'); return }
|
||||||
|
if (!pod || bookmarkBusy) return
|
||||||
|
const contentId = String(pod.id)
|
||||||
|
const saved = bookmarkedIds.has(contentId)
|
||||||
|
setBookmarkBusy(true)
|
||||||
|
try {
|
||||||
|
const response = saved
|
||||||
|
? await authFetch(`/api/members/me/bookmarks/podcast/${encodeURIComponent(contentId)}`, { method: 'DELETE' })
|
||||||
|
: await authFetch('/api/members/me/bookmarks', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
contentId,
|
||||||
|
contentType: 'podcast',
|
||||||
|
title: pod[lang].title,
|
||||||
|
summary: pod[lang].body,
|
||||||
|
coverImage: pod.cover,
|
||||||
|
path: '/podcast',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('bookmark_failed')
|
||||||
|
setBookmarkedIds(current => {
|
||||||
|
const next = new Set(current)
|
||||||
|
if (saved) next.delete(contentId)
|
||||||
|
else next.add(contentId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
} catch { /* keep the previous state when the request fails */ }
|
||||||
|
finally { setBookmarkBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
const showVideo = only !== 'pod'
|
const showVideo = only !== 'pod'
|
||||||
const showPod = only !== 'video'
|
const showPod = only !== 'video'
|
||||||
|
|
@ -200,17 +266,27 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
|
||||||
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
|
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
|
||||||
direction: lang === 'fa' ? 'rtl' : 'ltr',
|
direction: lang === 'fa' ? 'rtl' : 'ltr',
|
||||||
}}>
|
}}>
|
||||||
{/* buttons row + episode number */}
|
{/* bookmark */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12, direction: 'ltr' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-start', marginBottom: 12, direction: 'ltr' }}>
|
||||||
<div style={{ display: 'flex', gap: 10 }}>
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
<button style={{ width: 36, height: 36, borderRadius: '50%', border: '1px solid rgba(3,35,64,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', color: NAVY, cursor: 'pointer', transition: 'all 0.2s' }} className="hover:!bg-[#fff] hover:!border-transparent">
|
<button
|
||||||
<Download size={16} strokeWidth={2} />
|
type="button"
|
||||||
</button>
|
onClick={togglePodcastBookmark}
|
||||||
<button style={{ width: 36, height: 36, borderRadius: '50%', border: '1px solid rgba(3,35,64,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', color: NAVY, cursor: 'pointer', transition: 'all 0.2s' }} className="hover:!bg-[#fff] hover:!border-transparent">
|
disabled={bookmarkBusy}
|
||||||
<BookOpen size={16} strokeWidth={2} />
|
aria-label={bookmarkedIds.has(String(pod.id)) ? 'حذف از علاقهمندیها' : 'افزودن به علاقهمندیها'}
|
||||||
|
title={bookmarkedIds.has(String(pod.id)) ? 'حذف از علاقهمندیها' : 'ذخیره در علاقهمندیها'}
|
||||||
|
style={{
|
||||||
|
width: 36, height: 36, borderRadius: '50%',
|
||||||
|
border: `1px solid ${bookmarkedIds.has(String(pod.id)) ? GOLD : 'rgba(3,35,64,0.18)'}`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: bookmarkedIds.has(String(pod.id)) ? `${GOLD}22` : 'transparent',
|
||||||
|
color: bookmarkedIds.has(String(pod.id)) ? GOLD : NAVY,
|
||||||
|
cursor: bookmarkBusy ? 'wait' : 'pointer', transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Bookmark size={16} strokeWidth={2} fill={bookmarkedIds.has(String(pod.id)) ? 'currentColor' : 'none'} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, fontWeight: 800, color: GOLD, direction: lang === 'fa' ? 'rtl' : 'ltr' }}>{pod.epNum}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* title */}
|
{/* title */}
|
||||||
|
|
@ -227,6 +303,11 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
|
||||||
<div style={{ height: 1, background: 'rgba(3,35,64,0.1)', marginBottom: 16 }} />
|
<div style={{ height: 1, background: 'rgba(3,35,64,0.1)', marginBottom: 16 }} />
|
||||||
|
|
||||||
{/* play + waveform */}
|
{/* play + waveform */}
|
||||||
|
{pod.mediaUrl ? (
|
||||||
|
<div style={{ marginInlineStart: -16, minWidth: 0 }}>
|
||||||
|
<MediaWavePlayer src={pod.mediaUrl} episodeKey={pod.id} tone="light" compact />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexDirection: lang === 'fa' ? 'row-reverse' : 'row', minWidth: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexDirection: lang === 'fa' ? 'row-reverse' : 'row', minWidth: 0 }}>
|
||||||
<button aria-label="play" style={{ flexShrink: 0, width: 44, height: 44, borderRadius: '50%', border: 'none', cursor: 'pointer', background: GOLD, display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'transform 0.2s, background-color 0.2s' }} className="hover:!scale-105 hover:!bg-[#b88d46]">
|
<button aria-label="play" style={{ flexShrink: 0, width: 44, height: 44, borderRadius: '50%', border: 'none', cursor: 'pointer', background: GOLD, display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'transform 0.2s, background-color 0.2s' }} className="hover:!scale-105 hover:!bg-[#b88d46]">
|
||||||
<Play size={18} fill="#fff" color="#fff" style={{ marginLeft: 2 }} />
|
<Play size={18} fill="#fff" color="#fff" style={{ marginLeft: 2 }} />
|
||||||
|
|
@ -241,6 +322,7 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
|
||||||
<span style={{ fontSize: 11, color: 'rgba(3,35,64,0.6)', fontWeight: 700, flexShrink: 0 }}>{pod.duration}</span>
|
<span style={{ fontSize: 11, color: 'rgba(3,35,64,0.6)', fontWeight: 700, flexShrink: 0 }}>{pod.duration}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -259,8 +341,8 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: 16
|
gap: 16
|
||||||
}}>
|
}}>
|
||||||
{PODCASTS.filter((_, idx) => idx !== activePod).slice(0, 2).map((p) => {
|
{podcasts.filter((_, idx) => idx !== activePod).slice(0, 2).map((p) => {
|
||||||
const originalIndex = PODCASTS.findIndex(item => item.id === p.id);
|
const originalIndex = podcasts.findIndex(item => item.id === p.id);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
key={p.id}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
import { Headphones, ChevronLeft, ChevronRight, PlayCircle, Play, Pause, Volume2, VolumeX } from 'lucide-react'
|
import { authFetch, useAuth } from '@/context/AuthContext'
|
||||||
|
import { ChevronLeft, ChevronRight, PlayCircle, Play, Pause, Volume2, VolumeX, CalendarDays, Bookmark, Mic2 } from 'lucide-react'
|
||||||
import { podcasts as PODCASTS, videos as VIDEOS } from '@/data/media'
|
import { podcasts as PODCASTS, videos as VIDEOS } from '@/data/media'
|
||||||
import FeaturedReportBanner from '../Home/sections/FeaturedReportBanner'
|
import FeaturedReportBanner from '../Home/sections/FeaturedReportBanner'
|
||||||
|
|
||||||
const NAVY = '#032340'
|
const NAVY = '#032340'
|
||||||
const GOLD = '#CD9E53'
|
const GOLD = '#CD9E53'
|
||||||
const CARD = '#0d2137'
|
|
||||||
|
|
||||||
const T = {
|
const T = {
|
||||||
fa: { listen: 'پخش', watch: 'تماشا', allVids: 'همه ویدیوها', videocast: 'ویدیوکست', podcast: 'پادکست اندیشکده', episodes: 'اپیزودها' },
|
fa: { listen: 'پخش', watch: 'تماشا', allVids: 'همه ویدیوها', videocast: 'ویدیوکست', podcast: 'پادکست اندیشکده', episodes: 'اپیزودها' },
|
||||||
|
|
@ -41,9 +42,167 @@ const STYLE = `
|
||||||
background: conic-gradient(from var(--angle), #7a4f00, #CD9E53, #f0d080, #fff8e1, #CD9E53, #7a4f00);
|
background: conic-gradient(from var(--angle), #7a4f00, #CD9E53, #f0d080, #fff8e1, #CD9E53, #7a4f00);
|
||||||
box-shadow: 0 0 24px #CD9E5366, 0 0 48px #CD9E5322;
|
box-shadow: 0 0 24px #CD9E5366, 0 0 48px #CD9E5322;
|
||||||
}
|
}
|
||||||
|
.podcast-figma-feature { display:grid; grid-template-columns:minmax(0,1fr) 195px; }
|
||||||
|
.podcast-figma-content { padding:28px 30px 22px; }
|
||||||
|
.podcast-figma-cover { min-height:212px; }
|
||||||
|
.podcast-figma-intro { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:72px; }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.podcast-figma-feature { grid-template-columns:1fr; }
|
||||||
|
.podcast-figma-cover { min-height:220px; order:-1; }
|
||||||
|
.podcast-figma-content { padding:22px 18px 18px; }
|
||||||
|
.podcast-figma-intro { grid-template-columns:1fr; gap:24px; }
|
||||||
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
/* ══ Real waveform audio player ══ */
|
/* ══ Real waveform audio player ══ */
|
||||||
|
function mediaWaveBars(seedValue: string | number, count = 74) {
|
||||||
|
let seed = String(seedValue).split('').reduce((sum, char) => sum + char.charCodeAt(0), 17)
|
||||||
|
return Array.from({ length: count }, (_, index) => {
|
||||||
|
seed = (seed * 9301 + 49297) % 233280
|
||||||
|
const random = seed / 233280
|
||||||
|
const envelope = 0.48 + 0.28 * Math.sin(index * 0.31) + 0.16 * Math.sin(index * 0.83)
|
||||||
|
return Math.max(0.16, Math.min(0.96, envelope + random * 0.3))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══ Uploaded-file waveform player ══ */
|
||||||
|
function MediaWavePlayer({ src, episodeKey, light = false }: { src: string; episodeKey: string | number; light?: boolean }) {
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null)
|
||||||
|
const [playing, setPlaying] = useState(false)
|
||||||
|
const [muted, setMuted] = useState(false)
|
||||||
|
const [current, setCurrent] = useState(0)
|
||||||
|
const [duration, setDuration] = useState(0)
|
||||||
|
const [failed, setFailed] = useState(false)
|
||||||
|
const bars = useMemo(() => mediaWaveBars(episodeKey), [episodeKey])
|
||||||
|
const progress = duration > 0 ? Math.min(current / duration, 1) : 0
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio) return
|
||||||
|
audio.pause()
|
||||||
|
audio.currentTime = 0
|
||||||
|
setPlaying(false)
|
||||||
|
setMuted(false)
|
||||||
|
setCurrent(0)
|
||||||
|
setDuration(0)
|
||||||
|
setFailed(false)
|
||||||
|
audio.load()
|
||||||
|
}, [src, episodeKey])
|
||||||
|
|
||||||
|
const toggle = async () => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio || failed) return
|
||||||
|
if (audio.paused) {
|
||||||
|
try {
|
||||||
|
await audio.play()
|
||||||
|
setPlaying(true)
|
||||||
|
} catch {
|
||||||
|
setFailed(true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
audio.pause()
|
||||||
|
setPlaying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seek = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio || !duration) return
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect()
|
||||||
|
const ratio = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width))
|
||||||
|
audio.currentTime = ratio * duration
|
||||||
|
setCurrent(audio.currentTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio) return
|
||||||
|
audio.muted = !audio.muted
|
||||||
|
setMuted(audio.muted)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (seconds: number) => {
|
||||||
|
if (!Number.isFinite(seconds) || seconds < 0) return '0:00'
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
return `${minutes}:${String(Math.floor(seconds % 60)).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={src}
|
||||||
|
preload="metadata"
|
||||||
|
onLoadedMetadata={(event) => setDuration(Number.isFinite(event.currentTarget.duration) ? event.currentTarget.duration : 0)}
|
||||||
|
onDurationChange={(event) => setDuration(Number.isFinite(event.currentTarget.duration) ? event.currentTarget.duration : 0)}
|
||||||
|
onTimeUpdate={(event) => setCurrent(event.currentTarget.currentTime)}
|
||||||
|
onPlay={() => setPlaying(true)}
|
||||||
|
onPause={() => setPlaying(false)}
|
||||||
|
onEnded={() => { setPlaying(false); setCurrent(0) }}
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="slider"
|
||||||
|
aria-label="موقعیت پخش پادکست"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={Math.max(0, Math.round(duration))}
|
||||||
|
aria-valuenow={Math.round(current)}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={seek}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio || !duration) return
|
||||||
|
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
|
||||||
|
event.preventDefault()
|
||||||
|
const delta = event.key === 'ArrowRight' ? 5 : -5
|
||||||
|
audio.currentTime = Math.max(0, Math.min(duration, audio.currentTime + delta))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%', maxWidth: '100%', height: 64, display: 'flex', alignItems: 'center', gap: 2,
|
||||||
|
cursor: duration ? 'pointer' : 'default', direction: 'ltr',
|
||||||
|
borderRadius: 10, padding: '0 4px', outline: 'none', overflow: 'hidden', boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bars.map((height, index) => {
|
||||||
|
const played = (index + 0.5) / bars.length <= progress
|
||||||
|
return (
|
||||||
|
<span key={index} style={{
|
||||||
|
flex: '1 1 0', minWidth: 1, maxWidth: 6,
|
||||||
|
height: `${Math.round(height * 54)}px`, borderRadius: 999,
|
||||||
|
background: played ? GOLD : (light ? 'rgba(3,35,64,0.55)' : 'rgba(255,255,255,0.18)'),
|
||||||
|
boxShadow: played ? `0 0 8px ${GOLD}55` : 'none',
|
||||||
|
transition: 'background-color 160ms ease, box-shadow 160ms ease',
|
||||||
|
}} />
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, direction: 'ltr' }}>
|
||||||
|
<button type="button" onClick={toggle} disabled={failed} aria-label={playing ? 'توقف پادکست' : 'پخش پادکست'} style={{
|
||||||
|
width: 42, height: 42, borderRadius: '50%', border: 'none', flexShrink: 0,
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: failed ? 'rgba(3,35,64,0.12)' : GOLD,
|
||||||
|
color: NAVY, cursor: failed ? 'not-allowed' : 'pointer',
|
||||||
|
boxShadow: playing ? `0 0 18px ${GOLD}66` : 'none',
|
||||||
|
}}>
|
||||||
|
{playing ? <Pause size={17} fill={NAVY} /> : <Play size={17} fill={NAVY} style={{ marginLeft: 2 }} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span style={{ minWidth: 38, color: light ? NAVY : '#fff', fontSize: 11, fontWeight: 800 }}>{fmt(current)}</span>
|
||||||
|
<span style={{ minWidth: 38, textAlign: 'right', color: light ? 'rgba(3,35,64,0.55)' : 'rgba(255,255,255,0.48)', fontSize: 11, fontWeight: 700 }}>{fmt(duration)}</span>
|
||||||
|
<button type="button" onClick={toggleMute} aria-label={muted ? 'فعال کردن صدا' : 'قطع صدا'} style={{ border: 0, background: 'transparent', padding: 5, cursor: 'pointer', display: 'flex' }}>
|
||||||
|
{muted ? <VolumeX size={17} color={light ? NAVY : 'rgba(255,255,255,0.55)'} /> : <Volume2 size={17} color={light ? NAVY : 'rgba(255,255,255,0.55)'} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{failed && <div style={{ color: '#fca5a5', fontSize: 11, textAlign: 'right' }}>فایل صوتی قابل پخش نیست. آدرس فایل یا فرمت آن را بررسی کنید.</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function WavePlayer({ episodeKey }: { episodeKey: string | number }) {
|
function WavePlayer({ episodeKey }: { episodeKey: string | number }) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
const animRef = useRef<number>(0)
|
const animRef = useRef<number>(0)
|
||||||
|
|
@ -308,67 +467,56 @@ function WavePlayer({ episodeKey }: { episodeKey: string | number }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── active glow pill ─── */
|
|
||||||
function ActivePill({ active }: { active: boolean }) {
|
|
||||||
return active ? (
|
|
||||||
<span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: GOLD, boxShadow: `0 0 8px ${GOLD}`, flexShrink: 0 }} />
|
|
||||||
) : 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 (
|
|
||||||
<button onClick={onClick} style={{
|
|
||||||
all: 'unset', display: 'flex', alignItems: 'center', gap: 12,
|
|
||||||
padding: '10px 12px', borderRadius: 12, cursor: 'pointer',
|
|
||||||
background: active ? 'rgba(205,158,83,0.10)' : 'transparent',
|
|
||||||
border: active ? `1px solid rgba(205,158,83,0.25)` : '1px solid transparent',
|
|
||||||
transition: 'all .2s', width: '100%', boxSizing: 'border-box',
|
|
||||||
flexDirection: isFa ? 'row-reverse' : 'row',
|
|
||||||
}}>
|
|
||||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
|
||||||
<img src={pod.cover} alt="" style={{
|
|
||||||
width: 48, height: 48, borderRadius: 10, objectFit: 'cover', display: 'block',
|
|
||||||
border: active ? `2px solid ${GOLD}` : '2px solid rgba(255,255,255,0.08)',
|
|
||||||
transition: 'border-color .2s',
|
|
||||||
}} />
|
|
||||||
{active && (
|
|
||||||
<div style={{ position: 'absolute', inset: 0, borderRadius: 10, background: 'rgba(205,158,83,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
||||||
<div style={{ width: 0, height: 0, borderTop: '5px solid transparent', borderBottom: '5px solid transparent', borderLeft: `8px solid ${GOLD}`, marginLeft: 2 }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, minWidth: 0, textAlign: isFa ? 'right' : 'left' }}>
|
|
||||||
<div style={{
|
|
||||||
fontSize: 11.5, fontWeight: 800, color: active ? '#fff' : 'rgba(255,255,255,0.65)',
|
|
||||||
lineHeight: 1.4, marginBottom: 3,
|
|
||||||
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
|
|
||||||
transition: 'color .2s',
|
|
||||||
}}>{pod[lang].title}</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, direction: 'ltr', justifyContent: isFa ? 'flex-end' : 'flex-start' }}>
|
|
||||||
<ActivePill active={active} />
|
|
||||||
<span style={{ fontSize: 10, color: active ? GOLD : 'rgba(255,255,255,0.25)', fontWeight: 700 }}>{pod.epNum}</span>
|
|
||||||
<span style={{ fontSize: 10, color: 'rgba(255,255,255,0.2)' }}>·</span>
|
|
||||||
<span style={{ fontSize: 10, color: 'rgba(255,255,255,0.25)' }}>{pod.duration}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══ MAIN ══ */
|
/* ══ MAIN ══ */
|
||||||
export default function Podcast() {
|
export default function Podcast() {
|
||||||
const { lang } = useLang()
|
const { lang } = useLang()
|
||||||
|
const { member } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
const t = T[lang]
|
const t = T[lang]
|
||||||
const isFa = lang === 'fa'
|
const isFa = lang === 'fa'
|
||||||
const [active, setActive] = useState(0)
|
const [active, setActive] = useState(0)
|
||||||
const [activeVid, setActiveVid] = useState(0)
|
const [activeVid, setActiveVid] = useState(0)
|
||||||
const [vidPlaying, setVidPlaying] = useState(false)
|
const [vidPlaying, setVidPlaying] = useState(false)
|
||||||
|
const [bookmarkedIds, setBookmarkedIds] = useState<Set<string>>(new Set())
|
||||||
|
const [bookmarkBusy, setBookmarkBusy] = useState(false)
|
||||||
const pod = PODCASTS[active]
|
const pod = PODCASTS[active]
|
||||||
const vid = VIDEOS[activeVid]
|
const vid = VIDEOS[activeVid]
|
||||||
|
const showPodGuest = Boolean(pod[lang].guest?.trim())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!member) { setBookmarkedIds(new Set()); return }
|
||||||
|
let mounted = true
|
||||||
|
authFetch('/api/members/me/bookmarks')
|
||||||
|
.then(async response => response.ok ? response.json() : [])
|
||||||
|
.then(items => {
|
||||||
|
if (!mounted || !Array.isArray(items)) return
|
||||||
|
setBookmarkedIds(new Set(items.filter(item => item.contentType === 'podcast').map(item => String(item.contentId))))
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return () => { mounted = false }
|
||||||
|
}, [member])
|
||||||
|
|
||||||
|
const togglePodcastBookmark = async () => {
|
||||||
|
if (!member) { navigate('/login'); return }
|
||||||
|
if (bookmarkBusy) return
|
||||||
|
const contentId = String(pod.id)
|
||||||
|
const saved = bookmarkedIds.has(contentId)
|
||||||
|
setBookmarkBusy(true)
|
||||||
|
try {
|
||||||
|
const response = saved
|
||||||
|
? await authFetch(`/api/members/me/bookmarks/podcast/${encodeURIComponent(contentId)}`, { method: 'DELETE' })
|
||||||
|
: await authFetch('/api/members/me/bookmarks', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ contentId, contentType: 'podcast', title: pod[lang].title, summary: pod[lang].body, coverImage: pod.cover, path: '/podcast' }),
|
||||||
|
})
|
||||||
|
if (!response.ok) return
|
||||||
|
setBookmarkedIds(current => {
|
||||||
|
const next = new Set(current)
|
||||||
|
if (saved) next.delete(contentId); else next.add(contentId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
} finally { setBookmarkBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div dir={isFa ? 'rtl' : 'ltr'} style={{ background: '#fff', minHeight: '100vh', color: '#fff', ['--paper' as string]: '#fff', ['--page-bg' as string]: '#fff' }}>
|
<div dir={isFa ? 'rtl' : 'ltr'} style={{ background: '#fff', minHeight: '100vh', color: '#fff', ['--paper' as string]: '#fff', ['--page-bg' as string]: '#fff' }}>
|
||||||
|
|
@ -376,167 +524,70 @@ export default function Podcast() {
|
||||||
|
|
||||||
{/* ══ PODCAST SECTION ══ */}
|
{/* ══ PODCAST SECTION ══ */}
|
||||||
<div style={{ background: 'transparent' }}>
|
<div style={{ background: 'transparent' }}>
|
||||||
<div className="max-w-7xl mx-auto px-8 max-md:px-4" style={{ paddingTop: 56, paddingBottom: 64 }}>
|
<div className="max-w-7xl mx-auto px-8 max-md:px-4" style={{ paddingTop: 56, paddingBottom: 72 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
{/* label */}
|
<span style={{ color: '#606060', fontSize: 14 }}>{isFa ? 'مشاهده همه' : 'View all'}</span>
|
||||||
<div style={{
|
<h1 style={{ margin: 0, color: NAVY, fontSize: 24, fontWeight: 800, borderRight: isFa ? `5px solid ${GOLD}` : 0, borderLeft: !isFa ? `5px solid ${GOLD}` : 0, paddingRight: isFa ? 12 : 0, paddingLeft: !isFa ? 12 : 0 }}>
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
{isFa ? 'پادکستها' : 'Podcasts'}
|
||||||
background: 'rgba(205,158,83,0.12)', border: '1px solid rgba(205,158,83,0.25)',
|
</h1>
|
||||||
borderRadius: 20, padding: '5px 14px', marginBottom: 32,
|
|
||||||
}}>
|
|
||||||
<Headphones size={13} color={GOLD} strokeWidth={2} />
|
|
||||||
<span style={{ fontSize: 12, fontWeight: 800, color: GOLD, letterSpacing: '0.04em' }}>{t.podcast}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* main grid */}
|
<section className="podcast-figma-feature" style={{ background: '#ededed', borderRadius: 16, overflow: 'hidden', color: NAVY }}>
|
||||||
<div style={{
|
<div className="podcast-figma-content" style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||||
display: 'grid',
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||||
gridTemplateColumns: isFa ? '1fr 300px' : '300px 1fr',
|
<button type="button" onClick={togglePodcastBookmark} disabled={bookmarkBusy} aria-label={bookmarkedIds.has(String(pod.id)) ? (isFa ? 'حذف از علاقهمندیها' : 'Remove bookmark') : (isFa ? 'ذخیره در علاقهمندیها' : 'Bookmark')} style={{ width: 36, height: 36, border: 0, background: 'transparent', color: bookmarkedIds.has(String(pod.id)) ? GOLD : NAVY, cursor: bookmarkBusy ? 'wait' : 'pointer', display: 'grid', placeItems: 'center' }}>
|
||||||
gap: 0,
|
<Bookmark size={27} strokeWidth={1.6} fill={bookmarkedIds.has(String(pod.id)) ? 'currentColor' : 'none'} />
|
||||||
background: CARD,
|
</button>
|
||||||
borderRadius: 28,
|
<div style={{ textAlign: isFa ? 'right' : 'left' }}>
|
||||||
overflow: 'hidden',
|
<div style={{ color: GOLD, fontSize: 17, marginBottom: 14 }}>{pod.epNum}</div>
|
||||||
boxShadow: '0 32px 80px -24px rgba(0,0,0,0.7)',
|
<h2 style={{ margin: 0, color: NAVY, fontSize: 'clamp(20px,2.2vw,29px)', lineHeight: 1.45, fontWeight: 700 }}>{pod[lang].title}</h2>
|
||||||
minHeight: 560,
|
<div style={{ marginTop: 10, display: 'flex', alignItems: 'center', gap: 7, color: '#606060', fontSize: 13, justifyContent: isFa ? 'flex-start' : 'flex-end' }}>
|
||||||
}} className="max-lg:!grid-cols-1">
|
<CalendarDays size={19} color={GOLD} />
|
||||||
|
<span>{pod.date}</span>
|
||||||
{/* sidebar */}
|
</div>
|
||||||
<div style={{
|
</div>
|
||||||
borderRight: isFa ? 'none' : '1px solid rgba(255,255,255,0.06)',
|
|
||||||
borderLeft: isFa ? '1px solid rgba(255,255,255,0.06)' : 'none',
|
|
||||||
display: 'flex', flexDirection: 'column',
|
|
||||||
order: isFa ? 2 : 1,
|
|
||||||
}}>
|
|
||||||
<div style={{ padding: '20px 16px 14px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
|
||||||
<div style={{ fontSize: 11, fontWeight: 900, color: 'rgba(255,255,255,0.3)', letterSpacing: '0.08em', textTransform: 'uppercase' }}>{t.episodes}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 8px', display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<div style={{ height: 1, background: '#adadad', margin: '16px 0 4px' }} />
|
||||||
{PODCASTS.map((p, i) => (
|
<div style={{ marginTop: 'auto' }}>
|
||||||
<EpItem key={p.id} pod={p} active={active === i} onClick={() => setActive(i)} lang={lang} />
|
{pod.mediaUrl
|
||||||
))}
|
? <MediaWavePlayer src={pod.mediaUrl} episodeKey={pod.id} light />
|
||||||
|
: <WavePlayer episodeKey={pod.id} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* hero */}
|
<div className="podcast-figma-cover" style={{ position: 'relative', background: NAVY }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', order: isFa ? 1 : 2, position: 'relative', overflow: 'hidden' }}>
|
<img src={pod.cover} alt={pod[lang].title} style={{ width: '100%', height: '100%', minHeight: 'inherit', objectFit: 'cover', display: 'block' }} />
|
||||||
|
|
||||||
{/* blurred bg */}
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', inset: 0,
|
|
||||||
backgroundImage: `url(${pod.cover})`,
|
|
||||||
backgroundSize: 'cover', backgroundPosition: 'center',
|
|
||||||
filter: 'blur(48px) saturate(0.3)', opacity: 0.2, transform: 'scale(1.15)',
|
|
||||||
}} />
|
|
||||||
<div style={{ position: 'absolute', inset: 0, background: `linear-gradient(135deg, ${CARD} 35%, rgba(13,33,55,0.6) 100%)` }} />
|
|
||||||
|
|
||||||
{/* inner grid: text | cover-image */}
|
|
||||||
<div style={{
|
|
||||||
position: 'relative', zIndex: 1,
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: isFa ? '300px 1fr' : '1fr 300px',
|
|
||||||
height: '100%',
|
|
||||||
}} className="max-lg:!flex max-lg:!flex-col">
|
|
||||||
|
|
||||||
{/* ── COVER image column ── */}
|
|
||||||
{/* outer: spinning gradient border */}
|
|
||||||
<div style={{
|
|
||||||
order: isFa ? 1 : 2,
|
|
||||||
margin: 10,
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: 3,
|
|
||||||
background: 'conic-gradient(from var(--angle,0deg), #7a4f00, #CD9E53, #f0d080, #fff8e1, #CD9E53, #7a4f00)',
|
|
||||||
animation: 'borderRotate 3s linear infinite',
|
|
||||||
boxShadow: `0 0 28px ${GOLD}55, 0 0 56px ${GOLD}22`,
|
|
||||||
flexShrink: 0,
|
|
||||||
alignSelf: 'stretch',
|
|
||||||
}} className="max-lg:!order-first max-lg:!my-4 max-lg:!h-56 max-lg:!self-auto">
|
|
||||||
|
|
||||||
{/* inner: clips image to rounded rect */}
|
|
||||||
<div style={{ borderRadius: 13, overflow: 'hidden', height: '100%', position: 'relative' }}>
|
|
||||||
<img
|
|
||||||
src={pod.cover}
|
|
||||||
alt={pod[lang].title}
|
|
||||||
style={{
|
|
||||||
position: 'absolute', inset: 0,
|
|
||||||
width: '100%', height: '100%',
|
|
||||||
objectFit: 'cover',
|
|
||||||
objectPosition: 'center',
|
|
||||||
display: 'block',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', inset: 0, pointerEvents: 'none',
|
|
||||||
background: isFa
|
|
||||||
? 'linear-gradient(to left, rgba(13,33,55,0) 50%, rgba(13,33,55,0.85) 100%)'
|
|
||||||
: 'linear-gradient(to right, rgba(13,33,55,0) 50%, rgba(13,33,55,0.85) 100%)',
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── text column ── */}
|
|
||||||
<div style={{
|
|
||||||
order: isFa ? 2 : 1,
|
|
||||||
display: 'flex', flexDirection: 'column',
|
|
||||||
padding: '36px 36px 28px',
|
|
||||||
}} className="max-lg:!px-6 max-lg:!py-6">
|
|
||||||
|
|
||||||
{/* ep badge */}
|
|
||||||
<div style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
|
||||||
background: 'rgba(255,255,255,0.07)', border: '1px solid rgba(255,255,255,0.12)',
|
|
||||||
borderRadius: 8, padding: '4px 10px', marginBottom: 20, width: 'fit-content',
|
|
||||||
}}>
|
|
||||||
<span style={{ fontSize: 11, fontWeight: 900, color: GOLD }}>{pod.epNum}</span>
|
|
||||||
<span style={{ width: 1, height: 10, background: 'rgba(255,255,255,0.15)' }} />
|
|
||||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.4)', fontWeight: 600 }}>{pod.date}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* title */}
|
|
||||||
<h1 style={{
|
|
||||||
fontSize: 'clamp(17px,1.8vw,24px)', fontWeight: 900, color: '#fff',
|
|
||||||
lineHeight: 1.4, margin: '0 0 10px', letterSpacing: '-0.3px',
|
|
||||||
textAlign: isFa ? 'right' : 'left',
|
|
||||||
}}>{pod[lang].title}</h1>
|
|
||||||
|
|
||||||
{/* guest */}
|
|
||||||
<div style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
|
||||||
background: 'rgba(205,158,83,0.12)', borderRadius: 6, padding: '4px 10px',
|
|
||||||
marginBottom: 16, width: 'fit-content',
|
|
||||||
}}>
|
|
||||||
<div style={{ width: 5, height: 5, borderRadius: '50%', background: GOLD }} />
|
|
||||||
<span style={{ fontSize: 12, color: GOLD, fontWeight: 700 }}>{pod[lang].guest}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* body */}
|
|
||||||
<p style={{
|
|
||||||
fontSize: 12.5, lineHeight: 1.9, color: 'rgba(255,255,255,0.45)',
|
|
||||||
margin: '0 0 20px', textAlign: 'justify', flex: 1,
|
|
||||||
display: '-webkit-box', WebkitLineClamp: 4, WebkitBoxOrient: 'vertical', overflow: 'hidden',
|
|
||||||
}}>{pod[lang].body}</p>
|
|
||||||
|
|
||||||
{/* ── AUDIO PLAYER ── */}
|
|
||||||
<div style={{
|
|
||||||
background: 'rgba(0,0,0,0.25)', borderRadius: 16,
|
|
||||||
padding: '14px 16px', marginBottom: 20,
|
|
||||||
border: '1px solid rgba(255,255,255,0.07)',
|
|
||||||
}}>
|
|
||||||
{pod.mediaUrl
|
|
||||||
? <audio controls preload="none" src={pod.mediaUrl} style={{ width: '100%', display: 'block' }} />
|
|
||||||
: <WavePlayer episodeKey={pod.id} />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* bottom: rating only */}
|
|
||||||
<div style={{ paddingTop: 16, borderTop: '1px solid rgba(255,255,255,0.07)' }}>
|
|
||||||
<span style={{ fontSize: 12, color: GOLD, fontWeight: 800, background: 'rgba(205,158,83,0.1)', padding: '3px 10px', borderRadius: 6 }}>
|
|
||||||
★ {pod.rating}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
|
<section className="podcast-figma-intro" style={{ marginTop: 54, color: NAVY, alignItems: 'start' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||||||
|
<Mic2 size={28} color={GOLD} />
|
||||||
|
<h2 style={{ margin: 0, fontSize: 24, lineHeight: 1.5 }}>{pod[lang].title}</h2>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: 0, color: '#989898', fontSize: 16, lineHeight: 2.05, textAlign: 'justify' }}>{pod[lang].body}</p>
|
||||||
|
{showPodGuest && (
|
||||||
|
<div style={{ marginTop: 18, fontSize: 16, color: NAVY, fontWeight: 700 }}>
|
||||||
|
{isFa ? 'مهمان برنامه: ' : 'Guest: '}<span style={{ color: GOLD }}>{pod[lang].guest}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 14 }}>
|
||||||
|
{PODCASTS.filter((_, index) => index !== active).slice(0, 2).map((item) => {
|
||||||
|
const sourceIndex = PODCASTS.indexOf(item)
|
||||||
|
return (
|
||||||
|
<button key={item.id} type="button" onClick={() => setActive(sourceIndex)} style={{ border: 0, background: '#f9f9f9', borderRadius: 12, padding: 8, display: 'grid', gridTemplateColumns: isFa ? '1fr 132px' : '132px 1fr', gap: 18, alignItems: 'center', minHeight: 142, cursor: 'pointer', color: NAVY, textAlign: isFa ? 'right' : 'left', fontFamily: 'inherit' }}>
|
||||||
|
<div style={{ padding: '12px 14px' }}>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700, marginBottom: 16 }}>{item[lang].title}</div>
|
||||||
|
<div style={{ color: '#606060', fontSize: 13 }}>{item.duration}</div>
|
||||||
|
</div>
|
||||||
|
<img src={item.cover} alt="" style={{ width: '100%', height: 126, borderRadius: 16, objectFit: 'cover', order: isFa ? 2 : 0 }} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -797,7 +848,9 @@ export default function Podcast() {
|
||||||
fontSize: 13.5, fontWeight: 800, color: NAVY, lineHeight: 1.45, marginBottom: 4,
|
fontSize: 13.5, fontWeight: 800, color: NAVY, lineHeight: 1.45, marginBottom: 4,
|
||||||
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
|
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
|
||||||
}}>{p[lang].title}</div>
|
}}>{p[lang].title}</div>
|
||||||
<div style={{ fontSize: 11.5, color: '#666', fontWeight: 600 }}>{p[lang].guest}</div>
|
{Boolean(p[lang].guest?.trim()) && (
|
||||||
|
<div style={{ fontSize: 11.5, color: '#666', fontWeight: 600 }}>{p[lang].guest}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 8, direction: 'ltr', justifyContent: isFa ? 'flex-end' : 'flex-start' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 8, direction: 'ltr', justifyContent: isFa ? 'flex-end' : 'flex-start' }}>
|
||||||
<span style={{ fontSize: 10.5, color: GOLD, fontWeight: 700 }}>★ {p.rating}</span>
|
<span style={{ fontSize: 10.5, color: GOLD, fontWeight: 700 }}>★ {p.rating}</span>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Bookmark, CalendarDays, ChevronLeft, Mic2, Pause, Play, RotateCcw, RotateCw, Video, Volume2, VolumeX } from 'lucide-react'
|
||||||
|
import { useLang } from '@/context/LangContext'
|
||||||
|
import { authFetch, useAuth } from '@/context/AuthContext'
|
||||||
|
import { podcasts as PODCASTS, videos as VIDEOS } from '@/data/media'
|
||||||
|
|
||||||
|
const NAVY = '#032340'
|
||||||
|
const GOLD = '#CD9E53'
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
.media-page{background:#fff;color:#032340;padding:48px 0 96px}.media-shell{width:min(1132px,calc(100% - 40px));margin:auto}.media-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:18px}.media-heading h1,.media-heading h2{font-size:24px;font-weight:700;margin:0;border-inline-start:5px solid #CD9E53;padding-inline-start:13px}.media-view-all{display:inline-flex;align-items:center;gap:7px;color:#606060;font-size:14px}.video-stage{position:relative;width:100%;aspect-ratio:1.9/1;border-radius:16px;overflow:hidden;background:#061b2f}.video-stage img,.video-stage video{width:100%;height:100%;object-fit:cover;display:block}.video-stage video{object-fit:contain;background:#000}.video-overlay{position:absolute;inset:42% 0 0;background:linear-gradient(0deg,rgba(25,65,101,.92),transparent)}.video-play{position:absolute;inset:0;margin:auto;width:70px;height:70px;border:0;border-radius:50%;background:rgba(255,255,255,.9);color:#032340;display:grid;place-items:center;cursor:pointer;box-shadow:0 12px 36px rgba(0,0,0,.25)}.video-meta{position:absolute;inset:auto 28px 22px;display:flex;align-items:center;justify-content:space-between;color:#fff}.video-meta strong{font-size:16px}.video-meta span{font-size:13px;opacity:.86}.video-details{display:grid;grid-template-columns:491px minmax(0,1fr);gap:76px;align-items:start;margin:42px 0 78px}.playlist-title,.story-title{display:flex;align-items:center;justify-content:flex-start;gap:11px;font-size:23px;font-weight:700;margin-bottom:18px}.playlist{display:flex;flex-direction:column;gap:18px}.playlist-item{all:unset;box-sizing:border-box;cursor:pointer;display:grid;grid-template-columns:minmax(0,1fr) 266px;align-items:center;gap:14px;text-align:right;color:#032340}.playlist-copy strong{display:block;font-size:15px;line-height:1.65;margin-bottom:8px}.playlist-copy span{display:flex;align-items:center;gap:6px;color:#777;font-size:12px}.playlist-thumb{height:102px;border-radius:8px;overflow:hidden;position:relative}.playlist-thumb img{width:100%;height:100%;object-fit:cover}.playlist-thumb i{position:absolute;inset:0;margin:auto;width:42px;height:42px;border-radius:50%;background:rgba(255,255,255,.92);display:grid;place-items:center;color:#032340}.playlist-item.active strong{color:#CD9E53}.video-story p,.pod-story p{margin:0;color:#7c7c7c;font-size:15px;line-height:2.25;text-align:justify}.video-story .tags{color:#CD9E53;margin-top:12px}.podcast-section{padding-top:2px}.pod-feature{display:grid;grid-template-columns:minmax(0,1fr) 195px;background:#ededed;border-radius:16px;overflow:hidden;min-height:248px}.pod-feature-main{padding:25px 28px 18px;display:flex;flex-direction:column;min-width:0}.pod-topline{display:flex;justify-content:space-between;gap:20px}.pod-label{color:#CD9E53;font-size:16px;margin-bottom:9px}.pod-title{font-size:22px;line-height:1.55;margin:0;font-weight:700}.pod-date{display:flex;align-items:center;gap:6px;color:#777;font-size:12px;margin-top:8px}.bookmark-btn{width:38px;height:38px;border:0;background:transparent;color:#032340;display:grid;place-items:center;cursor:pointer}.wave-wrap{margin-top:auto;border-top:1px solid #b6b6b6;padding-top:9px}.pod-cover{min-height:248px}.pod-cover img{width:100%;height:100%;object-fit:cover;display:block}.player-wave{height:58px;display:flex;align-items:center;gap:2px;direction:ltr;overflow:hidden;cursor:pointer}.player-wave span{flex:1 1 0;min-width:1px;max-width:5px;border-radius:9px;background:rgba(3,35,64,.35)}.player-wave span.played{background:#CD9E53}.player-controls{display:flex;align-items:center;gap:12px;direction:ltr;color:#032340;font-size:11px}.round-play{width:38px;height:38px;border:0;border-radius:50%;background:#CD9E53;color:#032340;display:grid;place-items:center;cursor:pointer}.mute{border:0;background:transparent;color:#032340;display:grid;place-items:center;cursor:pointer}.pod-lower{display:grid;grid-template-columns:1fr 1fr;gap:76px;margin-top:30px}.pod-story-title{display:flex;align-items:center;gap:10px;font-size:20px;font-weight:700;margin-bottom:14px}.guest{margin-top:17px;color:#032340;font-size:13px}.guest b{color:#CD9E53}.episode-cards{display:flex;flex-direction:column;gap:14px}.episode-card{all:unset;box-sizing:border-box;cursor:pointer;display:grid;grid-template-columns:minmax(0,1fr) 166px;background:#f7f7f7;border-radius:12px;overflow:hidden;min-height:112px;color:#032340}.episode-card-copy{padding:15px 17px;text-align:right}.episode-card-copy small{color:#CD9E53;font-size:10px}.episode-card-copy strong{display:block;font-size:14px;line-height:1.65;margin:8px 0}.episode-card-copy span{font-size:11px;color:#888}.episode-card img{width:100%;height:100%;object-fit:cover}.media-empty{color:#888;font-size:13px}.media-page button:focus-visible{outline:3px solid rgba(205,158,83,.55);outline-offset:3px}
|
||||||
|
@media(max-width:800px){.media-page{padding-top:30px}.media-shell{width:min(100% - 28px,1132px)}.media-heading h1,.media-heading h2{font-size:20px}.video-stage{aspect-ratio:16/10}.video-details{grid-template-columns:1fr;gap:36px;margin:30px 0 58px}.playlist-item{grid-template-columns:minmax(0,1fr) 44%;}.playlist-thumb{height:88px}.pod-feature{grid-template-columns:1fr}.pod-cover{order:-1;min-height:240px;max-height:300px}.pod-feature-main{padding:20px 17px}.pod-lower{grid-template-columns:1fr;gap:30px}.pod-story{order:-1}.video-meta{inset:auto 14px 14px}.video-meta strong{font-size:13px}.pod-title{font-size:18px}.media-view-all{font-size:12px}.episode-card{grid-template-columns:minmax(0,1fr) 120px}}
|
||||||
|
@media(max-width:460px){.media-shell{width:calc(100% - 20px)}.media-heading{margin-bottom:14px}.video-stage{border-radius:11px}.video-play{width:52px;height:52px}.video-meta span{display:none}.video-details{margin-top:25px}.playlist-title,.story-title{font-size:19px}.playlist-item{grid-template-columns:1fr 132px;gap:10px}.playlist-copy strong{font-size:12px}.playlist-copy span{font-size:10px}.playlist-thumb{height:76px}.pod-feature{border-radius:12px}.pod-cover{min-height:210px}.player-wave{height:52px}.episode-card{grid-template-columns:1fr 105px}.episode-card-copy{padding:12px}.episode-card-copy strong{font-size:12px}.video-story p,.pod-story p{font-size:13px;line-height:2.1}}
|
||||||
|
|
||||||
|
.pod-feature{grid-template-columns:minmax(0,1fr) 248px}.pod-cover{width:248px;min-height:248px;aspect-ratio:1}.pod-cover img{aspect-ratio:1;object-fit:cover}
|
||||||
|
.pod-story .tags{color:#CD9E53;margin-top:12px;white-space:pre-wrap}
|
||||||
|
.tag-list{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}.tag-chip{border:1px solid rgba(205,158,83,.42);background:rgba(205,158,83,.08);color:#9a6a1e;border-radius:999px;padding:5px 11px;font:inherit;font-size:12px;cursor:pointer;transition:background .15s,color .15s}.tag-chip:hover{background:#CD9E53;color:#032340}
|
||||||
|
.media-divider{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:16px;margin:0 0 58px}.media-divider::before,.media-divider::after{content:'';height:1px;background:linear-gradient(90deg,transparent,rgba(3,35,64,.2),rgba(205,158,83,.7))}.media-divider::after{background:linear-gradient(90deg,rgba(205,158,83,.7),rgba(3,35,64,.2),transparent)}.media-divider-mark{width:42px;height:42px;border:1px solid rgba(205,158,83,.5);border-radius:50%;display:grid;place-items:center;background:#fff;box-shadow:0 8px 24px rgba(3,35,64,.08);position:relative}.media-divider-mark::before{content:'';width:8px;height:8px;background:#CD9E53;transform:rotate(45deg);border-radius:2px}.media-divider-mark::after{content:'';position:absolute;inset:6px;border:1px solid rgba(3,35,64,.1);border-radius:50%}
|
||||||
|
.wave-wrap{width:calc(100% - 20px);margin-inline:10px}
|
||||||
|
.player-wave{width:100%}.player-wave span{max-width:none}
|
||||||
|
.skip-btn{position:relative;width:30px;height:30px;border:0;background:transparent;color:#032340;display:grid;place-items:center;cursor:pointer}.skip-btn small{position:absolute;font-size:8px;font-weight:900}
|
||||||
|
@media(max-width:800px){.pod-feature{grid-template-columns:1fr}.wave-wrap{width:calc(100% - 12px);margin-inline:6px}.pod-cover{width:min(100%,320px);min-height:0;aspect-ratio:1;margin-inline:auto}.pod-cover img{aspect-ratio:1}}
|
||||||
|
@media(max-width:600px){
|
||||||
|
.podcast-section .media-heading{margin-bottom:16px}
|
||||||
|
.pod-feature{display:flex;flex-direction:column;border-radius:16px;padding-top:18px}
|
||||||
|
.pod-cover{order:-1;width:168px;height:168px;min-height:168px;max-height:168px;margin:0 auto;border-radius:14px;overflow:hidden;box-shadow:0 10px 28px rgba(3,35,64,.14)}
|
||||||
|
.pod-cover img{width:168px;height:168px;aspect-ratio:1;object-fit:cover}
|
||||||
|
.pod-feature-main{padding:18px 18px 20px}
|
||||||
|
.pod-topline{align-items:flex-start;gap:8px}
|
||||||
|
.pod-label{font-size:13px;margin-bottom:6px}
|
||||||
|
.pod-title{font-size:17px;line-height:1.65}
|
||||||
|
.pod-date{font-size:10.5px;line-height:1.8;flex-wrap:wrap}
|
||||||
|
.bookmark-btn{width:34px;height:34px;flex:0 0 34px}
|
||||||
|
.wave-wrap{width:100%;margin-inline:0;margin-top:16px;padding-top:10px}
|
||||||
|
.player-wave{height:50px;gap:1.5px}
|
||||||
|
.player-controls{justify-content:center;gap:8px;flex-wrap:nowrap}
|
||||||
|
.pod-lower{margin-top:34px;gap:28px}
|
||||||
|
.pod-story-title{font-size:17px;line-height:1.7;margin-bottom:10px}
|
||||||
|
.episode-cards{gap:0;width:100%;align-items:stretch;border-top:1px solid rgba(3,35,64,.09)}
|
||||||
|
.episode-card{direction:ltr;width:100%;grid-template-columns:minmax(0,1fr) 132px;height:104px;min-height:104px;padding:13px 0;background:transparent;border:0;border-bottom:1px solid rgba(3,35,64,.09);border-radius:0;box-shadow:none;gap:12px}
|
||||||
|
.episode-card-copy{direction:rtl;text-align:right;padding:0;display:flex;flex-direction:column;justify-content:center;min-width:0}
|
||||||
|
.episode-card-copy small{font-size:9.5px}
|
||||||
|
.episode-card-copy strong{font-size:12px;line-height:1.65;margin:4px 0 6px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||||
|
.episode-card-copy span{font-size:9.5px;line-height:1.6;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#818995}
|
||||||
|
.episode-card img{grid-column:2;width:132px;height:78px;align-self:center;object-fit:cover;border-radius:9px;box-shadow:none}
|
||||||
|
.tag-list{gap:6px}.tag-chip{font-size:10.5px;padding:4px 9px}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
function barsFor(seedValue: string | number, count = 82) {
|
||||||
|
let seed = String(seedValue).split('').reduce((a, c) => a + c.charCodeAt(0), 19)
|
||||||
|
return Array.from({ length: count }, (_, i) => {
|
||||||
|
seed = (seed * 9301 + 49297) % 233280
|
||||||
|
const envelope = .42 + .25 * Math.sin(i * .31) + .13 * Math.sin(i * .83)
|
||||||
|
return Math.max(.18, Math.min(.94, envelope + (seed / 233280) * .28))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaDuration({ src, fallback = '', video = false, fa = true }: { src?: string; fallback?: string; video?: boolean; fa?: boolean }) {
|
||||||
|
const [seconds, setSeconds] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
setSeconds(0)
|
||||||
|
if (!src) return
|
||||||
|
const media = document.createElement(video ? 'video' : 'audio')
|
||||||
|
media.preload = 'metadata'
|
||||||
|
const loaded = () => { if (Number.isFinite(media.duration)) setSeconds(media.duration); media.removeAttribute('src'); media.load() }
|
||||||
|
media.addEventListener('loadedmetadata', loaded, { once: true })
|
||||||
|
media.src = src
|
||||||
|
return () => { media.removeEventListener('loadedmetadata', loaded); media.removeAttribute('src'); media.load() }
|
||||||
|
}, [src, video])
|
||||||
|
if (!seconds) return fallback ? <>{fallback}</> : null
|
||||||
|
const total = Math.round(seconds)
|
||||||
|
const hours = Math.floor(total / 3600)
|
||||||
|
const minutes = Math.floor((total % 3600) / 60)
|
||||||
|
const secs = String(total % 60).padStart(2, '0')
|
||||||
|
const value = hours ? `${hours}:${String(minutes).padStart(2, '0')}:${secs}` : `${minutes}:${secs}`
|
||||||
|
return <>{fa ? value.replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[Number(d)]) : value}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
function AudioWave({ src, id }: { src?: string; id: string | number }) {
|
||||||
|
const audio = useRef<HTMLAudioElement>(null)
|
||||||
|
const [playing, setPlaying] = useState(false)
|
||||||
|
const [muted, setMuted] = useState(false)
|
||||||
|
const [current, setCurrent] = useState(0)
|
||||||
|
const [duration, setDuration] = useState(0)
|
||||||
|
const bars = useMemo(() => barsFor(id), [id])
|
||||||
|
const progress = duration ? current / duration : 0
|
||||||
|
useEffect(() => { setPlaying(false); setCurrent(0); setDuration(0); audio.current?.load() }, [src, id])
|
||||||
|
const fmt = (v: number) => Number.isFinite(v) ? `${Math.floor(v / 60)}:${String(Math.floor(v % 60)).padStart(2, '0')}` : '0:00'
|
||||||
|
const toggle = async () => {
|
||||||
|
if (!audio.current || !src) return
|
||||||
|
if (audio.current.paused) await audio.current.play(); else audio.current.pause()
|
||||||
|
}
|
||||||
|
const seek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!audio.current || !duration) return
|
||||||
|
const box = e.currentTarget.getBoundingClientRect()
|
||||||
|
audio.current.currentTime = Math.max(0, Math.min(1, (e.clientX - box.left) / box.width)) * duration
|
||||||
|
}
|
||||||
|
const skip = (seconds: number) => {
|
||||||
|
if (!audio.current) return
|
||||||
|
const end = duration || audio.current.duration || 0
|
||||||
|
audio.current.currentTime = Math.max(0, Math.min(end, audio.current.currentTime + seconds))
|
||||||
|
setCurrent(audio.current.currentTime)
|
||||||
|
}
|
||||||
|
return <div>
|
||||||
|
<audio ref={audio} src={src} preload="metadata" onLoadedMetadata={e => setDuration(Number.isFinite(e.currentTarget.duration) ? e.currentTarget.duration : 0)} onTimeUpdate={e => setCurrent(e.currentTarget.currentTime)} onPlay={() => setPlaying(true)} onPause={() => setPlaying(false)} onEnded={() => setPlaying(false)} />
|
||||||
|
<div className="player-wave" onClick={seek} role="slider" aria-label="موقعیت پخش" aria-valuemin={0} aria-valuemax={Math.round(duration)} aria-valuenow={Math.round(current)} tabIndex={0}>
|
||||||
|
{bars.map((h, i) => <span key={i} className={(i + .5) / bars.length <= progress ? 'played' : ''} style={{ height: `${Math.round(h * 50)}px` }} />)}
|
||||||
|
</div>
|
||||||
|
<div className="player-controls">
|
||||||
|
<button className="skip-btn" onClick={() => skip(-10)} disabled={!src} aria-label="۱۰ ثانیه عقب"><RotateCcw size={21} /><small>۱۰</small></button>
|
||||||
|
<button className="round-play" onClick={toggle} disabled={!src} aria-label={playing ? 'توقف' : 'پخش'}>{playing ? <Pause size={15} fill="currentColor" /> : <Play size={15} fill="currentColor" />}</button>
|
||||||
|
<button className="skip-btn" onClick={() => skip(10)} disabled={!src} aria-label="۱۰ ثانیه جلو"><RotateCw size={21} /><small>۱۰</small></button>
|
||||||
|
<b>{fmt(current)}</b><span>{fmt(duration)}</span>
|
||||||
|
<button className="mute" onClick={() => { if (!audio.current) return; audio.current.muted = !audio.current.muted; setMuted(audio.current.muted) }} aria-label="صدا">{muted ? <VolumeX size={17} /> : <Volume2 size={17} />}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PodcastFigma() {
|
||||||
|
const { lang } = useLang()
|
||||||
|
const { member } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const fa = lang === 'fa'
|
||||||
|
const [activeVideo, setActiveVideo] = useState(0)
|
||||||
|
const [videoPlaying, setVideoPlaying] = useState(false)
|
||||||
|
const [activePodcast, setActivePodcast] = useState(0)
|
||||||
|
const [bookmarks, setBookmarks] = useState<Set<string>>(new Set())
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const video = VIDEOS[activeVideo]
|
||||||
|
const podcast = PODCASTS[activePodcast]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!member) { setBookmarks(new Set()); return }
|
||||||
|
authFetch('/api/members/me/bookmarks').then(r => r.ok ? r.json() : []).then(items => {
|
||||||
|
if (Array.isArray(items)) setBookmarks(new Set(items.filter(x => x.contentType === 'podcast').map(x => String(x.contentId))))
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [member])
|
||||||
|
|
||||||
|
const toggleBookmark = async () => {
|
||||||
|
if (!member) { navigate('/login'); return }
|
||||||
|
if (busy) return
|
||||||
|
const id = String(podcast.id), saved = bookmarks.has(id)
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const response = saved
|
||||||
|
? await authFetch(`/api/members/me/bookmarks/podcast/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||||
|
: await authFetch('/api/members/me/bookmarks', { method: 'POST', body: JSON.stringify({ contentId: id, contentType: 'podcast', title: podcast[lang].title, summary: podcast[lang].body, coverImage: podcast.cover, path: '/podcast' }) })
|
||||||
|
if (response.ok) setBookmarks(current => { const next = new Set(current); saved ? next.delete(id) : next.add(id); return next })
|
||||||
|
} finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!video || !podcast) return <div className="media-page"><div className="media-shell media-empty">محتوایی برای نمایش وجود ندارد.</div></div>
|
||||||
|
|
||||||
|
return <main className="media-page" dir={fa ? 'rtl' : 'ltr'}>
|
||||||
|
<style>{CSS}</style>
|
||||||
|
<div className="media-shell">
|
||||||
|
<section>
|
||||||
|
<header className="media-heading"><h1>{fa ? 'ویدیوکستها' : 'Videocasts'}</h1><span className="media-view-all">{fa ? 'مشاهده همه' : 'View all'} <ChevronLeft size={15} /></span></header>
|
||||||
|
<div className="video-stage">
|
||||||
|
{videoPlaying && video.mediaUrl ? <video src={video.mediaUrl} controls autoPlay onEnded={() => setVideoPlaying(false)} /> : <>
|
||||||
|
<img src={video.thumb} alt={video[lang].title} /><div className="video-overlay" />
|
||||||
|
<button className="video-play" onClick={() => video.mediaUrl && setVideoPlaying(true)} aria-label={fa ? 'پخش ویدیو' : 'Play video'}><Play size={28} fill="currentColor" /></button>
|
||||||
|
<div className="video-meta"><strong>{video[lang].title}</strong><span><MediaDuration src={video.mediaUrl} fallback={video.duration} video fa={fa} /></span></div>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="video-details">
|
||||||
|
<article className="video-story"><div className="story-title">{video[lang].title}<Video size={27} strokeWidth={1.6} /></div>{video[lang].body && <p>{video[lang].body}</p>}{Boolean(video.tags?.length) && <div className="tag-list">{video.tags!.map(tag => <button type="button" className="tag-chip" key={tag} onClick={() => navigate(`/search?tag=${encodeURIComponent(tag)}`)}>#{tag.replace(/\s+/g, '_')}</button>)}</div>}</article>
|
||||||
|
<div>
|
||||||
|
<div className="playlist-title"><Video size={27} strokeWidth={1.6} />{fa ? 'پلیلیست' : 'Playlist'}</div>
|
||||||
|
<div className="playlist">{VIDEOS.slice(0, 3).map((item, index) => <button key={item.id} className={`playlist-item ${index === activeVideo ? 'active' : ''}`} onClick={() => { setActiveVideo(index); setVideoPlaying(false) }}>
|
||||||
|
<div className="playlist-copy"><strong>{item[lang].title}</strong><span><CalendarDays size={16} color={GOLD} />{item.date}{(item.mediaUrl || item.duration) && <> · <MediaDuration src={item.mediaUrl} fallback={item.duration} video fa={fa} /></>}</span></div>
|
||||||
|
<div className="playlist-thumb"><img src={item.thumb} alt="" /><i><Play size={18} fill="currentColor" /></i></div>
|
||||||
|
</button>)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="media-divider" aria-hidden="true"><span className="media-divider-mark" /></div>
|
||||||
|
|
||||||
|
<section className="podcast-section">
|
||||||
|
<header className="media-heading"><h2>{fa ? 'پادکستها' : 'Podcasts'}</h2><span className="media-view-all">{fa ? 'مشاهده همه' : 'View all'} <ChevronLeft size={15} /></span></header>
|
||||||
|
<div className="pod-feature">
|
||||||
|
<div className="pod-feature-main">
|
||||||
|
<div className="pod-topline"><div><div className="pod-label">{podcast.epNum}</div><h3 className="pod-title">{podcast[lang].title}</h3><div className="pod-date"><CalendarDays size={17} color={GOLD} />{podcast.date}{(podcast.mediaUrl || podcast.duration) && <> · <MediaDuration src={podcast.mediaUrl} fallback={podcast.duration} fa={fa} /></>}</div></div><button className="bookmark-btn" onClick={toggleBookmark} disabled={busy} aria-label="ذخیره"><Bookmark size={26} fill={bookmarks.has(String(podcast.id)) ? 'currentColor' : 'none'} color={bookmarks.has(String(podcast.id)) ? GOLD : NAVY} /></button></div>
|
||||||
|
<div className="wave-wrap"><AudioWave src={podcast.mediaUrl} id={podcast.id} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="pod-cover"><img src={podcast.cover} alt={podcast[lang].title} /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pod-lower">
|
||||||
|
<article className="pod-story"><div className="pod-story-title"><Mic2 size={23} color={GOLD} />{podcast[lang].title}</div>{podcast[lang].body && <p>{podcast[lang].body}</p>}{Boolean(podcast[lang].guest?.trim()) && <div className="guest">{fa ? 'مهمان برنامه:' : 'Guest:'} <b>{podcast[lang].guest}</b></div>}{Boolean(podcast.tags?.length) && <div className="tag-list">{podcast.tags!.map(tag => <button type="button" className="tag-chip" key={tag} onClick={() => navigate(`/search?tag=${encodeURIComponent(tag)}`)}>#{tag.replace(/\s+/g, '_')}</button>)}</div>}</article>
|
||||||
|
<div className="episode-cards">{PODCASTS.filter((_, i) => i !== activePodcast).slice(0, 2).map(item => <button className="episode-card" key={item.id} onClick={() => setActivePodcast(PODCASTS.findIndex(p => p.id === item.id))}><div className="episode-card-copy"><small>{item.epNum}</small><strong>{item[lang].title}</strong><span>{item.date}{(item.mediaUrl || item.duration) && <> · <MediaDuration src={item.mediaUrl} fallback={item.duration} fa={fa} /></>}</span></div><img src={item.cover} alt="" /></button>)}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { useAuth, authFetch } from '@/context/AuthContext'
|
import { useAuth, authFetch } from '@/context/AuthContext'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
import { User, CalendarDays, KeyRound, Receipt, Heart, ChevronLeft, LogOut, Mail, Phone, Link2, SquarePen, Lock, Briefcase, type LucideIcon } from 'lucide-react'
|
import { User, CalendarDays, KeyRound, Receipt, Heart, ChevronLeft, LogOut, Mail, Phone, Link2, SquarePen, Lock, Briefcase, Bookmark, Trash2, type LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
const GOLD = '#CD9E53'
|
const GOLD = '#CD9E53'
|
||||||
const NAVY = '#032340'
|
const NAVY = '#032340'
|
||||||
|
|
@ -51,6 +51,8 @@ export default function ProfilePage() {
|
||||||
</>
|
</>
|
||||||
) : section === 'password' ? (
|
) : section === 'password' ? (
|
||||||
<PasswordCard fa={fa} />
|
<PasswordCard fa={fa} />
|
||||||
|
) : section === 'saved' ? (
|
||||||
|
<SavedItemsCard fa={fa} />
|
||||||
) : (
|
) : (
|
||||||
<Placeholder fa={fa} title={MENU.find(m => m.key === section)?.label || ''} />
|
<Placeholder fa={fa} title={MENU.find(m => m.key === section)?.label || ''} />
|
||||||
)}
|
)}
|
||||||
|
|
@ -104,6 +106,89 @@ export default function ProfilePage() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SavedBookmark = {
|
||||||
|
id: number
|
||||||
|
contentId: string
|
||||||
|
contentType: string
|
||||||
|
title: string
|
||||||
|
summary?: string
|
||||||
|
coverImage?: string
|
||||||
|
path: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function SavedItemsCard({ fa }: { fa: boolean }) {
|
||||||
|
const [items, setItems] = useState<SavedBookmark[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
authFetch('/api/members/me/bookmarks')
|
||||||
|
.then(async response => {
|
||||||
|
if (!response.ok) throw new Error('bookmarks_failed')
|
||||||
|
return response.json()
|
||||||
|
})
|
||||||
|
.then(data => { if (active) setItems(Array.isArray(data) ? data : []) })
|
||||||
|
.catch(() => { if (active) setError(true) })
|
||||||
|
.finally(() => { if (active) setLoading(false) })
|
||||||
|
return () => { active = false }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const remove = async (item: SavedBookmark) => {
|
||||||
|
const response = await authFetch(
|
||||||
|
`/api/members/me/bookmarks/${encodeURIComponent(item.contentType)}/${encodeURIComponent(item.contentId)}`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
)
|
||||||
|
if (response.ok) setItems(current => current.filter(saved => saved.id !== item.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={card}>
|
||||||
|
<CardHead title={fa ? 'مطالب ذخیرهشده' : 'Saved content'} />
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ padding: '48px 0', textAlign: 'center', color: '#8a8f98', fontSize: 13.5 }}>
|
||||||
|
{fa ? 'در حال دریافت علاقهمندیها…' : 'Loading saved content…'}
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div style={{ padding: '48px 0', textAlign: 'center', color: '#b42318', fontSize: 13.5 }}>
|
||||||
|
{fa ? 'دریافت مطالب ذخیرهشده ناموفق بود.' : 'Could not load saved content.'}
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div style={{ padding: '48px 0', textAlign: 'center' }}>
|
||||||
|
<Bookmark size={30} color={GOLD} style={{ marginBottom: 12 }} />
|
||||||
|
<div style={{ color: NAVY, fontWeight: 800, fontSize: 15 }}>{fa ? 'هنوز مطلبی ذخیره نکردهاید' : 'No saved content yet'}</div>
|
||||||
|
<div style={{ color: '#8a8f98', fontSize: 13, marginTop: 6 }}>{fa ? 'از دکمه نشانک کنار مطالب استفاده کنید.' : 'Use the bookmark button beside content.'}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{items.map(item => (
|
||||||
|
<article key={item.id} style={{ display: 'flex', alignItems: 'center', gap: 14, padding: 12, borderRadius: 12, background: '#f7f8fa' }}>
|
||||||
|
{item.coverImage ? (
|
||||||
|
<img src={item.coverImage} alt="" style={{ width: 92, height: 68, objectFit: 'cover', borderRadius: 9, flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<div style={{ width: 92, height: 68, borderRadius: 9, background: `${GOLD}1a`, display: 'grid', placeItems: 'center', flexShrink: 0 }}>
|
||||||
|
<Bookmark size={20} color={GOLD} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1, minWidth: 0, textAlign: fa ? 'right' : 'left' }}>
|
||||||
|
<span style={{ color: GOLD, fontSize: 11, fontWeight: 800 }}>{item.contentType === 'podcast' ? (fa ? 'پادکست' : 'Podcast') : item.contentType}</span>
|
||||||
|
<Link to={item.path} style={{ display: 'block', color: NAVY, textDecoration: 'none', fontSize: 14.5, fontWeight: 850, marginTop: 3, lineHeight: 1.6 }}>
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
{item.summary && <p style={{ margin: '3px 0 0', color: '#737985', fontSize: 12, lineHeight: 1.7, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.summary}</p>}
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => remove(item)} aria-label={fa ? 'حذف از علاقهمندیها' : 'Remove bookmark'} style={{ width: 34, height: 34, borderRadius: '50%', border: 0, background: '#fff', color: '#b42318', display: 'grid', placeItems: 'center', cursor: 'pointer', flexShrink: 0 }}>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/* ═══ Identity card (read ↔ inline edit) ═══ */
|
/* ═══ Identity card (read ↔ inline edit) ═══ */
|
||||||
function IdentityCard({ member, fa }: { member: { name: string; email: string; phone?: string }; fa: boolean }) {
|
function IdentityCard({ member, fa }: { member: { name: string; email: string; phone?: string }; fa: boolean }) {
|
||||||
const { updateMember } = useAuth()
|
const { updateMember } = useAuth()
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useParams, Navigate, useNavigate } from 'react-router-dom'
|
import { Link, useParams, Navigate, useNavigate } from 'react-router-dom'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { ArrowLeft, Download, Calendar, ChevronLeft, ChevronRight } from 'lucide-react'
|
import { ArrowLeft, Download, Calendar, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
import { RADAR_FUTURE, RADAR_SLUG_LABEL } from '@/content/radarFuture'
|
import { RADAR_FUTURE, RADAR_SLUG_LABEL, bootstrapRadarFuture } from '@/content/radarFuture'
|
||||||
import { bannerFor } from '@/data/banners'
|
import { bannerFor } from '@/data/banners'
|
||||||
import {
|
import {
|
||||||
RADAR_CATEGORY_PAGES,
|
RADAR_CATEGORY_PAGES,
|
||||||
|
|
@ -16,6 +16,11 @@ const GOLD = '#CD9E53'
|
||||||
const PAPER = '#faf7f4'
|
const PAPER = '#faf7f4'
|
||||||
const PER_PAGE = 12
|
const PER_PAGE = 12
|
||||||
const EASE = [0.22, 1, 0.36, 1] as const
|
const EASE = [0.22, 1, 0.36, 1] as const
|
||||||
|
const normalizeCategoryLabel = (value?: string) =>
|
||||||
|
String(value || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/ي/g, 'ی')
|
||||||
|
.replace(/ك/g, 'ک')
|
||||||
|
|
||||||
/* ─── small reveal wrapper (animates in once on mount, never hides content) ─── */
|
/* ─── small reveal wrapper (animates in once on mount, never hides content) ─── */
|
||||||
function Reveal({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
|
function Reveal({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
|
||||||
|
|
@ -204,15 +209,25 @@ function SpecialBanner({ kicker, title, desc, lang, img, ctaUrl, ctaLabel }: { k
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RIGHT SIDE — CMS cover image or fallback mockup, keeping background and tilt */}
|
{/* BOOK — matches the homepage banner sizing and responsive behavior */}
|
||||||
<div style={{ width: '48%', position: 'relative', minHeight: 240 }} className="max-md:!w-full max-md:!min-height-none max-md:!order-1">
|
<div style={{ width: '48%', position: 'relative', alignSelf: 'stretch' }}
|
||||||
<div style={{
|
className="max-md:!w-full max-md:!order-1 max-md:!flex max-md:!justify-center max-md:!pt-6">
|
||||||
position: 'absolute', top: -38, right: 30, width: '92%', height: 'auto', zIndex: 10,
|
<img
|
||||||
transform: 'rotate(-4deg)', transformOrigin: 'bottom right',
|
src={img || BOOK_IMG}
|
||||||
filter: 'drop-shadow(0 20px 40px rgba(0,0,0,0.35))',
|
alt={title}
|
||||||
}} className="max-md:!static max-md:!w-full max-md:!rotate-0 max-md:!p-6">
|
style={{
|
||||||
<img src={img || BOOK_IMG} alt="" style={{ width: '100%', height: 'auto', display: 'block' }} />
|
position: 'absolute', insetInlineEnd: '-2%', top: '50%', transform: 'translateY(-50%)',
|
||||||
</div>
|
width: '116%', maxWidth: 'none', height: 'auto',
|
||||||
|
filter: 'drop-shadow(0 30px 46px rgba(0,0,0,0.5))',
|
||||||
|
}}
|
||||||
|
className="max-md:!hidden"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src={img || BOOK_IMG}
|
||||||
|
alt=""
|
||||||
|
style={{ filter: 'drop-shadow(0 16px 30px rgba(0,0,0,0.45))' }}
|
||||||
|
className="hidden max-md:!block max-md:!w-[85%] max-md:!max-w-[300px] max-md:!h-auto"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -257,6 +272,8 @@ export default function RadarCategory() {
|
||||||
const { lang } = useLang()
|
const { lang } = useLang()
|
||||||
const { category } = useParams<{ category: string }>()
|
const { category } = useParams<{ category: string }>()
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
const [items, setItems] = useState(() => [...RADAR_FUTURE])
|
||||||
|
const [loading, setLoading] = useState(() => RADAR_FUTURE.length === 0)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const cfg = category ? RADAR_CATEGORY_PAGES[category as RadarSlug] : undefined
|
const cfg = category ? RADAR_CATEGORY_PAGES[category as RadarSlug] : undefined
|
||||||
|
|
@ -264,8 +281,31 @@ export default function RadarCategory() {
|
||||||
|
|
||||||
const c = cfg[lang]
|
const c = cfg[lang]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
bootstrapRadarFuture().finally(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
setItems([...RADAR_FUTURE])
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1)
|
||||||
|
}, [category])
|
||||||
|
|
||||||
// card pool = رادار آینده block articles whose category == this sub-menu label
|
// card pool = رادار آینده block articles whose category == this sub-menu label
|
||||||
const base = RADAR_FUTURE.filter((r) => r.category === RADAR_SLUG_LABEL[category as string])
|
const base = useMemo(
|
||||||
|
() => {
|
||||||
|
const label = normalizeCategoryLabel(RADAR_SLUG_LABEL[category as string])
|
||||||
|
return items.filter((r) => normalizeCategoryLabel(r.category) === label)
|
||||||
|
},
|
||||||
|
[category, items],
|
||||||
|
)
|
||||||
|
|
||||||
if (!base.length) {
|
if (!base.length) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -273,14 +313,18 @@ export default function RadarCategory() {
|
||||||
<div className="max-w-7xl mx-auto px-12 max-md:!px-4" style={{ paddingTop: 40, paddingBottom: 72 }}>
|
<div className="max-w-7xl mx-auto px-12 max-md:!px-4" style={{ paddingTop: 40, paddingBottom: 72 }}>
|
||||||
<Breadcrumb label={c.label} lang={lang} />
|
<Breadcrumb label={c.label} lang={lang} />
|
||||||
<p style={{ color: NAVY, fontSize: 15, opacity: 0.7, padding: '40px 0' }}>
|
<p style={{ color: NAVY, fontSize: 15, opacity: 0.7, padding: '40px 0' }}>
|
||||||
{lang === 'fa' ? 'هنوز مطلبی برای این بخش منتشر نشده است.' : 'No articles published in this section yet.'}
|
{loading
|
||||||
|
? (lang === 'fa' ? 'در حال بارگذاری مطالب رادار آینده…' : 'Loading Future Radar articles…')
|
||||||
|
: (lang === 'fa' ? 'هنوز مطلبی برای این بخش منتشر نشده است.' : 'No articles published in this section yet.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const seed = base
|
// CMS `featured` controls the large hero card. If a category has no featured
|
||||||
const cards = seed
|
// article yet, use its newest CMS article instead of static page content.
|
||||||
|
const featured = base.find((item) => item.featured) ?? base[0]
|
||||||
|
const cards = base.filter((item) => item.id !== featured.id)
|
||||||
|
|
||||||
// latest 2 mini-cards
|
// latest 2 mini-cards
|
||||||
const latest = cards.slice(0, 2)
|
const latest = cards.slice(0, 2)
|
||||||
|
|
@ -306,13 +350,19 @@ export default function RadarCategory() {
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginBottom: 40 }} className="max-lg:!grid-cols-1 max-md:!gap-4 max-md:!mb-7">
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginBottom: 40 }} className="max-lg:!grid-cols-1 max-md:!gap-4 max-md:!mb-7">
|
||||||
{/* RIGHT (first in RTL): big featured */}
|
{/* RIGHT (first in RTL): big featured */}
|
||||||
<FeaturedCard img={c.featured.img} title={c.featured.title} date={c.featured.date} lang={lang} onClick={() => navigate(`/radar/post/${seed[0]?.id ?? 'r1'}`)} />
|
<FeaturedCard
|
||||||
|
img={featured.img}
|
||||||
|
title={featured[lang].title}
|
||||||
|
date={featured[lang].date}
|
||||||
|
lang={lang}
|
||||||
|
onClick={() => navigate(`/radar/post/${featured.id}`)}
|
||||||
|
/>
|
||||||
{/* LEFT (second in RTL): latest box */}
|
{/* LEFT (second in RTL): latest box */}
|
||||||
<div style={{ background: '#efe9e1', borderRadius: 16, padding: 24, display: 'flex', flexDirection: 'column' }} className="max-md:!p-4">
|
<div style={{ background: '#efe9e1', borderRadius: 16, padding: 24, display: 'flex', flexDirection: 'column' }} className="max-md:!p-4">
|
||||||
<h2 style={{ fontSize: 16, fontWeight: 900, color: GOLD, margin: '0 0 18px', textAlign: lang === 'fa' ? 'right' : 'left' }}>{c.latestHeading}</h2>
|
<h2 style={{ fontSize: 16, fontWeight: 900, color: GOLD, margin: '0 0 18px', textAlign: lang === 'fa' ? 'right' : 'left' }}>{c.latestHeading}</h2>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, flex: 1, justifyContent: 'space-around' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, flex: 1, justifyContent: 'space-around' }}>
|
||||||
{latest.map((item, idx) => (
|
{latest.map((item) => (
|
||||||
<MiniCard key={item.id} img={item.img} title={item[lang].title} excerpt={item[lang].excerpt} date={item[lang].date} lang={lang} onClick={() => navigate(`/radar/post/${seed[idx]?.id ?? seed[0]?.id ?? 'r1'}`)} />
|
<MiniCard key={item.id} img={item.img} title={item[lang].title} excerpt={item[lang].excerpt} date={item[lang].date} lang={lang} onClick={() => navigate(`/radar/post/${item.id}`)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -321,29 +371,31 @@ export default function RadarCategory() {
|
||||||
|
|
||||||
{/* ── FIRST GRID (4) ── */}
|
{/* ── FIRST GRID (4) ── */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 18 }} className="max-lg:!grid-cols-2 max-sm:!grid-cols-1">
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 18 }} className="max-lg:!grid-cols-2 max-sm:!grid-cols-1">
|
||||||
{visible.slice(0, 4).map((item, idx) => (
|
{visible.slice(0, 4).map((item) => (
|
||||||
<Reveal key={item.id}>
|
<Reveal key={item.id}>
|
||||||
<GridCard img={item.img} title={item[lang].title} excerpt={item[lang].excerpt} date={item[lang].date} lang={lang} onClick={() => navigate(`/radar/post/${seed[idx % seed.length].id}`)} />
|
<GridCard img={item.img} title={item[lang].title} excerpt={item[lang].excerpt} date={item[lang].date} lang={lang} onClick={() => navigate(`/radar/post/${item.id}`)} />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── SPECIAL BANNER (CMS banner for this radar page, else page default) ── */}
|
{/* The special-analysis banner is intentionally limited to sustainability. */}
|
||||||
<Reveal>
|
{category === 'sustainability' && (
|
||||||
{(() => {
|
<Reveal>
|
||||||
const cms = bannerFor('radar-' + category)
|
{(() => {
|
||||||
const bk = cms
|
const cms = bannerFor('radar-sustainability')
|
||||||
? { kicker: cms[lang].overline || c.banner.kicker, title: cms[lang].title || c.banner.title, desc: cms[lang].subtitle || c.banner.desc, img: cms.image, ctaUrl: cms.ctaUrl, ctaLabel: cms[lang].cta }
|
const bk = cms
|
||||||
: { kicker: c.banner.kicker, title: c.banner.title, desc: c.banner.desc }
|
? { kicker: cms[lang].overline || c.banner.kicker, title: cms[lang].title || c.banner.title, desc: cms[lang].subtitle || c.banner.desc, img: cms.image, ctaUrl: cms.ctaUrl, ctaLabel: cms[lang].cta }
|
||||||
return <SpecialBanner {...bk} lang={lang} />
|
: { kicker: c.banner.kicker, title: c.banner.title, desc: c.banner.desc }
|
||||||
})()}
|
return <SpecialBanner {...bk} lang={lang} />
|
||||||
</Reveal>
|
})()}
|
||||||
|
</Reveal>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── SECOND GRID (rest) ── */}
|
{/* ── SECOND GRID (rest) ── */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 18 }} className="max-lg:!grid-cols-2 max-sm:!grid-cols-1">
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 18 }} className="max-lg:!grid-cols-2 max-sm:!grid-cols-1">
|
||||||
{visible.slice(4).map((item, idx) => (
|
{visible.slice(4).map((item) => (
|
||||||
<Reveal key={item.id}>
|
<Reveal key={item.id}>
|
||||||
<GridCard img={item.img} title={item[lang].title} excerpt={item[lang].excerpt} date={item[lang].date} lang={lang} onClick={() => navigate(`/radar/post/${seed[(idx + 4) % seed.length].id}`)} />
|
<GridCard img={item.img} title={item[lang].title} excerpt={item[lang].excerpt} date={item[lang].date} lang={lang} onClick={() => navigate(`/radar/post/${item.id}`)} />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { useState, type ReactElement } from 'react'
|
import { useEffect, useState, type ReactElement } from 'react'
|
||||||
import { useParams, Navigate, Link } from 'react-router-dom'
|
import { useParams, Navigate, Link } from 'react-router-dom'
|
||||||
import AuthPage from '@/pages/Auth/AuthPage'
|
import AuthPage from '@/pages/Auth/AuthPage'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Calendar, Tag, UserPlus, LogIn, Send, Share2, Globe, Mail, ArrowLeft, ExternalLink } from 'lucide-react'
|
import { Calendar, Tag, UserPlus, LogIn, Send, Share2, Globe, Mail, ArrowLeft, ExternalLink } from 'lucide-react'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
import { useAuth } from '@/context/AuthContext'
|
import { useAuth } from '@/context/AuthContext'
|
||||||
import { RADAR_FUTURE, type RadarFutureArticle, type RFBlock } from '@/content/radarFuture'
|
import { RADAR_FUTURE, bootstrapRadarFuture, type RadarFutureArticle, type RFBlock } from '@/content/radarFuture'
|
||||||
import { MagicTextGroup } from '@/components/ui/magic-text'
|
import { MagicTextGroup } from '@/components/ui/magic-text'
|
||||||
|
|
||||||
const NAVY = '#032340'
|
const NAVY = '#032340'
|
||||||
|
|
@ -144,13 +144,36 @@ export default function RadarPost() {
|
||||||
const { lang } = useLang()
|
const { lang } = useLang()
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const isFa = lang === 'fa'
|
const isFa = lang === 'fa'
|
||||||
|
const [items, setItems] = useState(() => [...RADAR_FUTURE])
|
||||||
|
const [loading, setLoading] = useState(() => RADAR_FUTURE.length === 0)
|
||||||
|
|
||||||
const item = RADAR_FUTURE.find(r => r.id === id)
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
bootstrapRadarFuture().finally(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
setItems([...RADAR_FUTURE])
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const item = items.find(r => r.id === id)
|
||||||
const { member } = useAuth()
|
const { member } = useAuth()
|
||||||
const loggedIn = member !== null
|
const loggedIn = member !== null
|
||||||
const [showAuth, setShowAuth] = useState(false)
|
const [showAuth, setShowAuth] = useState(false)
|
||||||
const handleAuthSuccess = () => setShowAuth(false)
|
const handleAuthSuccess = () => setShowAuth(false)
|
||||||
|
|
||||||
|
if (!item && loading) {
|
||||||
|
return (
|
||||||
|
<div dir={isFa ? 'rtl' : 'ltr'} style={{ minHeight: '70vh', display: 'grid', placeItems: 'center', color: NAVY, fontWeight: 800 }}>
|
||||||
|
{isFa ? 'در حال بارگذاری مطلب…' : 'Loading article…'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!item) return <Navigate to="/radar" replace />
|
if (!item) return <Navigate to="/radar" replace />
|
||||||
|
|
||||||
const content = item[lang]
|
const content = item[lang]
|
||||||
|
|
@ -220,7 +243,7 @@ export default function RadarPost() {
|
||||||
let run: string[] = []
|
let run: string[] = []
|
||||||
const flush = (k: string) => {
|
const flush = (k: string) => {
|
||||||
if (!run.length) return
|
if (!run.length) return
|
||||||
out.push(<MagicTextGroup key={k} paragraphs={run} dir={lang === 'fa' ? 'rtl' : 'ltr'} style={{ fontSize: 15.5, lineHeight: 2.2, color: NAVY, margin: '0 0 18px', fontWeight: 500 }} />)
|
out.push(<MagicTextGroup key={k} paragraphs={run} dir={lang === 'fa' ? 'rtl' : 'ltr'} style={{ fontSize: 15.5, lineHeight: 2.2, color: NAVY, margin: '0 0 18px', textAlign: 'justify', fontWeight: 500 }} />)
|
||||||
run = []
|
run = []
|
||||||
}
|
}
|
||||||
blocks.forEach((b, i) => {
|
blocks.forEach((b, i) => {
|
||||||
|
|
@ -250,7 +273,7 @@ export default function RadarPost() {
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
{/* RELATED SIDEBAR */}
|
{/* RELATED SIDEBAR */}
|
||||||
<RelatedSidebar items={RADAR_FUTURE} currentId={item.id} lang={lang} />
|
<RelatedSidebar items={items} currentId={item.id} lang={lang} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,20 @@ import { events } from '@/data/events'
|
||||||
const GOLD = '#CD9E53'
|
const GOLD = '#CD9E53'
|
||||||
const NAVY = '#032340'
|
const NAVY = '#032340'
|
||||||
|
|
||||||
type Result = { id: string; title: string; summary: string; meta: string; badge: string; color: string; url: string }
|
type Result = { id: string; title: string; summary: string; meta: string; badge: string; color: string; url: string; tags: string[] }
|
||||||
|
|
||||||
|
function contentTags(value: unknown, isFa: boolean): string[] {
|
||||||
|
const tags = (value as { tags?: unknown })?.tags
|
||||||
|
if (!Array.isArray(tags)) return []
|
||||||
|
return tags.map(tag => {
|
||||||
|
if (typeof tag === 'string') return tag
|
||||||
|
if (tag && typeof tag === 'object') {
|
||||||
|
const localized = tag as { fa?: unknown; en?: unknown }
|
||||||
|
return String(isFa ? localized.fa ?? localized.en ?? '' : localized.en ?? localized.fa ?? '')
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}).map(tag => tag.replace(/^#/, '').trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
/* Aggregate every searchable content source into one flat, normalized list.
|
/* Aggregate every searchable content source into one flat, normalized list.
|
||||||
All of these are already bootstrapped from the panel into memory at startup,
|
All of these are already bootstrapped from the panel into memory at startup,
|
||||||
|
|
@ -21,30 +34,30 @@ function buildIndex(isFa: boolean): Result[] {
|
||||||
const L = (o: { fa: string; en: string }) => (isFa ? o.fa : o.en)
|
const L = (o: { fa: string; en: string }) => (isFa ? o.fa : o.en)
|
||||||
|
|
||||||
for (const r of reports) {
|
for (const r of reports) {
|
||||||
out.push({ id: `report-${r.id}`, title: r.title, summary: r.summary || '', meta: r.category || '', badge: 'گزارش', color: '#1d4ed8', url: `/reports/${r.id}` })
|
out.push({ id: `report-${r.id}`, title: r.title, summary: r.summary || '', meta: r.category || '', badge: 'گزارش', color: '#1d4ed8', url: `/reports/${r.id}`, tags: contentTags(r, isFa) })
|
||||||
}
|
}
|
||||||
for (const p of POSTS) {
|
for (const p of POSTS) {
|
||||||
const t = isFa ? p.fa : p.en
|
const t = isFa ? p.fa : p.en
|
||||||
out.push({ id: `post-${p.id}`, title: t.title, summary: t.lead || '', meta: L(p.category), badge: 'مطلب', color: '#9b1c1c', url: `/posts/${p.id}` })
|
out.push({ id: `post-${p.id}`, title: t.title, summary: t.lead || '', meta: L(p.category), badge: 'مطلب', color: '#9b1c1c', url: `/posts/${p.id}`, tags: contentTags(p, isFa) })
|
||||||
}
|
}
|
||||||
for (const it of RADAR_ITEMS) {
|
for (const it of RADAR_ITEMS) {
|
||||||
const t = isFa ? it.fa : it.en
|
const t = isFa ? it.fa : it.en
|
||||||
out.push({ id: `glance-${it.id}`, title: t.title, summary: t.excerpt || '', meta: L(RADAR_CATEGORY_LABEL[it.category]), badge: 'در یک نگاه', color: GOLD, url: `/radar/post/${it.id}` })
|
out.push({ id: `glance-${it.id}`, title: t.title, summary: t.excerpt || '', meta: L(RADAR_CATEGORY_LABEL[it.category]), badge: 'در یک نگاه', color: GOLD, url: `/radar/post/${it.id}`, tags: contentTags(it, isFa) })
|
||||||
}
|
}
|
||||||
for (const it of RADAR_FUTURE) {
|
for (const it of RADAR_FUTURE) {
|
||||||
const t = isFa ? it.fa : it.en
|
const t = isFa ? it.fa : it.en
|
||||||
out.push({ id: `radar-${it.id}`, title: t.title, summary: t.excerpt || '', meta: it.category || '', badge: 'رادار آینده', color: '#166534', url: `/radar/post/${it.id}` })
|
out.push({ id: `radar-${it.id}`, title: t.title, summary: t.excerpt || '', meta: it.category || '', badge: 'رادار آینده', color: '#166534', url: `/radar/post/${it.id}`, tags: contentTags(it, isFa) })
|
||||||
}
|
}
|
||||||
for (const v of videos) {
|
for (const v of videos) {
|
||||||
const t = isFa ? v.fa : v.en
|
const t = isFa ? v.fa : v.en
|
||||||
out.push({ id: `video-${v.id}`, title: t.title, summary: t.body || '', meta: t.guest || '', badge: 'ویدیوکست', color: '#4a1d96', url: '/podcast' })
|
out.push({ id: `video-${v.id}`, title: t.title, summary: t.body || '', meta: t.guest || '', badge: 'ویدیوکست', color: '#4a1d96', url: '/podcast', tags: contentTags(v, isFa) })
|
||||||
}
|
}
|
||||||
for (const p of podcasts) {
|
for (const p of podcasts) {
|
||||||
const t = isFa ? p.fa : p.en
|
const t = isFa ? p.fa : p.en
|
||||||
out.push({ id: `podcast-${p.id}`, title: t.title, summary: t.body || '', meta: t.ep || '', badge: 'پادکست', color: '#7c2d12', url: '/podcast' })
|
out.push({ id: `podcast-${p.id}`, title: t.title, summary: t.body || '', meta: t.ep || '', badge: 'پادکست', color: '#7c2d12', url: '/podcast', tags: contentTags(p, isFa) })
|
||||||
}
|
}
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
out.push({ id: `event-${e.id}`, title: e.title, summary: '', meta: '', badge: 'رویداد', color: '#0f766e', url: '/events' })
|
out.push({ id: `event-${e.id}`, title: e.title, summary: '', meta: '', badge: 'رویداد', color: '#0f766e', url: '/events', tags: contentTags(e, isFa) })
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
@ -54,13 +67,15 @@ export default function SearchResults() {
|
||||||
const isFa = lang === 'fa'
|
const isFa = lang === 'fa'
|
||||||
const [params] = useSearchParams()
|
const [params] = useSearchParams()
|
||||||
const q = (params.get('q') || '').trim()
|
const q = (params.get('q') || '').trim()
|
||||||
|
const tag = (params.get('tag') || '').replace(/^#/, '').trim()
|
||||||
|
|
||||||
const results = useMemo(() => {
|
const results = useMemo(() => {
|
||||||
if (!q) return []
|
if (!q && !tag) return []
|
||||||
const needle = q.toLowerCase()
|
const needle = (tag || q).toLowerCase()
|
||||||
const has = (v: unknown) => String(v ?? '').toLowerCase().includes(needle)
|
const has = (v: unknown) => String(v ?? '').toLowerCase().includes(needle)
|
||||||
return buildIndex(isFa).filter((r) => has(r.title) || has(r.summary) || has(r.meta))
|
const sameTag = (value: string) => value.replace(/^#/, '').trim().toLowerCase() === needle
|
||||||
}, [q, isFa])
|
return buildIndex(isFa).filter((r) => tag ? r.tags.some(sameTag) : has(r.title) || has(r.summary) || has(r.meta) || r.tags.some(has))
|
||||||
|
}, [q, tag, isFa])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div dir={isFa ? 'rtl' : 'ltr'} style={{ maxWidth: 1000, margin: '0 auto', padding: 'clamp(24px,5vw,56px) clamp(16px,4vw,40px)', minHeight: '60vh' }}>
|
<div dir={isFa ? 'rtl' : 'ltr'} style={{ maxWidth: 1000, margin: '0 auto', padding: 'clamp(24px,5vw,56px) clamp(16px,4vw,40px)', minHeight: '60vh' }}>
|
||||||
|
|
@ -68,16 +83,16 @@ export default function SearchResults() {
|
||||||
<h1 style={{ fontSize: 24, fontWeight: 900, color: NAVY, margin: 0 }}>
|
<h1 style={{ fontSize: 24, fontWeight: 900, color: NAVY, margin: 0 }}>
|
||||||
{isFa ? 'نتایج جستجو' : 'Search results'}
|
{isFa ? 'نتایج جستجو' : 'Search results'}
|
||||||
</h1>
|
</h1>
|
||||||
{q && (
|
{(q || tag) && (
|
||||||
<p style={{ fontSize: 14, color: 'var(--ink-4)', margin: '8px 0 0' }}>
|
<p style={{ fontSize: 14, color: 'var(--ink-4)', margin: '8px 0 0' }}>
|
||||||
{isFa
|
{isFa
|
||||||
? `${results.length.toLocaleString('fa-IR')} نتیجه برای «${q}»`
|
? `${results.length.toLocaleString('fa-IR')} نتیجه برای «${tag ? `#${tag}` : q}»`
|
||||||
: `${results.length} results for “${q}”`}
|
: `${results.length} results for “${tag ? `#${tag}` : q}”`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!q ? (
|
{!q && !tag ? (
|
||||||
<p style={{ color: 'var(--ink-5)', fontSize: 15 }}>{isFa ? 'عبارتی برای جستجو وارد کنید.' : 'Enter a search term.'}</p>
|
<p style={{ color: 'var(--ink-5)', fontSize: 15 }}>{isFa ? 'عبارتی برای جستجو وارد کنید.' : 'Enter a search term.'}</p>
|
||||||
) : results.length === 0 ? (
|
) : results.length === 0 ? (
|
||||||
<div style={{ padding: '64px 0', textAlign: 'center', color: 'var(--ink-5)' }}>
|
<div style={{ padding: '64px 0', textAlign: 'center', color: 'var(--ink-5)' }}>
|
||||||
|
|
@ -99,6 +114,7 @@ export default function SearchResults() {
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6, flexWrap: 'wrap' }}>
|
||||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#fff', background: r.color, borderRadius: 6, padding: '2px 8px' }}>{r.badge}</span>
|
<span style={{ fontSize: 11, fontWeight: 700, color: '#fff', background: r.color, borderRadius: 6, padding: '2px 8px' }}>{r.badge}</span>
|
||||||
{r.meta && <span style={{ fontSize: 11, color: 'var(--ink-5)' }}>{r.meta}</span>}
|
{r.meta && <span style={{ fontSize: 11, color: 'var(--ink-5)' }}>{r.meta}</span>}
|
||||||
|
{r.tags.map(item => <span key={item} style={{ fontSize: 10.5, color: GOLD }}>#{item}</span>)}
|
||||||
</div>
|
</div>
|
||||||
<h3 style={{ fontSize: 16, fontWeight: 800, color: NAVY, margin: '0 0 4px' }}>{r.title}</h3>
|
<h3 style={{ fontSize: 16, fontWeight: 800, color: NAVY, margin: '0 0 4px' }}>{r.title}</h3>
|
||||||
{r.summary && (
|
{r.summary && (
|
||||||
|
|
|
||||||
|
|
@ -1,76 +1,70 @@
|
||||||
import { useState } from 'react'
|
import { useState, type CSSProperties } from 'react'
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { Eye, Globe, Factory, Target, type LucideIcon } from 'lucide-react'
|
import { Eye, Globe, Factory, type LucideIcon } from 'lucide-react'
|
||||||
import WorldSection from './sections/WorldSection'
|
import WorldSection from './sections/WorldSection'
|
||||||
import IndustrySection from './sections/IndustrySection'
|
import IndustrySection from './sections/IndustrySection'
|
||||||
import SelfSection from './sections/SelfSection'
|
|
||||||
|
|
||||||
const NAVY = '#032340'
|
const NAVY = '#032340'
|
||||||
const GOLD = '#CD9E53'
|
const GOLD = '#CD9E53'
|
||||||
|
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
{ id: 'world', titleFa: 'دیدن جهان', subtitleFa: 'روندها · سیاستها · بازار', accentColor: '#1a6bcf' },
|
{ id: 'world', titleFa: 'دیدن جهان', subtitleFa: 'روندها · سیاستها · بازار', accentColor: '#1a6bcf', Icon: Globe, component: WorldSection },
|
||||||
{ id: 'industry', titleFa: 'دیدن صنعت', subtitleFa: 'فولاد · رقبا · قیمتها', accentColor: '#b8893d' },
|
{ id: 'industry', titleFa: 'دیدن صنعت', subtitleFa: 'فولاد · رقبا · قیمتها', accentColor: '#b8893d', Icon: Factory, component: IndustrySection },
|
||||||
{ id: 'self', titleFa: 'دیدن خود', subtitleFa: 'استراتژی · پروژهها · عملکرد', accentColor: NAVY },
|
] as const
|
||||||
]
|
|
||||||
|
|
||||||
const DOCK_ICONS: Record<string, LucideIcon> = { world: Globe, industry: Factory, self: Target }
|
type SectionId = (typeof SECTIONS)[number]['id']
|
||||||
|
|
||||||
const SECTION_COMPONENTS = [WorldSection, IndustrySection, SelfSection]
|
function SwitcherOption({
|
||||||
|
id,
|
||||||
/* ── right-side dock button with a hover label ── */
|
option,
|
||||||
function DockButton({ Icon, label, color, active, onClick }: { Icon: LucideIcon; label: string; color: string; active: boolean; onClick: () => void }) {
|
label,
|
||||||
const [hover, setHover] = useState(false)
|
Icon,
|
||||||
|
activeSectionId,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
id: SectionId
|
||||||
|
option: string
|
||||||
|
label: string
|
||||||
|
Icon: LucideIcon
|
||||||
|
activeSectionId: SectionId
|
||||||
|
onChange: (id: SectionId) => void
|
||||||
|
}) {
|
||||||
|
const customAttrs = { 'c-option': option } as Record<string, string>
|
||||||
return (
|
return (
|
||||||
<div
|
<label className="steel-switcher__option" title={label} aria-label={label}>
|
||||||
style={{ position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}
|
<input
|
||||||
onMouseEnter={() => setHover(true)}
|
{...customAttrs}
|
||||||
onMouseLeave={() => setHover(false)}
|
className="steel-switcher__input"
|
||||||
>
|
type="radio"
|
||||||
<AnimatePresence>
|
name="steel-dashboard-section"
|
||||||
{hover && (
|
checked={activeSectionId === id}
|
||||||
<motion.span
|
onChange={() => onChange(id)}
|
||||||
initial={{ opacity: 0, x: 6 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 6 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
style={{ position: 'absolute', right: 'calc(100% + 10px)', whiteSpace: 'nowrap', background: '#032340', color: '#fff', fontSize: 12, fontWeight: 700, padding: '5px 11px', borderRadius: 8, boxShadow: '0 6px 18px rgba(3,35,64,0.3)', pointerEvents: 'none' }}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</motion.span>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
title={label}
|
title={label}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
style={{
|
/>
|
||||||
width: 44, height: 44, borderRadius: 13, border: 'none', cursor: 'pointer',
|
<Icon className="steel-switcher__icon" size={20} strokeWidth={2.2} />
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
</label>
|
||||||
background: active ? color : hover ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.1)',
|
|
||||||
color: active ? '#fff' : 'rgba(255,255,255,0.78)',
|
|
||||||
transition: 'background 180ms, transform 180ms, box-shadow 180ms',
|
|
||||||
transform: hover ? 'scale(1.08)' : 'scale(1)',
|
|
||||||
boxShadow: active ? `0 6px 18px ${color}66` : 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon size={20} strokeWidth={2} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Page ── */
|
/* ── Page ── */
|
||||||
export default function SteelDashboard() {
|
export default function SteelDashboard() {
|
||||||
const [activeSectionId, setActiveSectionId] = useState('world')
|
const [activeSectionId, setActiveSectionId] = useState<SectionId>('world')
|
||||||
|
const [previousOption, setPreviousOption] = useState('1')
|
||||||
|
|
||||||
const activeIdx = SECTIONS.findIndex(s => s.id === activeSectionId)
|
const activeSection = SECTIONS.find(s => s.id === activeSectionId) ?? SECTIONS[0]
|
||||||
|
const ActiveComponent = activeSection.component
|
||||||
|
const switcherAttrs = { 'c-previous': previousOption } as Record<string, string>
|
||||||
|
|
||||||
const selectSection = (id: string) => {
|
const selectSection = (id: SectionId) => {
|
||||||
|
const currentOption = String(SECTIONS.findIndex(s => s.id === activeSectionId) + 1)
|
||||||
|
setPreviousOption(currentOption)
|
||||||
setActiveSectionId(id)
|
setActiveSectionId(id)
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div dir="rtl" style={{ minHeight: '100vh', background: '#f3f4f6', fontFamily: 'Vazir, sans-serif' }}>
|
<div dir="rtl" className="steel-dashboard-page" style={{ minHeight: '100vh', background: '#f3f4f6', fontFamily: 'Vazir, sans-serif' }}>
|
||||||
|
|
||||||
{/* page header */}
|
{/* page header */}
|
||||||
<div style={{ background: NAVY, padding: '28px 0 24px' }}>
|
<div style={{ background: NAVY, padding: '28px 0 24px' }}>
|
||||||
|
|
@ -88,7 +82,7 @@ export default function SteelDashboard() {
|
||||||
STEEL FORESIGHT · DASHBOARD
|
STEEL FORESIGHT · DASHBOARD
|
||||||
</div>
|
</div>
|
||||||
<h1 style={{ fontSize: 'clamp(16px,2.2vw,26px)', fontWeight: 900, color: '#fff', margin: 0 }}>
|
<h1 style={{ fontSize: 'clamp(16px,2.2vw,26px)', fontWeight: 900, color: '#fff', margin: 0 }}>
|
||||||
سامانه هوشمند رصد فولاد مبارکه
|
سامانه هوشمند رصد فولاد آینده
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|
@ -108,7 +102,7 @@ export default function SteelDashboard() {
|
||||||
{/* section content — animated on switch */}
|
{/* section content — animated on switch */}
|
||||||
<div
|
<div
|
||||||
className="max-w-7xl mx-auto px-12 max-md:px-5"
|
className="max-w-7xl mx-auto px-12 max-md:px-5"
|
||||||
style={{ paddingTop: 40, paddingBottom: 60 }}
|
style={{ paddingTop: 40, paddingBottom: 60, paddingRight: 'max(3rem, 72px)' }}
|
||||||
>
|
>
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
@ -118,32 +112,29 @@ export default function SteelDashboard() {
|
||||||
exit={{ opacity: 0, y: -10 }}
|
exit={{ opacity: 0, y: -10 }}
|
||||||
transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
|
transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
|
||||||
>
|
>
|
||||||
{(() => { const C = SECTION_COMPONENTS[activeIdx]; return C ? <C /> : null })()}
|
<ActiveComponent />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* right-side dock */}
|
<fieldset
|
||||||
<div style={{
|
{...switcherAttrs}
|
||||||
position: 'fixed', right: 16, top: '50%', transform: 'translateY(-50%)',
|
className="steel-switcher"
|
||||||
zIndex: 100, display: 'flex', flexDirection: 'column', gap: 22,
|
style={{ '--steel-active': activeSection.accentColor } as CSSProperties}
|
||||||
padding: '18px 8px', borderRadius: 20,
|
>
|
||||||
background: 'rgba(3,35,64,0.5)',
|
<legend className="steel-switcher__legend">بخشهای داشبورد</legend>
|
||||||
backdropFilter: 'blur(18px) saturate(160%)', WebkitBackdropFilter: 'blur(18px) saturate(160%)',
|
{SECTIONS.map((s, index) => (
|
||||||
border: '1px solid rgba(255,255,255,0.22)',
|
<SwitcherOption
|
||||||
boxShadow: '0 18px 50px rgba(3,35,64,0.35), inset 0 1px 0 rgba(255,255,255,0.18)',
|
|
||||||
}}>
|
|
||||||
{SECTIONS.map((s) => (
|
|
||||||
<DockButton
|
|
||||||
key={s.id}
|
key={s.id}
|
||||||
Icon={DOCK_ICONS[s.id]}
|
id={s.id}
|
||||||
|
option={String(index + 1)}
|
||||||
label={s.titleFa}
|
label={s.titleFa}
|
||||||
color={s.accentColor}
|
Icon={s.Icon}
|
||||||
active={activeSectionId === s.id}
|
activeSectionId={activeSectionId}
|
||||||
onClick={() => selectSection(s.id)}
|
onChange={selectSection}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,51 @@ const injectedCss = `
|
||||||
0% { transform: translateX(100%); }
|
0% { transform: translateX(100%); }
|
||||||
100% { transform: translateX(-100%); }
|
100% { transform: translateX(-100%); }
|
||||||
}
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.industry-section { padding: 20px 0 !important; }
|
||||||
|
.industry-masthead {
|
||||||
|
align-items: flex-start !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
gap: 8px !important;
|
||||||
|
margin-bottom: 14px !important;
|
||||||
|
}
|
||||||
|
.industry-masthead-main { width: 100%; align-items: center !important; gap: 9px !important; }
|
||||||
|
.industry-masthead-kicker { display: none; }
|
||||||
|
.industry-masthead-title { font-size: 18px !important; }
|
||||||
|
.industry-masthead-source { width: 100%; font-size: 10px !important; line-height: 1.8; }
|
||||||
|
.industry-price-table { display: none; }
|
||||||
|
.industry-price-mobile { display: grid !important; gap: 10px; padding: 10px; background: #f8fafc; }
|
||||||
|
.industry-market-context { display: grid !important; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.industry-market-context > div { padding: 9px 10px !important; border-left: 0 !important; border-bottom: 1px solid #e2e6eb; }
|
||||||
|
.industry-competitor-columns { display: none !important; }
|
||||||
|
.industry-competitor-row { flex-wrap: wrap !important; gap: 8px !important; padding: 12px !important; }
|
||||||
|
.industry-competitor-rank { display: none !important; }
|
||||||
|
.industry-competitor-company { flex: 1 1 150px !important; }
|
||||||
|
.industry-competitor-output { flex: 1 1 100% !important; order: 4; }
|
||||||
|
.industry-competitor-growth,
|
||||||
|
.industry-competitor-tech,
|
||||||
|
.industry-competitor-use { flex: 1 1 auto !important; }
|
||||||
|
.risk-grid { grid-template-columns: 1fr !important; }
|
||||||
|
.industry-risk-scatter { border-left: 0 !important; border-bottom: 1px solid #eef2f6; padding: 8px 10px 14px !important; overflow: visible; }
|
||||||
|
.industry-risk-chart {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.industry-risk-mobile-map { display: grid !important; }
|
||||||
|
.industry-risk-mobile-axes { display: flex !important; }
|
||||||
|
.industry-risk-tooltip {
|
||||||
|
position: static !important;
|
||||||
|
inset: auto !important;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: none !important;
|
||||||
|
margin-top: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: normal;
|
||||||
|
white-space: normal !important;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.industry-risk-tooltip * { max-width: 100%; white-space: normal !important; }
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
if (typeof document !== 'undefined' && !document.getElementById('industry-css')) {
|
if (typeof document !== 'undefined' && !document.getElementById('industry-css')) {
|
||||||
const tag = document.createElement('style');
|
const tag = document.createElement('style');
|
||||||
|
|
@ -28,10 +73,12 @@ if (typeof document !== 'undefined' && !document.getElementById('industry-css'))
|
||||||
|
|
||||||
// ─── Sparkline data ────────────────────────────────────────────────────────────
|
// ─── Sparkline data ────────────────────────────────────────────────────────────
|
||||||
const sparkData: Record<string, number[]> = {
|
const sparkData: Record<string, number[]> = {
|
||||||
'HRC (آمریکا)': [950, 920, 900, 875, 862],
|
'HRC (آمریکا)': [830, 950, 1040, 1100, 1100],
|
||||||
'سنگ آهن ۶۲٪ Fe': [114, 108, 103, 98, 101],
|
'HRC/فولاد چین': [466, 462, 456, 452, 450],
|
||||||
'قراضه HMS': [365, 370, 378, 382, 385],
|
'سنگ آهن ۶۲٪ Fe': [105, 102, 100, 98.57, 98.72],
|
||||||
'زغال کُک': [238, 220, 205, 195, 200],
|
'قراضه HMS': [386, 390, 396, 396, 396],
|
||||||
|
'زغالسنگ Newcastle': [151, 145, 136, 129.95, 128.6],
|
||||||
|
'نفت خام (Brent)': [75.5, 76.0, 76.1, 79.3, 79.45],
|
||||||
};
|
};
|
||||||
|
|
||||||
function Spark({ data, color }: { data: number[]; color: string }) {
|
function Spark({ data, color }: { data: number[]; color: string }) {
|
||||||
|
|
@ -72,46 +119,46 @@ interface PriceRow {
|
||||||
|
|
||||||
const prices: PriceRow[] = [
|
const prices: PriceRow[] = [
|
||||||
{
|
{
|
||||||
product: 'HRC (آمریکا)', price: '$862/t',
|
product: 'HRC (آمریکا)', price: '~$1,100/t',
|
||||||
daily: null, monthly: '+12٪', yearly: '+8٪',
|
daily: null, monthly: null, yearly: '+۳۰٪',
|
||||||
source: 'Platts', upM: true, upY: true,
|
source: 'Barron’s / CRU proxy', upY: true,
|
||||||
weekLow: 780, weekHigh: 970, current: 862,
|
weekLow: 700, weekHigh: 1200, current: 1100,
|
||||||
note: 'تقاضای ساختمانی آمریکا قوی؛ تعرفه ۲۵٪ از واردات حمایت میکند',
|
note: 'شاخص HRC آمریکا در گزارشهای عمومی بازار حوالی ۱۱۰۰ دلار/تن گزارش شده؛ تعرفهها و افت واردات از قیمت داخلی حمایت میکند',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
product: 'HRC (چین)', price: '$517/t',
|
product: 'HRC/فولاد چین', price: '~$450/t',
|
||||||
daily: '-0.4٪', monthly: '-6٪', yearly: '-14٪',
|
daily: '-۰.۴۶٪', monthly: '-۲.۸۶٪', yearly: '-۰.۹۷٪',
|
||||||
source: 'MySteel', upD: false, upM: false, upY: false,
|
source: 'Trading Economics / SHFE', upD: false, upM: false, upY: false,
|
||||||
weekLow: 490, weekHigh: 620, current: 517,
|
weekLow: 420, weekHigh: 540, current: 450,
|
||||||
note: 'اضافهعرضه داخلی؛ صادرات رکورد ۱۱۷Mt فشار نزولی دارد',
|
note: 'شاخص عمومی فولاد چین ۳۰۵۵ یوان/تن است؛ بهعنوان proxy برای فشار قیمت فولاد تخت چین استفاده شده، نه quote رسمی Platts HRC',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
product: 'سنگ آهن ۶۲٪ Fe', price: '$101.1/t',
|
product: 'سنگ آهن ۶۲٪ Fe', price: '$98.72/t',
|
||||||
daily: '-0.1٪', monthly: '-8.3٪', yearly: '+6.7٪',
|
daily: '+۰.۱۵٪', monthly: '-۲.۸۳٪', yearly: '+۲.۰۸٪',
|
||||||
source: 'SGX', upD: false, upM: false, upY: true,
|
source: 'Trading Economics', upD: true, upM: false, upY: true,
|
||||||
weekLow: 92, weekHigh: 140, current: 101,
|
weekLow: 92, weekHigh: 140, current: 98.72,
|
||||||
note: 'کاهش تولید چدن در چین؛ بازیابی تقاضا هند',
|
note: 'سنگآهن ۶۲٪ CFR چین حوالی ۹۸.۷۲ دلار/تن؛ افت ماهانه ادامه دارد اما نسبت به سال قبل کمی بالاتر است',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
product: 'قراضه HMS', price: '$385/t',
|
product: 'قراضه HMS', price: '~$396/t',
|
||||||
daily: null, monthly: '-5٪', yearly: '+12.6٪',
|
daily: null, monthly: null, yearly: '+۳٪',
|
||||||
source: 'Metaloop', upM: false, upY: true,
|
source: 'SDI / market proxy', upY: true,
|
||||||
weekLow: 340, weekHigh: 410, current: 385,
|
weekLow: 340, weekHigh: 430, current: 396,
|
||||||
note: 'تقاضای EAF اروپا و ترکیه؛ محدودیت صادرات آمریکا',
|
note: 'عدد قراضه بهصورت proxy از هزینه متوسط قراضه گزارششده توسط Steel Dynamics استفاده شده؛ برای HMS ترکیه quote آزاد قابل اتکا در دسترس عمومی نبود',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
product: 'زغال کُک', price: '$200/t',
|
product: 'زغالسنگ Newcastle', price: '$128.60/t',
|
||||||
daily: null, monthly: null, yearly: null,
|
daily: '-۱.۰۴٪', monthly: '-۱۵.۲۶٪', yearly: '+۱۴.۸۲٪',
|
||||||
source: 'IEA',
|
source: 'Trading Economics / ICE', upD: false, upM: false, upY: true,
|
||||||
weekLow: 175, weekHigh: 260, current: 200,
|
weekLow: 110, weekHigh: 180, current: 128.6,
|
||||||
note: 'ذخایر استرالیا کافی؛ تقاضای آسیا تثبیت شده',
|
note: 'این ردیف به شاخص زغالسنگ حرارتی Newcastle تغییر داده شد؛ قیمت زغال ککشو premium معمولاً پشت Fastmarkets/Platts است',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
product: 'نفت خام (Brent)', price: '$80/bbl',
|
product: 'نفت خام (Brent)', price: '$79.45/bbl',
|
||||||
daily: null, monthly: null, yearly: null,
|
daily: '+۴.۵۲٪', monthly: '-۴.۴۷٪', yearly: '+۱۴.۷۹٪',
|
||||||
source: 'ICE',
|
source: 'Trading Economics / ICE', upD: true, upM: false, upY: true,
|
||||||
weekLow: 70, weekHigh: 95, current: 80,
|
weekLow: 65, weekHigh: 105, current: 79.45,
|
||||||
note: 'تاثیر غیرمستقیم از طریق هزینه انرژی و حملونقل',
|
note: 'برنت با ریسک تنگه هرمز و تنش آمریکا و ایران جهش روزانه داشته؛ اثر آن روی هزینه انرژی، حمل و نرخهای منطقهای غیرمستقیم است',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -308,7 +355,7 @@ function PricesModule() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
setTimestamp(`${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`);
|
setTimestamp(faDigits(`${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`));
|
||||||
};
|
};
|
||||||
const id = setInterval(tick, 60_000);
|
const id = setInterval(tick, 60_000);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
|
|
@ -353,7 +400,7 @@ function PricesModule() {
|
||||||
</span>
|
</span>
|
||||||
<span style={{ color: '#22c55e', fontSize: 14, animation: 'livePulse 2s ease-in-out infinite' }}>●</span>
|
<span style={{ color: '#22c55e', fontSize: 14, animation: 'livePulse 2s ease-in-out infinite' }}>●</span>
|
||||||
<span style={{ color: '#64748b', fontSize: 11, fontFamily: "Vazir, sans-serif" }}>
|
<span style={{ color: '#64748b', fontSize: 11, fontFamily: "Vazir, sans-serif" }}>
|
||||||
زنده · ۲۰ ژوئن ۲۰۲۵
|
زنده · ۲۳ تیر ۱۴۰۵ / ۱۳ ژوئیه ۲۰۲۶
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -395,7 +442,7 @@ function PricesModule() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Price table */}
|
{/* Price table */}
|
||||||
<motion.div style={{ overflowX: 'auto' }} viewport={{ once: true, margin: '-40px' }} onViewportEnter={revealRows}>
|
<motion.div className="industry-price-table" style={{ overflowX: 'auto' }} viewport={{ once: true, margin: '-40px' }} onViewportEnter={revealRows}>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', direction: 'rtl' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', direction: 'rtl' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ background: '#f8fafc' }}>
|
<tr style={{ background: '#f8fafc' }}>
|
||||||
|
|
@ -534,8 +581,38 @@ function PricesModule() {
|
||||||
</table>
|
</table>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="industry-price-mobile" style={{ display: 'none' }}>
|
||||||
|
{prices.map((row) => {
|
||||||
|
const isExpanded = expandedPrice.has(row.product)
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={`mobile-${row.product}`}
|
||||||
|
onClick={() => setExpandedPrice((prev) => { const next = new Set(prev); if (next.has(row.product)) next.delete(row.product); else next.add(row.product); return next })}
|
||||||
|
style={{ background: isExpanded ? '#eff6ff' : '#fff', border: `1px solid ${isExpanded ? '#bfdbfe' : '#e2e8f0'}`, borderRadius: 12, padding: 12, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 10 }}>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ color: NAVY, fontSize: 13, fontWeight: 900, lineHeight: 1.7 }}>{row.product}</div>
|
||||||
|
<div style={{ color: '#94a3b8', fontSize: 10, marginTop: 3 }}>{row.source}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'Vazir, sans-serif', fontSize: 15, fontWeight: 900, color: NAVY, whiteSpace: 'nowrap' }}>{faDigits(row.price)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 7, marginTop: 12 }}>
|
||||||
|
{[['روزانه', row.daily, row.upD], ['ماهانه', row.monthly, row.upM], ['سالانه', row.yearly, row.upY]].map(([label, value, up]) => (
|
||||||
|
<div key={String(label)} style={{ background: '#f8fafc', borderRadius: 8, padding: 7, textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 9, color: '#94a3b8', marginBottom: 3 }}>{label}</div>
|
||||||
|
<ChangeCell val={value as string | null} up={up as boolean | undefined} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{isExpanded && <p style={{ margin: '12px 0 0', paddingTop: 10, borderTop: '1px solid #bfdbfe', color: '#334155', fontSize: 11, lineHeight: 1.85 }}>{faDigits(row.note)}</p>}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Market context rows */}
|
{/* Market context rows */}
|
||||||
<div style={{ background: '#f8fafc', borderTop: '1px solid #e2e6eb', display: 'flex', direction: 'rtl' }}>
|
<div className="industry-market-context" style={{ background: '#f8fafc', borderTop: '1px solid #e2e6eb', display: 'flex', direction: 'rtl' }}>
|
||||||
{marketContext.map((ctx, i) => (
|
{marketContext.map((ctx, i) => (
|
||||||
<div
|
<div
|
||||||
key={ctx.label}
|
key={ctx.label}
|
||||||
|
|
@ -670,7 +747,7 @@ function CompetitorsModule() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* column hints */}
|
{/* column hints */}
|
||||||
<div style={{ padding: '7px 16px', display: 'flex', gap: 12, direction: 'rtl', fontSize: 9, fontWeight: 700, letterSpacing: 0.5, color: '#94a3b8', borderBottom: '1px solid #eef2f6', alignItems: 'center' }}>
|
<div className="industry-competitor-columns" style={{ padding: '7px 16px', display: 'flex', gap: 12, direction: 'rtl', fontSize: 9, fontWeight: 700, letterSpacing: 0.5, color: '#94a3b8', borderBottom: '1px solid #eef2f6', alignItems: 'center' }}>
|
||||||
<span style={{ width: 26, textAlign: 'center' }}>#</span>
|
<span style={{ width: 26, textAlign: 'center' }}>#</span>
|
||||||
<span style={{ flex: '0 0 172px' }}>شرکت</span>
|
<span style={{ flex: '0 0 172px' }}>شرکت</span>
|
||||||
<span style={{ flex: 1 }}>تولید فولاد خام ۲۰۲۴ (Mt)</span>
|
<span style={{ flex: 1 }}>تولید فولاد خام ۲۰۲۴ (Mt)</span>
|
||||||
|
|
@ -708,9 +785,9 @@ function CompetitorsModule() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{c.isUs && <BorderBeam duration={7} size={120} borderWidth={2} colorFrom="#CD9E53" colorTo="#032340" />}
|
{c.isUs && <BorderBeam duration={7} size={120} borderWidth={2} colorFrom="#CD9E53" colorTo="#032340" />}
|
||||||
<div style={{ padding: '11px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div className="industry-competitor-row" style={{ padding: '11px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<span style={{ width: 26, textAlign: 'center', fontFamily: 'Vazir, sans-serif', fontWeight: 800, fontSize: 12, color: c.isUs ? '#CD9E53' : '#94a3b8' }}>{faN(c.rank)}</span>
|
<span className="industry-competitor-rank" style={{ width: 26, textAlign: 'center', fontFamily: 'Vazir, sans-serif', fontWeight: 800, fontSize: 12, color: c.isUs ? '#CD9E53' : '#94a3b8' }}>{faN(c.rank)}</span>
|
||||||
<div style={{ flex: '0 0 172px', display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
<div className="industry-competitor-company" style={{ flex: '0 0 172px', display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||||
<CompanyLogo name={c.name} flag={c.flag} size={36} />
|
<CompanyLogo name={c.name} flag={c.flag} size={36} />
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div style={{ fontWeight: 800, fontSize: 15, color: NAVY, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', letterSpacing: '-0.2px', lineHeight: 1.25 }}>
|
<div style={{ fontWeight: 800, fontSize: 15, color: NAVY, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', letterSpacing: '-0.2px', lineHeight: 1.25 }}>
|
||||||
|
|
@ -719,24 +796,24 @@ function CompetitorsModule() {
|
||||||
<div style={{ fontSize: 10, color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}><span style={{ fontSize: 11 }}>{c.flag}</span>{c.country}</div>
|
<div style={{ fontSize: 10, color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}><span style={{ fontSize: 11 }}>{c.flag}</span>{c.country}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 8, direction: 'ltr' }}>
|
<div className="industry-competitor-output" style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 8, direction: 'ltr' }}>
|
||||||
<div style={{ flex: 1, height: 10, borderRadius: 99, background: '#eef2f6', overflow: 'hidden' }}>
|
<div style={{ flex: 1, height: 10, borderRadius: 99, background: '#eef2f6', overflow: 'hidden' }}>
|
||||||
<motion.div initial={{ width: 0 }} animate={{ width: `${barW}%` }} transition={{ duration: 0.8, delay: 0.1 + i * 0.025, ease: [0.22, 1, 0.36, 1] }}
|
<motion.div initial={{ width: 0 }} animate={{ width: `${barW}%` }} transition={{ duration: 0.8, delay: 0.1 + i * 0.025, ease: [0.22, 1, 0.36, 1] }}
|
||||||
style={{ height: '100%', borderRadius: 99, background: c.isUs ? 'linear-gradient(90deg,#CD9E53,#b8893d)' : `linear-gradient(90deg, ${REGION_COLOR[c.region]}, ${REGION_COLOR[c.region]}bb)` }} />
|
style={{ height: '100%', borderRadius: 99, background: c.isUs ? 'linear-gradient(90deg,#CD9E53,#b8893d)' : `linear-gradient(90deg, ${REGION_COLOR[c.region]}, ${REGION_COLOR[c.region]}bb)` }} />
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontFamily: 'Vazir, sans-serif', fontWeight: 800, fontSize: 13, color: NAVY, minWidth: 50, textAlign: 'left' }}>{faN(c.outputMt)}</span>
|
<span style={{ fontFamily: 'Vazir, sans-serif', fontWeight: 800, fontSize: 13, color: NAVY, minWidth: 50, textAlign: 'left' }}>{faN(c.outputMt)}</span>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ flex: '0 0 64px', textAlign: 'center', fontFamily: 'Vazir, sans-serif', fontWeight: 700, fontSize: 12, color: c.growthPct > 0 ? '#16a34a' : c.growthPct < 0 ? '#dc2626' : '#64748b' }}>
|
<span className="industry-competitor-growth" style={{ flex: '0 0 64px', textAlign: 'center', fontFamily: 'Vazir, sans-serif', fontWeight: 700, fontSize: 12, color: c.growthPct > 0 ? '#16a34a' : c.growthPct < 0 ? '#dc2626' : '#64748b' }}>
|
||||||
{c.growthPct > 0 ? '▲ ' : c.growthPct < 0 ? '▼ ' : ''}{faN(Math.abs(c.growthPct))}٪
|
{c.growthPct > 0 ? '▲ ' : c.growthPct < 0 ? '▼ ' : ''}{faN(Math.abs(c.growthPct))}٪
|
||||||
</span>
|
</span>
|
||||||
<div style={{ flex: '0 0 86px' }}>
|
<div className="industry-competitor-tech" style={{ flex: '0 0 86px' }}>
|
||||||
<div style={{ height: 8, borderRadius: 99, overflow: 'hidden', display: 'flex', direction: 'ltr', border: '1px solid #e2e8f0' }}>
|
<div style={{ height: 8, borderRadius: 99, overflow: 'hidden', display: 'flex', direction: 'ltr', border: '1px solid #e2e8f0' }}>
|
||||||
<div style={{ width: `${c.techMix.eafDri}%`, background: '#CD9E53' }} />
|
<div style={{ width: `${c.techMix.eafDri}%`, background: '#CD9E53' }} />
|
||||||
<div style={{ width: `${c.techMix.bfBof}%`, background: '#475569' }} />
|
<div style={{ width: `${c.techMix.bfBof}%`, background: '#475569' }} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 8, color: '#94a3b8', textAlign: 'center', marginTop: 2 }}>{faN(c.techMix.eafDri)}٪ کمکربن</div>
|
<div style={{ fontSize: 8, color: '#94a3b8', textAlign: 'center', marginTop: 2 }}>{faN(c.techMix.eafDri)}٪ کمکربن</div>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ flex: '0 0 56px', textAlign: 'center', fontFamily: 'Vazir, sans-serif', fontWeight: 700, fontSize: 12, color: '#475569' }}>{faN(c.utilizationPct)}٪</span>
|
<span className="industry-competitor-use" style={{ flex: '0 0 56px', textAlign: 'center', fontFamily: 'Vazir, sans-serif', fontWeight: 700, fontSize: 12, color: '#475569' }}>{faN(c.utilizationPct)}٪</span>
|
||||||
<span style={{ width: 14, color: '#cbd5e1', fontSize: 11 }}>{isOpen ? '▲' : '▼'}</span>
|
<span style={{ width: 14, color: '#cbd5e1', fontSize: 11 }}>{isOpen ? '▲' : '▼'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -838,8 +915,8 @@ function RisksModule() {
|
||||||
|
|
||||||
<div className="risk-grid" style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1.7fr) minmax(0,1fr)' }}>
|
<div className="risk-grid" style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1.7fr) minmax(0,1fr)' }}>
|
||||||
{/* scatter */}
|
{/* scatter */}
|
||||||
<div style={{ padding: '8px 16px 16px', position: 'relative', borderLeft: '1px solid #eef2f6' }}>
|
<div className="industry-risk-scatter" style={{ padding: '8px 16px 16px', position: 'relative', borderLeft: '1px solid #eef2f6' }}>
|
||||||
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display: 'block', overflow: 'visible' }} onMouseLeave={() => setHovered(null)}>
|
<svg className="industry-risk-chart" viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display: 'block', overflow: 'visible' }} onMouseLeave={() => setHovered(null)}>
|
||||||
{/* quadrant zones */}
|
{/* quadrant zones */}
|
||||||
<rect x={px(50)} y={py(100)} width={innerW / 2} height={innerH / 2} fill="#fee2e2" opacity={0.7} />
|
<rect x={px(50)} y={py(100)} width={innerW / 2} height={innerH / 2} fill="#fee2e2" opacity={0.7} />
|
||||||
<rect x={PAD} y={py(100)} width={innerW / 2} height={innerH / 2} fill="#fef3c7" opacity={0.5} />
|
<rect x={PAD} y={py(100)} width={innerW / 2} height={innerH / 2} fill="#fef3c7" opacity={0.5} />
|
||||||
|
|
@ -887,9 +964,58 @@ function RisksModule() {
|
||||||
})}
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
<div className="industry-risk-mobile-map" style={{
|
||||||
|
display: 'none',
|
||||||
|
position: 'relative',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gridTemplateRows: '1fr 1fr',
|
||||||
|
minHeight: 250,
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: 10,
|
||||||
|
direction: 'ltr',
|
||||||
|
}}>
|
||||||
|
<div style={{ background: '#dcfce7', borderRight: '1px dashed #d1d5db', borderBottom: '1px dashed #d1d5db', padding: 8, color: '#15803d', fontSize: 9, direction: 'rtl' }}>اثر کم<br />احتمال کم</div>
|
||||||
|
<div style={{ background: '#fef9c3', borderBottom: '1px dashed #d1d5db', padding: 8, color: '#a16207', fontSize: 9, textAlign: 'right', direction: 'rtl' }}>اثر کم<br />احتمال بالا</div>
|
||||||
|
<div style={{ background: '#fef3c7', borderRight: '1px dashed #d1d5db', padding: 8, color: '#a16207', fontSize: 9, direction: 'rtl' }}>اثر بالا<br />احتمال کم</div>
|
||||||
|
<div style={{ background: '#fee2e2', padding: 8, color: '#dc2626', fontSize: 9, textAlign: 'right', direction: 'rtl' }}>اثر بالا<br />احتمال بالا</div>
|
||||||
|
{shown.map((risk) => {
|
||||||
|
const color = RISK_CAT[risk.category].color
|
||||||
|
const isActive = active?.id === risk.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`mobile-${risk.id}`}
|
||||||
|
type="button"
|
||||||
|
title={risk.nameFa}
|
||||||
|
onClick={() => setPinned(pinned?.id === risk.id ? null : risk)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `clamp(16px, ${risk.probabilityPct}%, calc(100% - 16px))`,
|
||||||
|
bottom: `clamp(16px, ${risk.impactPct}%, calc(100% - 16px))`,
|
||||||
|
width: isActive ? 20 : 16,
|
||||||
|
height: isActive ? 20 : 16,
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: '2px solid #fff',
|
||||||
|
background: color,
|
||||||
|
boxShadow: isActive ? `0 0 0 3px ${color}55` : '0 2px 5px rgba(3,35,64,0.18)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
zIndex: isActive ? 2 : 1,
|
||||||
|
padding: 0,
|
||||||
|
transform: 'translate(-50%, 50%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="industry-risk-mobile-axes" style={{ display: 'none', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '8px 4px 0', color: '#64748b', fontSize: 9, direction: 'rtl' }}>
|
||||||
|
<span>شدت اثر: کم ← زیاد</span>
|
||||||
|
<span>احتمال وقوع: کم ← زیاد</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* hover/pin tooltip */}
|
{/* hover/pin tooltip */}
|
||||||
{active && (
|
{active && (
|
||||||
<div style={{ position: 'absolute', top: 14, left: 14, maxWidth: 290, background: NAVY, color: '#fff', borderRadius: 10, padding: '12px 14px', boxShadow: '0 14px 36px rgba(0,0,0,0.28)', direction: 'rtl', zIndex: 5, borderTop: `3px solid ${RISK_CAT[active.category].color}` }}>
|
<div className="industry-risk-tooltip" style={{ position: 'absolute', top: 14, left: 14, maxWidth: 290, background: NAVY, color: '#fff', borderRadius: 10, padding: '12px 14px', boxShadow: '0 14px 36px rgba(0,0,0,0.28)', direction: 'rtl', zIndex: 5, borderTop: `3px solid ${RISK_CAT[active.category].color}` }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, flexWrap: 'wrap' }}>
|
||||||
<span style={{ fontSize: 9, fontWeight: 700, background: RISK_CAT[active.category].color, padding: '2px 8px', borderRadius: 99 }}>{RISK_CAT[active.category].fa}</span>
|
<span style={{ fontSize: 9, fontWeight: 700, background: RISK_CAT[active.category].color, padding: '2px 8px', borderRadius: 99 }}>{RISK_CAT[active.category].fa}</span>
|
||||||
<span style={{ fontSize: 9, color: TREND_FA[active.trend].color, fontWeight: 700 }}>{TREND_FA[active.trend].icon} {TREND_FA[active.trend].fa}</span>
|
<span style={{ fontSize: 9, color: TREND_FA[active.trend].color, fontWeight: 700 }}>{TREND_FA[active.trend].icon} {TREND_FA[active.trend].fa}</span>
|
||||||
|
|
@ -1075,36 +1201,137 @@ export default function IndustrySection() {
|
||||||
const steelScores = [
|
const steelScores = [
|
||||||
{
|
{
|
||||||
title: 'قدرت بازار',
|
title: 'قدرت بازار',
|
||||||
description: 'شاخص ترکیبی قیمت HRC، حجم معاملات و سنتیمنت. بالاتر = فرصت قیمتگذاری بهتر برای فولاد مبارکه.',
|
description: 'قدرت قیمتگذاری ورق تخت در بازارهای غیرچینی. عدد بالا یعنی امکان فروش با premium بهتر، اما نه بدون ریسک.',
|
||||||
initialScore: 67,
|
initialScore: 76,
|
||||||
|
meaning:
|
||||||
|
'این شاخص میسنجد فولاد مبارکه در قیمتگذاری HRC، ورق پوششدار و گریدهای صنعتی چقدر فضای مانور دارد. عدد ۷۶ یعنی بازار هنوز برای فروش ارزشافزوده جذاب است، ولی فشار صادرات چین اجازه قیمتگذاری بیمحابا نمیدهد.',
|
||||||
|
basis: [
|
||||||
|
'هند در فوریه ۲۰۲۶ شاهد رشد HRC تا حدود ₹۵۴٬۰۰۰/t بود؛ این رشد به هزینه مواد اولیه، safeguard duty و تقاضای زیرساختی نسبت داده شده است.',
|
||||||
|
'اروپا از ۲۰۲۶ واردات فولاد را سختتر کرده: سهمیه بدون تعرفه ۱۸.۳ Mt و تعرفه ۵۰٪ بالاتر از سهمیه، که میتواند premium بازارهای محافظتشده را بالا نگه دارد.',
|
||||||
|
'آمریکا تعرفه فولاد و آلومینیوم را از ژوئن ۲۰۲۵ به ۵۰٪ رسانده؛ این سیاست قیمت داخلی آمریکا را از فولاد ارزان وارداتی جدا میکند.',
|
||||||
|
'در سمت منفی، صادرات چین در سطح بسیار بالا و ضعف تقاضای داخلی چین فشار نزولی روی بازارهای خاورمیانه و آسیا ایجاد میکند.',
|
||||||
|
],
|
||||||
|
implication:
|
||||||
|
'برای فولاد مبارکه، فرصت اصلی در فروش HRC و ورقهای پوششدار به بازارهای منطقهای با قرارداد کوتاهمدت و تعدیل قیمت است. فروش commodity با قیمت ثابت بلندمدت خطرناک است، چون چین میتواند قیمت منطقه را سریع پایین بکشد.',
|
||||||
|
actions: [
|
||||||
|
'قیمتگذاری هفتگی بر اساس HRC چین، هند و ترکیه انجام شود، نه فقط قیمت داخلی.',
|
||||||
|
'در GCC و عراق بسته محصولی HRC + گالوانیزه + ورق رنگی پیشنهاد شود تا رقابت از قیمت خام به کیفیت و تحویل منتقل شود.',
|
||||||
|
'برای مشتریهای پروژهای، clause تعدیل قیمت و زمان تحویل کوتاهتر از ۶۰ روز استفاده شود.',
|
||||||
|
],
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
label: 'Economic Times: HRC India at ₹54,000/t, 2026',
|
||||||
|
url: 'https://m.economictimes.com/markets/expert-view/steel-hrc-prices-hit-2-year-high-at-54000-how-long-will-the-rally-last/articleshow/128847391.cms',
|
||||||
|
note: 'رشد قیمت HRC هند به دو سالهترین سطح، بهدلیل هزینه مواد اولیه، safeguard duty و تقاضای زیرساختی.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'AP: EU steel quota 18.3 Mt and 50% tariff',
|
||||||
|
url: 'https://apnews.com/article/fe0c6ff0063a1679a2ed36b0f06da96a',
|
||||||
|
note: 'کاهش سهمیه واردات فولاد اروپا و تعرفه ۵۰٪ بالاتر از سهمیه.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Politico: U.S. steel tariff increased to 50%',
|
||||||
|
url: 'https://www.politico.com/news/2025/05/30/trump-steel-tariff-pennsylvania-00378480',
|
||||||
|
note: 'افزایش تعرفه فولاد آمریکا از ۲۵٪ به ۵۰٪ در ۲۰۲۵، عامل premium بازارهای محافظتشده.',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'ریسک زنجیره تامین',
|
title: 'ریسک زنجیره تامین',
|
||||||
description: 'ریسک تامین سنگآهن ($۱۰۱/t)، زغالکک ($۲۰۰/t) و قطعات یدکی. عدد بالا = ریسک پایینتر.',
|
description: 'تابآوری تأمین سنگآهن، انرژی، قطعات و مواد مصرفی. عدد ۵۴ یعنی وضعیت متوسط و نیازمند کنترل فعال.',
|
||||||
initialScore: 45,
|
initialScore: 54,
|
||||||
|
meaning:
|
||||||
|
'این شاخص ریسک مواد اولیه و نهادههای عملیاتی فولاد مبارکه را میسنجد. چون مسیر اصلی مبارکه DRI-EAF است، وابستگی به زغالکک کمتر از BF/BOF است؛ اما ریسک سنگآهن، گاز، برق، آب، قطعات یدکی و تحریم هنوز جدی است.',
|
||||||
|
basis: [
|
||||||
|
'طبق پروفایل تولید فولاد مبارکه، سنگآهن از گلگهر و چادرملو به گندله، سپس DRI و بعد EAF/اسلب/کویل تبدیل میشود؛ پس کیفیت و پایداری تأمین داخلی حیاتی است.',
|
||||||
|
'Moody’s برای سنگآهن بازه ۸۰ تا ۱۰۰ دلار در ۱۸ ماه آینده را محتمل دانسته؛ یعنی شوک شدید مثبت کمتر محتمل است، اما ضعف تقاضا و عرضه مازاد روی قیمت اثر دارد.',
|
||||||
|
'رشد HRC هند در ۲۰۲۶ بخشی به افزایش هزینه مواد اولیه بهویژه زغالکک نسبت داده شده؛ هرچند مبارکه کمتر از BF به زغالکک وابسته است، ولی بازار جهانی نهادهها روی قیمت منطقه اثر میگذارد.',
|
||||||
|
'ریسک قطعات یدکی و تجهیزات برای تولیدکننده ایرانی بهخاطر تحریم و محدودیت واردات باید جدا از قیمت مواد اولیه سنجیده شود.',
|
||||||
|
],
|
||||||
|
implication:
|
||||||
|
'امتیاز ۵۴ یعنی زنجیره تأمین شکننده نیست، ولی برای برنامه صادراتی تهاجمی هم کافی نیست. مزیت DRI-EAF مبارکه باید با قراردادهای پایدار سنگآهن/گندله، ذخیره قطعات بحرانی و مدیریت انرژی تکمیل شود.',
|
||||||
|
actions: [
|
||||||
|
'لیست قطعات بحرانی خطوط احیا، EAF، ریختهگری و نورد گرم به سطح minimum stock عملیاتی وصل شود.',
|
||||||
|
'برای سنگآهن/گندله سناریوی قیمت ۸۰، ۹۰ و ۱۰۰ دلار در margin صادراتی لحاظ شود.',
|
||||||
|
'مصرف گاز و برق هر تن فولاد برای محصولات صادراتی جداگانه محاسبه شود تا در مذاکرات قیمت و CBAM آینده قابل دفاع باشد.',
|
||||||
|
],
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
label: 'Mobarakeh Steel Company production process',
|
||||||
|
url: 'https://en.wikipedia.org/wiki/Mobarakeh_Steel_Company',
|
||||||
|
note: 'فرایند گندله، DRI، EAF، اسلب، نورد گرم و محصولات پوششدار فولاد مبارکه.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Economic Times: iron ore $80-100 outlook',
|
||||||
|
url: 'https://economictimes.indiatimes.com/industry/indl-goods/svs/metals-mining/iron-ore-price-to-remain-between-80-100-amid-demand-slump-supply-growth/articleshow/121156956.cms',
|
||||||
|
note: 'Moody’s بازه ۸۰ تا ۱۰۰ دلار برای سنگآهن را در ۱۸ ماه آینده مطرح کرده است.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Economic Times: HRC rally and raw material costs',
|
||||||
|
url: 'https://m.economictimes.com/markets/expert-view/steel-hrc-prices-hit-2-year-high-at-54000-how-long-will-the-rally-last/articleshow/128847391.cms',
|
||||||
|
note: 'افزایش قیمت HRC هند به هزینه مواد اولیه، safeguard duty و تقاضای زیرساختی نسبت داده شده است.',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'مومنتوم صادراتی',
|
title: 'مومنتوم صادراتی',
|
||||||
description: 'رشد صادرات ۳ ماهه، نفوذ به بازارهای هدف عراق/هند، و سرعت عقد قرارداد جدید.',
|
description: 'شتاب واقعی صادرات در برابر فشار چین، تعرفهها و محدودیت بازار مقصد. عدد ۱۳ یعنی بسیار ضعیف.',
|
||||||
initialScore: 31,
|
initialScore: 13,
|
||||||
|
meaning:
|
||||||
|
'این شاخص نشان میدهد شرایط بیرونی برای رشد صادرات فولاد مبارکه چقدر مساعد است. عدد ۱۳ یعنی بازار جهانی در ظاهر پرتقاضا نیست؛ مسیرهای فروش با فشار چین، دیوارهای تعرفهای و محدودیت compliance روبهرو هستند.',
|
||||||
|
basis: [
|
||||||
|
'چین در ۲۰۲۵ حدود ۹۶۰.۸ Mt فولاد خام تولید کرده و صادرات فولاد آن حدود ۱۳۱ Mt گزارش شده؛ این حجم صادرات فشار مستقیم روی خاورمیانه و آسیا میآورد.',
|
||||||
|
'هند رشد تقاضا دارد، اما خودش در ۲۰۲۵ به ۱۶۴.۹ Mt تولید فولاد خام رسیده و رقیب ظرفیتساز است، نه فقط بازار هدف.',
|
||||||
|
'اروپا با CBAM و سهمیه/تعرفه جدید، صادرات مستقیم بدون داده CO₂ را سخت کرده است.',
|
||||||
|
'آمریکا با تعرفه ۵۰٪ فولاد عملاً بازار مستقیم را برای بسیاری از عرضهکنندگان خارجی بسته و باعث انحراف جریان تجارت به بازارهای منطقهای میشود.',
|
||||||
|
],
|
||||||
|
implication:
|
||||||
|
'برای فولاد مبارکه، صادرات باید دفاعی و انتخابی باشد: عراق، آسیای مرکزی و GCC با قرارداد کوتاهمدت قابل بررسیاند؛ اروپا بدون بسته کربن و مسیر compliance اولویت مستقیم ندارد؛ هند و ترکیه فقط در گرید خاص یا شکاف عرضه مناسباند.',
|
||||||
|
actions: [
|
||||||
|
'بازارهای هدف به سه سطح تقسیم شوند: ورود فعال عراق/آسیای مرکزی، ورود پریمیوم GCC، رصد هند/ترکیه/اروپا.',
|
||||||
|
'برای هر قرارداد صادراتی، قیمت چین و هزینه حمل همان هفته در فرم approval بیاید.',
|
||||||
|
'پکیج مستندات CO₂، انرژی و traceability برای صادرات غیرمستقیم اروپا آماده شود.',
|
||||||
|
],
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
label: 'worldsteel annual production data, 2025',
|
||||||
|
url: 'https://worldsteel.org/steel-topics/statistics/annual-production-steel-data/',
|
||||||
|
note: 'داده تولید ۲۰۲۵: چین ۹۶۰.۸ Mt، هند ۱۶۴.۹ Mt، ترکیه ۳۸.۱ Mt، ایران ۳۱.۸ Mt.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Times of India / worldsteel: India 164.9 Mt and +9% demand outlook',
|
||||||
|
url: 'https://timesofindia.indiatimes.com/city/kolkata/indias-crude-steel-output-grows-10/articleshow/127499102.cms',
|
||||||
|
note: 'رشد تولید هند و پیشبینی رشد تقاضای فولاد هند در ۲۰۲۶.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'European Commission: CBAM',
|
||||||
|
url: 'https://taxation-customs.ec.europa.eu/carbon-border-adjustment-mechanism_en',
|
||||||
|
note: 'CBAM از ۲۰۲۶ وارد فاز قطعی شده و داده کربن برای صادرات به زنجیره اروپا تعیینکننده است.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'AP: EU steel import quota and 50% tariff',
|
||||||
|
url: 'https://apnews.com/article/fe0c6ff0063a1679a2ed36b0f06da96a',
|
||||||
|
note: 'سهمیه ۱۸.۳ Mt و تعرفه ۵۰٪ اروپا، عامل محدودکننده جریان صادرات فولاد.',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section dir="rtl" style={{ padding: '32px 0', fontFamily: "'Vazir', 'Segoe UI', sans-serif" }}>
|
<section dir="rtl" className="industry-section" style={{ padding: '32px 0', fontFamily: "'Vazir', 'Segoe UI', sans-serif" }}>
|
||||||
{/* Section header */}
|
{/* Section header */}
|
||||||
<div style={{
|
<div className="industry-masthead" style={{
|
||||||
display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
|
display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
|
||||||
marginBottom: 16, direction: 'rtl',
|
marginBottom: 16, direction: 'rtl',
|
||||||
borderBottom: `2px solid ${ACCENT}`, paddingBottom: 10,
|
borderBottom: `2px solid ${ACCENT}`, paddingBottom: 10,
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12 }}>
|
<div className="industry-masthead-main" style={{ display: 'flex', alignItems: 'baseline', gap: 12 }}>
|
||||||
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '2px', textTransform: 'uppercase', color: ACCENT }}>
|
<span className="industry-masthead-kicker" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '2px', textTransform: 'uppercase', color: ACCENT }}>
|
||||||
INDUSTRY INTELLIGENCE
|
INDUSTRY INTELLIGENCE
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 18, fontWeight: 800, color: NAVY }}>پایش صنعت جهانی فولاد</span>
|
<span className="industry-masthead-title" style={{ fontSize: 18, fontWeight: 800, color: NAVY }}>پایش صنعت جهانی فولاد</span>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontFamily: "Vazir, sans-serif", fontSize: 11, color: '#94a3b8' }}>
|
<span className="industry-masthead-source" style={{ fontFamily: "Vazir, sans-serif", fontSize: 11, color: '#94a3b8' }}>
|
||||||
۱۴۰۴/۰۳/۳۰ · World Steel Association · Platts · MySteel
|
۱۴۰۴/۰۳/۳۰ · World Steel Association · Platts · MySteel
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,90 @@
|
||||||
|
const faDigits = '۰۱۲۳۴۵۶۷۸۹'
|
||||||
|
const arDigits = '٠١٢٣٤٥٦٧٨٩'
|
||||||
|
|
||||||
|
const faMonths: Record<string, number> = {
|
||||||
|
فروردین: 1,
|
||||||
|
اردیبهشت: 2,
|
||||||
|
خرداد: 3,
|
||||||
|
تیر: 4,
|
||||||
|
مرداد: 5,
|
||||||
|
شهریور: 6,
|
||||||
|
مهر: 7,
|
||||||
|
آبان: 8,
|
||||||
|
آذر: 9,
|
||||||
|
دی: 10,
|
||||||
|
بهمن: 11,
|
||||||
|
اسفند: 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
type DateLike = {
|
||||||
|
date?: string | null
|
||||||
|
dateFa?: string | null
|
||||||
|
dateEn?: string | null
|
||||||
|
dateIso?: string | null
|
||||||
|
createdAt?: string | null
|
||||||
|
updatedAt?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeDigits = (value: string) =>
|
||||||
|
value.replace(/[۰-۹٠-٩]/g, (d) => {
|
||||||
|
const fa = faDigits.indexOf(d)
|
||||||
|
if (fa >= 0) return String(fa)
|
||||||
|
const ar = arDigits.indexOf(d)
|
||||||
|
return ar >= 0 ? String(ar) : d
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentPersianYear = () => {
|
||||||
|
try {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US-u-ca-persian', { year: 'numeric' }).formatToParts(new Date())
|
||||||
|
const year = Number(parts.find((part) => part.type === 'year')?.value)
|
||||||
|
if (Number.isFinite(year) && year > 1300) return year
|
||||||
|
} catch {
|
||||||
|
/* keep deterministic fallback for browsers without Persian calendar support */
|
||||||
|
}
|
||||||
|
return 1405
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsePersianDateKey = (value?: string | null) => {
|
||||||
|
if (!value) return 0
|
||||||
|
const normalized = normalizeDigits(value)
|
||||||
|
const day = Number((normalized.match(/\d{1,2}/) || ['0'])[0])
|
||||||
|
const explicitYear = Number((normalized.match(/\d{4}/) || ['0'])[0])
|
||||||
|
const year = explicitYear || currentPersianYear()
|
||||||
|
const month = Object.entries(faMonths).find(([name]) => value.includes(name))?.[1] ?? 0
|
||||||
|
return year && month && day ? year * 10000 + month * 100 + day : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseGregorianDateKey = (value?: string | null) => {
|
||||||
|
if (!value) return 0
|
||||||
|
const parsed = Date.parse(value)
|
||||||
|
if (!Number.isFinite(parsed)) return 0
|
||||||
|
const date = new Date(parsed)
|
||||||
|
return date.getUTCFullYear() * 10000 + (date.getUTCMonth() + 1) * 100 + date.getUTCDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDateSortKey = (item: DateLike) => {
|
||||||
|
const persianKey = parsePersianDateKey(item.dateFa || item.date)
|
||||||
|
if (persianKey) return persianKey
|
||||||
|
return (
|
||||||
|
parseGregorianDateKey(item.dateIso) ||
|
||||||
|
parseGregorianDateKey(item.dateEn) ||
|
||||||
|
parseGregorianDateKey(item.updatedAt) ||
|
||||||
|
parseGregorianDateKey(item.createdAt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sortByDateDesc = <T extends DateLike>(items: T[]) =>
|
||||||
|
[...items].sort((a, b) => getDateSortKey(b) - getDateSortKey(a))
|
||||||
|
|
||||||
|
const getPublishedSortKey = (item: DateLike) => {
|
||||||
|
const published = Date.parse(item.createdAt || '')
|
||||||
|
if (Number.isFinite(published)) return published
|
||||||
|
|
||||||
|
const updated = Date.parse(item.updatedAt || '')
|
||||||
|
if (Number.isFinite(updated)) return updated
|
||||||
|
|
||||||
|
return getDateSortKey(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sortByPublishedDesc = <T extends DateLike>(items: T[]) =>
|
||||||
|
[...items].sort((a, b) => getPublishedSortKey(b) - getPublishedSortKey(a))
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
export const isUsableSourceUrl = (value?: string | null) =>
|
||||||
|
typeof value === 'string' && (/^https?:\/\//i.test(value) || value.startsWith('/'))
|
||||||
|
|
||||||
|
export const resolveSourceUrl = (source?: string | null, explicitUrl?: string | null) => {
|
||||||
|
if (isUsableSourceUrl(explicitUrl)) return explicitUrl || undefined
|
||||||
|
if (isUsableSourceUrl(source)) return source || undefined
|
||||||
|
const sourceText = typeof source === 'string' ? source.trim() : ''
|
||||||
|
const isBareDomain = /^[a-z0-9.-]+\.[a-z]{2,}(\/.*)?$/i.test(sourceText) && !/\s/.test(sourceText)
|
||||||
|
if (isBareDomain) return `https://${sourceText}`
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import path from 'path'
|
||||||
|
|
||||||
// hosts allowed through Vite's host check (for tunnels: tunnelmole, localtunnel, cloudflare)
|
// hosts allowed through Vite's host check (for tunnels: tunnelmole, localtunnel, cloudflare)
|
||||||
const ALLOWED_TUNNEL_HOSTS = ['.tunnelmole.net', '.loca.lt', '.trycloudflare.com']
|
const ALLOWED_TUNNEL_HOSTS = ['.tunnelmole.net', '.loca.lt', '.trycloudflare.com']
|
||||||
|
const CMS_TARGET = 'https://cms-steelforesight.liara.run'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
|
@ -17,12 +18,19 @@ export default defineConfig({
|
||||||
allowedHosts: ALLOWED_TUNNEL_HOSTS,
|
allowedHosts: ALLOWED_TUNNEL_HOSTS,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3001',
|
target: CMS_TARGET,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
},
|
},
|
||||||
'/uploads': {
|
'/uploads': {
|
||||||
target: 'http://localhost:3001',
|
target: CMS_TARGET,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
|
},
|
||||||
|
'/media': {
|
||||||
|
target: CMS_TARGET,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -455,7 +455,7 @@ async function generateAtAGlance(job, panelBaseUrl, session) {
|
||||||
: news.map((n, i) => `${i + 1}. ${n.title}\nsource: ${n.link}`).join('\n\n');
|
: news.map((n, i) => `${i + 1}. ${n.title}\nsource: ${n.link}`).join('\n\n');
|
||||||
const ai = await aiJson([
|
const ai = await aiJson([
|
||||||
{ role: 'system', content: buildSystemPrompt('glance', cat.label) },
|
{ role: 'system', content: buildSystemPrompt('glance', cat.label) },
|
||||||
{ role: 'user', content: `Write short "At a Glance" items for category: ${cat.label}. Return JSON only: {"items":[{"titleFa":"","excerptFa":"","bodyFa":"","analysisFa":"","tags":[],"sourceUrl":""}]}. Use the saved source material/news below. Prefer exact source URLs from the material.\n\n${newsText}` },
|
{ role: 'user', content: `Write short "At a Glance" items for category: ${cat.label}. Return JSON only: {"items":[{"titleFa":"","excerptFa":"","bodyFa":"","analysisFa":"","tags":[],"sourceTitle":"","sourceUrl":""}]}. Use the saved source material/news below. Prefer exact source URLs from the material. sourceTitle is the publisher/site name, for example Reuters.\n\n${newsText}` },
|
||||||
], 7000);
|
], 7000);
|
||||||
const items = Array.isArray(ai?.items) ? ai.items : [];
|
const items = Array.isArray(ai?.items) ? ai.items : [];
|
||||||
updateJob(job, { step: 'saving', progress: 70, message: `drafting ${items.length} items` });
|
updateJob(job, { step: 'saving', progress: 70, message: `drafting ${items.length} items` });
|
||||||
|
|
@ -469,7 +469,8 @@ async function generateAtAGlance(job, panelBaseUrl, session) {
|
||||||
bodyFa: item.bodyFa || null,
|
bodyFa: item.bodyFa || null,
|
||||||
analysisFa: item.analysisFa || null,
|
analysisFa: item.analysisFa || null,
|
||||||
tags: Array.isArray(item.tags) ? item.tags : [],
|
tags: Array.isArray(item.tags) ? item.tags : [],
|
||||||
source: item.sourceUrl || null,
|
source: item.sourceTitle || item.source || null,
|
||||||
|
sourceUrl: item.sourceUrl || null,
|
||||||
dateFa,
|
dateFa,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
23
panel/db.js
23
panel/db.js
|
|
@ -110,6 +110,7 @@ await pool.query(`
|
||||||
author_role TEXT,
|
author_role TEXT,
|
||||||
author_initial TEXT,
|
author_initial TEXT,
|
||||||
publish_date TEXT,
|
publish_date TEXT,
|
||||||
|
episode_number TEXT,
|
||||||
pages INTEGER DEFAULT 0,
|
pages INTEGER DEFAULT 0,
|
||||||
price INTEGER DEFAULT 0,
|
price INTEGER DEFAULT 0,
|
||||||
is_free INTEGER DEFAULT 1,
|
is_free INTEGER DEFAULT 1,
|
||||||
|
|
@ -255,6 +256,7 @@ await pool.query(`
|
||||||
excerpt_fa TEXT,
|
excerpt_fa TEXT,
|
||||||
body_fa TEXT,
|
body_fa TEXT,
|
||||||
analysis_fa TEXT,
|
analysis_fa TEXT,
|
||||||
|
source_url TEXT,
|
||||||
title_en TEXT,
|
title_en TEXT,
|
||||||
date_en TEXT,
|
date_en TEXT,
|
||||||
excerpt_en TEXT,
|
excerpt_en TEXT,
|
||||||
|
|
@ -431,8 +433,10 @@ for (const sql of [
|
||||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'admin'",
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'admin'",
|
||||||
'ALTER TABLE articles ADD COLUMN IF NOT EXISTS blocks TEXT',
|
'ALTER TABLE articles ADD COLUMN IF NOT EXISTS blocks TEXT',
|
||||||
'ALTER TABLE articles ADD COLUMN IF NOT EXISTS sources TEXT',
|
'ALTER TABLE articles ADD COLUMN IF NOT EXISTS sources TEXT',
|
||||||
|
'ALTER TABLE articles ADD COLUMN IF NOT EXISTS episode_number TEXT',
|
||||||
'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS tags TEXT',
|
'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS tags TEXT',
|
||||||
'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS source TEXT',
|
'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS source TEXT',
|
||||||
|
'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS source_url TEXT',
|
||||||
"ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'published'",
|
"ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'published'",
|
||||||
'ALTER TABLE members ADD COLUMN IF NOT EXISTS last_login TIMESTAMPTZ',
|
'ALTER TABLE members ADD COLUMN IF NOT EXISTS last_login TIMESTAMPTZ',
|
||||||
'ALTER TABLE members ADD COLUMN IF NOT EXISTS token_version INTEGER NOT NULL DEFAULT 0',
|
'ALTER TABLE members ADD COLUMN IF NOT EXISTS token_version INTEGER NOT NULL DEFAULT 0',
|
||||||
|
|
@ -455,6 +459,23 @@ await pool.query(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_activity_member ON member_activity(member_id, created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_activity_member ON member_activity(member_id, created_at DESC);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// per-member bookmarks for articles, podcasts and other site content
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS member_bookmarks (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
|
||||||
|
content_id TEXT NOT NULL,
|
||||||
|
content_type TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
summary TEXT,
|
||||||
|
cover_image TEXT,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE(member_id, content_type, content_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bookmarks_member ON member_bookmarks(member_id, created_at DESC);
|
||||||
|
`);
|
||||||
|
|
||||||
// newsletter subscribers (footer signup) — email is unique
|
// newsletter subscribers (footer signup) — email is unique
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS subscribers (
|
CREATE TABLE IF NOT EXISTS subscribers (
|
||||||
|
|
@ -721,6 +742,7 @@ export function rowToRadarItem(row) {
|
||||||
analysisFa: row.analysis_fa || null,
|
analysisFa: row.analysis_fa || null,
|
||||||
tags: row.tags ? JSON.parse(row.tags) : [],
|
tags: row.tags ? JSON.parse(row.tags) : [],
|
||||||
source: row.source || null,
|
source: row.source || null,
|
||||||
|
sourceUrl: row.source_url || null,
|
||||||
titleEn: row.title_en,
|
titleEn: row.title_en,
|
||||||
dateEn: row.date_en,
|
dateEn: row.date_en,
|
||||||
excerptEn: row.excerpt_en,
|
excerptEn: row.excerpt_en,
|
||||||
|
|
@ -793,6 +815,7 @@ export function rowToArticle(row) {
|
||||||
authorRole: row.author_role,
|
authorRole: row.author_role,
|
||||||
authorInitial: row.author_initial,
|
authorInitial: row.author_initial,
|
||||||
publishDate: row.publish_date,
|
publishDate: row.publish_date,
|
||||||
|
episodeNumber: row.episode_number,
|
||||||
pages: row.pages,
|
pages: row.pages,
|
||||||
price: row.price,
|
price: row.price,
|
||||||
isFree: !!row.is_free,
|
isFree: !!row.is_free,
|
||||||
|
|
|
||||||
|
|
@ -520,7 +520,7 @@ function renderEditor(article, draft = {}) {
|
||||||
title: '', category: draft.category || ctInfo.defaultCategory,
|
title: '', category: draft.category || ctInfo.defaultCategory,
|
||||||
type: draft.type || ctInfo.defaultType,
|
type: draft.type || ctInfo.defaultType,
|
||||||
author: '', authorRole: '', authorInitial: '',
|
author: '', authorRole: '', authorInitial: '',
|
||||||
publishDate: '', pages: 0, price: 0, isFree: true,
|
publishDate: '', episodeNumber: '', pages: 0, price: 0, isFree: true,
|
||||||
summary: '', body: '', coverImage: '', tags: [], featured: false,
|
summary: '', body: '', coverImage: '', tags: [], featured: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -581,6 +581,11 @@ function renderEditor(article, draft = {}) {
|
||||||
<input name="publishDate" value="${escapeAttr(a.publishDate)}" placeholder="بهمن ۱۴۰۳" />
|
<input name="publishDate" value="${escapeAttr(a.publishDate)}" placeholder="بهمن ۱۴۰۳" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
${contentType === 'podcast' ? `
|
||||||
|
<label>شماره اپیزود
|
||||||
|
<input name="episodeNumber" value="${escapeAttr(a.episodeNumber || '')}" placeholder="مثلاً ۷۲" />
|
||||||
|
</label>` : `<input type="hidden" name="episodeNumber" value="" />`}
|
||||||
|
|
||||||
${hasAuthor ? `
|
${hasAuthor ? `
|
||||||
<label>نویسنده / مجری
|
<label>نویسنده / مجری
|
||||||
<input name="author" value="${escapeAttr(a.author)}" />
|
<input name="author" value="${escapeAttr(a.author)}" />
|
||||||
|
|
@ -795,6 +800,7 @@ function renderEditor(article, draft = {}) {
|
||||||
authorRole: fd.get('authorRole') || null,
|
authorRole: fd.get('authorRole') || null,
|
||||||
authorInitial:fd.get('authorInitial') || null,
|
authorInitial:fd.get('authorInitial') || null,
|
||||||
publishDate: fd.get('publishDate') || null,
|
publishDate: fd.get('publishDate') || null,
|
||||||
|
episodeNumber:fd.get('episodeNumber') || null,
|
||||||
pages: Number(fd.get('pages') || 0),
|
pages: Number(fd.get('pages') || 0),
|
||||||
price: Number(fd.get('price') || 0),
|
price: Number(fd.get('price') || 0),
|
||||||
isFree: isFreeVal === 'on' || isFreeVal === 'true',
|
isFree: isFreeVal === 'on' || isFreeVal === 'true',
|
||||||
|
|
@ -1672,7 +1678,7 @@ async function renderRadarList() {
|
||||||
function renderRadarEditor(item) {
|
function renderRadarEditor(item) {
|
||||||
const r = item || { category:'', img:'', authorName:'', authorAvatar:'',
|
const r = item || { category:'', img:'', authorName:'', authorAvatar:'',
|
||||||
titleFa:'', dateFa:'', excerptFa:'', bodyFa:'', analysisFa:'',
|
titleFa:'', dateFa:'', excerptFa:'', bodyFa:'', analysisFa:'',
|
||||||
titleEn:'', dateEn:'', excerptEn:'', bodyEn:'', analysisEn:'', tags:[], source:'', sortOrder:0 };
|
titleEn:'', dateEn:'', excerptEn:'', bodyEn:'', analysisEn:'', tags:[], source:'', sourceUrl:'', sortOrder:0 };
|
||||||
const isEdit = !!item;
|
const isEdit = !!item;
|
||||||
|
|
||||||
function sectionBadges(cat) {
|
function sectionBadges(cat) {
|
||||||
|
|
@ -1725,7 +1731,8 @@ function renderRadarEditor(item) {
|
||||||
</div>
|
</div>
|
||||||
<label>تاریخ (فارسی) <input name="dateFa" value="${escapeAttr(r.dateFa||'')}" placeholder="۱۴ خرداد ۱۴۰۴" /></label>
|
<label>تاریخ (فارسی) <input name="dateFa" value="${escapeAttr(r.dateFa||'')}" placeholder="۱۴ خرداد ۱۴۰۴" /></label>
|
||||||
<label>نام نویسنده <input name="authorName" value="${escapeAttr(r.authorName||'')}" /></label>
|
<label>نام نویسنده <input name="authorName" value="${escapeAttr(r.authorName||'')}" /></label>
|
||||||
<label>منبع <input name="source" value="${escapeAttr(r.source||'')}" placeholder="مثلاً Financial Times" /></label>
|
<label>نام منبع <input name="source" value="${escapeAttr(r.source||'')}" placeholder="مثلاً Reuters" /></label>
|
||||||
|
<label>لینک منبع <input name="sourceUrl" value="${escapeAttr(r.sourceUrl||'')}" placeholder="https://www.reuters.com/..." /></label>
|
||||||
<div class="col span-2">
|
<div class="col span-2">
|
||||||
<label>هشتگها (با ویرگول جدا کنید) <input name="tags" value="${escapeAttr((r.tags||[]).join('، '))}" placeholder="فولاد سبز، هیدروژن، اروپا" /></label>
|
<label>هشتگها (با ویرگول جدا کنید) <input name="tags" value="${escapeAttr((r.tags||[]).join('، '))}" placeholder="فولاد سبز، هیدروژن، اروپا" /></label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1799,6 +1806,7 @@ function renderRadarEditor(item) {
|
||||||
bodyFa: fd.get('bodyFa')||null, bodyEn: fd.get('bodyEn')||null,
|
bodyFa: fd.get('bodyFa')||null, bodyEn: fd.get('bodyEn')||null,
|
||||||
analysisFa: fd.get('analysisFa')||null, analysisEn: fd.get('analysisEn')||null,
|
analysisFa: fd.get('analysisFa')||null, analysisEn: fd.get('analysisEn')||null,
|
||||||
source: fd.get('source')||null,
|
source: fd.get('source')||null,
|
||||||
|
sourceUrl: fd.get('sourceUrl')||null,
|
||||||
tags: String(fd.get('tags')||'').split(/[,،]/).map(s=>s.trim()).filter(Boolean),
|
tags: String(fd.get('tags')||'').split(/[,،]/).map(s=>s.trim()).filter(Boolean),
|
||||||
sortOrder: Number(fd.get('sortOrder')||0),
|
sortOrder: Number(fd.get('sortOrder')||0),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
204
panel/server.js
204
panel/server.js
|
|
@ -13,6 +13,7 @@ import jwt from 'jsonwebtoken';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { randomInt, createHmac, randomUUID } from 'node:crypto';
|
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, rowToIntegration, rowToInstituteStat, rowToMarketPrice, rowToMarketChartPoint, rowToVisionItem, rowToAdvisoryMember } from './db.js';
|
||||||
|
|
@ -50,16 +51,17 @@ try {
|
||||||
// buffer the upload in memory so we can transcode it to WebP before writing to disk
|
// buffer the upload in memory so we can transcode it to WebP before writing to disk
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: multer.memoryStorage(),
|
storage: multer.memoryStorage(),
|
||||||
limits: { fileSize: 10 * 1024 * 1024 },
|
limits: { fileSize: 20 * 1024 * 1024 },
|
||||||
fileFilter: (_req, file, cb) => {
|
fileFilter: (_req, file, cb) => {
|
||||||
if (/^image\/(jpe?g|png|webp|gif|avif|tiff?)$/.test(file.mimetype)) cb(null, true);
|
if (/^image\/(jpe?g|png|webp|gif|avif|heic|heif|tiff?)$/.test(file.mimetype)) cb(null, true);
|
||||||
else cb(new Error('Only image uploads are allowed (jpeg, png, webp, gif)'));
|
else cb(new Error('Only image uploads are allowed (jpeg, png, webp, gif, avif, heic)'));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// raw (non-image) uploads — e.g. videos — streamed straight to object storage.
|
// Raw media is staged on disk instead of RAM. A 200 MB memoryStorage upload can
|
||||||
|
// exhaust a small container with only a few concurrent authenticated requests.
|
||||||
const uploadRaw = multer({
|
const uploadRaw = multer({
|
||||||
storage: multer.memoryStorage(),
|
dest: process.env.UPLOAD_TMP_DIR || path.join(os.tmpdir(), 'steelforesight-uploads'),
|
||||||
limits: { fileSize: 200 * 1024 * 1024 },
|
limits: { fileSize: 200 * 1024 * 1024 },
|
||||||
fileFilter: (_req, file, cb) => {
|
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);
|
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);
|
||||||
|
|
@ -89,12 +91,13 @@ const s3 = S3_ENABLED
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// upload a buffer to the bucket and return its public URL (bucket must allow public reads)
|
// upload a buffer to the bucket and return its public URL (bucket must allow public reads)
|
||||||
async function putObject(key, body, contentType) {
|
async function putObject(key, body, contentType, contentLength) {
|
||||||
await s3.send(new PutObjectCommand({
|
await s3.send(new PutObjectCommand({
|
||||||
Bucket: process.env.LIARA_BUCKET,
|
Bucket: process.env.LIARA_BUCKET,
|
||||||
Key: key,
|
Key: key,
|
||||||
Body: body,
|
Body: body,
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
|
...(Number.isFinite(contentLength) ? { ContentLength: contentLength } : {}),
|
||||||
}));
|
}));
|
||||||
return `${MEDIA_BASE}/media/${key}`;
|
return `${MEDIA_BASE}/media/${key}`;
|
||||||
}
|
}
|
||||||
|
|
@ -111,7 +114,11 @@ function sniffKind(buf) {
|
||||||
if (b.subarray(0, 4).toString('ascii') === 'GIF8') return 'image'; // GIF
|
if (b.subarray(0, 4).toString('ascii') === 'GIF8') return 'image'; // GIF
|
||||||
if (b.subarray(0, 4).toString('ascii') === 'RIFF' && b.subarray(8, 12).toString('ascii') === 'WEBP') return 'image';
|
if (b.subarray(0, 4).toString('ascii') === 'RIFF' && b.subarray(8, 12).toString('ascii') === 'WEBP') return 'image';
|
||||||
if (hex(4) === '49492a00' || hex(4) === '4d4d002a') return 'image'; // TIFF
|
if (hex(4) === '49492a00' || hex(4) === '4d4d002a') return 'image'; // TIFF
|
||||||
if (b.subarray(4, 8).toString('ascii') === 'ftyp') return 'video'; // MP4/MOV/m4a
|
if (b.subarray(4, 8).toString('ascii') === 'ftyp') {
|
||||||
|
const brand = b.subarray(8, 12).toString('ascii').toLowerCase();
|
||||||
|
if (/^(avif|avis|heic|heix|hevc|hevx|mif1|msf1)$/.test(brand)) return 'image';
|
||||||
|
return 'video'; // MP4/MOV/m4a
|
||||||
|
}
|
||||||
if (hex(4) === '1a45dfa3') return 'video'; // WebM/MKV
|
if (hex(4) === '1a45dfa3') return 'video'; // WebM/MKV
|
||||||
if (hex(2) === 'fffb' || hex(2) === 'fff3' || b.subarray(0, 3).toString('ascii') === 'ID3') return 'audio'; // MP3
|
if (hex(2) === 'fffb' || hex(2) === 'fff3' || b.subarray(0, 3).toString('ascii') === 'ID3') return 'audio'; // MP3
|
||||||
if (b.subarray(0, 4).toString('ascii') === 'OggS') return 'audio'; // Ogg
|
if (b.subarray(0, 4).toString('ascii') === 'OggS') return 'audio'; // Ogg
|
||||||
|
|
@ -227,6 +234,43 @@ const contactLimiter = rateLimit({
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Broad abuse protection for API reads/writes. Authentication and contact
|
||||||
|
// endpoints keep their stricter limiters below; media streaming is excluded.
|
||||||
|
const apiLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 600,
|
||||||
|
message: { error: 'too_many_requests' },
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
skip: (req) => req.method === 'OPTIONS',
|
||||||
|
});
|
||||||
|
app.use('/api', apiLimiter);
|
||||||
|
|
||||||
|
const uploadLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 20,
|
||||||
|
message: { error: 'too_many_uploads' },
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bound temporary-disk and outbound-bandwidth pressure independently of the
|
||||||
|
// request rate. The slot is always released on response completion/disconnect.
|
||||||
|
let activeUploads = 0;
|
||||||
|
function uploadSlot(req, res, next) {
|
||||||
|
if (activeUploads >= 2) return res.status(503).json({ error: 'upload_capacity_reached' });
|
||||||
|
activeUploads += 1;
|
||||||
|
let released = false;
|
||||||
|
const release = () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
activeUploads = Math.max(0, activeUploads - 1);
|
||||||
|
};
|
||||||
|
res.once('finish', release);
|
||||||
|
res.once('close', release);
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
// Per-account login lockout — complements the per-IP loginLimiter by bounding a
|
// Per-account login lockout — complements the per-IP loginLimiter by bounding a
|
||||||
// distributed / credential-stuffing attack that rotates IPs against ONE account.
|
// distributed / credential-stuffing attack that rotates IPs against ONE account.
|
||||||
// ponytail: in-memory Map (resets on restart, single-process); fine for this panel.
|
// ponytail: in-memory Map (resets on restart, single-process); fine for this panel.
|
||||||
|
|
@ -364,7 +408,7 @@ app.get('/api/auth/me', authRequired, (req, res) => {
|
||||||
|
|
||||||
const PUBLIC_FIELDS = `
|
const PUBLIC_FIELDS = `
|
||||||
id, title, category, type, author, author_role, author_initial,
|
id, title, category, type, author, author_role, author_initial,
|
||||||
publish_date, pages, price, is_free, summary, body, cover_image,
|
publish_date, episode_number, pages, price, is_free, summary, body, cover_image,
|
||||||
blocks, sources, tags, featured, status, created_at, updated_at
|
blocks, sources, tags, featured, status, created_at, updated_at
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -393,7 +437,7 @@ app.get('/api/articles/:id', async (req, res) => {
|
||||||
res.json(rowToArticle(row));
|
res.json(rowToArticle(row));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/uploads', authRequired, upload.single('file'), async (req, res) => {
|
app.post('/api/uploads', authRequired, uploadLimiter, uploadSlot, upload.single('file'), async (req, res) => {
|
||||||
if (!req.file) return res.status(400).json({ error: 'no_file' });
|
if (!req.file) return res.status(400).json({ error: 'no_file' });
|
||||||
// validate by content (magic bytes), not the client-supplied mimetype
|
// validate by content (magic bytes), not the client-supplied mimetype
|
||||||
if (sniffKind(req.file.buffer) !== 'image') return res.status(415).json({ error: 'invalid_image' });
|
if (sniffKind(req.file.buffer) !== 'image') return res.status(415).json({ error: 'invalid_image' });
|
||||||
|
|
@ -402,22 +446,30 @@ app.post('/api/uploads', authRequired, upload.single('file'), async (req, res) =
|
||||||
const originalSize = original.length;
|
const originalSize = original.length;
|
||||||
const isAnimated = req.file.mimetype === 'image/gif';
|
const isAnimated = req.file.mimetype === 'image/gif';
|
||||||
|
|
||||||
// cap very large images, then transcode to WebP. Step quality down until the
|
// Keep enough detail for full-width article heroes while enforcing a practical
|
||||||
// output is ≤ ~25% of the original (or we hit the quality floor).
|
// absolute size budget. The old "25% of the original" target could push WebP
|
||||||
const target = Math.round(originalSize * 0.25);
|
// quality as low as 32 and visibly damage already-compressed photographs.
|
||||||
let out = null;
|
const targetBytes = 700 * 1024;
|
||||||
for (const quality of [80, 70, 60, 50, 40, 32]) {
|
let finalBuf = null;
|
||||||
const buf = await sharp(original, { animated: isAnimated })
|
for (const quality of [84, 81, 78, 76, 74]) {
|
||||||
|
const buf = await sharp(original, { animated: isAnimated, limitInputPixels: 40_000_000 })
|
||||||
.rotate() // respect EXIF orientation
|
.rotate() // respect EXIF orientation
|
||||||
.resize({ width: 2000, height: 2000, fit: 'inside', withoutEnlargement: true })
|
.resize({ width: 2400, height: 2400, fit: 'inside', withoutEnlargement: true })
|
||||||
.webp({ quality, effort: 4 })
|
.webp({ quality, effort: 5, smartSubsample: true })
|
||||||
|
.toBuffer();
|
||||||
|
finalBuf = buf;
|
||||||
|
if (buf.length <= targetBytes) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highly detailed images can remain above the budget even at quality 74.
|
||||||
|
// Reduce dimensions slightly instead of destroying detail with very low quality.
|
||||||
|
if (finalBuf.length > targetBytes) {
|
||||||
|
finalBuf = await sharp(original, { animated: isAnimated, limitInputPixels: 40_000_000 })
|
||||||
|
.rotate()
|
||||||
|
.resize({ width: 2000, height: 2000, fit: 'inside', withoutEnlargement: true })
|
||||||
|
.webp({ quality: 76, effort: 5, smartSubsample: true })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
out = buf;
|
|
||||||
if (buf.length <= target) break;
|
|
||||||
}
|
}
|
||||||
// never write a file larger than the original
|
|
||||||
const finalBuf = out.length < originalSize ? out : await sharp(original, { animated: isAnimated })
|
|
||||||
.webp({ quality: 80 }).toBuffer();
|
|
||||||
|
|
||||||
const filename = `${nanoid(12)}.webp`;
|
const filename = `${nanoid(12)}.webp`;
|
||||||
|
|
||||||
|
|
@ -445,20 +497,43 @@ app.post('/api/uploads', authRequired, upload.single('file'), async (req, res) =
|
||||||
});
|
});
|
||||||
|
|
||||||
// raw file upload (videos / pdf) → object storage. Requires S3 to be configured.
|
// raw file upload (videos / pdf) → object storage. Requires S3 to be configured.
|
||||||
app.post('/api/uploads/raw', authRequired, uploadRaw.single('file'), async (req, res) => {
|
app.post('/api/uploads/raw', authRequired, uploadLimiter, uploadSlot, uploadRaw.single('file'), async (req, res) => {
|
||||||
if (!req.file) return res.status(400).json({ error: 'no_file' });
|
if (!req.file) return res.status(400).json({ error: 'no_file' });
|
||||||
if (!s3) return res.status(503).json({ error: 'object_storage_not_configured' });
|
if (!s3) {
|
||||||
// validate by content (magic bytes); only real video/audio/pdf is accepted
|
fs.promises.unlink(req.file.path).catch(() => {});
|
||||||
const kind = sniffKind(req.file.buffer);
|
return res.status(503).json({ error: 'object_storage_not_configured' });
|
||||||
if (kind !== 'video' && kind !== 'audio' && kind !== 'pdf') return res.status(415).json({ error: 'invalid_media' });
|
}
|
||||||
try {
|
try {
|
||||||
const ext = (req.file.originalname.match(/\.[a-z0-9]+$/i)?.[0] || '').toLowerCase();
|
// Validate a small header from disk by magic bytes; never trust MIME/extension.
|
||||||
|
const handle = await fs.promises.open(req.file.path, 'r');
|
||||||
|
const header = Buffer.alloc(32);
|
||||||
|
try { await handle.read(header, 0, header.length, 0); }
|
||||||
|
finally { await handle.close(); }
|
||||||
|
const kind = sniffKind(header);
|
||||||
|
if (kind !== 'video' && kind !== 'audio' && kind !== 'pdf') {
|
||||||
|
return res.status(415).json({ error: 'invalid_media' });
|
||||||
|
}
|
||||||
|
const declaredKindMatches =
|
||||||
|
(kind === 'pdf' && req.file.mimetype === 'application/pdf') ||
|
||||||
|
(kind === 'audio' && req.file.mimetype.startsWith('audio/')) ||
|
||||||
|
(kind === 'video' && (req.file.mimetype.startsWith('video/') ||
|
||||||
|
['audio/mp4', 'audio/x-m4a'].includes(req.file.mimetype)));
|
||||||
|
if (!declaredKindMatches) return res.status(415).json({ error: 'media_type_mismatch' });
|
||||||
|
const mimeExtensions = {
|
||||||
|
'audio/mpeg': '.mp3', 'audio/mp3': '.mp3', 'audio/wav': '.wav', 'audio/x-wav': '.wav',
|
||||||
|
'audio/ogg': '.ogg', 'audio/webm': '.webm', 'audio/aac': '.aac', 'audio/x-m4a': '.m4a',
|
||||||
|
'audio/mp4': '.m4a', 'video/mp4': '.mp4', 'video/webm': '.webm',
|
||||||
|
'video/quicktime': '.mov', 'video/x-matroska': '.mkv', 'application/pdf': '.pdf',
|
||||||
|
};
|
||||||
|
const ext = (mimeExtensions[req.file.mimetype] || '.bin').toLowerCase();
|
||||||
const key = `${nanoid(12)}${ext}`;
|
const key = `${nanoid(12)}${ext}`;
|
||||||
const url = await putObject(key, req.file.buffer, req.file.mimetype);
|
const url = await putObject(key, fs.createReadStream(req.file.path), req.file.mimetype, req.file.size);
|
||||||
res.json({ url, absoluteUrl: url, size: req.file.size, contentType: req.file.mimetype });
|
res.json({ url, absoluteUrl: url, size: req.file.size, contentType: req.file.mimetype });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[upload:raw] failed:', err.message);
|
console.error('[upload:raw] failed:', err.message);
|
||||||
res.status(500).json({ error: 'upload_failed' });
|
res.status(500).json({ error: 'upload_failed' });
|
||||||
|
} finally {
|
||||||
|
fs.promises.unlink(req.file.path).catch(() => {});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -471,6 +546,7 @@ function normalizeArticleBody(b) {
|
||||||
author_role: b.authorRole || null,
|
author_role: b.authorRole || null,
|
||||||
author_initial: b.authorInitial || null,
|
author_initial: b.authorInitial || null,
|
||||||
publish_date: b.publishDate || null,
|
publish_date: b.publishDate || null,
|
||||||
|
episode_number: String(b.episodeNumber || '').trim() || null,
|
||||||
pages: Number(b.pages) || 0,
|
pages: Number(b.pages) || 0,
|
||||||
price: Number(b.price) || 0,
|
price: Number(b.price) || 0,
|
||||||
is_free: b.isFree ? 1 : 0,
|
is_free: b.isFree ? 1 : 0,
|
||||||
|
|
@ -492,11 +568,11 @@ app.post('/api/articles', authRequired, async (req, res) => {
|
||||||
await db.prepare(`
|
await db.prepare(`
|
||||||
INSERT INTO articles (
|
INSERT INTO articles (
|
||||||
id, title, category, type, author, author_role, author_initial,
|
id, title, category, type, author, author_role, author_initial,
|
||||||
publish_date, pages, price, is_free, summary, body, cover_image,
|
publish_date, episode_number, pages, price, is_free, summary, body, cover_image,
|
||||||
blocks, sources, tags, featured, status
|
blocks, sources, tags, featured, status
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@id, @title, @category, @type, @author, @author_role, @author_initial,
|
@id, @title, @category, @type, @author, @author_role, @author_initial,
|
||||||
@publish_date, @pages, @price, @is_free, @summary, @body, @cover_image,
|
@publish_date, @episode_number, @pages, @price, @is_free, @summary, @body, @cover_image,
|
||||||
@blocks, @sources, @tags, @featured, @status
|
@blocks, @sources, @tags, @featured, @status
|
||||||
)
|
)
|
||||||
`).run({ id, ...a });
|
`).run({ id, ...a });
|
||||||
|
|
@ -513,7 +589,7 @@ app.put('/api/articles/:id', authRequired, async (req, res) => {
|
||||||
UPDATE articles SET
|
UPDATE articles SET
|
||||||
title = @title, category = @category, type = @type,
|
title = @title, category = @category, type = @type,
|
||||||
author = @author, author_role = @author_role, author_initial = @author_initial,
|
author = @author, author_role = @author_role, author_initial = @author_initial,
|
||||||
publish_date = @publish_date, pages = @pages, price = @price, is_free = @is_free,
|
publish_date = @publish_date, episode_number = @episode_number, pages = @pages, price = @price, is_free = @is_free,
|
||||||
summary = @summary, body = @body, cover_image = @cover_image,
|
summary = @summary, body = @body, cover_image = @cover_image,
|
||||||
blocks = @blocks, sources = @sources,
|
blocks = @blocks, sources = @sources,
|
||||||
tags = @tags, featured = @featured, status = @status,
|
tags = @tags, featured = @featured, status = @status,
|
||||||
|
|
@ -827,7 +903,7 @@ app.delete('/api/events/:id', authRequired, async (req, res) => {
|
||||||
const RADAR_FIELDS = `id, category, img, author_name, author_avatar,
|
const RADAR_FIELDS = `id, category, img, author_name, author_avatar,
|
||||||
title_fa, date_fa, excerpt_fa, body_fa, analysis_fa,
|
title_fa, date_fa, excerpt_fa, body_fa, analysis_fa,
|
||||||
title_en, date_en, excerpt_en, body_en, analysis_en,
|
title_en, date_en, excerpt_en, body_en, analysis_en,
|
||||||
tags, source, status, sort_order, created_at, updated_at`;
|
tags, source, source_url, status, sort_order, created_at, updated_at`;
|
||||||
|
|
||||||
// public: only published items
|
// public: only published items
|
||||||
app.get('/api/radar', async (req, res) => {
|
app.get('/api/radar', async (req, res) => {
|
||||||
|
|
@ -869,6 +945,7 @@ function normalizeRadarBody(b) {
|
||||||
analysis_en: b.analysisEn || null,
|
analysis_en: b.analysisEn || null,
|
||||||
tags: JSON.stringify(Array.isArray(b.tags) ? b.tags : []),
|
tags: JSON.stringify(Array.isArray(b.tags) ? b.tags : []),
|
||||||
source: b.source || null,
|
source: b.source || null,
|
||||||
|
source_url: b.sourceUrl || b.source_url || null,
|
||||||
sort_order: Number(b.sortOrder) || 0,
|
sort_order: Number(b.sortOrder) || 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -880,10 +957,10 @@ app.post('/api/radar', authRequired, async (req, res) => {
|
||||||
const status = req.body?.status === 'draft' ? 'draft' : 'published';
|
const status = req.body?.status === 'draft' ? 'draft' : 'published';
|
||||||
await db.prepare(`INSERT INTO radar_items (id,category,img,author_name,author_avatar,
|
await db.prepare(`INSERT INTO radar_items (id,category,img,author_name,author_avatar,
|
||||||
title_fa,date_fa,excerpt_fa,body_fa,analysis_fa,
|
title_fa,date_fa,excerpt_fa,body_fa,analysis_fa,
|
||||||
title_en,date_en,excerpt_en,body_en,analysis_en,tags,source,status,sort_order)
|
title_en,date_en,excerpt_en,body_en,analysis_en,tags,source,source_url,status,sort_order)
|
||||||
VALUES (@id,@category,@img,@author_name,@author_avatar,
|
VALUES (@id,@category,@img,@author_name,@author_avatar,
|
||||||
@title_fa,@date_fa,@excerpt_fa,@body_fa,@analysis_fa,
|
@title_fa,@date_fa,@excerpt_fa,@body_fa,@analysis_fa,
|
||||||
@title_en,@date_en,@excerpt_en,@body_en,@analysis_en,@tags,@source,@status,@sort_order)
|
@title_en,@date_en,@excerpt_en,@body_en,@analysis_en,@tags,@source,@source_url,@status,@sort_order)
|
||||||
`).run({ id, status, ...r });
|
`).run({ id, status, ...r });
|
||||||
res.status(201).json(rowToRadarItem(await db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(id)));
|
res.status(201).json(rowToRadarItem(await db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(id)));
|
||||||
});
|
});
|
||||||
|
|
@ -897,7 +974,7 @@ app.put('/api/radar/:id', authRequired, async (req, res) => {
|
||||||
body_fa=@body_fa,analysis_fa=@analysis_fa,
|
body_fa=@body_fa,analysis_fa=@analysis_fa,
|
||||||
title_en=@title_en,date_en=@date_en,excerpt_en=@excerpt_en,
|
title_en=@title_en,date_en=@date_en,excerpt_en=@excerpt_en,
|
||||||
body_en=@body_en,analysis_en=@analysis_en,
|
body_en=@body_en,analysis_en=@analysis_en,
|
||||||
tags=@tags,source=@source,
|
tags=@tags,source=@source,source_url=@source_url,
|
||||||
sort_order=@sort_order,updated_at=now() WHERE id=@id
|
sort_order=@sort_order,updated_at=now() WHERE id=@id
|
||||||
`).run({ id: req.params.id, ...r });
|
`).run({ id: req.params.id, ...r });
|
||||||
res.json(rowToRadarItem(await 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)));
|
||||||
|
|
@ -2030,6 +2107,61 @@ app.post('/api/members/activity', memberRequired, jsonOnly, async (req, res) =>
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const BOOKMARK_TYPES = new Set(['article', 'podcast', 'video', 'radar', 'report', 'event']);
|
||||||
|
|
||||||
|
app.get('/api/members/me/bookmarks', memberRequired, async (req, res) => {
|
||||||
|
const rows = await db.prepare(`
|
||||||
|
SELECT id, content_id, content_type, title, summary, cover_image, path, created_at
|
||||||
|
FROM member_bookmarks
|
||||||
|
WHERE member_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`).all(req.member.sub);
|
||||||
|
res.json(rows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
contentId: row.content_id,
|
||||||
|
contentType: row.content_type,
|
||||||
|
title: row.title,
|
||||||
|
summary: row.summary,
|
||||||
|
coverImage: row.cover_image,
|
||||||
|
path: row.path,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/members/me/bookmarks', memberRequired, jsonOnly, async (req, res) => {
|
||||||
|
const contentId = String(req.body?.contentId || '').trim().slice(0, 120);
|
||||||
|
const contentType = String(req.body?.contentType || '').trim().toLowerCase();
|
||||||
|
const title = String(req.body?.title || '').trim().slice(0, 300);
|
||||||
|
const summary = String(req.body?.summary || '').trim().slice(0, 1500) || null;
|
||||||
|
const coverImage = String(req.body?.coverImage || '').trim().slice(0, 1000) || null;
|
||||||
|
const contentPath = String(req.body?.path || '').trim().slice(0, 500);
|
||||||
|
if (!contentId || !title || !contentPath || !BOOKMARK_TYPES.has(contentType)) {
|
||||||
|
return res.status(400).json({ error: 'invalid_bookmark' });
|
||||||
|
}
|
||||||
|
const row = await db.prepare(`
|
||||||
|
INSERT INTO member_bookmarks (member_id, content_id, content_type, title, summary, cover_image, path)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (member_id, content_type, content_id)
|
||||||
|
DO UPDATE SET title = EXCLUDED.title, summary = EXCLUDED.summary,
|
||||||
|
cover_image = EXCLUDED.cover_image, path = EXCLUDED.path
|
||||||
|
RETURNING id, content_id, content_type, title, summary, cover_image, path, created_at
|
||||||
|
`).get(req.member.sub, contentId, contentType, title, summary, coverImage, contentPath);
|
||||||
|
res.status(201).json({
|
||||||
|
id: row.id, contentId: row.content_id, contentType: row.content_type,
|
||||||
|
title: row.title, summary: row.summary, coverImage: row.cover_image,
|
||||||
|
path: row.path, createdAt: row.created_at,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/members/me/bookmarks/:contentType/:contentId', memberRequired, async (req, res) => {
|
||||||
|
const contentType = String(req.params.contentType || '').toLowerCase();
|
||||||
|
const contentId = String(req.params.contentId || '').slice(0, 120);
|
||||||
|
if (!BOOKMARK_TYPES.has(contentType) || !contentId) return res.status(400).json({ error: 'invalid_bookmark' });
|
||||||
|
await db.prepare('DELETE FROM member_bookmarks WHERE member_id = ? AND content_type = ? AND content_id = ?')
|
||||||
|
.run(req.member.sub, contentType, contentId);
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
/* set password via SMS code — for members who don't know their current password (e.g. OTP signups) */
|
/* set password via SMS code — for members who don't know their current password (e.g. OTP signups) */
|
||||||
app.post('/api/members/me/password-otp/send', memberRequired, async (req, res) => {
|
app.post('/api/members/me/password-otp/send', memberRequired, async (req, res) => {
|
||||||
const row = await db.prepare('SELECT phone FROM members WHERE id = ?').get(req.member.sub);
|
const row = await db.prepare('SELECT phone FROM members WHERE id = ?').get(req.member.sub);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue