- DB: ACDNT, SPIL_DATA, PRED_EXEC, ACDNT_WEATHER, ACDNT_MEDIA 5개 테이블 생성 - 시드: 사고 12건, 유출정보 12건, 예측실행 18건, 기상 6건, 미디어 6건 - 백엔드: incidentsService + incidentsRouter (사고 목록/상세/예측/기상/미디어 5개 API) - 프론트: IncidentsView, IncidentTable, IncidentsLeftPanel, MediaModal mock → API 전환 - mockIncidents, WEATHER_DATA, MEDIA_DATA 3개 mock 완전 제거 - SECTION_DATA, MOCK_SENSITIVE, mockVessels는 별도 도메인으로 유지 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
445 lines
25 KiB
TypeScript
Executable File
445 lines
25 KiB
TypeScript
Executable File
import { useState, useEffect } from 'react'
|
|
import type { Incident } from './IncidentsLeftPanel'
|
|
import { fetchIncidentMedia } from '../services/incidentsApi'
|
|
import type { MediaInfo } from '../services/incidentsApi'
|
|
|
|
type MediaTab = 'all' | 'photo' | 'video' | 'satellite' | 'cctv'
|
|
|
|
const MEDIA_TABS: { id: MediaTab; label: string; icon: string }[] = [
|
|
{ id: 'all', label: '전체', icon: '' },
|
|
{ id: 'photo', label: '사진', icon: '📷' },
|
|
{ id: 'video', label: '영상', icon: '🎬' },
|
|
{ id: 'satellite', label: '위성', icon: '🛰' },
|
|
{ id: 'cctv', label: 'CCTV', icon: '📹' },
|
|
]
|
|
|
|
function str(obj: Record<string, unknown> | null, key: string, fallback = '—'): string {
|
|
if (!obj || obj[key] == null) return fallback;
|
|
return String(obj[key]);
|
|
}
|
|
|
|
function num(obj: Record<string, unknown> | null, key: string, fallback = 0): number {
|
|
if (!obj || obj[key] == null) return fallback;
|
|
return Number(obj[key]);
|
|
}
|
|
|
|
function bool(obj: Record<string, unknown> | null, key: string): boolean {
|
|
if (!obj || obj[key] == null) return false;
|
|
return Boolean(obj[key]);
|
|
}
|
|
|
|
/* ════════════════════════════════════════════════════
|
|
MediaModal
|
|
════════════════════════════════════════════════════ */
|
|
export function MediaModal({ incident, onClose }: { incident: Incident; onClose: () => void }) {
|
|
const [activeTab, setActiveTab] = useState<MediaTab>('all')
|
|
const [selectedCam, setSelectedCam] = useState(0)
|
|
const [media, setMedia] = useState<MediaInfo | null>(null)
|
|
|
|
useEffect(() => {
|
|
fetchIncidentMedia(parseInt(incident.id)).then(setMedia);
|
|
}, [incident.id]);
|
|
|
|
// Timeline dots (UI constant)
|
|
const timelineDots = [
|
|
{ pct: 5, color: '#ef4444', label: incident.time },
|
|
{ pct: 30, color: '#ef4444', label: '' },
|
|
{ pct: 42, color: '#ef4444', label: '' },
|
|
{ pct: 58, color: '#f59e0b', label: '' },
|
|
{ pct: 78, color: '#ef4444', label: '' },
|
|
{ pct: 95, color: '#6b7280', label: '' },
|
|
]
|
|
|
|
if (!media) {
|
|
return (
|
|
<div onClick={(e) => { if (e.target === e.currentTarget) onClose() }} style={{
|
|
position: 'fixed', inset: 0, zIndex: 10000,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(6px)',
|
|
}}>
|
|
<div style={{
|
|
width: 300, padding: 40, background: '#0d1117', border: '1px solid #30363d',
|
|
borderRadius: 14, textAlign: 'center', color: '#8b949e', fontFamily: 'var(--fK)', fontSize: 12,
|
|
}}>
|
|
현장정보를 불러오는 중...
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const total = media.photoCnt + media.videoCnt + media.satCnt + media.cctvCnt
|
|
|
|
const showPhoto = activeTab === 'all' || activeTab === 'photo'
|
|
const showVideo = activeTab === 'all' || activeTab === 'video'
|
|
const showSat = activeTab === 'all' || activeTab === 'satellite'
|
|
const showCctv = activeTab === 'all' || activeTab === 'cctv'
|
|
|
|
return (
|
|
<div onClick={(e) => { if (e.target === e.currentTarget) onClose() }} style={{
|
|
position: 'fixed', inset: 0, zIndex: 10000,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(6px)',
|
|
}}>
|
|
<div style={{
|
|
width: '95vw', height: '92vh', maxWidth: 1600,
|
|
background: '#0d1117', border: '1px solid #30363d', borderRadius: 14,
|
|
overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
|
boxShadow: '0 24px 64px rgba(0,0,0,0.7)',
|
|
}}>
|
|
{/* ── Header ─────────────────────────────────── */}
|
|
<div style={{
|
|
flexShrink: 0, padding: '12px 20px',
|
|
background: 'linear-gradient(135deg,#161b22,#0d1117)',
|
|
borderBottom: '1px solid #30363d',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
<span style={{ fontSize: 18 }}>📋</span>
|
|
<div>
|
|
<div style={{ fontSize: 14, fontWeight: 800, color: '#f0f6fc', fontFamily: 'var(--fK)' }}>
|
|
현장정보 — {incident.name}
|
|
</div>
|
|
<div style={{ fontSize: 10, color: '#8b949e', fontFamily: 'var(--fM)' }}>
|
|
{incident.name} · {incident.date} · 사진 {media.photoCnt} / 영상 {media.videoCnt} / 위성 {media.satCnt} / CCTV {media.cctvCnt}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
{/* Tabs */}
|
|
<div style={{ display: 'flex', gap: 2 }}>
|
|
{MEDIA_TABS.map(t => (
|
|
<button key={t.id} onClick={() => setActiveTab(t.id)} style={{
|
|
padding: '5px 12px', borderRadius: 6, fontSize: 11, fontWeight: activeTab === t.id ? 700 : 400,
|
|
fontFamily: 'var(--fK)', cursor: 'pointer', border: 'none',
|
|
background: activeTab === t.id ? 'rgba(168,85,247,0.15)' : 'transparent',
|
|
color: activeTab === t.id ? '#c084fc' : '#8b949e',
|
|
}}>
|
|
{t.icon ? `${t.icon} ${t.label}` : t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{/* Upload */}
|
|
<button style={{
|
|
padding: '5px 14px', borderRadius: 6, fontSize: 11, fontWeight: 700,
|
|
fontFamily: 'var(--fK)', cursor: 'pointer', border: 'none',
|
|
background: 'linear-gradient(135deg,rgba(168,85,247,0.3),rgba(168,85,247,0.15))',
|
|
color: '#c084fc',
|
|
}}>📤 업로드</button>
|
|
{/* Close */}
|
|
<span onClick={onClose} style={{
|
|
fontSize: 18, cursor: 'pointer', color: '#8b949e', padding: '2px 6px',
|
|
borderRadius: 4,
|
|
}}>✕</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Timeline ────────────────────────────────── */}
|
|
<div style={{
|
|
flexShrink: 0, padding: '6px 20px', borderBottom: '1px solid #21262d',
|
|
display: 'flex', alignItems: 'center', gap: 10,
|
|
}}>
|
|
<span style={{ fontSize: 9, color: '#8b949e', fontFamily: 'var(--fK)', whiteSpace: 'nowrap' }}>TIMELINE</span>
|
|
<div style={{ flex: 1, position: 'relative', height: 16 }}>
|
|
<div style={{ position: 'absolute', top: 7, left: 0, right: 0, height: 2, background: '#21262d', borderRadius: 1 }} />
|
|
{timelineDots.map((d, i) => (
|
|
<div key={i} style={{
|
|
position: 'absolute', left: `${d.pct}%`, top: 3,
|
|
width: 10, height: 10, borderRadius: '50%', background: d.color,
|
|
boxShadow: `0 0 6px ${d.color}`, transform: 'translateX(-5px)',
|
|
}} />
|
|
))}
|
|
</div>
|
|
<div style={{ fontSize: 8, color: '#8b949e', fontFamily: 'var(--fM)', display: 'flex', gap: 8, whiteSpace: 'nowrap' }}>
|
|
<span style={{ color: '#ef4444' }}>● 초기</span>
|
|
<span style={{ color: '#f59e0b' }}>● 대응</span>
|
|
<span style={{ color: '#8b949e' }}>● 종료</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── 2x2 Grid Content ────────────────────────── */}
|
|
<div style={{
|
|
flex: 1, display: 'grid', overflow: 'hidden',
|
|
gridTemplateColumns: (showPhoto || showSat) && (showVideo || showCctv) ? '1fr 1fr' : '1fr',
|
|
gridTemplateRows: (showPhoto || showVideo) && (showSat || showCctv) ? '1fr 1fr' : '1fr',
|
|
gap: 1, background: '#21262d',
|
|
}}>
|
|
{/* ── Q1: 현장사진 ──────────────────────────── */}
|
|
{showPhoto && (
|
|
<div style={{ background: '#0d1117', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
{/* Section header */}
|
|
<div style={{
|
|
flexShrink: 0, padding: '8px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
borderBottom: '1px solid #21262d',
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<span style={{ fontSize: 12 }}>📷</span>
|
|
<span style={{ fontSize: 12, fontWeight: 700, color: '#f0f6fc', fontFamily: 'var(--fK)' }}>
|
|
현장사진 — {str(media.photoMeta, 'title', '현장 사진')}
|
|
</span>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
<NavBtn label="◀" /> <NavBtn label="▶" /> <NavBtn label="↗" />
|
|
</div>
|
|
</div>
|
|
{/* Photo content */}
|
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 8 }}>
|
|
<div style={{ fontSize: 48, color: '#30363d' }}>📷</div>
|
|
<div style={{ fontSize: 12, color: '#c9d1d9', fontWeight: 600, fontFamily: 'var(--fK)' }}>
|
|
{incident.name.replace('유류유출', '유출 현장').replace('오염', '현장')} 해상 사진
|
|
</div>
|
|
<div style={{ fontSize: 9, color: '#8b949e', fontFamily: 'var(--fM)' }}>
|
|
{str(media.photoMeta, 'date')} · {str(media.photoMeta, 'by')}
|
|
</div>
|
|
</div>
|
|
{/* Thumbnails */}
|
|
<div style={{ flexShrink: 0, padding: '8px 12px', borderTop: '1px solid #21262d' }}>
|
|
<div style={{ display: 'flex', gap: 6, marginBottom: 6 }}>
|
|
{Array.from({ length: Math.min(num(media.photoMeta, 'thumbCount'), 7) }).map((_, i) => (
|
|
<div key={i} style={{
|
|
width: 40, height: 36, borderRadius: 4,
|
|
background: i === 0 ? 'rgba(168,85,247,0.15)' : '#161b22',
|
|
border: i === 0 ? '2px solid rgba(168,85,247,0.5)' : '1px solid #30363d',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontSize: 14, color: '#30363d', cursor: 'pointer',
|
|
}}>📷</div>
|
|
))}
|
|
</div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<span style={{ fontSize: 8, color: '#8b949e', fontFamily: 'var(--fK)' }}>
|
|
📷 사진 {num(media.photoMeta, 'thumbCount')}장 · {str(media.photoMeta, 'stage')}
|
|
</span>
|
|
<span style={{ fontSize: 8, color: '#a78bfa', cursor: 'pointer', fontFamily: 'var(--fK)' }}>🔗 R&D 연계</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Q2: 드론 영상 ─────────────────────────── */}
|
|
{showVideo && (
|
|
<div style={{ background: '#0d1117', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
<div style={{
|
|
flexShrink: 0, padding: '8px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
borderBottom: '1px solid #21262d',
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<span style={{ fontSize: 12 }}>🎬</span>
|
|
<span style={{ fontSize: 12, fontWeight: 700, color: '#f0f6fc', fontFamily: 'var(--fK)' }}>
|
|
드론 영상 — {str(media.droneMeta, 'title', '드론 영상')}
|
|
</span>
|
|
</div>
|
|
<span style={{
|
|
padding: '2px 8px', borderRadius: 4, fontSize: 9, fontWeight: 700,
|
|
background: 'rgba(239,68,68,0.15)', color: '#ef4444',
|
|
}}>● REC</span>
|
|
</div>
|
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 8 }}>
|
|
<div style={{ fontSize: 48, color: '#30363d' }}>🎬</div>
|
|
<div style={{ fontSize: 12, color: '#c9d1d9', fontWeight: 600, fontFamily: 'var(--fK)' }}>
|
|
드론 항공 촬영 영상
|
|
</div>
|
|
<div style={{ fontSize: 9, color: '#8b949e', fontFamily: 'var(--fM)' }}>
|
|
{str(media.droneMeta, 'device')} · {str(media.droneMeta, 'alt')} 고도
|
|
</div>
|
|
</div>
|
|
{/* Video controls */}
|
|
<div style={{ flexShrink: 0, padding: '10px 16px', borderTop: '1px solid #21262d', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
|
|
<span style={{ fontSize: 12, color: '#8b949e', cursor: 'pointer' }}>⏮</span>
|
|
<div style={{
|
|
width: 28, height: 28, borderRadius: '50%', background: 'rgba(168,85,247,0.15)',
|
|
border: '1px solid rgba(168,85,247,0.3)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontSize: 12, color: '#c084fc', cursor: 'pointer',
|
|
}}>▶</div>
|
|
<span style={{ fontSize: 12, color: '#8b949e', cursor: 'pointer' }}>⏭</span>
|
|
<span style={{ fontSize: 10, color: '#8b949e', fontFamily: 'var(--fM)' }}>02:34 / {str(media.droneMeta, 'duration')}</span>
|
|
</div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<span style={{ fontSize: 8, color: '#8b949e', fontFamily: 'var(--fK)' }}>
|
|
🎬 영상 {num(media.droneMeta, 'videoCount')}건 · {str(media.droneMeta, 'stage')}
|
|
</span>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<span style={{ fontSize: 8, color: '#58a6ff', cursor: 'pointer', fontFamily: 'var(--fK)' }}>📂 전체보기</span>
|
|
<span style={{ fontSize: 8, color: '#a78bfa', cursor: 'pointer', fontFamily: 'var(--fK)' }}>🔗 R&D 연계</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Q3: 위성영상 ──────────────────────────── */}
|
|
{showSat && (
|
|
<div style={{ background: '#0d1117', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
<div style={{
|
|
flexShrink: 0, padding: '8px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
borderBottom: '1px solid #21262d',
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<span style={{ fontSize: 12 }}>🛰</span>
|
|
<span style={{ fontSize: 12, fontWeight: 700, color: '#f0f6fc', fontFamily: 'var(--fK)' }}>
|
|
위성영상 — {str(media.satMeta, 'title', '위성영상')}
|
|
</span>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
<NavBtn label="◀" /> <NavBtn label="▶" /> <NavBtn label="↗" />
|
|
</div>
|
|
</div>
|
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
|
|
{str(media.satMeta, 'detection') !== '—' && (
|
|
<div style={{
|
|
position: 'absolute', top: '15%', left: '10%', width: '55%', height: '60%',
|
|
border: '2px dashed #ef4444', borderRadius: 4,
|
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 6,
|
|
}}>
|
|
<div style={{ position: 'absolute', top: -10, left: 8, fontSize: 9, fontWeight: 700, color: '#ef4444', fontFamily: 'var(--fM)', background: '#0d1117', padding: '0 4px' }}>
|
|
{str(media.satMeta, 'detection')}
|
|
</div>
|
|
<div style={{ fontSize: 40, color: '#30363d' }}>🛰</div>
|
|
<div style={{ fontSize: 11, color: '#c9d1d9', fontWeight: 600, fontFamily: 'var(--fK)' }}>
|
|
{str(media.satMeta, 'title', '위성영상')} 위성영상
|
|
</div>
|
|
<div style={{ fontSize: 8, color: '#8b949e', fontFamily: 'var(--fM)' }}>
|
|
{str(media.satMeta, 'date')} · 해상도 {str(media.satMeta, 'resolution')}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{str(media.satMeta, 'detection') === '—' && (
|
|
<div style={{ textAlign: 'center' }}>
|
|
<div style={{ fontSize: 40, color: '#30363d' }}>🛰</div>
|
|
<div style={{ fontSize: 11, color: '#8b949e', fontFamily: 'var(--fK)', marginTop: 8 }}>위성영상 없음</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div style={{ flexShrink: 0, padding: '8px 12px', borderTop: '1px solid #21262d' }}>
|
|
{num(media.satMeta, 'thumbCount') > 0 && (
|
|
<div style={{ display: 'flex', gap: 6, marginBottom: 6 }}>
|
|
{Array.from({ length: num(media.satMeta, 'thumbCount') }).map((_, i) => (
|
|
<div key={i} style={{
|
|
width: 40, height: 36, borderRadius: 4,
|
|
background: i === 0 ? 'rgba(168,85,247,0.15)' : '#161b22',
|
|
border: i === 0 ? '2px solid rgba(168,85,247,0.5)' : '1px solid #30363d',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontSize: 14, color: '#30363d', cursor: 'pointer',
|
|
}}>🛰</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<span style={{ fontSize: 8, color: '#8b949e', fontFamily: 'var(--fK)' }}>
|
|
🛰 위성 {num(media.satMeta, 'thumbCount')}장 · {str(media.satMeta, 'sensor')}
|
|
</span>
|
|
<span style={{ fontSize: 8, color: '#58a6ff', cursor: 'pointer', fontFamily: 'var(--fK)' }}>🔍 편집/측 비교</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Q4: CCTV ──────────────────────────────── */}
|
|
{showCctv && (
|
|
<div style={{ background: '#0d1117', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
<div style={{
|
|
flexShrink: 0, padding: '8px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
borderBottom: '1px solid #21262d',
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<span style={{ fontSize: 12 }}>📹</span>
|
|
<span style={{ fontSize: 12, fontWeight: 700, color: '#f0f6fc', fontFamily: 'var(--fK)' }}>
|
|
CCTV — {str(media.cctvMeta, 'title', 'CCTV')}
|
|
</span>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
{bool(media.cctvMeta, 'live') && (
|
|
<span style={{
|
|
padding: '2px 8px', borderRadius: 4, fontSize: 9, fontWeight: 700,
|
|
background: 'rgba(34,197,94,0.15)', color: '#22c55e',
|
|
}}>● LIVE</span>
|
|
)}
|
|
<NavBtn label="↗" />
|
|
</div>
|
|
</div>
|
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 8, position: 'relative' }}>
|
|
{bool(media.cctvMeta, 'live') && (
|
|
<div style={{ position: 'absolute', top: 10, left: 16, fontSize: 9, fontWeight: 700, color: '#ef4444', fontFamily: 'var(--fM)' }}>
|
|
● LIVE {new Date().toLocaleTimeString('ko-KR', { hour12: false })}
|
|
</div>
|
|
)}
|
|
<div style={{ fontSize: 48, color: '#30363d' }}>📹</div>
|
|
<div style={{ fontSize: 12, color: '#c9d1d9', fontWeight: 600, fontFamily: 'var(--fK)' }}>
|
|
{str(media.cctvMeta, 'title', 'CCTV').replace('#', 'CCTV #')}
|
|
</div>
|
|
<div style={{ fontSize: 9, color: '#8b949e', fontFamily: 'var(--fM)' }}>
|
|
{str(media.cctvMeta, 'ptz')} · {str(media.cctvMeta, 'angle')} · {bool(media.cctvMeta, 'live') ? '실시간 스트리밍' : '녹화 영상'}
|
|
</div>
|
|
</div>
|
|
{/* CAM buttons */}
|
|
<div style={{ flexShrink: 0, padding: '10px 16px', borderTop: '1px solid #21262d', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
{Array.from({ length: num(media.cctvMeta, 'camCount') }).map((_, i) => (
|
|
<button key={i} onClick={() => setSelectedCam(i)} style={{
|
|
padding: '6px 16px', borderRadius: 4, fontSize: 10, fontWeight: 600,
|
|
fontFamily: 'var(--fM)', cursor: 'pointer',
|
|
background: selectedCam === i ? 'rgba(168,85,247,0.12)' : '#161b22',
|
|
border: selectedCam === i ? '1px solid rgba(168,85,247,0.4)' : '1px solid #30363d',
|
|
color: selectedCam === i ? '#c084fc' : '#8b949e',
|
|
}}>CAM{i + 1}</button>
|
|
))}
|
|
</div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<span style={{ fontSize: 8, color: '#8b949e', fontFamily: 'var(--fK)' }}>
|
|
📹 CCTV {num(media.cctvMeta, 'camCount')}채널 · {str(media.cctvMeta, 'location')}
|
|
</span>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<span style={{ fontSize: 8, color: '#ef4444', cursor: 'pointer', fontFamily: 'var(--fK)' }}>🔴 녹화영상</span>
|
|
<span style={{ fontSize: 8, color: '#58a6ff', cursor: 'pointer', fontFamily: 'var(--fK)' }}>🎥 PTZ</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Bottom Bar ──────────────────────────────── */}
|
|
<div style={{
|
|
flexShrink: 0, padding: '8px 20px',
|
|
background: '#161b22', borderTop: '1px solid #30363d',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
}}>
|
|
<div style={{ display: 'flex', gap: 16, fontSize: 10, fontFamily: 'var(--fM)', color: '#8b949e' }}>
|
|
<span>📷 사진 <b style={{ color: '#f0f6fc' }}>{media.photoCnt}</b></span>
|
|
<span>🎬 영상 <b style={{ color: '#f0f6fc' }}>{media.videoCnt}</b></span>
|
|
<span>🛰 위성 <b style={{ color: '#f0f6fc' }}>{media.satCnt}</b></span>
|
|
<span>📹 CCTV <b style={{ color: '#f0f6fc' }}>{media.cctvCnt}</b></span>
|
|
<span>📎 총 <b style={{ color: '#c084fc' }}>{total}건</b></span>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<BottomBtn icon="📥" label="다운로드" bg="rgba(100,116,139,0.1)" bd="rgba(100,116,139,0.2)" fg="#8b949e" />
|
|
<BottomBtn icon="📝" label="보고서" bg="rgba(59,130,246,0.1)" bd="rgba(59,130,246,0.2)" fg="#58a6ff" />
|
|
<BottomBtn icon="🔗" label="R&D 분석 연계" bg="rgba(168,85,247,0.1)" bd="rgba(168,85,247,0.25)" fg="#c084fc" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function NavBtn({ label }: { label: string }) {
|
|
return (
|
|
<button style={{
|
|
width: 22, height: 22, borderRadius: 4, fontSize: 10,
|
|
background: '#161b22', border: '1px solid #30363d', color: '#8b949e',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
|
|
}}>{label}</button>
|
|
)
|
|
}
|
|
|
|
function BottomBtn({ icon, label, bg, bd, fg }: { icon: string; label: string; bg: string; bd: string; fg: string }) {
|
|
return (
|
|
<button style={{
|
|
padding: '6px 14px', borderRadius: 6, fontSize: 10, fontWeight: 700,
|
|
fontFamily: 'var(--fK)', cursor: 'pointer',
|
|
background: bg, border: `1px solid ${bd}`, color: fg,
|
|
display: 'flex', alignItems: 'center', gap: 4,
|
|
}}>{icon} {label}</button>
|
|
)
|
|
}
|