6개 대형 View(AerialView, AssetsView, ReportsView, PreScatView, AdminView, LeftPanel)를 서브탭 단위로 분할하여 모듈 경계를 명확히 함. - AerialView (2,526줄 → 8파일): MediaManagement, OilAreaAnalysis, RealtimeDrone 등 - AssetsView (2,047줄 → 8파일): AssetManagement, AssetMap, ShipInsurance 등 - ReportsView (1,596줄 → 5파일): TemplateFormEditor, ReportGenerator 등 - PreScatView (1,390줄 → 7파일): ScatLeftPanel, ScatMap, ScatPopup 등 - AdminView (1,306줄 → 7파일): UsersPanel, PermissionsPanel, MenusPanel 등 - LeftPanel (1,237줄 → 5파일): PredictionInputSection, InfoLayerSection, OilBoomSection 등 FEATURE_ID 레지스트리(common/constants/featureIds.ts) 및 감사로그 서브탭 추적 훅(useFeatureTracking) 추가. .gitignore의 scat/ → /scat/ 수정 (scat 탭 파일 추적 누락 수정) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
336 lines
19 KiB
TypeScript
336 lines
19 KiB
TypeScript
import { useState, useRef, useEffect } from 'react'
|
||
|
||
// ── Types & Mock Data ──
|
||
|
||
interface MediaFile {
|
||
id: number
|
||
incident: string
|
||
location: string
|
||
filename: string
|
||
equipment: string
|
||
equipType: 'drone' | 'plane' | 'satellite'
|
||
mediaType: '사진' | '영상' | '적외선' | 'SAR' | '가시광' | '광학'
|
||
datetime: string
|
||
size: string
|
||
resolution: string
|
||
}
|
||
|
||
const mediaFiles: MediaFile[] = [
|
||
{ id: 1, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_001.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:20', size: '12.4 MB', resolution: '5472×3648' },
|
||
{ id: 2, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_002.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:21', size: '11.8 MB', resolution: '5472×3648' },
|
||
{ id: 3, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_003.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:22', size: '13.1 MB', resolution: '5472×3648' },
|
||
{ id: 4, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_004.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:23', size: '12.9 MB', resolution: '5472×3648' },
|
||
{ id: 5, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_005.jpg', equipment: 'Mavic3', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:24', size: '11.5 MB', resolution: '5472×3648' },
|
||
{ id: 6, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_006.jpg', equipment: 'Mavic3', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:25', size: '13.3 MB', resolution: '5472×3648' },
|
||
{ id: 7, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론영상_01.mp4', equipment: 'DJI M300', equipType: 'drone', mediaType: '영상', datetime: '2026-01-18 15:30', size: '842 MB', resolution: '4K 30fps' },
|
||
{ id: 8, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론영상_02.mp4', equipment: 'Mavic3', equipType: 'drone', mediaType: '영상', datetime: '2026-01-18 16:00', size: '624 MB', resolution: '4K 30fps' },
|
||
{ id: 9, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_항공_광역_01.tif', equipment: 'CN-235', equipType: 'plane', mediaType: '적외선', datetime: '2026-01-18 14:00', size: '156 MB', resolution: '8192×6144' },
|
||
{ id: 10, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_항공_광역_02.tif', equipment: 'CN-235', equipType: 'plane', mediaType: '가시광', datetime: '2026-01-18 14:10', size: '148 MB', resolution: '8192×6144' },
|
||
{ id: 11, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_항공영상_01.mp4', equipment: 'B-512', equipType: 'plane', mediaType: '영상', datetime: '2026-01-18 14:30', size: '1.2 GB', resolution: 'FHD 60fps' },
|
||
{ id: 12, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: 'Sentinel1_SAR_20260118.tif', equipment: 'Sentinel-1', equipType: 'satellite', mediaType: 'SAR', datetime: '2026-01-18 10:00', size: '420 MB', resolution: '10m/px' },
|
||
{ id: 13, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: 'KompSat5_여수_20260118.tif', equipment: '다목적5호', equipType: 'satellite', mediaType: 'SAR', datetime: '2026-01-18 11:00', size: '380 MB', resolution: '1m/px' },
|
||
{ id: 14, incident: '통영 해역 기름오염', location: '34.85°N, 128.43°E', filename: '통영_드론_001.jpg', equipment: 'Mavic3', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 09:30', size: '10.2 MB', resolution: '5472×3648' },
|
||
{ id: 15, incident: '군산항 인근 오염', location: '35.97°N, 126.72°E', filename: '군산_항공촬영_01.tif', equipment: 'B-512', equipType: 'plane', mediaType: '가시광', datetime: '2026-01-18 13:00', size: '132 MB', resolution: '8192×6144' },
|
||
]
|
||
|
||
const equipIcon = (t: string) => t === 'drone' ? '🛸' : t === 'plane' ? '✈' : '🛰'
|
||
|
||
const equipTagCls = (t: string) =>
|
||
t === 'drone'
|
||
? 'bg-[rgba(59,130,246,0.12)] text-primary-blue'
|
||
: t === 'plane'
|
||
? 'bg-[rgba(34,197,94,0.12)] text-status-green'
|
||
: 'bg-[rgba(168,85,247,0.12)] text-primary-purple'
|
||
|
||
const mediaTagCls = (t: string) =>
|
||
t === '영상'
|
||
? 'bg-[rgba(239,68,68,0.12)] text-status-red'
|
||
: 'bg-[rgba(234,179,8,0.12)] text-status-yellow'
|
||
|
||
const FilterBtn = ({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) => (
|
||
<button
|
||
onClick={onClick}
|
||
className={`px-2.5 py-1 text-[10px] font-semibold rounded font-korean transition-colors ${
|
||
active
|
||
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan/30'
|
||
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
|
||
}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
)
|
||
|
||
// ── Component ──
|
||
|
||
export function MediaManagement() {
|
||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||
const [equipFilter, setEquipFilter] = useState<string>('all')
|
||
const [typeFilter, setTypeFilter] = useState<Set<string>>(new Set())
|
||
const [searchTerm, setSearchTerm] = useState('')
|
||
const [sortBy, setSortBy] = useState('latest')
|
||
const [showUpload, setShowUpload] = useState(false)
|
||
const modalRef = useRef<HTMLDivElement>(null)
|
||
|
||
useEffect(() => {
|
||
const handler = (e: MouseEvent) => {
|
||
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||
setShowUpload(false)
|
||
}
|
||
}
|
||
if (showUpload) document.addEventListener('mousedown', handler)
|
||
return () => document.removeEventListener('mousedown', handler)
|
||
}, [showUpload])
|
||
|
||
const filtered = mediaFiles.filter(f => {
|
||
if (equipFilter !== 'all' && f.equipType !== equipFilter) return false
|
||
if (typeFilter.size > 0) {
|
||
const isPhoto = !['영상'].includes(f.mediaType)
|
||
const isVideo = f.mediaType === '영상'
|
||
if (typeFilter.has('photo') && !isPhoto) return false
|
||
if (typeFilter.has('video') && !isVideo) return false
|
||
}
|
||
if (searchTerm && !f.filename.toLowerCase().includes(searchTerm.toLowerCase())) return false
|
||
return true
|
||
})
|
||
|
||
const sorted = [...filtered].sort((a, b) => {
|
||
if (sortBy === 'name') return a.filename.localeCompare(b.filename)
|
||
if (sortBy === 'size') return parseFloat(b.size) - parseFloat(a.size)
|
||
return b.datetime.localeCompare(a.datetime)
|
||
})
|
||
|
||
const toggleId = (id: number) => {
|
||
setSelectedIds(prev => {
|
||
const next = new Set(prev)
|
||
if (next.has(id)) { next.delete(id) } else { next.add(id) }
|
||
return next
|
||
})
|
||
}
|
||
|
||
const toggleAll = () => {
|
||
if (selectedIds.size === sorted.length) {
|
||
setSelectedIds(new Set())
|
||
} else {
|
||
setSelectedIds(new Set(sorted.map(f => f.id)))
|
||
}
|
||
}
|
||
|
||
const toggleTypeFilter = (t: string) => {
|
||
setTypeFilter(prev => {
|
||
const next = new Set(prev)
|
||
if (next.has(t)) { next.delete(t) } else { next.add(t) }
|
||
return next
|
||
})
|
||
}
|
||
|
||
const droneCount = mediaFiles.filter(f => f.equipType === 'drone').length
|
||
const planeCount = mediaFiles.filter(f => f.equipType === 'plane').length
|
||
const satCount = mediaFiles.filter(f => f.equipType === 'satellite').length
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
{/* Filters */}
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div className="flex gap-1.5 items-center">
|
||
<span className="text-[11px] text-text-3 font-korean">촬영 장비:</span>
|
||
<FilterBtn label="전체" active={equipFilter === 'all'} onClick={() => setEquipFilter('all')} />
|
||
<FilterBtn label="🛸 드론" active={equipFilter === 'drone'} onClick={() => setEquipFilter('drone')} />
|
||
<FilterBtn label="✈ 유인항공기" active={equipFilter === 'plane'} onClick={() => setEquipFilter('plane')} />
|
||
<FilterBtn label="🛰 위성" active={equipFilter === 'satellite'} onClick={() => setEquipFilter('satellite')} />
|
||
<span className="w-px h-4 bg-border mx-1" />
|
||
<span className="text-[11px] text-text-3 font-korean">유형:</span>
|
||
<FilterBtn label="📷 사진" active={typeFilter.has('photo')} onClick={() => toggleTypeFilter('photo')} />
|
||
<FilterBtn label="🎬 영상" active={typeFilter.has('video')} onClick={() => toggleTypeFilter('video')} />
|
||
</div>
|
||
<div className="flex gap-2 items-center">
|
||
<input
|
||
type="text"
|
||
placeholder="파일명 검색..."
|
||
value={searchTerm}
|
||
onChange={e => setSearchTerm(e.target.value)}
|
||
className="px-3 py-1.5 bg-bg-0 border border-border rounded-sm text-text-1 font-korean text-[11px] outline-none w-40 focus:border-primary-cyan"
|
||
/>
|
||
<select
|
||
value={sortBy}
|
||
onChange={e => setSortBy(e.target.value)}
|
||
className="prd-i py-1.5 w-auto"
|
||
>
|
||
<option value="latest">최신순</option>
|
||
<option value="name">이름순</option>
|
||
<option value="size">크기순</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Summary Stats */}
|
||
<div className="flex gap-2.5 mb-4">
|
||
{[
|
||
{ icon: '📸', value: String(mediaFiles.length), label: '총 파일', color: 'text-primary-cyan' },
|
||
{ icon: '🛸', value: String(droneCount), label: '드론', color: 'text-text-1' },
|
||
{ icon: '✈', value: String(planeCount), label: '유인항공기', color: 'text-text-1' },
|
||
{ icon: '🛰', value: String(satCount), label: '위성', color: 'text-text-1' },
|
||
{ icon: '💾', value: '3.8 GB', label: '총 용량', color: 'text-text-1' },
|
||
].map((s, i) => (
|
||
<div key={i} className="flex-1 flex items-center gap-2.5 px-4 py-3 bg-bg-3 border border-border rounded-sm">
|
||
<span className="text-xl">{s.icon}</span>
|
||
<div>
|
||
<div className={`text-base font-bold font-mono ${s.color}`}>{s.value}</div>
|
||
<div className="text-[10px] text-text-3 font-korean">{s.label}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* File Table */}
|
||
<div className="flex-1 bg-bg-3 border border-border rounded-md overflow-hidden flex flex-col">
|
||
<div className="overflow-auto flex-1">
|
||
<table className="w-full text-left" style={{ tableLayout: 'fixed' }}>
|
||
<colgroup>
|
||
<col style={{ width: 36 }} />
|
||
<col style={{ width: 36 }} />
|
||
<col style={{ width: 120 }} />
|
||
<col style={{ width: 130 }} />
|
||
<col />
|
||
<col style={{ width: 95 }} />
|
||
<col style={{ width: 85 }} />
|
||
<col style={{ width: 145 }} />
|
||
<col style={{ width: 85 }} />
|
||
<col style={{ width: 95 }} />
|
||
<col style={{ width: 50 }} />
|
||
</colgroup>
|
||
<thead>
|
||
<tr className="border-b border-border bg-bg-2">
|
||
<th className="px-2 py-2.5 text-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedIds.size === sorted.length && sorted.length > 0}
|
||
onChange={toggleAll}
|
||
className="accent-primary-blue"
|
||
/>
|
||
</th>
|
||
<th className="px-1 py-2.5" />
|
||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean whitespace-nowrap">사고명</th>
|
||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean whitespace-nowrap">위치</th>
|
||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean">파일명</th>
|
||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean">장비</th>
|
||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean">유형</th>
|
||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean whitespace-nowrap">촬영일시</th>
|
||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean whitespace-nowrap">용량</th>
|
||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean whitespace-nowrap">해상도</th>
|
||
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 text-center">📥</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{sorted.map(f => (
|
||
<tr
|
||
key={f.id}
|
||
onClick={() => toggleId(f.id)}
|
||
className={`border-b border-border/50 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.02)] ${
|
||
selectedIds.has(f.id) ? 'bg-[rgba(6,182,212,0.06)]' : ''
|
||
}`}
|
||
>
|
||
<td className="px-2 py-2 text-center" onClick={e => e.stopPropagation()}>
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedIds.has(f.id)}
|
||
onChange={() => toggleId(f.id)}
|
||
className="accent-primary-blue"
|
||
/>
|
||
</td>
|
||
<td className="px-1 py-2 text-base">{equipIcon(f.equipType)}</td>
|
||
<td className="px-2 py-2 text-[10px] font-semibold text-text-1 font-korean truncate">{f.incident}</td>
|
||
<td className="px-2 py-2 text-[10px] text-primary-cyan font-mono truncate">{f.location}</td>
|
||
<td className="px-2 py-2 text-[11px] font-semibold text-text-1 font-korean truncate">{f.filename}</td>
|
||
<td className="px-2 py-2">
|
||
<span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${equipTagCls(f.equipType)}`}>
|
||
{f.equipment}
|
||
</span>
|
||
</td>
|
||
<td className="px-2 py-2">
|
||
<span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${mediaTagCls(f.mediaType)}`}>
|
||
{f.mediaType === '영상' ? '🎬' : '📷'} {f.mediaType}
|
||
</span>
|
||
</td>
|
||
<td className="px-2 py-2 text-[11px] font-mono">{f.datetime}</td>
|
||
<td className="px-2 py-2 text-[11px] font-mono">{f.size}</td>
|
||
<td className="px-2 py-2 text-[11px] font-mono">{f.resolution}</td>
|
||
<td className="px-2 py-2 text-center" onClick={e => e.stopPropagation()}>
|
||
<button className="px-2 py-1 text-[10px] rounded bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-primary-cyan/20 hover:bg-[rgba(6,182,212,0.2)] transition-colors">
|
||
📥
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bottom Actions */}
|
||
<div className="flex justify-between items-center mt-4 pt-3.5 border-t border-border">
|
||
<div className="text-[11px] text-text-3 font-korean">
|
||
선택된 파일: <span className="text-primary-cyan font-semibold">{selectedIds.size}</span>건
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button onClick={toggleAll} className="px-3 py-1.5 text-[11px] font-semibold rounded bg-bg-3 border border-border text-text-2 hover:bg-bg-hover transition-colors font-korean">
|
||
☑ 전체선택
|
||
</button>
|
||
<button className="px-3 py-1.5 text-[11px] font-semibold rounded bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-primary-cyan/30 hover:bg-[rgba(6,182,212,0.2)] transition-colors font-korean">
|
||
📥 선택 다운로드
|
||
</button>
|
||
<button className="px-3 py-1.5 text-[11px] font-semibold rounded bg-[rgba(168,85,247,0.1)] text-primary-purple border border-primary-purple/30 hover:bg-[rgba(168,85,247,0.2)] transition-colors font-korean">
|
||
🧩 유출유면적분석으로 →
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Upload Modal */}
|
||
{showUpload && (
|
||
<div className="fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center">
|
||
<div ref={modalRef} className="bg-bg-1 border border-border rounded-md w-[480px] max-h-[80vh] overflow-y-auto p-6">
|
||
<div className="flex justify-between items-center mb-4">
|
||
<span className="text-base font-bold font-korean">📤 영상·사진 업로드</span>
|
||
<button onClick={() => setShowUpload(false)} className="text-text-3 text-lg hover:text-text-1">✕</button>
|
||
</div>
|
||
<div className="border-2 border-dashed border-border-light rounded-md py-8 px-4 text-center mb-4 cursor-pointer hover:border-primary-cyan/40 transition-colors">
|
||
<div className="text-3xl mb-2 opacity-50">📁</div>
|
||
<div className="text-[13px] font-semibold mb-1 font-korean">파일을 드래그하거나 클릭하여 업로드</div>
|
||
<div className="text-[11px] text-text-3 font-korean">JPG, TIFF, GeoTIFF, MP4, MOV 지원 · 최대 2GB</div>
|
||
</div>
|
||
<div className="mb-3">
|
||
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">촬영 장비</label>
|
||
<select className="prd-i w-full">
|
||
<option>드론 (DJI M300 RTK)</option>
|
||
<option>드론 (DJI Mavic 3E)</option>
|
||
<option>유인항공기 (CN-235)</option>
|
||
<option>유인항공기 (헬기 B-512)</option>
|
||
<option>위성 (Sentinel-1)</option>
|
||
<option>위성 (다목적위성5호)</option>
|
||
<option>기타</option>
|
||
</select>
|
||
</div>
|
||
<div className="mb-3">
|
||
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">연관 사고</label>
|
||
<select className="prd-i w-full">
|
||
<option>여수항 유류유출 (2026-01-18)</option>
|
||
<option>통영 해역 기름오염 (2026-01-18)</option>
|
||
<option>군산항 인근 오염 (2026-01-18)</option>
|
||
</select>
|
||
</div>
|
||
<div className="mb-4">
|
||
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">메모</label>
|
||
<textarea
|
||
className="prd-i w-full h-[60px] resize-y"
|
||
placeholder="촬영 조건, 비고 등..."
|
||
/>
|
||
</div>
|
||
<button className="w-full py-3 rounded-sm text-sm font-bold font-korean text-white border-none cursor-pointer" style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}>
|
||
📤 업로드 실행
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|