806 lines
33 KiB
TypeScript
Executable File
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>
|
|
);
|
|
}
|