722 lines
29 KiB
TypeScript
722 lines
29 KiB
TypeScript
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
import {
|
|
fetchAerialMedia,
|
|
downloadAerialMedia,
|
|
getAerialMediaViewUrl,
|
|
uploadAerialMedia,
|
|
} from '../services/aerialApi';
|
|
import type { AerialMediaItem } from '../services/aerialApi';
|
|
import { navigateToTab } from '@common/hooks/useSubMenu';
|
|
|
|
// ── 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 = () => 'text-fg';
|
|
|
|
const mediaTagCls = () => 'text-fg';
|
|
|
|
const FilterBtn = ({
|
|
label,
|
|
active,
|
|
onClick,
|
|
}: {
|
|
label: string;
|
|
active: boolean;
|
|
onClick: () => void;
|
|
}) => (
|
|
<button
|
|
onClick={onClick}
|
|
className={`px-2.5 py-1 text-caption font-semibold rounded font-korean transition-colors ${
|
|
active
|
|
? 'bg-[rgba(6,182,212,0.15)] text-color-accent border border-[rgba(6,182,212,0.3)]'
|
|
: 'border border-stroke text-fg-sub hover:bg-bg-surface-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 [downloadingId, setDownloadingId] = useState<number | null>(null);
|
|
const [bulkDownloading, setBulkDownloading] = useState(false);
|
|
const [downloadResult, setDownloadResult] = useState<{ total: number; success: number } | null>(
|
|
null,
|
|
);
|
|
const [previewItem, setPreviewItem] = useState<AerialMediaItem | null>(null);
|
|
const modalRef = useRef<HTMLDivElement>(null);
|
|
const previewRef = useRef<HTMLDivElement>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [dragOver, setDragOver] = useState(false);
|
|
// const [uploadEquip, setUploadEquip] = useState('drone');
|
|
// const [uploadEquipNm, setUploadEquipNm] = useState('드론 (DJI M300 RTK)');
|
|
// const [uploadMemo, setUploadMemo] = useState('');
|
|
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
if (!previewItem) return;
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') setPreviewItem(null);
|
|
};
|
|
document.addEventListener('keydown', onKey);
|
|
return () => document.removeEventListener('keydown', onKey);
|
|
}, [previewItem]);
|
|
|
|
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 handleBulkDownload = async () => {
|
|
if (bulkDownloading || selectedIds.size === 0) return;
|
|
setBulkDownloading(true);
|
|
let success = 0;
|
|
const total = selectedIds.size;
|
|
for (const sn of selectedIds) {
|
|
const item = mediaItems.find((f) => f.aerialMediaSn === sn);
|
|
if (!item) continue;
|
|
try {
|
|
await downloadAerialMedia(sn, item.orgnlNm ?? item.fileNm);
|
|
success++;
|
|
} catch {
|
|
// 실패 건 스킵
|
|
}
|
|
}
|
|
setBulkDownloading(false);
|
|
setDownloadResult({ total, success });
|
|
};
|
|
|
|
const handleDownload = async (e: React.MouseEvent, item: AerialMediaItem) => {
|
|
e.stopPropagation();
|
|
if (downloadingId !== null) return;
|
|
setDownloadingId(item.aerialMediaSn);
|
|
try {
|
|
await downloadAerialMedia(item.aerialMediaSn, item.orgnlNm ?? item.fileNm);
|
|
} catch {
|
|
alert('다운로드 실패: 이미지를 찾을 수 없습니다.');
|
|
} finally {
|
|
setDownloadingId(null);
|
|
}
|
|
};
|
|
|
|
const handleUploadSubmit = async () => {
|
|
if (!uploadFile || uploading) return;
|
|
setUploading(true);
|
|
try {
|
|
await uploadAerialMedia(uploadFile, {
|
|
// equipTpCd: uploadEquip,
|
|
// equipNm: uploadEquipNm,
|
|
// memo: uploadMemo,
|
|
});
|
|
setShowUpload(false);
|
|
setUploadFile(null);
|
|
// setUploadMemo('');
|
|
await loadData();
|
|
} catch {
|
|
alert('업로드 실패: 다시 시도해주세요.');
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
};
|
|
|
|
const handleFileDrop = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setDragOver(false);
|
|
const file = e.dataTransfer.files[0];
|
|
if (file) setUploadFile(file);
|
|
};
|
|
|
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) setUploadFile(file);
|
|
};
|
|
|
|
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-label-2 text-fg-disabled 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-label-2 text-fg-disabled 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-base border border-stroke rounded-sm text-fg font-korean text-label-2 outline-none w-40 focus:border-color-accent"
|
|
/>
|
|
<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>
|
|
<button
|
|
onClick={() => setShowUpload(true)}
|
|
className="px-3 py-1.5 text-label-2 font-semibold rounded bg-[rgba(6,182,212,0.08)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.15)] transition-colors font-korean"
|
|
>
|
|
+ 이미지 등록
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary Stats */}
|
|
<div className="flex gap-2.5 mb-4">
|
|
{[
|
|
{
|
|
icon: '📸',
|
|
value: loading ? '…' : String(mediaItems.length),
|
|
label: '총 파일',
|
|
color: 'text-fg',
|
|
},
|
|
{
|
|
icon: '🛸',
|
|
value: loading ? '…' : String(droneCount),
|
|
label: '드론',
|
|
color: 'text-fg',
|
|
},
|
|
{
|
|
icon: '✈',
|
|
value: loading ? '…' : String(planeCount),
|
|
label: '유인항공기',
|
|
color: 'text-fg',
|
|
},
|
|
{ icon: '🛰', value: loading ? '…' : String(satCount), label: '위성', color: 'text-fg' },
|
|
{ icon: '💾', value: '—', label: '총 용량', color: 'text-fg' },
|
|
].map((s, i) => (
|
|
<div
|
|
key={i}
|
|
className="flex-1 flex items-center gap-2.5 px-4 py-3 border border-stroke 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-caption text-fg-disabled font-korean">{s.label}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* File Table */}
|
|
<div className="flex-1 border border-stroke rounded-md overflow-hidden flex flex-col">
|
|
<div className="overflow-auto flex-1 scrollbar-thin">
|
|
<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: 60 }} />
|
|
</colgroup>
|
|
<thead>
|
|
<tr className="border-b border-stroke bg-bg-elevated">
|
|
<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-caption font-semibold text-fg-disabled font-korean whitespace-nowrap">
|
|
사고명
|
|
</th>
|
|
<th className="px-2 py-2.5 text-caption font-semibold text-fg-disabled font-korean whitespace-nowrap">
|
|
위치
|
|
</th>
|
|
<th className="px-2 py-2.5 text-caption font-semibold text-fg-disabled font-korean">
|
|
파일명
|
|
</th>
|
|
<th className="px-2 py-2.5 text-caption font-semibold text-fg-disabled font-korean">
|
|
장비
|
|
</th>
|
|
<th className="px-2 py-2.5 text-caption font-semibold text-fg-disabled font-korean">
|
|
유형
|
|
</th>
|
|
<th className="px-2 py-2.5 text-caption font-semibold text-fg-disabled font-korean whitespace-nowrap">
|
|
촬영일시
|
|
</th>
|
|
<th className="px-2 py-2.5 text-caption font-semibold text-fg-disabled font-korean whitespace-nowrap">
|
|
용량
|
|
</th>
|
|
<th className="px-2 py-2.5 text-caption font-semibold text-fg-disabled font-korean whitespace-nowrap">
|
|
해상도
|
|
</th>
|
|
<th className="px-2 py-2.5 text-caption font-semibold text-fg-disabled text-center">
|
|
다운로드
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr>
|
|
<td
|
|
colSpan={11}
|
|
className="px-4 py-8 text-center text-label-2 text-fg-disabled font-korean"
|
|
>
|
|
불러오는 중...
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
sorted.map((f) => {
|
|
const isPhoto = f.mediaTpCd !== '영상';
|
|
return (
|
|
<tr
|
|
key={f.aerialMediaSn}
|
|
className={`border-b border-stroke 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 cursor-pointer"
|
|
onClick={() => toggleId(f.aerialMediaSn)}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedIds.has(f.aerialMediaSn)}
|
|
onChange={() => toggleId(f.aerialMediaSn)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="accent-primary-blue"
|
|
/>
|
|
</td>
|
|
<td className="px-1 py-2 text-base">{equipIcon(f.equipTpCd)}</td>
|
|
<td className="px-2 py-2 text-caption font-semibold text-fg font-korean truncate">
|
|
{f.acdntSn != null ? String(f.acdntSn) : '—'}
|
|
</td>
|
|
<td className="px-2 py-2 text-caption text-fg font-mono truncate">
|
|
{f.locDc ?? '—'}
|
|
</td>
|
|
<td className="px-2 py-2 text-label-2 text-fg font-korean truncate">
|
|
{f.fileNm}
|
|
</td>
|
|
<td className="px-2 py-2">
|
|
<span className={`text-caption font-korean ${equipTagCls()}`}>
|
|
{f.equipNm}
|
|
</span>
|
|
</td>
|
|
<td className="px-2 py-2">
|
|
{isPhoto ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => setPreviewItem(f)}
|
|
className={`text-caption font-semibold font-korean hover:underline cursor-pointer ${mediaTagCls()}`}
|
|
>
|
|
📷 {f.mediaTpCd}
|
|
</button>
|
|
) : (
|
|
<span
|
|
className={`text-caption font-semibold font-korean ${mediaTagCls()}`}
|
|
>
|
|
🎬 {f.mediaTpCd}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-2 py-2 text-label-2 font-mono">{formatDtm(f.takngDtm)}</td>
|
|
<td className="px-2 py-2 text-label-2 font-mono">{f.fileSz ?? '—'}</td>
|
|
<td className="px-2 py-2 text-label-2 font-mono">{f.resolution ?? '—'}</td>
|
|
<td className="px-2 py-2 text-center">
|
|
<button
|
|
onClick={(e) => handleDownload(e, f)}
|
|
disabled={downloadingId === f.aerialMediaSn}
|
|
className="px-2 py-1 text-caption disabled:opacity-50"
|
|
>
|
|
{downloadingId === f.aerialMediaSn ? '⏳' : '📥'}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom Actions */}
|
|
<div className="flex justify-between items-center mt-4 pt-3.5 border-t border-stroke">
|
|
<div className="text-label-2 text-fg-disabled font-korean">
|
|
선택된 파일: <span className="text-color-accent font-semibold">{selectedIds.size}</span>건
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={toggleAll}
|
|
className="px-3 py-1.5 text-label-2 font-semibold rounded bg-bg-card border border-stroke text-fg-sub hover:bg-bg-surface-hover transition-colors font-korean"
|
|
>
|
|
전체선택
|
|
</button>
|
|
<button
|
|
onClick={handleBulkDownload}
|
|
disabled={bulkDownloading || selectedIds.size === 0}
|
|
className="px-3 py-1.5 text-label-2 font-semibold rounded bg-[rgba(6,182,212,0.08)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.15)] transition-colors font-korean disabled:opacity-50"
|
|
>
|
|
{bulkDownloading ? '⏳ 다운로드 중...' : '선택 다운로드'}
|
|
</button>
|
|
<button
|
|
onClick={() => navigateToTab('prediction', 'analysis')}
|
|
className="px-3 py-1.5 text-label-2 font-semibold rounded bg-[rgba(6,182,212,0.08)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.15)] transition-colors font-korean"
|
|
>
|
|
유출유확산예측으로 →
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 선택 다운로드 결과 팝업 */}
|
|
{downloadResult && (
|
|
<div className="fixed inset-0 z-[300] bg-black/60 backdrop-blur-sm flex items-center justify-center">
|
|
<div className="bg-bg-surface border border-stroke rounded-md p-6 w-72 text-center">
|
|
<div className="text-2xl mb-3">📥</div>
|
|
<div className="text-body-2 font-bold font-korean mb-3">다운로드 완료</div>
|
|
<div className="text-title-4 font-korean text-fg-sub mb-1">
|
|
총 <span className="text-color-accent font-bold">{downloadResult.total}</span>건 선택
|
|
</div>
|
|
<div className="text-title-4 font-korean text-fg-sub mb-4">
|
|
<span className="text-color-success font-bold">{downloadResult.success}</span>건
|
|
다운로드 성공
|
|
{downloadResult.total - downloadResult.success > 0 && (
|
|
<>
|
|
{' '}
|
|
/{' '}
|
|
<span className="text-color-danger font-bold">
|
|
{downloadResult.total - downloadResult.success}
|
|
</span>
|
|
건 실패
|
|
</>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={() => setDownloadResult(null)}
|
|
className="px-6 py-2 text-body-2 font-semibold rounded bg-[rgba(6,182,212,0.15)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.25)] 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-surface border border-stroke 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);
|
|
setUploadFile(null);
|
|
}}
|
|
className="text-fg-disabled text-lg hover:text-fg"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
setDragOver(true);
|
|
}}
|
|
onDragLeave={() => setDragOver(false)}
|
|
onDrop={handleFileDrop}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className={`border-2 border-dashed rounded-md py-8 px-4 text-center mb-4 cursor-pointer transition-colors ${
|
|
dragOver
|
|
? 'border-color-accent bg-[rgba(6,182,212,0.06)]'
|
|
: uploadFile
|
|
? 'border-[rgba(6,182,212,0.3)] bg-[rgba(6,182,212,0.04)]'
|
|
: 'border-stroke-light hover:border-[rgba(6,182,212,0.4)]'
|
|
}`}
|
|
>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".jpg,.jpeg,.png,.tif,.tiff,.mp4,.mov"
|
|
onChange={handleFileSelect}
|
|
className="hidden"
|
|
/>
|
|
{uploadFile ? (
|
|
<>
|
|
<div className="text-3xl mb-2">✅</div>
|
|
<div className="text-title-4 font-semibold mb-1 font-korean truncate px-4">
|
|
{uploadFile.name}
|
|
</div>
|
|
<div className="text-label-2 text-fg-disabled font-korean">
|
|
{(uploadFile.size / (1024 * 1024)).toFixed(2)} MB · 클릭하여 변경
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="text-3xl mb-2 opacity-50">📁</div>
|
|
<div className="text-title-4 font-semibold mb-1 font-korean">
|
|
파일을 드래그하거나 클릭하여 업로드
|
|
</div>
|
|
<div className="text-label-2 text-fg-disabled font-korean">
|
|
JPG, TIFF, GeoTIFF, MP4, MOV 지원 · 최대 2GB
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
{/* <div className="mb-3">
|
|
<label className="block text-caption font-semibold mb-1.5 text-fg-sub font-korean">
|
|
촬영 장비
|
|
</label>
|
|
<select
|
|
className="prd-i w-full"
|
|
value={uploadEquipNm}
|
|
onChange={(e) => {
|
|
setUploadEquipNm(e.target.value);
|
|
const v = e.target.value;
|
|
if (v.startsWith('드론')) setUploadEquip('drone');
|
|
else if (v.startsWith('유인')) setUploadEquip('plane');
|
|
else if (v.startsWith('위성')) setUploadEquip('satellite');
|
|
else setUploadEquip('drone');
|
|
}}
|
|
>
|
|
<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-caption font-semibold mb-1.5 text-fg-sub 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-caption font-semibold mb-1.5 text-fg-sub font-korean">
|
|
메모
|
|
</label>
|
|
<textarea
|
|
className="prd-i w-full h-[60px] resize-y"
|
|
placeholder="촬영 조건, 비고 등..."
|
|
value={uploadMemo}
|
|
onChange={(e) => setUploadMemo(e.target.value)}
|
|
/>
|
|
</div> */}
|
|
<button
|
|
onClick={handleUploadSubmit}
|
|
disabled={!uploadFile || uploading}
|
|
className="w-full py-3 rounded-sm text-body-2 font-bold font-korean cursor-pointer hover:brightness-125 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
|
style={{
|
|
background: 'rgba(6,182,212,0.15)',
|
|
border: '1px solid rgba(6,182,212,0.3)',
|
|
color: 'var(--color-accent)',
|
|
}}
|
|
>
|
|
{uploading ? '업로드 중...' : '업로드 실행'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Image Preview Modal */}
|
|
{previewItem && (
|
|
<div
|
|
className="fixed inset-0 z-[250] bg-black/70 backdrop-blur-sm flex items-center justify-center"
|
|
onClick={() => setPreviewItem(null)}
|
|
>
|
|
<div
|
|
ref={previewRef}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="bg-bg-surface border border-stroke rounded-md w-[720px] max-w-[90vw] max-h-[85vh] flex flex-col overflow-hidden"
|
|
>
|
|
<div className="flex justify-between items-center px-4 py-2.5 border-b border-stroke">
|
|
<div className="flex flex-col min-w-0">
|
|
<span className="text-label-1 font-bold font-korean text-fg truncate">
|
|
{previewItem.fileNm}
|
|
</span>
|
|
<span className="text-caption text-fg-disabled font-mono">
|
|
{formatDtm(previewItem.takngDtm)} · {previewItem.equipNm}
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={() => setPreviewItem(null)}
|
|
className="text-fg-disabled text-lg hover:text-fg ml-3"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div className="flex-1 flex items-center justify-center overflow-hidden bg-black/30 relative min-h-[320px]">
|
|
<img
|
|
src={getAerialMediaViewUrl(previewItem.aerialMediaSn)}
|
|
alt={previewItem.orgnlNm ?? previewItem.fileNm}
|
|
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] text-fg-disabled">📷</div>
|
|
<div className="text-label-1 text-fg-disabled font-korean">
|
|
이미지를 불러올 수 없습니다
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2 px-4 py-2.5 border-t border-stroke">
|
|
<button
|
|
onClick={(e) => handleDownload(e, previewItem)}
|
|
disabled={downloadingId === previewItem.aerialMediaSn}
|
|
className="px-3 py-1.5 text-label-2 font-semibold rounded bg-[rgba(6,182,212,0.08)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.15)] transition-colors font-korean disabled:opacity-50"
|
|
>
|
|
{downloadingId === previewItem.aerialMediaSn ? '⏳ 다운로드 중...' : '📥 다운로드'}
|
|
</button>
|
|
<button
|
|
onClick={() => setPreviewItem(null)}
|
|
className="px-3 py-1.5 text-label-2 font-semibold rounded bg-bg-card border border-stroke text-fg-sub hover:bg-bg-surface-hover transition-colors font-korean"
|
|
>
|
|
닫기
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|