브랜드 에셋 사운드 스튜디오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 */
.bottom-button-container { .bottom-button-container {
position: fixed;
bottom: 32px;
left: 0;
right: 0;
display: flex; display: flex;
justify-content: center; justify-content: center;
padding: 1.5rem; z-index: 30;
flex-shrink: 0; 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 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 */
.sound-studio-header { .sound-studio-header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 64px;
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
padding: 0.5rem 2rem; padding: 0 1rem;
gap: 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 { .btn-back-new {
@ -7426,28 +7472,35 @@
.sound-studio-title { .sound-studio-title {
font-size: 2rem; font-size: 2rem;
font-weight: 700; font-weight: 700;
color: #E5F1F2; color: #ffffff;
margin: 1rem auto 1.5rem auto; text-align: center;
padding: 0 2rem;
max-width: 1200px;
width: 100%; width: 100%;
margin: 0 auto 1rem;
padding: 0;
line-height: 1.19; line-height: 1.19;
letter-spacing: -0.006em; letter-spacing: -0.006em;
} }
/* Sound Studio Container */ /* Sound Studio Container */
.sound-studio-container { .sound-studio-container {
height: 96%;
min-height: 600px;
background-color: #01393B; background-color: #01393B;
border-radius: 40px; border-radius: 24px;
padding: 2rem; padding: 1.25rem 1rem;
margin: 0 auto 1.5rem auto; margin: 0 auto;
max-width: 1136px; max-width: 1440px;
width: calc(100% - 4rem); width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.25rem; gap: 1.25rem;
flex: 1; }
min-height: 0;
@media (min-width: 768px) {
.sound-studio-container {
border-radius: 40px;
padding: 2rem;
}
} }
/* Sound Studio Columns */ /* Sound Studio Columns */
@ -7466,10 +7519,15 @@
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
gap: 1.25rem; gap: 1.25rem;
flex-shrink: 0; flex-shrink: 0;
} }
.sound-column > *:not(.btn-generate-sound):not(.error-message-new):not(.status-message-new) {
width: 100%;
}
/* Lyrics Column */ /* Lyrics Column */
.lyrics-column { .lyrics-column {
width: 100%; width: 100%;
@ -7794,12 +7852,12 @@
background-color: #002224; background-color: #002224;
border-radius: 8px; border-radius: 8px;
padding: 1rem; padding: 1rem;
min-height: 200px;
overflow: hidden; overflow: hidden;
} }
.lyrics-textarea { .lyrics-textarea {
width: 100%; width: 100%;
height: 200px;
background: transparent; background: transparent;
border: none; border: none;
color: var(--color-text-white); color: var(--color-text-white);
@ -7825,12 +7883,13 @@
/* Generate Sound Button */ /* Generate Sound Button */
.btn-generate-sound { .btn-generate-sound {
width: 100%; height: 48px;
padding: 0.625rem 2.5rem; min-width: 120px;
background-color: #01393B; padding: 0.625rem 1.25rem;
border: 1px solid var(--color-mint); background-color: #94FBE0;
border: none;
border-radius: var(--radius-full); border-radius: var(--radius-full);
color: var(--color-mint); color: #000000;
font-size: var(--text-sm); font-size: var(--text-sm);
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
@ -7844,8 +7903,7 @@
} }
.btn-generate-sound:hover:not(.disabled) { .btn-generate-sound:hover:not(.disabled) {
background-color: var(--color-mint); background-color: #6ef5ca;
color: #000000;
} }
.btn-generate-sound.disabled { .btn-generate-sound.disabled {
@ -7942,27 +8000,10 @@
/* Responsive Styles for Sound Studio */ /* Responsive Styles for Sound Studio */
@media (max-width: 639px) { @media (max-width: 639px) {
.sound-studio-header {
padding: 0.5rem 1rem;
flex-direction: column;
align-items: stretch;
}
.progress-indicator { .progress-indicator {
width: 100%; 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 { .sound-type-grid {
gap: 0.5rem; gap: 0.5rem;
} }
@ -7980,28 +8021,9 @@
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
font-size: var(--text-xs); font-size: var(--text-xs);
} }
.lyrics-display {
min-height: 150px;
}
} }
@media (min-width: 640px) and (max-width: 767px) { @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 { .sound-studio-columns {
gap: 2rem; gap: 2rem;
} }
@ -8014,21 +8036,15 @@
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
.sound-studio-container {
padding: 2.5rem;
}
.sound-studio-columns { .sound-studio-columns {
flex-direction: row; flex-direction: row;
gap: 3rem; gap: 3rem;
overflow: hidden;
} }
.sound-column { .sound-column {
flex: 1; flex: 1;
width: auto; width: auto;
min-height: 0; min-height: 0;
overflow-y: auto;
} }
.lyrics-column { .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 Management (Brand Asset) Styles
==================================== */ ==================================== */
/* Asset Header */ /* Asset Page Layout */
.asset-header { .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; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
padding: 0.5rem 2rem; padding: 0 1rem;
margin-bottom: 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 */ /* Asset Title */
@ -8128,35 +8176,26 @@
font-size: 2rem; font-size: 2rem;
font-weight: 700; font-weight: 700;
color: #E5F1F2; color: #E5F1F2;
margin: 1rem auto 1.5rem auto; padding: 0 0 1.5rem;
padding: 0 2rem; text-align: center;
max-width: 1200px;
width: 100%;
line-height: 1.19; line-height: 1.19;
letter-spacing: -0.006em; letter-spacing: -0.006em;
margin: 0;
} }
/* Asset Container */ /* Asset Container */
.asset-container { .asset-container {
background-color: #01393B; margin: 0 auto;
border-radius: 40px;
padding: 2rem;
margin: 0 auto 1.5rem auto;
max-width: 1136px; max-width: 1136px;
width: calc(100% - 4rem); width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: 1rem;
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
.asset-container { .asset-container {
flex-direction: row; flex-direction: row;
overflow: hidden;
} }
} }
@ -8164,18 +8203,24 @@
.asset-column { .asset-column {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.25rem; gap: 1rem;
} }
.asset-column-left { .asset-column-left {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
flex-shrink: 0; flex-shrink: 0;
background-color: #01393B;
border-radius: 24px;
padding: 1.25rem 1rem;
} }
.asset-column-right { .asset-column-right {
width: 100%; width: 100%;
flex-shrink: 0; flex-shrink: 0;
gap: 1rem;
display: flex;
flex-direction: column;
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
@ -8183,6 +8228,8 @@
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
border-radius: 40px;
padding: 2rem;
} }
.asset-column-right { .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 */
.asset-section-title { .asset-section-title {
font-size: 1.125rem; font-size: 1.5rem;
font-weight: 600; font-weight: 600;
color: #94FBE0; color: #94FBE0;
line-height: 1.19; line-height: 1.19;
letter-spacing: -0.006em; letter-spacing: -0.009em;
margin: 0; 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 */
.asset-image-list { .asset-image-list {
min-height: 120px;
max-height: 200px;
overflow-y: auto;
overscroll-behavior: contain;
background-color: #002224; background-color: #002224;
border-radius: 8px; border-radius: 8px;
padding: 0.5rem; padding: 0.5rem;
max-height: 1080px;
overflow-y: auto;
} }
@media (min-width: 1024px) { /* Load More Button */
.asset-image-list { .asset-load-more {
flex: 1; width: 100%;
min-height: 0; padding: 0.625rem 1rem;
max-height: none; 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 { .asset-load-more:hover {
width: 6px; background-color: #003A3C;
} border-color: #4AABAF;
.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 Image Grid */ /* Asset Image Grid */
@ -8294,10 +8382,19 @@
/* Asset Upload Section */ /* Asset Upload Section */
.asset-upload-section { .asset-upload-section {
display: flex; display: none;
flex-direction: column; flex-direction: column;
gap: 1.25rem; gap: 1rem;
flex: 1; flex: 1;
background-color: #01393B;
border-radius: 40px;
padding: 2rem;
}
@media (min-width: 1024px) {
.asset-upload-section {
display: flex;
}
} }
.asset-upload-zone { .asset-upload-zone {
@ -8332,20 +8429,31 @@
.asset-ratio-section { .asset-ratio-section {
display: flex; display: flex;
flex-direction: column; 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 { .asset-ratio-buttons {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.5rem;
} }
.asset-ratio-button { .asset-ratio-button {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
padding: 1rem; padding: 0 1rem;
height: 48px;
background-color: #002224; background-color: #002224;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 8px; border-radius: 8px;
@ -8356,6 +8464,7 @@
font-weight: 600; font-weight: 600;
line-height: 1.19; line-height: 1.19;
letter-spacing: -0.006em; letter-spacing: -0.006em;
width: 100%;
} }
.asset-ratio-button:hover { .asset-ratio-button:hover {
@ -8367,6 +8476,17 @@
background-color: #002224; 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 { .asset-ratio-icon {
width: 32px; width: 32px;
height: 32px; height: 32px;
@ -8392,14 +8512,7 @@
border-radius: 4px; border-radius: 4px;
} }
/* Asset Bottom Button */ /* Asset Next 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; background-color: #AE72F9;
@ -8426,83 +8539,36 @@
/* Responsive Styles */ /* Responsive Styles */
@media (max-width: 639px) { @media (max-width: 639px) {
.asset-header {
padding: 0.5rem 1rem;
}
.asset-title { .asset-title {
font-size: 1.5rem; font-size: 1.5rem;
padding: 0 1rem; padding-bottom: 1rem;
margin: 0.75rem auto 1rem auto;
} }
.asset-container { .asset-scroll-area {
padding: 1.25rem; padding: 80px 1rem 120px;
width: calc(100% - 2rem);
gap: 1.25rem;
} }
.asset-image-grid { .asset-image-grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.asset-bottom {
padding: 0 1rem 1rem 1rem;
}
} }
@media (min-width: 640px) and (max-width: 767px) { @media (min-width: 640px) and (max-width: 767px) {
.asset-header {
padding: 0.5rem 1.5rem;
}
.asset-title { .asset-title {
font-size: 1.75rem; font-size: 1.75rem;
padding: 0 1.5rem; padding-bottom: 1.25rem;
margin: 0.875rem auto 1.25rem auto;
} }
.asset-container { .asset-scroll-area {
padding: 1.5rem; padding: 80px 1.5rem 120px;
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;
} }
} }
/* Height-based responsive adjustments */ /* Height-based responsive adjustments */
@media (max-height: 800px) {
.asset-container {
padding: 1.75rem;
}
.asset-upload-zone {
min-height: 120px;
}
}
@media (max-height: 700px) { @media (max-height: 700px) {
.asset-container {
padding: 1.5rem;
}
.asset-title { .asset-title {
font-size: 1.75rem; font-size: 1.75rem;
margin: 0.75rem auto 1rem auto; padding-bottom: 0.75rem;
} }
.asset-upload-zone { .asset-upload-zone {
@ -8511,20 +8577,6 @@
} }
@media (max-height: 600px) { @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 { .asset-upload-zone {
min-height: 80px; min-height: 80px;
} }

View File

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

View File

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

View File

@ -6,6 +6,7 @@ import { uploadImages } from '../../utils/api';
interface AssetManagementContentProps { interface AssetManagementContentProps {
onNext: (imageTaskId: string) => void; onNext: (imageTaskId: string) => void;
onBack?: () => void;
imageList: ImageItem[]; imageList: ImageItem[];
onRemoveImage: (index: number) => void; onRemoveImage: (index: number) => void;
onAddImages: (files: File[]) => void; onAddImages: (files: File[]) => void;
@ -13,20 +14,22 @@ interface AssetManagementContentProps {
type VideoRatio = 'vertical' | 'horizontal'; type VideoRatio = 'vertical' | 'horizontal';
const IMAGES_PER_PAGE = 12;
const AssetManagementContent: React.FC<AssetManagementContentProps> = ({ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
onNext, onNext,
onBack,
imageList, imageList,
onRemoveImage, onRemoveImage,
onAddImages, onAddImages,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const imageListRef = useRef<HTMLDivElement>(null);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null); const [uploadError, setUploadError] = useState<string | null>(null);
const [videoRatio, setVideoRatio] = useState<VideoRatio>('vertical'); const [videoRatio, setVideoRatio] = useState<VideoRatio>('vertical');
const [displayCount, setDisplayCount] = useState(IMAGES_PER_PAGE);
// Load video ratio from localStorage on mount
useEffect(() => { useEffect(() => {
const savedRatio = localStorage.getItem('castad_video_ratio') as VideoRatio; const savedRatio = localStorage.getItem('castad_video_ratio') as VideoRatio;
if (savedRatio === 'vertical' || savedRatio === 'horizontal') { 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) => { const handleVideoRatioChange = (ratio: VideoRatio) => {
setVideoRatio(ratio); setVideoRatio(ratio);
localStorage.setItem('castad_video_ratio', ratio); localStorage.setItem('castad_video_ratio', ratio);
@ -51,7 +53,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
setUploadError(null); setUploadError(null);
try { try {
// URL 이미지와 파일 이미지 분리
const urlImages: ImageUrlItem[] = imageList const urlImages: ImageUrlItem[] = imageList
.filter((item: ImageItem): item is ImageItem & { type: 'url' } => item.type === 'url') .filter((item: ImageItem): item is ImageItem & { type: 'url' } => item.type === 'url')
.map((item) => ({ url: item.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') .filter((item: ImageItem): item is ImageItem & { type: 'file' } => item.type === 'file')
.map((item) => item.file); .map((item) => item.file);
// 이미지가 하나라도 있으면 업로드
if (urlImages.length > 0 || fileImages.length > 0) { if (urlImages.length > 0 || fileImages.length > 0) {
const response = await uploadImages(urlImages, fileImages); const response = await uploadImages(urlImages, fileImages);
onNext(response.task_id); 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) => { const handleDragOver = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -86,14 +81,10 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
const handleDrop = (e: React.DragEvent) => { const handleDrop = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const files = Array.from(e.dataTransfer.files).filter((file: File) =>
const files = Array.from(e.dataTransfer.files).filter(file =>
file.type.startsWith('image/') file.type.startsWith('image/')
); );
if (files.length > 0) onAddImages(files);
if (files.length > 0) {
onAddImages(files);
}
}; };
const handleFileSelect = () => { const handleFileSelect = () => {
@ -108,24 +99,48 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
} }
}; };
const visibleImages = imageList.slice(0, displayCount);
const hasMore = imageList.length > displayCount;
return ( return (
<main className="page-container"> <main className="asset-page">
{/* Title */} {/* 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> <h1 className="asset-title">{t('assetManagement.title')}</h1>
{/* Main Content Container */}
<div className="asset-container"> <div className="asset-container">
{/* Left Column - Selected Images */} {/* Left Column - Selected Images */}
<div className="asset-column asset-column-left"> <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> <h3 className="asset-section-title">{t('assetManagement.selectedImages')}</h3>
<div <span className="asset-section-subtitle">{t('assetManagement.minImages')}</span>
ref={imageListRef} </div>
onWheel={handleImageListWheel} <button onClick={handleFileSelect} className="asset-mobile-upload-btn">
className="asset-image-list" <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"/>
{imageList.length > 0 ? ( <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"> <div className="asset-image-grid">
{imageList.map((item, i) => ( {visibleImages.map((item, i) => (
<div key={i} className="asset-image-item"> <div key={i} className="asset-image-item">
<img <img
src={getImageSrc(item)} src={getImageSrc(item)}
@ -146,13 +161,22 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
</div> </div>
))} ))}
</div> </div>
) : null} )}
</div> </div>
{hasMore && (
<button
className="asset-load-more"
onClick={() => setDisplayCount(prev => prev + IMAGES_PER_PAGE)}
>
{t('assetManagement.loadMore')}
</button>
)}
</div> </div>
{/* Right Column - Upload and Video Ratio */} {/* Right Column - Upload and Video Ratio */}
<div className="asset-column asset-column-right"> <div className="asset-column asset-column-right">
{/* Image Upload Section */} {/* Image Upload Section (desktop only) */}
<div className="asset-upload-section"> <div className="asset-upload-section">
<h3 className="asset-section-title">{t('assetManagement.imageUpload')}</h3> <h3 className="asset-section-title">{t('assetManagement.imageUpload')}</h3>
<div <div
@ -167,14 +191,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
))} ))}
</p> </p>
</div> </div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={handleFileChange}
className="hidden"
/>
</div> </div>
{/* Video Ratio Section */} {/* Video Ratio Section */}
@ -188,7 +204,8 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
<div className="asset-ratio-icon asset-ratio-icon-vertical"> <div className="asset-ratio-icon asset-ratio-icon-vertical">
<div className="asset-ratio-box"></div> <div className="asset-ratio-box"></div>
</div> </div>
<span>9:16</span> <span className="asset-ratio-label">9:16</span>
<span className="asset-ratio-subtitle">{t('assetManagement.youtubeShorts')}</span>
</button> </button>
<button <button
onClick={() => handleVideoRatioChange('horizontal')} onClick={() => handleVideoRatioChange('horizontal')}
@ -197,15 +214,17 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
<div className="asset-ratio-icon asset-ratio-icon-horizontal"> <div className="asset-ratio-icon asset-ratio-icon-horizontal">
<div className="asset-ratio-box"></div> <div className="asset-ratio-box"></div>
</div> </div>
<span>16:9</span> <span className="asset-ratio-label">16:9</span>
<span className="asset-ratio-subtitle">{t('assetManagement.youtubeVideo')}</span>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
{/* Bottom Button */} {/* Fixed Footer - 다음 단계 버튼 */}
<div className="asset-bottom"> <div className="asset-sticky-footer">
{uploadError && ( {uploadError && (
<p className="text-red-500 text-sm mb-2">{uploadError}</p> <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')} {isUploading ? t('assetManagement.uploading') : t('assetManagement.nextStep')}
</button> </button>
</div> </div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={handleFileChange}
className="hidden"
/>
</main> </main>
); );
}; };

View File

@ -363,6 +363,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
case 1: case 1:
return ( return (
<AssetManagementContent <AssetManagementContent
onBack={() => goToWizardStep(0)}
onNext={(taskId: string) => { onNext={(taskId: string) => {
// Clear video generation state to start fresh // Clear video generation state to start fresh
localStorage.removeItem(VIDEO_GENERATION_KEY); 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'; const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling';
return ( return (
<main className="page-container"> <main className="sound-studio-page">
{audioUrl && ( {audioUrl && (
<audio ref={audioRef} src={audioUrl} preload="metadata" /> <audio ref={audioRef} src={audioUrl} preload="metadata" />
)} )}
@ -517,6 +517,41 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
)} )}
</div> </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> </div>
{/* Right Column - Lyrics */} {/* Right Column - Lyrics */}
@ -578,41 +613,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
</div> </div>
</div> </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> </div>
{/* Bottom Button */} {/* Bottom Button */}
@ -653,3 +653,4 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
}; };
export default SoundStudioContent; export default SoundStudioContent;