브랜드 에셋 사운드 스튜디오UI 수정

feature-dashboard
김성경 2026-03-06 10:47:51 +09:00
parent cd4114f74d
commit b05c7bd080
6 changed files with 489 additions and 397 deletions

570
index.css
View File

@ -511,10 +511,24 @@
/* Bottom Button Container */
.bottom-button-container {
position: fixed;
bottom: 32px;
left: 0;
right: 0;
display: flex;
justify-content: center;
padding: 1.5rem;
flex-shrink: 0;
z-index: 30;
pointer-events: none;
}
.bottom-button-container > * {
pointer-events: all;
}
@media (min-width: 768px) {
.bottom-button-container {
left: 240px;
}
}
/* =====================================================
@ -7336,13 +7350,45 @@
Sound Studio Styles
===================================================== */
/* Sound Studio Page */
.sound-studio-page {
height: 100%;
background-color: #002224;
overflow-y: auto;
overflow-x: hidden;
padding: 80px 1rem 120px;
}
@media (min-width: 768px) {
.sound-studio-page {
padding: 80px 2rem 120px;
left: 240px;
}
}
/* Sound Studio Header */
.sound-studio-header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 64px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 2rem;
gap: 1rem;
padding: 0 1rem;
z-index: 30;
pointer-events: none;
}
.sound-studio-header > * {
pointer-events: all;
}
@media (min-width: 768px) {
.sound-studio-header {
left: 240px;
padding: 0 2rem;
}
}
.btn-back-new {
@ -7426,28 +7472,35 @@
.sound-studio-title {
font-size: 2rem;
font-weight: 700;
color: #E5F1F2;
margin: 1rem auto 1.5rem auto;
padding: 0 2rem;
max-width: 1200px;
color: #ffffff;
text-align: center;
width: 100%;
margin: 0 auto 1rem;
padding: 0;
line-height: 1.19;
letter-spacing: -0.006em;
}
/* Sound Studio Container */
.sound-studio-container {
height: 96%;
min-height: 600px;
background-color: #01393B;
border-radius: 40px;
padding: 2rem;
margin: 0 auto 1.5rem auto;
max-width: 1136px;
width: calc(100% - 4rem);
border-radius: 24px;
padding: 1.25rem 1rem;
margin: 0 auto;
max-width: 1440px;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.25rem;
flex: 1;
min-height: 0;
}
@media (min-width: 768px) {
.sound-studio-container {
border-radius: 40px;
padding: 2rem;
}
}
/* Sound Studio Columns */
@ -7466,10 +7519,15 @@
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.25rem;
flex-shrink: 0;
}
.sound-column > *:not(.btn-generate-sound):not(.error-message-new):not(.status-message-new) {
width: 100%;
}
/* Lyrics Column */
.lyrics-column {
width: 100%;
@ -7794,12 +7852,12 @@
background-color: #002224;
border-radius: 8px;
padding: 1rem;
min-height: 200px;
overflow: hidden;
}
.lyrics-textarea {
width: 100%;
height: 200px;
background: transparent;
border: none;
color: var(--color-text-white);
@ -7825,12 +7883,13 @@
/* Generate Sound Button */
.btn-generate-sound {
width: 100%;
padding: 0.625rem 2.5rem;
background-color: #01393B;
border: 1px solid var(--color-mint);
height: 48px;
min-width: 120px;
padding: 0.625rem 1.25rem;
background-color: #94FBE0;
border: none;
border-radius: var(--radius-full);
color: var(--color-mint);
color: #000000;
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
@ -7844,8 +7903,7 @@
}
.btn-generate-sound:hover:not(.disabled) {
background-color: var(--color-mint);
color: #000000;
background-color: #6ef5ca;
}
.btn-generate-sound.disabled {
@ -7942,27 +8000,10 @@
/* Responsive Styles for Sound Studio */
@media (max-width: 639px) {
.sound-studio-header {
padding: 0.5rem 1rem;
flex-direction: column;
align-items: stretch;
}
.progress-indicator {
width: 100%;
}
.sound-studio-title {
font-size: 1.5rem;
padding: 0 1rem;
margin: 0.75rem auto 1rem auto;
}
.sound-studio-container {
padding: 1.25rem;
width: calc(100% - 2rem);
}
.sound-type-grid {
gap: 0.5rem;
}
@ -7980,28 +8021,9 @@
padding: 0.5rem 0.75rem;
font-size: var(--text-xs);
}
.lyrics-display {
min-height: 150px;
}
}
@media (min-width: 640px) and (max-width: 767px) {
.sound-studio-header {
padding: 0.5rem 1.5rem;
}
.sound-studio-title {
font-size: 1.75rem;
padding: 0 1.5rem;
margin: 0.875rem auto 1.25rem auto;
}
.sound-studio-container {
padding: 1.5rem;
width: calc(100% - 3rem);
}
.sound-studio-columns {
gap: 2rem;
}
@ -8014,21 +8036,15 @@
}
@media (min-width: 1024px) {
.sound-studio-container {
padding: 2.5rem;
}
.sound-studio-columns {
flex-direction: row;
gap: 3rem;
overflow: hidden;
}
.sound-column {
flex: 1;
width: auto;
min-height: 0;
overflow-y: auto;
}
.lyrics-column {
@ -8046,81 +8062,113 @@
}
}
/* Height-based responsive adjustments */
@media (max-height: 800px) {
.sound-studio-container {
gap: 1rem;
}
.sound-studio-columns {
gap: 2rem;
}
}
@media (max-height: 700px) {
.sound-studio-title {
font-size: 1.75rem;
margin: 0.75rem 2rem 1rem 2rem;
}
.sound-studio-container {
padding: 1.5rem;
gap: 1rem;
}
.sound-studio-columns {
gap: 1.5rem;
}
.sound-studio-section {
gap: 0.5rem;
}
.lyrics-display {
min-height: 180px;
}
}
@media (max-height: 600px) {
.sound-studio-header {
padding: 0.375rem 2rem;
}
.sound-studio-title {
font-size: 1.5rem;
margin: 0.5rem 2rem 0.75rem 2rem;
}
.sound-studio-container {
padding: 1.25rem;
gap: 0.875rem;
}
.sound-studio-columns {
gap: 1.25rem;
}
.sound-type-btn,
.genre-btn {
padding: 0.625rem 0.75rem;
}
.lyrics-display {
min-height: 150px;
}
}
/* ====================================
Asset Management (Brand Asset) Styles
==================================== */
/* Asset Header */
.asset-header {
/* Asset Page Layout */
.asset-page {
height: 100%;
background-color: #002224;
position: relative;
}
/* Fixed Header - 뒤로가기 */
.asset-sticky-header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 64px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 2rem;
margin-bottom: 1rem;
padding: 0 1rem;
z-index: 30;
pointer-events: none;
}
.asset-sticky-header > * {
pointer-events: all;
}
@media (min-width: 768px) {
.asset-sticky-header {
left: 240px;
padding: 0 2rem;
}
}
/* 뒤로가기 버튼 */
.asset-back-btn {
display: flex;
align-items: center;
gap: 4px;
background-color: #462E64;
color: #CFABFB;
font-size: 0.875rem;
font-weight: 600;
padding: 0 1.25rem 0 0.5rem;
height: 36px;
border: 1px solid #694596;
border-radius: 999px;
cursor: pointer;
white-space: nowrap;
transition: background-color 0.2s;
line-height: 1.19;
letter-spacing: -0.006em;
}
.asset-back-btn:hover {
background-color: #5a3a80;
}
/* Single Scroll Area */
.asset-scroll-area {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding: 80px 2rem 120px;
}
.asset-scroll-area::-webkit-scrollbar {
width: 6px;
}
.asset-scroll-area::-webkit-scrollbar-track {
background: transparent;
}
.asset-scroll-area::-webkit-scrollbar-thumb {
background: #379599;
border-radius: 3px;
}
.asset-scroll-area::-webkit-scrollbar-thumb:hover {
background: #4AABAF;
}
/* Fixed Footer - 다음 단계 */
.asset-sticky-footer {
position: fixed;
bottom: 32px;
left: 0;
right: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
z-index: 30;
pointer-events: none;
}
.asset-sticky-footer > * {
pointer-events: all;
}
@media (min-width: 768px) {
.asset-sticky-footer {
left: 240px;
}
}
/* Asset Title */
@ -8128,35 +8176,26 @@
font-size: 2rem;
font-weight: 700;
color: #E5F1F2;
margin: 1rem auto 1.5rem auto;
padding: 0 2rem;
max-width: 1200px;
width: 100%;
padding: 0 0 1.5rem;
text-align: center;
line-height: 1.19;
letter-spacing: -0.006em;
margin: 0;
}
/* Asset Container */
.asset-container {
background-color: #01393B;
border-radius: 40px;
padding: 2rem;
margin: 0 auto 1.5rem auto;
margin: 0 auto;
max-width: 1136px;
width: calc(100% - 4rem);
width: 100%;
display: flex;
flex-direction: column;
gap: 1.5rem;
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
gap: 1rem;
}
@media (min-width: 1024px) {
.asset-container {
flex-direction: row;
overflow: hidden;
}
}
@ -8164,18 +8203,24 @@
.asset-column {
display: flex;
flex-direction: column;
gap: 1.25rem;
gap: 1rem;
}
.asset-column-left {
width: 100%;
min-width: 0;
flex-shrink: 0;
background-color: #01393B;
border-radius: 24px;
padding: 1.25rem 1rem;
}
.asset-column-right {
width: 100%;
flex-shrink: 0;
gap: 1rem;
display: flex;
flex-direction: column;
}
@media (min-width: 1024px) {
@ -8183,6 +8228,8 @@
flex: 1;
min-height: 0;
overflow: hidden;
border-radius: 40px;
padding: 2rem;
}
.asset-column-right {
@ -8190,50 +8237,91 @@
}
}
/* Asset Section Header */
.asset-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-shrink: 0;
}
.asset-section-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.asset-section-subtitle {
font-size: 0.875rem;
font-weight: 400;
color: #6AB0B3;
line-height: 1.19;
letter-spacing: -0.006em;
}
/* Asset Section Title */
.asset-section-title {
font-size: 1.125rem;
font-size: 1.5rem;
font-weight: 600;
color: #94FBE0;
line-height: 1.19;
letter-spacing: -0.006em;
letter-spacing: -0.009em;
margin: 0;
}
/* Mobile Upload Button (visible only on mobile/tablet) */
.asset-mobile-upload-btn {
display: flex;
align-items: center;
gap: 4px;
background-color: #94FBE0;
color: #000;
font-size: 0.75rem;
font-weight: 600;
padding: 0 10px;
height: 24px;
border: none;
border-radius: 999px;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
@media (min-width: 1024px) {
.asset-mobile-upload-btn {
display: none;
}
}
/* Asset Image List */
.asset-image-list {
min-height: 120px;
max-height: 200px;
overflow-y: auto;
overscroll-behavior: contain;
background-color: #002224;
border-radius: 8px;
padding: 0.5rem;
max-height: 1080px;
overflow-y: auto;
}
@media (min-width: 1024px) {
.asset-image-list {
flex: 1;
min-height: 0;
max-height: none;
}
/* Load More Button */
.asset-load-more {
width: 100%;
padding: 0.625rem 1rem;
background-color: #002224;
color: #9BCACC;
font-size: 0.875rem;
font-weight: 600;
border: 1px solid #379599;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s;
line-height: 1.19;
letter-spacing: -0.006em;
}
.asset-image-list::-webkit-scrollbar {
width: 6px;
}
.asset-image-list::-webkit-scrollbar-track {
background: transparent;
}
.asset-image-list::-webkit-scrollbar-thumb {
background: #379599;
border-radius: 3px;
}
.asset-image-list::-webkit-scrollbar-thumb:hover {
background: #4AABAF;
.asset-load-more:hover {
background-color: #003A3C;
border-color: #4AABAF;
}
/* Asset Image Grid */
@ -8294,10 +8382,19 @@
/* Asset Upload Section */
.asset-upload-section {
display: flex;
display: none;
flex-direction: column;
gap: 1.25rem;
gap: 1rem;
flex: 1;
background-color: #01393B;
border-radius: 40px;
padding: 2rem;
}
@media (min-width: 1024px) {
.asset-upload-section {
display: flex;
}
}
.asset-upload-zone {
@ -8332,20 +8429,31 @@
.asset-ratio-section {
display: flex;
flex-direction: column;
gap: 1.25rem;
gap: 1rem;
background-color: #01393B;
border-radius: 24px;
padding: 1.25rem 1rem;
}
@media (min-width: 1024px) {
.asset-ratio-section {
border-radius: 40px;
padding: 2rem;
}
}
.asset-ratio-buttons {
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: 0.5rem;
}
.asset-ratio-button {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
padding: 0 1rem;
height: 48px;
background-color: #002224;
border: 1px solid transparent;
border-radius: 8px;
@ -8356,6 +8464,7 @@
font-weight: 600;
line-height: 1.19;
letter-spacing: -0.006em;
width: 100%;
}
.asset-ratio-button:hover {
@ -8367,6 +8476,17 @@
background-color: #002224;
}
.asset-ratio-label {
font-weight: 600;
color: #E5F1F2;
}
.asset-ratio-subtitle {
font-size: 0.875rem;
font-weight: 400;
color: #9BCACC;
}
.asset-ratio-icon {
width: 32px;
height: 32px;
@ -8392,14 +8512,7 @@
border-radius: 4px;
}
/* Asset Bottom Button */
.asset-bottom {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 0 2rem 1.5rem 2rem;
}
/* Asset Next Button */
.asset-next-button {
background-color: #AE72F9;
@ -8426,83 +8539,36 @@
/* Responsive Styles */
@media (max-width: 639px) {
.asset-header {
padding: 0.5rem 1rem;
}
.asset-title {
font-size: 1.5rem;
padding: 0 1rem;
margin: 0.75rem auto 1rem auto;
padding-bottom: 1rem;
}
.asset-container {
padding: 1.25rem;
width: calc(100% - 2rem);
gap: 1.25rem;
.asset-scroll-area {
padding: 80px 1rem 120px;
}
.asset-image-grid {
grid-template-columns: repeat(2, 1fr);
}
.asset-bottom {
padding: 0 1rem 1rem 1rem;
}
}
@media (min-width: 640px) and (max-width: 767px) {
.asset-header {
padding: 0.5rem 1.5rem;
}
.asset-title {
font-size: 1.75rem;
padding: 0 1.5rem;
margin: 0.875rem auto 1.25rem auto;
padding-bottom: 1.25rem;
}
.asset-container {
padding: 1.5rem;
width: calc(100% - 3rem);
}
.asset-bottom {
padding: 0 1.5rem 1.25rem 1.5rem;
}
}
@media (min-width: 768px) and (max-width: 1023px) {
.asset-container {
padding: 1.75rem;
}
}
@media (min-width: 1024px) {
.asset-container {
padding: 2.5rem;
.asset-scroll-area {
padding: 80px 1.5rem 120px;
}
}
/* Height-based responsive adjustments */
@media (max-height: 800px) {
.asset-container {
padding: 1.75rem;
}
.asset-upload-zone {
min-height: 120px;
}
}
@media (max-height: 700px) {
.asset-container {
padding: 1.5rem;
}
.asset-title {
font-size: 1.75rem;
margin: 0.75rem auto 1rem auto;
padding-bottom: 0.75rem;
}
.asset-upload-zone {
@ -8511,20 +8577,6 @@
}
@media (max-height: 600px) {
.asset-container {
padding: 1.25rem;
gap: 0.875rem;
}
.asset-column {
gap: 1rem;
}
.asset-upload-section,
.asset-ratio-section {
gap: 1rem;
}
.asset-upload-zone {
min-height: 80px;
}

View File

@ -129,6 +129,11 @@
"imageUpload": "Image Upload",
"dragAndDrop": "Drag and drop\nimages to upload",
"videoRatio": "Video Ratio",
"minImages": "Min. 5 images",
"youtubeShorts": "YouTube Shorts",
"youtubeVideo": "YouTube Video",
"back": "Go Back",
"loadMore": "Load more",
"uploadFailed": "Image upload failed.",
"uploading": "Uploading...",
"nextStep": "Next Step"

View File

@ -129,6 +129,11 @@
"imageUpload": "이미지 업로드",
"dragAndDrop": "이미지를 드래그하여\n업로드",
"videoRatio": "영상 비율",
"minImages": "최소 5장",
"youtubeShorts": "유튜브 쇼츠",
"youtubeVideo": "유튜브 일반",
"back": "뒤로가기",
"loadMore": "더보기",
"uploadFailed": "이미지 업로드에 실패했습니다.",
"uploading": "업로드 중...",
"nextStep": "다음 단계"

View File

@ -6,6 +6,7 @@ import { uploadImages } from '../../utils/api';
interface AssetManagementContentProps {
onNext: (imageTaskId: string) => void;
onBack?: () => void;
imageList: ImageItem[];
onRemoveImage: (index: number) => void;
onAddImages: (files: File[]) => void;
@ -13,20 +14,22 @@ interface AssetManagementContentProps {
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 imageListRef = useRef<HTMLDivElement>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [videoRatio, setVideoRatio] = useState<VideoRatio>('vertical');
const [displayCount, setDisplayCount] = useState(IMAGES_PER_PAGE);
// Load video ratio from localStorage on mount
useEffect(() => {
const savedRatio = localStorage.getItem('castad_video_ratio') as VideoRatio;
if (savedRatio === 'vertical' || savedRatio === 'horizontal') {
@ -34,7 +37,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
}
}, []);
// Save video ratio to localStorage when it changes
const handleVideoRatioChange = (ratio: VideoRatio) => {
setVideoRatio(ratio);
localStorage.setItem('castad_video_ratio', ratio);
@ -51,7 +53,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
setUploadError(null);
try {
// URL 이미지와 파일 이미지 분리
const urlImages: ImageUrlItem[] = imageList
.filter((item: ImageItem): item is ImageItem & { type: 'url' } => item.type === 'url')
.map((item) => ({ url: item.url }));
@ -60,7 +61,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
.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);
onNext(response.task_id);
@ -73,11 +73,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
}
};
const handleImageListWheel = (e: React.WheelEvent) => {
// 이 영역 안에서는 항상 스크롤 이벤트 전파 차단
e.stopPropagation();
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
@ -86,14 +81,10 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const files = Array.from(e.dataTransfer.files).filter(file =>
const files = Array.from(e.dataTransfer.files).filter((file: File) =>
file.type.startsWith('image/')
);
if (files.length > 0) {
onAddImages(files);
}
if (files.length > 0) onAddImages(files);
};
const handleFileSelect = () => {
@ -108,104 +99,132 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
}
};
const visibleImages = imageList.slice(0, displayCount);
const hasMore = imageList.length > displayCount;
return (
<main className="page-container">
{/* Title */}
<h1 className="asset-title">{t('assetManagement.title')}</h1>
<main className="asset-page">
{/* 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>
{/* Main Content Container */}
<div className="asset-container">
{/* Left Column - Selected Images */}
<div className="asset-column asset-column-left">
<h3 className="asset-section-title">{t('assetManagement.selectedImages')}</h3>
<div
ref={imageListRef}
onWheel={handleImageListWheel}
className="asset-image-list"
>
{imageList.length > 0 ? (
<div className="asset-image-grid">
{imageList.map((item, i) => (
<div key={i} className="asset-image-item">
<img
src={getImageSrc(item)}
alt={`${t('assetManagement.imageAlt')} ${i + 1}`}
/>
{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>
))}
{/* 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>
) : null}
</div>
</div>
{/* Right Column - Upload and Video Ratio */}
<div className="asset-column asset-column-right">
{/* Image Upload Section */}
<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>
<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>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={handleFileChange}
className="hidden"
/>
<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}`}
/>
{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>
{/* 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' : ''}`}
{/* 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"
>
<div className="asset-ratio-icon asset-ratio-icon-vertical">
<div className="asset-ratio-box"></div>
</div>
<span>9:16</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>16:9</span>
</button>
<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>
{/* Bottom Button */}
<div className="asset-bottom">
{/* Fixed Footer - 다음 단계 버튼 */}
<div className="asset-sticky-footer">
{uploadError && (
<p className="text-red-500 text-sm mb-2">{uploadError}</p>
)}
@ -217,6 +236,15 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
{isUploading ? t('assetManagement.uploading') : t('assetManagement.nextStep')}
</button>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={handleFileChange}
className="hidden"
/>
</main>
);
};

View File

@ -363,6 +363,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
case 1:
return (
<AssetManagementContent
onBack={() => goToWizardStep(0)}
onNext={(taskId: string) => {
// Clear video generation state to start fresh
localStorage.removeItem(VIDEO_GENERATION_KEY);

View File

@ -386,7 +386,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling';
return (
<main className="page-container">
<main className="sound-studio-page">
{audioUrl && (
<audio ref={audioRef} src={audioUrl} preload="metadata" />
)}
@ -517,6 +517,41 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
)}
</div>
</div>
{/* Generate Button */}
<button
onClick={handleGenerateMusic}
disabled={isGenerating}
className={`btn-generate-sound ${isGenerating ? 'disabled' : ''}`}
>
{isGenerating ? (
<>
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
{t('soundStudio.generating')}
</>
) : (
t('soundStudio.generateButton')
)}
</button>
{errorMessage && (
<div className="error-message-new">
{errorMessage}
</div>
)}
{isGenerating && statusMessage && (
<div className="status-message-new">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
{statusMessage}
</div>
)}
</div>
{/* Right Column - Lyrics */}
@ -578,41 +613,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
</div>
</div>
</div>
{/* Generate Button */}
<button
onClick={handleGenerateMusic}
disabled={isGenerating}
className={`btn-generate-sound ${isGenerating ? 'disabled' : ''}`}
>
{isGenerating ? (
<>
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
{t('soundStudio.generating')}
</>
) : (
t('soundStudio.generateButton')
)}
</button>
{errorMessage && (
<div className="error-message-new">
{errorMessage}
</div>
)}
{isGenerating && statusMessage && (
<div className="status-message-new">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
{statusMessage}
</div>
)}
</div>
{/* Bottom Button */}
@ -653,3 +653,4 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
};
export default SoundStudioContent;