- Board: 매뉴얼 CRUD + 첨부파일 API (012_board_ext.sql) - HNS: 분석 CRUD 5개 API (013_hns_analysis.sql) - Prediction: 분석/역추적/오일펜스 7개 API (014_prediction.sql) - Aerial: 미디어/CCTV/위성 6개 API + PostGIS (015_aerial.sql) - Rescue: 구난 작전/시나리오 3개 API + JSONB (016_rescue.sql) - backtrackMockData.ts 삭제 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
335 lines
16 KiB
TypeScript
335 lines
16 KiB
TypeScript
import { useState, useCallback, useRef, useEffect } from 'react'
|
|
import { fetchAerialMedia } from '../services/aerialApi'
|
|
import type { AerialMediaItem } from '../services/aerialApi'
|
|
|
|
// ── Helpers ──
|
|
|
|
function formatDtm(dtm: string | null): string {
|
|
if (!dtm) return '—'
|
|
const d = new Date(dtm)
|
|
return d.toISOString().slice(0, 16).replace('T', ' ')
|
|
}
|
|
|
|
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 [mediaItems, setMediaItems] = useState<AerialMediaItem[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
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)
|
|
|
|
const loadData = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const items = await fetchAerialMedia()
|
|
setMediaItems(items)
|
|
} catch (err) {
|
|
console.error('[aerial] 미디어 목록 조회 실패:', err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
loadData()
|
|
}, [loadData])
|
|
|
|
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 = mediaItems.filter(f => {
|
|
if (equipFilter !== 'all' && f.equipTpCd !== equipFilter) return false
|
|
if (typeFilter.size > 0) {
|
|
const isPhoto = f.mediaTpCd !== '영상'
|
|
const isVideo = f.mediaTpCd === '영상'
|
|
if (typeFilter.has('photo') && !isPhoto) return false
|
|
if (typeFilter.has('video') && !isVideo) return false
|
|
}
|
|
if (searchTerm && !f.fileNm.toLowerCase().includes(searchTerm.toLowerCase())) return false
|
|
return true
|
|
})
|
|
|
|
const sorted = [...filtered].sort((a, b) => {
|
|
if (sortBy === 'name') return a.fileNm.localeCompare(b.fileNm)
|
|
if (sortBy === 'size') return parseFloat(b.fileSz ?? '0') - parseFloat(a.fileSz ?? '0')
|
|
return (b.takngDtm ?? '').localeCompare(a.takngDtm ?? '')
|
|
})
|
|
|
|
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.aerialMediaSn)))
|
|
}
|
|
}
|
|
|
|
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 = mediaItems.filter(f => f.equipTpCd === 'drone').length
|
|
const planeCount = mediaItems.filter(f => f.equipTpCd === 'plane').length
|
|
const satCount = mediaItems.filter(f => f.equipTpCd === '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: loading ? '…' : String(mediaItems.length), label: '총 파일', color: 'text-primary-cyan' },
|
|
{ icon: '🛸', value: loading ? '…' : String(droneCount), label: '드론', color: 'text-text-1' },
|
|
{ icon: '✈', value: loading ? '…' : String(planeCount), label: '유인항공기', color: 'text-text-1' },
|
|
{ icon: '🛰', value: loading ? '…' : String(satCount), label: '위성', color: 'text-text-1' },
|
|
{ icon: '💾', value: '—', 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>
|
|
{loading ? (
|
|
<tr>
|
|
<td colSpan={11} className="px-4 py-8 text-center text-[11px] text-text-3 font-korean">불러오는 중...</td>
|
|
</tr>
|
|
) : sorted.map(f => (
|
|
<tr
|
|
key={f.aerialMediaSn}
|
|
onClick={() => toggleId(f.aerialMediaSn)}
|
|
className={`border-b border-border/50 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.02)] ${
|
|
selectedIds.has(f.aerialMediaSn) ? '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.aerialMediaSn)}
|
|
onChange={() => toggleId(f.aerialMediaSn)}
|
|
className="accent-primary-blue"
|
|
/>
|
|
</td>
|
|
<td className="px-1 py-2 text-base">{equipIcon(f.equipTpCd)}</td>
|
|
<td className="px-2 py-2 text-[10px] font-semibold text-text-1 font-korean truncate">{f.acdntSn != null ? String(f.acdntSn) : '—'}</td>
|
|
<td className="px-2 py-2 text-[10px] text-primary-cyan font-mono truncate">{f.locDc ?? '—'}</td>
|
|
<td className="px-2 py-2 text-[11px] font-semibold text-text-1 font-korean truncate">{f.fileNm}</td>
|
|
<td className="px-2 py-2">
|
|
<span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${equipTagCls(f.equipTpCd)}`}>
|
|
{f.equipNm}
|
|
</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.mediaTpCd)}`}>
|
|
{f.mediaTpCd === '영상' ? '🎬' : '📷'} {f.mediaTpCd}
|
|
</span>
|
|
</td>
|
|
<td className="px-2 py-2 text-[11px] font-mono">{formatDtm(f.takngDtm)}</td>
|
|
<td className="px-2 py-2 text-[11px] font-mono">{f.fileSz ?? '—'}</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>
|
|
)
|
|
}
|