feat: 항공영상 업로드 모달
This commit is contained in:
423
web-app/app/features/imagery/components/AerialRegisterModal.tsx
Normal file
423
web-app/app/features/imagery/components/AerialRegisterModal.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import { Button } from '~/shared/components/button/Button';
|
||||
import { Modal, ModalRegion } from '~/shared/components/modal';
|
||||
import { Select, type SelectOption } from '~/shared/components/select/Select';
|
||||
import { Tree, type TreeItemType, type TreeRef } from '~/shared/components/tree/Tree';
|
||||
|
||||
import { fetchFolderList, fetchRegions } from '../api/imagery';
|
||||
import { useAerialChunkUpload, type UploadingFile } from '../hooks/useAerialChunkUpload';
|
||||
|
||||
type AerialRegisterModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onUploadSuccess?: () => void;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
type: string;
|
||||
region: string;
|
||||
folderPath: string;
|
||||
};
|
||||
|
||||
const TYPE_OPTIONS: SelectOption[] = [
|
||||
{ value: 'mapFrame', label: '도곽' },
|
||||
];
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))}${sizes[i]}`;
|
||||
};
|
||||
|
||||
const ProgressBar = ({ progress }: { progress: number }) => {
|
||||
return (
|
||||
<div className="flex-1 h-2 bg-dabeeo-gray-eb overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FileItem = ({
|
||||
file,
|
||||
folderPath,
|
||||
onRemove,
|
||||
}: {
|
||||
file: UploadingFile;
|
||||
folderPath: string;
|
||||
onRemove: (id: string) => void;
|
||||
}) => {
|
||||
const fullPath = folderPath ? `${folderPath}/${file.fileName}` : file.fileName;
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<span className="text-sm text-dabeeo-black-34 truncate flex-1">{fullPath}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(file.id)}
|
||||
className="text-dabeeo-gray-99 hover:text-dabeeo-black-34 cursor-pointer"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M12.5 4L4 12.5M4 4L12.5 12.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AerialRegisterModal = ({ isOpen, onClose, onUploadSuccess }: AerialRegisterModalProps) => {
|
||||
const treeRef = useRef<TreeRef | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { control, setValue, watch, reset: resetForm } = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
type: 'mapFrame',
|
||||
region: '',
|
||||
folderPath: '',
|
||||
},
|
||||
});
|
||||
|
||||
const selectedRegion = watch('region');
|
||||
const selectedFolder = watch('folderPath');
|
||||
|
||||
const [regions, setRegions] = useState<SelectOption[]>([]);
|
||||
const [treeItems, setTreeItems] = useState<TreeItemType[]>([]);
|
||||
const [isLoadingFolders, setIsLoadingFolders] = useState(false);
|
||||
|
||||
const {
|
||||
files,
|
||||
addFiles,
|
||||
startUpload,
|
||||
removeFile,
|
||||
reset: resetUpload,
|
||||
isUploading,
|
||||
isAllCompleted,
|
||||
hasFiles,
|
||||
totalSize,
|
||||
uploadedSize,
|
||||
overallProgress,
|
||||
} = useAerialChunkUpload();
|
||||
|
||||
const memorizedSelectedKeys = useMemo(
|
||||
() => new Set(selectedFolder ? [selectedFolder] : []),
|
||||
[selectedFolder]
|
||||
);
|
||||
|
||||
// 지역 목록 조회
|
||||
useEffect(() => {
|
||||
const loadRegions = async () => {
|
||||
try {
|
||||
const data = await fetchRegions();
|
||||
setRegions(data.map((r) => ({ value: r.code, label: r.name })));
|
||||
} catch (error) {
|
||||
console.error('Failed to load regions:', error);
|
||||
}
|
||||
};
|
||||
if (isOpen) {
|
||||
loadRegions();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// 지역 선택 시 폴더 목록 조회
|
||||
useEffect(() => {
|
||||
if (!selectedRegion) {
|
||||
setTreeItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadFolders = async () => {
|
||||
setIsLoadingFolders(true);
|
||||
try {
|
||||
const data = await fetchFolderList(selectedRegion);
|
||||
const items: TreeItemType[] = data.folders.map((folder) => ({
|
||||
id: folder.fullPath,
|
||||
title: folder.folderNm,
|
||||
type: 'directory',
|
||||
}));
|
||||
setTreeItems(items);
|
||||
} catch (error) {
|
||||
console.error('Failed to load folders:', error);
|
||||
} finally {
|
||||
setIsLoadingFolders(false);
|
||||
}
|
||||
};
|
||||
loadFolders();
|
||||
}, [selectedRegion]);
|
||||
|
||||
const handleExpandFolder = async (key: string | number) => {
|
||||
const folderPath = String(key);
|
||||
try {
|
||||
const data = await fetchFolderList(folderPath);
|
||||
data.folders.forEach((folder) => {
|
||||
const newItem: TreeItemType = {
|
||||
id: folder.fullPath,
|
||||
title: folder.folderNm,
|
||||
type: 'directory',
|
||||
};
|
||||
treeRef.current?.addItem(newItem, folderPath);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load subfolders:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCollapseFolder = (key: string | number) => {
|
||||
treeRef.current?.clearItemsByParentKey(key);
|
||||
};
|
||||
|
||||
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = e.target.files;
|
||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||
|
||||
const fileArray = Array.from(selectedFiles);
|
||||
const addedFiles = addFiles(fileArray);
|
||||
|
||||
// 파일 선택 즉시 업로드 시작
|
||||
startUpload(addedFiles);
|
||||
|
||||
// input 초기화 (같은 파일 재선택 가능하도록)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
if (!selectedFolder) {
|
||||
ModalRegion.alert({ content: '업로드할 폴더를 선택해주세요.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasFiles) {
|
||||
ModalRegion.alert({ content: '업로드할 파일을 선택해주세요.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUploading) {
|
||||
ModalRegion.alert({ content: '파일 업로드가 진행 중입니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAllCompleted) {
|
||||
ModalRegion.alert({
|
||||
content: '파일 업로드가 완료되었습니다.',
|
||||
onCancel: () => {
|
||||
onUploadSuccess?.();
|
||||
handleClose();
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetUpload();
|
||||
resetForm();
|
||||
setTreeItems([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (isUploading || hasFiles) {
|
||||
ModalRegion.confirm({
|
||||
content: '업로드를 취소하시겠습니까?\n진행 중인 업로드가 중단됩니다.',
|
||||
confirmText: '예',
|
||||
cancelText: '아니오',
|
||||
onConfirm: (close) => {
|
||||
close();
|
||||
handleClose();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const currentUploadingFile = files.find((f) => f.status === 'uploading');
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={handleCancel} isKeyboardDismissDisabled>
|
||||
{() => (
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleUpload(); }}>
|
||||
<Modal.Header hasCloseButton={false}>업로드 폴더 선택</Modal.Header>
|
||||
<Modal.Body>
|
||||
<div className="flex flex-col gap-4 py-2 w-140">
|
||||
{/* 타입 선택 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="w-20 text-sm font-semibold text-dabeeo-black-34 shrink-0">
|
||||
타입 선택
|
||||
</label>
|
||||
<Controller
|
||||
name="type"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Select
|
||||
items={TYPE_OPTIONS}
|
||||
selectedKey={value}
|
||||
onChange={onChange}
|
||||
className="w-40"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 지역 선택 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="w-20 text-sm font-semibold text-dabeeo-black-34 shrink-0">
|
||||
지역 선택
|
||||
</label>
|
||||
<Controller
|
||||
name="region"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Select
|
||||
items={regions}
|
||||
selectedKey={value}
|
||||
onChange={(v) => {
|
||||
onChange(v);
|
||||
setValue('folderPath', '');
|
||||
}}
|
||||
placeholder="지역선택"
|
||||
className="w-40"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 폴더 선택 */}
|
||||
<div className="flex items-start gap-4">
|
||||
<label className="w-20 text-sm font-semibold text-dabeeo-black-34 shrink-0 pt-2">
|
||||
폴더 선택
|
||||
</label>
|
||||
<div className="flex-1 border border-dabeeo-gray-be max-h-60 min-h-40 overflow-y-auto">
|
||||
{isLoadingFolders ? (
|
||||
<div className="flex items-center justify-center h-40 text-sm text-dabeeo-gray-99">
|
||||
로딩 중...
|
||||
</div>
|
||||
) : treeItems.length > 0 ? (
|
||||
<Controller
|
||||
name="folderPath"
|
||||
control={control}
|
||||
render={({ field: { onChange } }) => (
|
||||
<Tree
|
||||
ref={treeRef}
|
||||
items={treeItems}
|
||||
selectedKeys={memorizedSelectedKeys}
|
||||
onExpand={(key, item) => {
|
||||
handleExpandFolder(key);
|
||||
onChange(String(key));
|
||||
}}
|
||||
onCollapse={(key) => {
|
||||
handleCollapseFolder(key);
|
||||
}}
|
||||
onSelect={(key) => {
|
||||
if (key) onChange(String(key));
|
||||
}}
|
||||
enableDragAndDrop={false}
|
||||
persistSelectionOnCollapse
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : selectedRegion ? (
|
||||
<div className="flex items-center justify-center h-40 text-sm text-dabeeo-gray-99">
|
||||
폴더가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-40 text-sm text-dabeeo-gray-99">
|
||||
지역을 선택해주세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-dabeeo-orange-main ml-24">
|
||||
※ 업로드 대상 폴더를 정확하게 지정하시기 바랍니다.
|
||||
</p>
|
||||
|
||||
{/* 파일명 */}
|
||||
<div className="flex items-start gap-4">
|
||||
<label className="w-20 text-sm font-semibold text-dabeeo-black-34 shrink-0 pt-2">
|
||||
파일명 <span className="text-dabeeo-red">*</span>
|
||||
</label>
|
||||
<div className="flex-1 border border-dabeeo-gray-be p-2 min-h-10 max-h-24 overflow-y-auto">
|
||||
{files.length > 0 ? (
|
||||
files.map((file) => (
|
||||
<FileItem key={file.id} file={file} folderPath={selectedFolder} onRemove={removeFile} />
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-dabeeo-gray-99">파일이 업로드 되면 자동 입력됩니다.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 파일 선택 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="w-20 text-sm font-semibold text-dabeeo-black-34 shrink-0">
|
||||
파일 <span className="text-dabeeo-red">*</span>
|
||||
</label>
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<div className="flex-1 h-9 border border-dabeeo-gray-be px-3 flex items-center">
|
||||
<span className="text-sm text-dabeeo-gray-99 truncate">
|
||||
{currentUploadingFile?.fileName || ''}
|
||||
</span>
|
||||
</div>
|
||||
<label
|
||||
htmlFor="aerial-file-input"
|
||||
className="h-9 px-4 bg-dabeeo-black-34 text-white text-sm font-medium flex items-center justify-center cursor-pointer hover:bg-dabeeo-black-47"
|
||||
>
|
||||
파일선택
|
||||
</label>
|
||||
<input
|
||||
id="aerial-file-input"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".tif,.tiff"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 진행률 표시 */}
|
||||
{hasFiles && (
|
||||
<div className="flex items-center gap-4 ml-24">
|
||||
<ProgressBar progress={overallProgress} />
|
||||
<span className="text-xs text-dabeeo-black-34 w-10 text-right">{overallProgress}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasFiles && (
|
||||
<div className="text-xs text-dabeeo-gray-99 ml-24">
|
||||
Loading {formatFileSize(uploadedSize)} / {formatFileSize(totalSize)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button type="button" color="gray" size="large" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
size="large"
|
||||
isDisabled={!hasFiles || isUploading}
|
||||
isPending={isUploading}
|
||||
>
|
||||
업로드
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user