feat: 항공영상 업로드 모달
This commit is contained in:
164
web-app/app/features/imagery/hooks/useAerialChunkUpload.ts
Normal file
164
web-app/app/features/imagery/hooks/useAerialChunkUpload.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { completeChunkUpload, uploadFileChunk } from '../api/imagery';
|
||||
|
||||
export type FileUploadStatus = 'idle' | 'uploading' | 'completed' | 'error'
|
||||
|
||||
export type UploadingFile = {
|
||||
id: string;
|
||||
file: File;
|
||||
fileName: string;
|
||||
progress: number;
|
||||
status: FileUploadStatus;
|
||||
totalSize: number;
|
||||
uploadedSize: number;
|
||||
uuid?: string;
|
||||
}
|
||||
|
||||
const CHUNK_SIZE = 10 * 1024 * 1024 // 10MB
|
||||
const MAX_CONCURRENT_UPLOADS = 3
|
||||
|
||||
export const useAerialChunkUpload = () => {
|
||||
const [files, setFiles] = useState<UploadingFile[]>([])
|
||||
const abortControllersRef = useRef<Map<string, AbortController>>(new Map())
|
||||
|
||||
const updateFileState = useCallback((id: string, updates: Partial<UploadingFile>) => {
|
||||
setFiles((prev) => prev.map((f) => f.id === id ? { ...f, ...updates } : f))
|
||||
}, [])
|
||||
|
||||
const uploadSingleFile = useCallback(async (uploadFile: UploadingFile) => {
|
||||
const { id, file } = uploadFile
|
||||
const abortController = new AbortController()
|
||||
abortControllersRef.current.set(id, abortController)
|
||||
|
||||
const { signal } = abortController
|
||||
const fileSize = file.size
|
||||
const fileName = file.name
|
||||
const totalChunks = Math.ceil(fileSize / CHUNK_SIZE)
|
||||
|
||||
updateFileState(id, { status: 'uploading', progress: 0 })
|
||||
|
||||
let uuid = ''
|
||||
|
||||
try {
|
||||
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
const start = chunkIndex * CHUNK_SIZE
|
||||
const end = Math.min(start + CHUNK_SIZE, fileSize)
|
||||
const chunkFile = file.slice(start, end)
|
||||
|
||||
const response = await uploadFileChunk({
|
||||
fileName,
|
||||
fileSize,
|
||||
chunkIndex,
|
||||
chunkTotalIndex: totalChunks - 1,
|
||||
chunkFile,
|
||||
})
|
||||
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
// 첫 번째 청크 응답에서 uuid 저장
|
||||
if (chunkIndex === 0 && response.uuid) {
|
||||
uuid = response.uuid
|
||||
updateFileState(id, { uuid })
|
||||
}
|
||||
|
||||
const progress = Math.round(((chunkIndex + 1) / totalChunks) * 100)
|
||||
const uploadedSize = end
|
||||
|
||||
updateFileState(id, {
|
||||
progress,
|
||||
uploadedSize,
|
||||
})
|
||||
}
|
||||
|
||||
// 청크 업로드 완료 후 completeChunkUpload 호출
|
||||
if (uuid) {
|
||||
await completeChunkUpload(uuid)
|
||||
}
|
||||
|
||||
updateFileState(id, { status: 'completed', progress: 100 })
|
||||
} catch (error) {
|
||||
if (!signal.aborted) {
|
||||
console.error('Upload error:', error)
|
||||
updateFileState(id, { status: 'error' })
|
||||
}
|
||||
} finally {
|
||||
abortControllersRef.current.delete(id)
|
||||
}
|
||||
}, [updateFileState])
|
||||
|
||||
const addFiles = useCallback((newFiles: File[]) => {
|
||||
const uploadFiles: UploadingFile[] = newFiles.map((file) => ({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
fileName: file.name,
|
||||
progress: 0,
|
||||
status: 'idle' as FileUploadStatus,
|
||||
totalSize: file.size,
|
||||
uploadedSize: 0,
|
||||
}))
|
||||
|
||||
setFiles((prev) => [...prev, ...uploadFiles])
|
||||
return uploadFiles
|
||||
}, [])
|
||||
|
||||
const startUpload = useCallback(async (filesToUpload?: UploadingFile[]) => {
|
||||
const targetFiles = filesToUpload || files.filter((f) => f.status === 'idle')
|
||||
|
||||
// 병렬 업로드 (최대 MAX_CONCURRENT_UPLOADS개씩)
|
||||
for (let i = 0; i < targetFiles.length; i += MAX_CONCURRENT_UPLOADS) {
|
||||
const batch = targetFiles.slice(i, i + MAX_CONCURRENT_UPLOADS)
|
||||
await Promise.all(batch.map((file) => uploadSingleFile(file)))
|
||||
}
|
||||
}, [files, uploadSingleFile])
|
||||
|
||||
const removeFile = useCallback((id: string) => {
|
||||
// 업로드 중이면 취소
|
||||
const controller = abortControllersRef.current.get(id)
|
||||
if (controller) {
|
||||
controller.abort()
|
||||
abortControllersRef.current.delete(id)
|
||||
}
|
||||
|
||||
setFiles((prev) => prev.filter((f) => f.id !== id))
|
||||
}, [])
|
||||
|
||||
const cancelAllUploads = useCallback(() => {
|
||||
abortControllersRef.current.forEach((controller) => controller.abort())
|
||||
abortControllersRef.current.clear()
|
||||
setFiles([])
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
cancelAllUploads()
|
||||
}, [cancelAllUploads])
|
||||
|
||||
const isUploading = files.some((f) => f.status === 'uploading')
|
||||
const isAllCompleted = files.length > 0 && files.every((f) => f.status === 'completed')
|
||||
const hasFiles = files.length > 0
|
||||
|
||||
const totalSize = files.reduce((acc, f) => acc + f.totalSize, 0)
|
||||
const uploadedSize = files.reduce((acc, f) => acc + f.uploadedSize, 0)
|
||||
const overallProgress = totalSize > 0 ? Math.round((uploadedSize / totalSize) * 100) : 0
|
||||
|
||||
return {
|
||||
files,
|
||||
addFiles,
|
||||
startUpload,
|
||||
removeFile,
|
||||
cancelAllUploads,
|
||||
reset,
|
||||
isUploading,
|
||||
isAllCompleted,
|
||||
hasFiles,
|
||||
totalSize,
|
||||
uploadedSize,
|
||||
overallProgress,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user