296 lines
11 KiB
TypeScript
Executable File
296 lines
11 KiB
TypeScript
Executable File
|
|
import React, { useRef, useState, useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { ImageItem, ImageUrlItem } from '../../types/api';
|
|
import { uploadImages } from '../../utils/api';
|
|
|
|
interface AssetManagementContentProps {
|
|
onNext: (imageTaskId: string) => void;
|
|
onBack?: () => void;
|
|
imageList: ImageItem[];
|
|
onRemoveImage: (index: number) => void;
|
|
onAddImages: (files: File[]) => void;
|
|
}
|
|
|
|
type VideoRatio = 'vertical' | 'horizontal';
|
|
|
|
const IMAGES_PER_PAGE = 12;
|
|
|
|
const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
|
onNext,
|
|
onBack,
|
|
imageList,
|
|
onRemoveImage,
|
|
onAddImages,
|
|
}) => {
|
|
const { t } = useTranslation();
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
const [uploadProgress, setUploadProgress] = useState(0);
|
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
const [videoRatio, setVideoRatio] = useState<VideoRatio>('vertical');
|
|
const [displayCount, setDisplayCount] = useState(IMAGES_PER_PAGE);
|
|
|
|
useEffect(() => {
|
|
const savedRatio = localStorage.getItem('castad_video_ratio') as VideoRatio;
|
|
if (savedRatio === 'vertical' || savedRatio === 'horizontal') {
|
|
setVideoRatio(savedRatio);
|
|
}
|
|
}, []);
|
|
|
|
const handleVideoRatioChange = (ratio: VideoRatio) => {
|
|
setVideoRatio(ratio);
|
|
localStorage.setItem('castad_video_ratio', ratio);
|
|
};
|
|
|
|
const getImageSrc = (item: ImageItem): string => {
|
|
return item.type === 'url' ? item.preview_url : item.preview;
|
|
};
|
|
|
|
const handleNextWithUpload = async () => {
|
|
if (imageList.length === 0) return;
|
|
|
|
setIsUploading(true);
|
|
setUploadProgress(0);
|
|
setUploadError(null);
|
|
|
|
const interval = setInterval(() => {
|
|
setUploadProgress(prev => {
|
|
if (prev >= 99) return prev;
|
|
return prev + (prev < 50 ? 0.6 : prev < 80 ? 0.3 : prev < 90 ? 0.15 : 0.11);
|
|
});
|
|
}, 200);
|
|
|
|
try {
|
|
const urlImages: ImageUrlItem[] = imageList
|
|
.filter((item: ImageItem): item is ImageItem & { type: 'url' } => item.type === 'url')
|
|
.map((item) => ({ url: item.url }));
|
|
|
|
const fileImages: File[] = imageList
|
|
.filter((item: ImageItem): item is ImageItem & { type: 'file' } => item.type === 'file')
|
|
.map((item) => item.file);
|
|
|
|
if (urlImages.length > 0 || fileImages.length > 0) {
|
|
const response = await uploadImages(urlImages, fileImages);
|
|
clearInterval(interval);
|
|
setUploadProgress(100);
|
|
// 이미지 변경 시 이전 음악/영상 생성 결과 초기화 — 반드시 재생성하도록 강제
|
|
localStorage.removeItem('castad_song_generation');
|
|
localStorage.removeItem('castad_song_completion');
|
|
localStorage.removeItem('castad_song_task_id');
|
|
localStorage.removeItem('castad_video_generation');
|
|
localStorage.removeItem('castad_video_complete');
|
|
onNext(response.task_id);
|
|
}
|
|
} catch (error) {
|
|
clearInterval(interval);
|
|
console.error('Image upload failed:', error);
|
|
setUploadError(error instanceof Error ? error.message : t('assetManagement.uploadFailed'));
|
|
} finally {
|
|
setIsUploading(false);
|
|
setUploadProgress(0);
|
|
}
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
};
|
|
|
|
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 handleFileSelect = () => {
|
|
fileInputRef.current?.click();
|
|
};
|
|
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files;
|
|
if (files && files.length > 0) {
|
|
onAddImages(Array.from(files));
|
|
e.target.value = '';
|
|
}
|
|
};
|
|
|
|
const visibleImages = imageList.slice(0, displayCount);
|
|
const hasMore = imageList.length > displayCount;
|
|
|
|
return (
|
|
<main className="asset-page">
|
|
{isUploading && (
|
|
<div className="asset-upload-overlay">
|
|
<div className="asset-upload-overlay-content">
|
|
<div className="loading-spinner">
|
|
<div className="loading-ring"></div>
|
|
<div className="loading-dot">
|
|
<div className="loading-dot-inner"></div>
|
|
</div>
|
|
</div>
|
|
<p className="comp2-loading-text">{t('assetManagement.uploading')}</p>
|
|
<div className="loading-progress-wrapper">
|
|
<div className="loading-progress-bar">
|
|
<div
|
|
className="loading-progress-fill"
|
|
style={{ width: `${uploadProgress}%` }}
|
|
/>
|
|
</div>
|
|
<span className="loading-progress-text">{Math.floor(uploadProgress)}%</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Fixed Header - 뒤로가기 버튼 */}
|
|
<div className="asset-sticky-header">
|
|
{onBack && (
|
|
<button onClick={onBack} className="asset-back-btn">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="15 18 9 12 15 6"/>
|
|
</svg>
|
|
{t('assetManagement.back')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Single Scrollable Content Area */}
|
|
<div className="asset-scroll-area">
|
|
<h1 className="asset-title">{t('assetManagement.title')}</h1>
|
|
|
|
<div className="asset-container">
|
|
{/* Left Column - Selected Images */}
|
|
<div className="asset-column asset-column-left">
|
|
<div className="asset-section-header">
|
|
<div className="asset-section-header-left">
|
|
<h3 className="asset-section-title">{t('assetManagement.selectedImages')}</h3>
|
|
<span className="asset-section-subtitle">{t('assetManagement.minImages')}</span>
|
|
</div>
|
|
<button onClick={handleFileSelect} className="asset-mobile-upload-btn">
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
<line x1="8" y1="2" x2="8" y2="14" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
|
<line x1="2" y1="8" x2="14" y2="8" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
|
</svg>
|
|
{t('assetManagement.imageUpload')}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="asset-image-list">
|
|
{visibleImages.length > 0 && (
|
|
<div className="asset-image-grid">
|
|
{visibleImages.map((item, i) => (
|
|
<div key={i} className="asset-image-item">
|
|
<img
|
|
src={getImageSrc(item)}
|
|
alt={`${t('assetManagement.imageAlt')} ${i + 1}`}
|
|
referrerPolicy="no-referrer"
|
|
/>
|
|
{item.type === 'file' && (
|
|
<div className="asset-image-badge">{t('assetManagement.uploadBadge')}</div>
|
|
)}
|
|
<button
|
|
onClick={() => onRemoveImage(i)}
|
|
className="asset-image-remove"
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{hasMore && (
|
|
<button
|
|
className="asset-load-more"
|
|
onClick={() => setDisplayCount(prev => prev + IMAGES_PER_PAGE)}
|
|
>
|
|
{t('assetManagement.loadMore')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right Column - Upload and Video Ratio */}
|
|
<div className="asset-column asset-column-right">
|
|
{/* Image Upload Section (desktop only) */}
|
|
<div className="asset-upload-section">
|
|
<h3 className="asset-section-title">{t('assetManagement.imageUpload')}</h3>
|
|
<div
|
|
onClick={handleFileSelect}
|
|
onDragOver={handleDragOver}
|
|
onDrop={handleDrop}
|
|
className="asset-upload-zone"
|
|
>
|
|
<p className="asset-upload-text">
|
|
{t('assetManagement.dragAndDrop').split('\n').map((line, i) => (
|
|
<React.Fragment key={i}>{i > 0 && <br/>}{line}</React.Fragment>
|
|
))}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Video Ratio Section */}
|
|
<div className="asset-ratio-section">
|
|
<h3 className="asset-section-title">{t('assetManagement.videoRatio')}</h3>
|
|
<div className="asset-ratio-buttons">
|
|
<button
|
|
onClick={() => handleVideoRatioChange('vertical')}
|
|
className={`asset-ratio-button ${videoRatio === 'vertical' ? 'active' : ''}`}
|
|
>
|
|
<div className="asset-ratio-icon asset-ratio-icon-vertical">
|
|
<div className="asset-ratio-box"></div>
|
|
</div>
|
|
<span className="asset-ratio-label">9:16</span>
|
|
<span className="asset-ratio-subtitle">{t('assetManagement.youtubeShorts')}</span>
|
|
</button>
|
|
<button
|
|
onClick={() => handleVideoRatioChange('horizontal')}
|
|
className={`asset-ratio-button ${videoRatio === 'horizontal' ? 'active' : ''}`}
|
|
>
|
|
<div className="asset-ratio-icon asset-ratio-icon-horizontal">
|
|
<div className="asset-ratio-box"></div>
|
|
</div>
|
|
<span className="asset-ratio-label">16:9</span>
|
|
<span className="asset-ratio-subtitle">{t('assetManagement.youtubeVideo')}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Fixed Footer - 다음 단계 버튼 */}
|
|
<div className="asset-sticky-footer">
|
|
{uploadError && (
|
|
<p className="text-red-500 text-sm mb-2">{uploadError}</p>
|
|
)}
|
|
<button
|
|
onClick={handleNextWithUpload}
|
|
disabled={imageList.length === 0 || isUploading}
|
|
className="asset-next-button"
|
|
>
|
|
{isUploading ? t('assetManagement.uploading') : t('assetManagement.nextStep')}
|
|
</button>
|
|
</div>
|
|
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
onChange={handleFileChange}
|
|
className="hidden"
|
|
/>
|
|
</main>
|
|
);
|
|
};
|
|
|
|
export default AssetManagementContent;
|