wing-ops/frontend/src/tabs/incidents/components/MediaModal.tsx

806 lines
33 KiB
TypeScript
Executable File

import { useState, useEffect } from 'react';
import type { Incident } from './IncidentsLeftPanel';
import {
fetchIncidentMedia,
fetchIncidentAerialMedia,
getMediaImageUrl,
} from '../services/incidentsApi';
import type { MediaInfo, AerialMediaItem } 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);
const [aerialImages, setAerialImages] = useState<AerialMediaItem[]>([]);
const [selectedImageIdx, setSelectedImageIdx] = useState(0);
useEffect(() => {
fetchIncidentMedia(parseInt(incident.id)).then(setMedia);
fetchIncidentAerialMedia(parseInt(incident.id)).then(setAerialImages);
}, [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();
}}
className="fixed inset-0 z-[10000] flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(6px)' }}
>
<div
className="text-center text-label-1 text-fg-disabled"
style={{
width: 300,
padding: 40,
background: 'var(--bg-base)',
border: '1px solid var(--stroke-default)',
borderRadius: 14,
}}
>
...
</div>
</div>
);
}
const total =
(media.photoCnt ?? 0) +
(media.videoCnt ?? 0) +
(media.satCnt ?? 0) +
(media.cctvCnt ?? 0) +
aerialImages.length;
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();
}}
className="fixed inset-0 z-[10000] flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(6px)' }}
>
<div
className="flex flex-col overflow-hidden"
style={{
width: '95vw',
height: '92vh',
maxWidth: 1600,
background: 'var(--bg-base)',
border: '1px solid var(--stroke-default)',
borderRadius: 14,
boxShadow: '0 24px 64px rgba(0,0,0,0.7)',
}}
>
{/* ── Header ─────────────────────────────────── */}
<div
className="shrink-0 flex items-center justify-between"
style={{
padding: '12px 20px',
background: 'linear-gradient(135deg,var(--bg-elevated),var(--bg-base))',
borderBottom: '1px solid var(--stroke-default)',
}}
>
<div className="flex items-center gap-[10px]">
<span className="text-lg">📋</span>
<div>
<div className="text-title-3 font-[800] text-fg"> {incident.name}</div>
<div className="text-caption text-fg-disabled font-mono">
{incident.name} · {incident.date} · {media.photoCnt} / {media.videoCnt} /
{media.satCnt} / CCTV {media.cctvCnt}
</div>
</div>
</div>
<div className="flex items-center gap-[8px]">
{/* Tabs */}
<div className="flex gap-[2px]">
{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,
cursor: 'pointer',
border: 'none',
background: activeTab === t.id ? 'rgba(168,85,247,0.15)' : 'transparent',
color: activeTab === t.id ? '#c084fc' : 'var(--fg-disabled)',
}}
>
{t.icon ? `${t.icon} ${t.label}` : t.label}
</button>
))}
</div>
{/* Upload */}
<button
style={{
padding: '5px 14px',
borderRadius: 6,
fontSize: 11,
fontWeight: 700,
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}
className="text-title-1 cursor-pointer text-fg-disabled rounded"
style={{ padding: '2px 6px' }}
>
</span>
</div>
</div>
{/* ── Timeline ────────────────────────────────── */}
<div
className="shrink-0 flex items-center gap-[10px]"
style={{ padding: '6px 20px', borderBottom: '1px solid var(--stroke-light)' }}
>
<span className="text-caption text-fg-disabled whitespace-nowrap">TIMELINE</span>
<div className="flex-1 relative" style={{ height: 16 }}>
<div
style={{
position: 'absolute',
top: 7,
left: 0,
right: 0,
height: 2,
background: 'var(--stroke-light)',
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 className="flex gap-2 text-caption font-mono text-fg-disabled whitespace-nowrap">
<span style={{ color: '#ef4444' }}> </span>
<span style={{ color: '#f59e0b' }}> </span>
<span className="text-fg-disabled"> </span>
</div>
</div>
{/* ── 2x2 Grid Content ────────────────────────── */}
<div
className="flex-1 overflow-hidden grid"
style={{
gridTemplateColumns:
(showPhoto || showSat) && (showVideo || showCctv) ? '1fr 1fr' : '1fr',
gridTemplateRows: (showPhoto || showVideo) && (showSat || showCctv) ? '1fr 1fr' : '1fr',
gap: 1,
background: 'var(--stroke-light)',
}}
>
{/* ── Q1: 현장사진 ──────────────────────────── */}
{showPhoto && (
<div className="flex flex-col overflow-hidden bg-bg-base">
{/* Section header */}
<div
className="shrink-0 flex items-center justify-between"
style={{ padding: '8px 16px', borderBottom: '1px solid var(--stroke-light)' }}
>
<div className="flex items-center gap-[6px]">
<span className="text-label-1">📷</span>
<span className="text-label-1 font-bold text-fg">
{' '}
{aerialImages.length > 0
? `${aerialImages.length}`
: str(media.photoMeta, 'title', '현장 사진')}
</span>
</div>
<div className="flex gap-[4px]">
{aerialImages.length > 1 && (
<>
<NavBtn
label="◀"
onClick={() => setSelectedImageIdx((p) => Math.max(0, p - 1))}
/>
<NavBtn
label="▶"
onClick={() =>
setSelectedImageIdx((p) => Math.min(aerialImages.length - 1, p + 1))
}
/>
</>
)}
<NavBtn label="↗" />
</div>
</div>
{/* Photo content */}
<div className="flex-1 flex items-center justify-center overflow-hidden relative">
{aerialImages.length > 0 ? (
<>
<img
src={getMediaImageUrl(aerialImages[selectedImageIdx].aerialMediaSn)}
alt={aerialImages[selectedImageIdx].orgnlNm ?? '현장 사진'}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.classList.remove(
'hidden',
);
}}
/>
<div className="hidden flex-col items-center gap-2">
<div className="text-[48px]" style={{ color: 'var(--stroke-default)' }}>
📷
</div>
<div className="text-label-1 text-fg-disabled">
</div>
</div>
{aerialImages.length > 1 && (
<>
<button
onClick={() => setSelectedImageIdx((prev) => Math.max(0, prev - 1))}
className="absolute left-2 top-1/2 -translate-y-1/2 flex items-center justify-center text-fg-disabled cursor-pointer rounded"
style={{
width: 28,
height: 28,
background: 'rgba(0,0,0,0.5)',
border: '1px solid var(--stroke-default)',
opacity: selectedImageIdx === 0 ? 0.3 : 1,
}}
disabled={selectedImageIdx === 0}
>
</button>
<button
onClick={() =>
setSelectedImageIdx((prev) =>
Math.min(aerialImages.length - 1, prev + 1),
)
}
className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center text-fg-disabled cursor-pointer rounded"
style={{
width: 28,
height: 28,
background: 'rgba(0,0,0,0.5)',
border: '1px solid var(--stroke-default)',
opacity: selectedImageIdx === aerialImages.length - 1 ? 0.3 : 1,
}}
disabled={selectedImageIdx === aerialImages.length - 1}
>
</button>
</>
)}
<div
className="absolute bottom-2 left-1/2 -translate-x-1/2 text-caption font-mono text-fg-disabled"
style={{
padding: '2px 8px',
background: 'rgba(0,0,0,0.6)',
borderRadius: 4,
}}
>
{selectedImageIdx + 1} / {aerialImages.length}
</div>
</>
) : (
<div className="flex flex-col items-center gap-2">
<div className="text-[48px]" style={{ color: 'var(--stroke-default)' }}>
📷
</div>
<div className="text-label-1 text-fg-sub font-semibold">
{incident.name.replace('유류유출', '유출 현장').replace('오염', '현장')}
</div>
<div className="text-caption text-fg-disabled font-mono">
{str(media.photoMeta, 'date')} · {str(media.photoMeta, 'by')}
</div>
</div>
)}
</div>
{/* Thumbnails */}
<div
className="shrink-0"
style={{ padding: '8px 12px', borderTop: '1px solid var(--stroke-light)' }}
>
{aerialImages.length > 0 ? (
<>
<div className="flex gap-1.5 overflow-x-auto" style={{ marginBottom: 6 }}>
{aerialImages.map((img, i) => (
<div
key={img.aerialMediaSn}
className="shrink-0 cursor-pointer overflow-hidden"
style={{
width: 48,
height: 40,
borderRadius: 4,
background:
i === selectedImageIdx
? 'rgba(6,182,212,0.15)'
: 'var(--bg-elevated)',
border:
i === selectedImageIdx
? '2px solid rgba(6,182,212,0.5)'
: '1px solid var(--stroke-default)',
}}
onClick={() => setSelectedImageIdx(i)}
>
<img
src={getMediaImageUrl(img.aerialMediaSn)}
alt={img.orgnlNm ?? `사진 ${i + 1}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => {
const el = e.target as HTMLImageElement;
el.style.display = 'none';
}}
/>
</div>
))}
</div>
<div className="flex justify-between items-center">
<span className="text-caption text-fg-disabled">
📷 {aerialImages.length}
{aerialImages[selectedImageIdx]?.takngDtm
? ` · ${new Date(aerialImages[selectedImageIdx].takngDtm!).toLocaleDateString('ko-KR')}`
: ''}
</span>
<span className="text-caption text-fg-disabled font-mono">
{aerialImages[selectedImageIdx]?.orgnlNm ?? ''}
</span>
</div>
</>
) : (
<>
<div className="flex gap-1.5" style={{ marginBottom: 6 }}>
{Array.from({ length: Math.min(num(media.photoMeta, 'thumbCount'), 7) }).map(
(_, i) => (
<div
key={i}
className="flex items-center justify-center text-title-3 cursor-pointer"
style={{
width: 40,
height: 36,
borderRadius: 4,
color: 'var(--stroke-default)',
background: i === 0 ? 'rgba(6,182,212,0.15)' : 'var(--bg-elevated)',
border:
i === 0
? '2px solid rgba(6,182,212,0.5)'
: '1px solid var(--stroke-default)',
}}
>
📷
</div>
),
)}
</div>
<div className="flex justify-between items-center">
<span className="text-caption text-fg-disabled">
📷 {num(media.photoMeta, 'thumbCount')} ·{' '}
{str(media.photoMeta, 'stage')}
</span>
<span className="text-caption text-color-tertiary cursor-pointer">
🔗 R&D
</span>
</div>
</>
)}
</div>
</div>
)}
{/* ── Q2: 드론 영상 ─────────────────────────── */}
{showVideo && (
<div className="flex flex-col overflow-hidden bg-bg-base">
<div
className="shrink-0 flex items-center justify-between"
style={{ padding: '8px 16px', borderBottom: '1px solid var(--stroke-light)' }}
>
<div className="flex items-center gap-[6px]">
<span className="text-label-1">🎬</span>
<span className="text-label-1 font-bold text-fg">
{str(media.droneMeta, 'title', '드론 영상')}
</span>
</div>
<span
className="text-caption font-bold text-color-danger rounded"
style={{
padding: '2px 8px',
background: 'rgba(239,68,68,0.15)',
}}
>
REC
</span>
</div>
<div className="flex-1 flex items-center justify-center flex-col gap-2">
<div className="text-[48px]" style={{ color: 'var(--stroke-default)' }}>
🎬
</div>
<div className="text-label-1 text-fg-sub font-semibold"> </div>
<div className="text-caption text-fg-disabled font-mono">
{str(media.droneMeta, 'device')} · {str(media.droneMeta, 'alt')}
</div>
</div>
{/* Video controls */}
<div
className="shrink-0 flex flex-col gap-2"
style={{ padding: '10px 16px', borderTop: '1px solid var(--stroke-light)' }}
>
<div className="flex items-center justify-center gap-3">
<span className="text-label-1 text-fg-disabled cursor-pointer"></span>
<div
className="flex items-center justify-center text-label-1 text-color-tertiary cursor-pointer"
style={{
width: 28,
height: 28,
borderRadius: '50%',
background: 'rgba(168,85,247,0.15)',
border: '1px solid rgba(168,85,247,0.3)',
}}
>
</div>
<span className="text-label-1 text-fg-disabled cursor-pointer"></span>
<span className="text-caption text-fg-disabled font-mono">
02:34 / {str(media.droneMeta, 'duration')}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-caption text-fg-disabled">
🎬 {num(media.droneMeta, 'videoCount')} · {str(media.droneMeta, 'stage')}
</span>
<div className="flex gap-[8px]">
<span className="text-caption text-color-info cursor-pointer">📂 </span>
<span className="text-caption text-color-tertiary cursor-pointer">
🔗 R&D
</span>
</div>
</div>
</div>
</div>
)}
{/* ── Q3: 위성영상 ──────────────────────────── */}
{showSat && (
<div className="flex flex-col overflow-hidden bg-bg-base">
<div
className="shrink-0 flex items-center justify-between"
style={{ padding: '8px 16px', borderBottom: '1px solid #21262d' }}
>
<div className="flex items-center gap-[6px]">
<span className="text-label-1">🛰</span>
<span className="text-label-1 font-bold text-fg">
{str(media.satMeta, 'title', '위성영상')}
</span>
</div>
<div className="flex gap-[4px]">
<NavBtn label="◀" /> <NavBtn label="▶" /> <NavBtn label="↗" />
</div>
</div>
<div className="flex-1 flex items-center justify-center 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
className="absolute text-caption font-bold text-color-danger font-mono bg-bg-base"
style={{ top: -10, left: 8, padding: '0 4px' }}
>
{str(media.satMeta, 'detection')}
</div>
<div className="text-[40px] text-fg-disabled">🛰</div>
<div className="text-label-2 text-fg-sub font-semibold">
{str(media.satMeta, 'title', '위성영상')}
</div>
<div className="text-caption text-fg-disabled font-mono">
{str(media.satMeta, 'date')} · {str(media.satMeta, 'resolution')}
</div>
</div>
)}
{str(media.satMeta, 'detection') === '—' && (
<div className="text-center">
<div className="text-[40px] text-fg-disabled">🛰</div>
<div className="text-label-2 text-fg-disabled" style={{ marginTop: 8 }}>
</div>
</div>
)}
</div>
<div
className="shrink-0"
style={{ padding: '8px 12px', borderTop: '1px solid #21262d' }}
>
{num(media.satMeta, 'thumbCount') > 0 && (
<div className="flex gap-1.5" style={{ marginBottom: 6 }}>
{Array.from({ length: num(media.satMeta, 'thumbCount') }).map((_, i) => (
<div
key={i}
className="flex items-center justify-center text-title-3 text-fg-disabled cursor-pointer"
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',
}}
>
🛰
</div>
))}
</div>
)}
<div className="flex justify-between items-center">
<span className="text-caption text-fg-disabled">
🛰 {num(media.satMeta, 'thumbCount')} · {str(media.satMeta, 'sensor')}
</span>
<span className="text-caption text-color-info cursor-pointer">
🔍 /
</span>
</div>
</div>
</div>
)}
{/* ── Q4: CCTV ──────────────────────────────── */}
{showCctv && (
<div className="flex flex-col overflow-hidden bg-bg-base">
<div
className="shrink-0 flex items-center justify-between"
style={{ padding: '8px 16px', borderBottom: '1px solid #21262d' }}
>
<div className="flex items-center gap-[6px]">
<span className="text-label-1">📹</span>
<span className="text-label-1 font-bold text-fg">
CCTV {str(media.cctvMeta, 'title', 'CCTV')}
</span>
</div>
<div className="flex items-center gap-[6px]">
{bool(media.cctvMeta, 'live') && (
<span
className="text-caption font-bold text-color-success rounded"
style={{
padding: '2px 8px',
background: 'rgba(34,197,94,0.15)',
}}
>
LIVE
</span>
)}
<NavBtn label="↗" />
</div>
</div>
<div className="flex-1 flex items-center justify-center flex-col gap-2 relative">
{bool(media.cctvMeta, 'live') && (
<div
className="absolute text-caption font-bold text-color-danger font-mono"
style={{ top: 10, left: 16 }}
>
LIVE {new Date().toLocaleTimeString('ko-KR', { hour12: false })}
</div>
)}
<div className="text-[48px] text-fg-disabled">📹</div>
<div className="text-label-1 text-fg-sub font-semibold">
{str(media.cctvMeta, 'title', 'CCTV').replace('#', 'CCTV #')}
</div>
<div className="text-caption text-fg-disabled font-mono">
{str(media.cctvMeta, 'ptz')} · {str(media.cctvMeta, 'angle')} ·{' '}
{bool(media.cctvMeta, 'live') ? '실시간 스트리밍' : '녹화 영상'}
</div>
</div>
{/* CAM buttons */}
<div
className="shrink-0 flex flex-col gap-2"
style={{ padding: '10px 16px', borderTop: '1px solid #21262d' }}
>
<div className="flex gap-[6px]">
{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(--font-mono)',
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 className="flex justify-between items-center">
<span className="text-caption text-fg-disabled">
📹 CCTV {num(media.cctvMeta, 'camCount')} ·{' '}
{str(media.cctvMeta, 'location')}
</span>
<div className="flex gap-[8px]">
<span className="text-caption text-color-danger cursor-pointer">
🔴
</span>
<span className="text-caption text-color-info cursor-pointer">🎥 PTZ</span>
</div>
</div>
</div>
</div>
)}
</div>
{/* ── Bottom Bar ──────────────────────────────── */}
<div
className="shrink-0 flex items-center justify-between"
style={{
padding: '8px 20px',
background: '#161b22',
borderTop: '1px solid #30363d',
}}
>
<div className="flex gap-4 text-caption font-mono text-fg-disabled">
<span>
📷 {' '}
<b className="text-fg">
{aerialImages.length > 0 ? aerialImages.length : (media.photoCnt ?? 0)}
</b>
</span>
<span>
🎬 <b className="text-fg">{media.videoCnt ?? 0}</b>
</span>
<span>
🛰 <b className="text-fg">{media.satCnt ?? 0}</b>
</span>
<span>
📹 CCTV <b className="text-fg">{media.cctvCnt ?? 0}</b>
</span>
<span>
📎 <b className="text-color-tertiary">{total}</b>
</span>
</div>
<div className="flex gap-[8px]">
<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, onClick }: { label: string; onClick?: () => void }) {
return (
<button
onClick={onClick}
className="flex items-center justify-center text-caption text-fg-disabled cursor-pointer rounded bg-bg-elevated"
style={{
width: 22,
height: 22,
border: '1px solid #30363d',
}}
>
{label}
</button>
);
}
function BottomBtn({
icon,
label,
bg,
bd,
fg,
}: {
icon: string;
label: string;
bg: string;
bd: string;
fg: string;
}) {
return (
<button
className="flex items-center gap-1 text-caption font-bold cursor-pointer rounded-sm"
style={{
padding: '6px 14px',
background: bg,
border: `1px solid ${bd}`,
color: fg,
}}
>
{icon} {label}
</button>
);
}