diff --git a/src/locales/en.json b/src/locales/en.json index 74ca9ab..627043f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -211,15 +211,21 @@ "selectedImages": "Selected Images", "imageAlt": "Image", "uploadBadge": "Uploaded", + "previewUnavailable": "No preview", "imageUpload": "Image Upload", "dragAndDrop": "Drag & drop or\nclick to upload", "videoRatio": "Video Ratio", - "minImages": "Min. 5 images", + "minImages": "Recommended 30+ images", "youtubeShorts": "YouTube Shorts", "youtubeVideo": "YouTube Video", "back": "Go Back", "loadMore": "Load more", "uploadFailed": "Image upload failed.", + "uploadErrorTitle": "Image Upload Error", + "uploadErrorConfirm": "OK", + "duplicateSkippedTitle": "Duplicate Images Skipped", + "duplicateSkippedMessage": "Skipped {{count}} image(s) that were already added.", + "preparingPreviews": "Preparing previews ({{done}} / {{total}})", "uploading": "Uploading... (30 sec – 1 min)", "nextStep": "Next Step" }, diff --git a/src/locales/ko.json b/src/locales/ko.json index 0dd1cb6..c66ae56 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -259,15 +259,21 @@ "selectedImages": "선택된 이미지", "imageAlt": "이미지", "uploadBadge": "업로드", + "previewUnavailable": "미리보기 없음", "imageUpload": "이미지 업로드", "dragAndDrop": "이미지를 끌어다 놓거나\n클릭하여 업로드", "videoRatio": "영상 비율", - "minImages": "최소 5장", + "minImages": "권장 30장 이상", "youtubeShorts": "유튜브 쇼츠", "youtubeVideo": "유튜브 일반", "back": "뒤로가기", "loadMore": "더보기", "uploadFailed": "이미지 업로드에 실패했습니다.", + "uploadErrorTitle": "이미지 업로드 초과", + "uploadErrorConfirm": "확인", + "duplicateSkippedTitle": "중복 이미지 제외", + "duplicateSkippedMessage": "이미 추가된 이미지 {{count}}장을 제외했습니다.", + "preparingPreviews": "미리보기 준비 중 ({{done}} / {{total}})", "uploading": "업로드 중 (30~60초 소요)", "nextStep": "다음 단계" }, diff --git a/src/pages/Dashboard/AssetManagementContent.tsx b/src/pages/Dashboard/AssetManagementContent.tsx index 50e57a4..ce5f5aa 100755 --- a/src/pages/Dashboard/AssetManagementContent.tsx +++ b/src/pages/Dashboard/AssetManagementContent.tsx @@ -3,13 +3,19 @@ import React, { useRef, useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { ImageItem, ImageUrlItem } from '../../types/api'; import { uploadImages } from '../../utils/api'; +import { isImageInputFile } from '../../utils/imageCompression.ts'; +import { MAX_IMAGES_PER_UPLOAD_TASK } from '../../utils/imageUpload.ts'; +import { splitDuplicateFiles } from '../../utils/imageDedup.ts'; interface AssetManagementContentProps { onNext: (imageTaskId: string) => void; onBack?: () => void; imageList: ImageItem[]; onRemoveImage: (index: number) => void; - onAddImages: (files: File[]) => void; + onAddImages: ( + files: File[], + onProgress: (done: number, total: number) => void + ) => Promise; } type VideoRatio = 'vertical' | 'horizontal'; @@ -27,9 +33,11 @@ const AssetManagementContent: React.FC = ({ const fileInputRef = useRef(null); const [isUploading, setIsUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); - const [uploadError, setUploadError] = useState(null); + // 업로드 실패와 중복 제외 안내가 같은 다이얼로그 마크업을 공유한다. + const [dialog, setDialog] = useState<{ title: string; message: string } | null>(null); const [videoRatio, setVideoRatio] = useState('vertical'); const [displayCount, setDisplayCount] = useState(IMAGES_PER_PAGE); + const [thumbnailProgress, setThumbnailProgress] = useState<{ done: number; total: number } | null>(null); useEffect(() => { const savedRatio = localStorage.getItem('castad_video_ratio') as VideoRatio; @@ -38,12 +46,26 @@ const AssetManagementContent: React.FC = ({ } }, []); + useEffect(() => { + if (!dialog) return; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + setDialog(null); + } + }; + + window.addEventListener('keydown', handleEscape); + return () => window.removeEventListener('keydown', handleEscape); + }, [dialog]); + const handleVideoRatioChange = (ratio: VideoRatio) => { setVideoRatio(ratio); localStorage.setItem('castad_video_ratio', ratio); }; - const getImageSrc = (item: ImageItem): string => { + const getImageSrc = (item: ImageItem): string | null => { return item.type === 'url' ? item.preview_url : item.preview; }; @@ -52,7 +74,7 @@ const AssetManagementContent: React.FC = ({ setIsUploading(true); setUploadProgress(0); - setUploadError(null); + setDialog(null); const interval = setInterval(() => { setUploadProgress(prev => { @@ -85,13 +107,37 @@ const AssetManagementContent: React.FC = ({ } catch (error) { clearInterval(interval); console.error('Image upload failed:', error); - setUploadError(error instanceof Error ? error.message : t('assetManagement.uploadFailed')); + setDialog({ + title: t('assetManagement.uploadErrorTitle'), + message: error instanceof Error ? error.message : t('assetManagement.uploadFailed'), + }); } finally { setIsUploading(false); setUploadProgress(0); } }; + const runAddImages = async (files: File[]) => { + const { newFiles, duplicateCount } = splitDuplicateFiles(imageList, files); + + if (newFiles.length > 0) { + setThumbnailProgress({ done: 0, total: newFiles.length }); + try { + await onAddImages(newFiles, (done, total) => setThumbnailProgress({ done, total })); + } finally { + setThumbnailProgress(null); + } + } + + // 썸네일 생성 오버레이에 가려지지 않도록 작업이 끝난 뒤에 안내한다. + if (duplicateCount > 0) { + setDialog({ + title: t('assetManagement.duplicateSkippedTitle'), + message: t('assetManagement.duplicateSkippedMessage', { count: duplicateCount }), + }); + } + }; + const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); @@ -100,10 +146,8 @@ const AssetManagementContent: React.FC = ({ const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); - const files = Array.from(e.dataTransfer.files).filter((file: File) => - file.type.startsWith('image/') - ); - if (files.length > 0) onAddImages(files); + const files = Array.from(e.dataTransfer.files).filter(isImageInputFile); + if (files.length > 0) void runAddImages(files); }; const handleFileSelect = () => { @@ -113,7 +157,7 @@ const AssetManagementContent: React.FC = ({ const handleFileChange = (e: React.ChangeEvent) => { const files = e.target.files; if (files && files.length > 0) { - onAddImages(Array.from(files)); + void runAddImages(Array.from(files)); e.target.value = ''; } }; @@ -123,6 +167,25 @@ const AssetManagementContent: React.FC = ({ return (
+ {thumbnailProgress && ( +
+
+
+
+
+
+
+
+

+ {t('assetManagement.preparingPreviews', { + done: thumbnailProgress.done, + total: thumbnailProgress.total, + })} +

+
+
+ )} + {isUploading && (
@@ -146,6 +209,37 @@ const AssetManagementContent: React.FC = ({
)} + {dialog && ( +
setDialog(null)} + > +
event.stopPropagation()} + > +

+ {dialog.title} +

+

+ {dialog.message} +

+ +
+
+ )} + {/* Fixed Header - 뒤로가기 버튼 */}
{onBack && ( @@ -169,6 +263,13 @@ const AssetManagementContent: React.FC = ({

{t('assetManagement.selectedImages')}

{t('assetManagement.minImages')} + MAX_IMAGES_PER_UPLOAD_TASK ? ' asset-section-count-over' : '' + }`} + > + {imageList.length} / {MAX_IMAGES_PER_UPLOAD_TASK} +
-
- ))} + {visibleImages.map((item, i) => { + const src = getImageSrc(item); + const fileName = item.type === 'file' ? item.file.name : ''; + return ( +
+ {src ? ( + {`${t('assetManagement.imageAlt')} + ) : ( +
+ + + + + + + {fileName || t('assetManagement.previewUnavailable')} + +
+ )} + {item.type === 'file' && ( +
{t('assetManagement.uploadBadge')}
+ )} + +
+ ); + })}
)} @@ -268,9 +388,6 @@ const AssetManagementContent: React.FC = ({ {/* Fixed Footer - 다음 단계 버튼 */}
- {uploadError && ( -

{uploadError}

- )}