feat(music-lab): Phase 1 UI — 보컬 성별, 제외 스타일, weight 슬라이더, 더보기 메뉴, CoverArtModal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -334,6 +334,13 @@ export function deleteLyrics(id) {
|
|||||||
return apiDelete(`/api/music/lyrics/library/${id}`);
|
return apiDelete(`/api/music/lyrics/library/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Phase 1: 커버 이미지 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// POST /api/music/cover-image body: { suno_task_id, track_id }
|
||||||
|
export function generateCoverImage(payload) {
|
||||||
|
return apiPost('/api/music/cover-image', payload);
|
||||||
|
}
|
||||||
|
|
||||||
// ── 로또 고도화 API ────────────────────────────────────────────────────────────
|
// ── 로또 고도화 API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// GET /api/lotto/stats/performance
|
// GET /api/lotto/stats/performance
|
||||||
|
|||||||
@@ -2449,3 +2449,89 @@
|
|||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0.6; }
|
50% { opacity: 0.6; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Phase 1: Vocal Gender Toggle ───────────────────────── */
|
||||||
|
.ms-gender-toggle { display: flex; gap: 6px; }
|
||||||
|
.ms-gender-btn {
|
||||||
|
flex: 1; padding: 8px 12px; border-radius: 8px;
|
||||||
|
background: var(--ms-surface); border: 1px solid var(--ms-line);
|
||||||
|
color: var(--ms-muted); font-family: 'Syne', sans-serif;
|
||||||
|
font-size: 0.82rem; cursor: pointer; transition: all 0.2s;
|
||||||
|
display: flex; align-items: center; gap: 6px; justify-content: center;
|
||||||
|
}
|
||||||
|
.ms-gender-btn:hover { border-color: var(--ms-accent); color: var(--ms-text); }
|
||||||
|
.ms-gender-btn.is-active { background: rgba(245, 166, 35, 0.12); border-color: var(--ms-accent); color: var(--ms-text); }
|
||||||
|
.ms-gender-btn.is-active.is-male { background: rgba(74, 158, 255, 0.12); border-color: #4a9eff; color: #4a9eff; }
|
||||||
|
.ms-gender-btn.is-active.is-female { background: rgba(255, 107, 157, 0.12); border-color: #ff6b9d; color: #ff6b9d; }
|
||||||
|
.ms-gender-btn__icon { font-size: 1.1rem; }
|
||||||
|
|
||||||
|
/* ── Phase 1: Negative Tags ─────────────────────────────── */
|
||||||
|
.ms-negative-tags { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.ms-negative-tags__presets { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.ms-neg-chip {
|
||||||
|
padding: 4px 12px; border-radius: 14px;
|
||||||
|
background: var(--ms-surface); border: 1px solid var(--ms-line);
|
||||||
|
color: var(--ms-muted); font-size: 0.78rem; cursor: pointer;
|
||||||
|
font-family: 'Syne', sans-serif; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.ms-neg-chip:hover { border-color: #e74c3c; color: var(--ms-text); }
|
||||||
|
.ms-neg-chip.is-active {
|
||||||
|
background: rgba(231, 76, 60, 0.12); border-color: #e74c3c; color: #e74c3c;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
.ms-negative-tags__input {
|
||||||
|
padding: 8px 12px; border-radius: 8px;
|
||||||
|
background: var(--ms-surface); border: 1px solid var(--ms-line);
|
||||||
|
color: var(--ms-text); font-family: 'Syne', sans-serif; font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.ms-negative-tags__input::placeholder { color: var(--ms-dim); }
|
||||||
|
.ms-param-hint--inline {
|
||||||
|
font-size: 0.72rem; color: var(--ms-dim); margin: 0 0 4px;
|
||||||
|
font-family: 'Courier Prime', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── More Menu ──────────────────────────────────────────── */
|
||||||
|
.ms-more-menu { position: relative; }
|
||||||
|
.ms-more-menu__dropdown {
|
||||||
|
position: absolute; bottom: 100%; right: 0;
|
||||||
|
background: var(--ms-surface2); border: 1px solid var(--ms-line);
|
||||||
|
border-radius: 8px; padding: 4px; min-width: 160px; z-index: 20;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.ms-more-menu__dropdown button {
|
||||||
|
display: block; width: 100%; padding: 8px 12px; border: none;
|
||||||
|
background: none; color: var(--ms-text); font-size: 0.82rem;
|
||||||
|
font-family: 'Syne', sans-serif; cursor: pointer; text-align: left;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.ms-more-menu__dropdown button:hover { background: rgba(245,166,35,0.1); }
|
||||||
|
.ms-more-menu__dropdown button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ── Modal ──────────────────────────────────────────────── */
|
||||||
|
.ms-modal-overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
|
||||||
|
display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||||
|
}
|
||||||
|
.ms-modal {
|
||||||
|
background: var(--ms-surface); border: 1px solid var(--ms-line);
|
||||||
|
border-radius: 16px; padding: 24px; max-width: 520px; width: 90%;
|
||||||
|
max-height: 90vh; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.ms-modal__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||||
|
.ms-modal__title { font-family: 'Bebas Neue', sans-serif; font-size: 1.3rem; color: var(--ms-text); }
|
||||||
|
.ms-modal__close { background: none; border: none; color: var(--ms-muted); font-size: 1.2rem; cursor: pointer; }
|
||||||
|
.ms-modal__actions { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }
|
||||||
|
|
||||||
|
/* ── Cover Art Grid ─────────────────────────────────────── */
|
||||||
|
.ms-cover-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.ms-cover-option {
|
||||||
|
border: 2px solid var(--ms-line); border-radius: 12px; overflow: hidden;
|
||||||
|
cursor: pointer; background: none; padding: 0; transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.ms-cover-option:hover { border-color: var(--ms-accent); }
|
||||||
|
.ms-cover-option.is-selected { border-color: var(--ms-accent); box-shadow: 0 0 12px rgba(245,166,35,0.3); }
|
||||||
|
.ms-cover-option__img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; }
|
||||||
|
.ms-cover-option__label {
|
||||||
|
display: block; padding: 8px; text-align: center;
|
||||||
|
font-family: 'Courier Prime', monospace; font-size: 0.78rem; color: var(--ms-muted);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import {
|
|||||||
getMusicModels,
|
getMusicModels,
|
||||||
extendMusicTrack,
|
extendMusicTrack,
|
||||||
removeVocals,
|
removeVocals,
|
||||||
|
generateCoverImage,
|
||||||
} from '../../api';
|
} from '../../api';
|
||||||
import './MusicStudio.css';
|
import './MusicStudio.css';
|
||||||
import AudioPlayer from './components/AudioPlayer';
|
import AudioPlayer from './components/AudioPlayer';
|
||||||
import { fmtTime } from './components/AudioPlayer';
|
import { fmtTime } from './components/AudioPlayer';
|
||||||
import CreditsBadge from './components/CreditsBadge';
|
import CreditsBadge from './components/CreditsBadge';
|
||||||
|
import CoverArtModal from './components/CoverArtModal';
|
||||||
import LyricsTab from './components/LyricsTab';
|
import LyricsTab from './components/LyricsTab';
|
||||||
|
|
||||||
/* ─────────────────────────────────────────────
|
/* ─────────────────────────────────────────────
|
||||||
@@ -325,7 +327,8 @@ const TrackResult = ({ track, onDownload, onNew }) => {
|
|||||||
/* ─────────────────────────────────────────────
|
/* ─────────────────────────────────────────────
|
||||||
Library Card
|
Library Card
|
||||||
───────────────────────────────────────────── */
|
───────────────────────────────────────────── */
|
||||||
const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, isGenerating }) => {
|
const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, isGenerating }) => {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const genre = GENRES.find((g) => g.id === track.genre);
|
const genre = GENRES.find((g) => g.id === track.genre);
|
||||||
const totalSec = track.duration_sec ?? null;
|
const totalSec = track.duration_sec ?? null;
|
||||||
const filename = track.audio_url ? track.audio_url.split('/').pop() : '';
|
const filename = track.audio_url ? track.audio_url.split('/').pop() : '';
|
||||||
@@ -386,29 +389,27 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo
|
|||||||
</div>
|
</div>
|
||||||
{hasSunoId && (
|
{hasSunoId && (
|
||||||
<div className="ms-lib-card__actions">
|
<div className="ms-lib-card__actions">
|
||||||
<button
|
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||||
type="button"
|
onClick={() => onExtend(track)} disabled={isGenerating}>
|
||||||
className="ms-btn ms-btn--ghost ms-btn--sm"
|
|
||||||
onClick={() => onExtend(track)}
|
|
||||||
disabled={isGenerating}
|
|
||||||
title="이 곡을 이어서 연장합니다"
|
|
||||||
>
|
|
||||||
⏩ Extend
|
⏩ Extend
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||||
type="button"
|
onClick={() => onVocalRemoval(track)} disabled={isGenerating}>
|
||||||
className="ms-btn ms-btn--ghost ms-btn--sm"
|
|
||||||
onClick={() => onVocalRemoval(track)}
|
|
||||||
disabled={isGenerating}
|
|
||||||
title="보컬과 인스트루멘탈을 분리합니다"
|
|
||||||
>
|
|
||||||
🎤 Vocal Split
|
🎤 Vocal Split
|
||||||
</button>
|
</button>
|
||||||
{track.audio_url && (
|
{track.audio_url && (
|
||||||
<a href={track.audio_url} download className="ms-btn ms-btn--ghost ms-btn--sm">
|
<a href={track.audio_url} download className="ms-btn ms-btn--ghost ms-btn--sm">↓ Download</a>
|
||||||
↓ Download
|
|
||||||
</a>
|
|
||||||
)}
|
)}
|
||||||
|
<div className="ms-more-menu">
|
||||||
|
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||||
|
onClick={() => setMenuOpen(!menuOpen)}>•••</button>
|
||||||
|
{menuOpen && (
|
||||||
|
<div className="ms-more-menu__dropdown">
|
||||||
|
<button type="button" onClick={() => { onCoverArt(track); setMenuOpen(false); }}
|
||||||
|
disabled={isGenerating}>🖼 Cover Art</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!hasSunoId && track.audio_url && (
|
{!hasSunoId && track.audio_url && (
|
||||||
@@ -428,7 +429,7 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo
|
|||||||
/* ─────────────────────────────────────────────
|
/* ─────────────────────────────────────────────
|
||||||
Library Section
|
Library Section
|
||||||
───────────────────────────────────────────── */
|
───────────────────────────────────────────── */
|
||||||
const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, isGenerating, loading }) => {
|
const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, isGenerating, loading }) => {
|
||||||
const [playingId, setPlayingId] = useState(null);
|
const [playingId, setPlayingId] = useState(null);
|
||||||
|
|
||||||
const handlePlay = (track) => {
|
const handlePlay = (track) => {
|
||||||
@@ -477,6 +478,7 @@ const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, isGene
|
|||||||
isPlaying={playingId === track.id}
|
isPlaying={playingId === track.id}
|
||||||
onExtend={onExtend}
|
onExtend={onExtend}
|
||||||
onVocalRemoval={onVocalRemoval}
|
onVocalRemoval={onVocalRemoval}
|
||||||
|
onCoverArt={onCoverArt}
|
||||||
isGenerating={isGenerating}
|
isGenerating={isGenerating}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -514,6 +516,15 @@ export default function MusicStudio() {
|
|||||||
const [model, setModel] = useState('V4');
|
const [model, setModel] = useState('V4');
|
||||||
const [models, setModels] = useState([]);
|
const [models, setModels] = useState([]);
|
||||||
|
|
||||||
|
/* ── Phase 1: 신규 파라미터 ── */
|
||||||
|
const [vocalGender, setVocalGender] = useState(null); // "m" | "f" | null
|
||||||
|
const [negativeTags, setNegativeTags] = useState('');
|
||||||
|
const [styleWeight, setStyleWeight] = useState(50); // UI: 0~100
|
||||||
|
const [audioWeight, setAudioWeight] = useState(50);
|
||||||
|
|
||||||
|
/* ── CoverArt 상태 ── */
|
||||||
|
const [coverArtModal, setCoverArtModal] = useState(null); // { trackId, images }
|
||||||
|
|
||||||
/* ── 생성 상태 ── */
|
/* ── 생성 상태 ── */
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [genProgress, setGenProgress] = useState(0);
|
const [genProgress, setGenProgress] = useState(0);
|
||||||
@@ -710,6 +721,10 @@ export default function MusicStudio() {
|
|||||||
...(provider === 'suno' ? {
|
...(provider === 'suno' ? {
|
||||||
lyrics: lyrics || undefined,
|
lyrics: lyrics || undefined,
|
||||||
instrumental,
|
instrumental,
|
||||||
|
vocal_gender: vocalGender || undefined,
|
||||||
|
negative_tags: negativeTags || undefined,
|
||||||
|
style_weight: styleWeight !== 50 ? styleWeight / 100 : undefined,
|
||||||
|
audio_weight: audioWeight !== 50 ? audioWeight / 100 : undefined,
|
||||||
} : {}),
|
} : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -798,6 +813,64 @@ export default function MusicStudio() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ── 커버 아트 핸들러 ── */
|
||||||
|
const handleCoverArt = async (track) => {
|
||||||
|
if (!track.task_id || isGenerating) return;
|
||||||
|
setTab('create');
|
||||||
|
setIsGenerating(true);
|
||||||
|
setTrack(null);
|
||||||
|
setGenProgress(0);
|
||||||
|
setGenStep('커버 이미지 생성 요청 중…');
|
||||||
|
setGenError(null);
|
||||||
|
try {
|
||||||
|
const res = await generateCoverImage({
|
||||||
|
suno_task_id: track.task_id,
|
||||||
|
track_id: track.id,
|
||||||
|
});
|
||||||
|
if (res?.task_id) {
|
||||||
|
taskIdRef.current = res.task_id;
|
||||||
|
setGenStep('AI가 커버 이미지를 생성하고 있습니다…');
|
||||||
|
setGenProgress(5);
|
||||||
|
clearInterval(pollRef.current);
|
||||||
|
pollRef.current = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const status = await getMusicStatus(res.task_id);
|
||||||
|
setGenProgress(status.progress ?? 0);
|
||||||
|
setGenStep(status.message ?? '처리 중…');
|
||||||
|
if (status.status === 'succeeded') {
|
||||||
|
clearInterval(pollRef.current);
|
||||||
|
setIsGenerating(false);
|
||||||
|
const images = JSON.parse(status.audio_url || '[]');
|
||||||
|
setCoverArtModal({ trackId: track.id, images });
|
||||||
|
} else if (status.status === 'failed') {
|
||||||
|
clearInterval(pollRef.current);
|
||||||
|
setIsGenerating(false);
|
||||||
|
setGenError(`커버 이미지 생성 실패: ${status.error ?? '알 수 없는 오류'}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
clearInterval(pollRef.current);
|
||||||
|
setIsGenerating(false);
|
||||||
|
setGenError('커버 이미지 상태 조회 실패');
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setIsGenerating(false);
|
||||||
|
setGenError('커버 이미지 생성에 실패했습니다');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCoverSelect = (imageUrl) => {
|
||||||
|
if (coverArtModal?.trackId) {
|
||||||
|
setLibrary((prev) => prev.map((t) =>
|
||||||
|
t.id === coverArtModal.trackId
|
||||||
|
? { ...t, cover_images: [imageUrl, ...(coverArtModal.images || []).filter(u => u !== imageUrl)] }
|
||||||
|
: t
|
||||||
|
));
|
||||||
|
}
|
||||||
|
setCoverArtModal(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleNewTrack = () => {
|
const handleNewTrack = () => {
|
||||||
setTrack(null);
|
setTrack(null);
|
||||||
setGenProgress(0);
|
setGenProgress(0);
|
||||||
@@ -865,6 +938,7 @@ export default function MusicStudio() {
|
|||||||
onRefresh={loadLibrary}
|
onRefresh={loadLibrary}
|
||||||
onExtend={handleExtend}
|
onExtend={handleExtend}
|
||||||
onVocalRemoval={handleVocalRemoval}
|
onVocalRemoval={handleVocalRemoval}
|
||||||
|
onCoverArt={handleCoverArt}
|
||||||
isGenerating={isGenerating}
|
isGenerating={isGenerating}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1157,6 +1231,96 @@ export default function MusicStudio() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Vocal Gender (Suno only) */}
|
||||||
|
{provider === 'suno' && (
|
||||||
|
<div className="ms-param-group">
|
||||||
|
<label className="ms-param-label">Vocal Gender</label>
|
||||||
|
<div className="ms-gender-toggle">
|
||||||
|
{[
|
||||||
|
{ value: null, label: 'Auto', icon: '🎵' },
|
||||||
|
{ value: 'm', label: 'Male', icon: '♂' },
|
||||||
|
{ value: 'f', label: 'Female', icon: '♀' },
|
||||||
|
].map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.label}
|
||||||
|
type="button"
|
||||||
|
className={`ms-gender-btn ${vocalGender === opt.value ? 'is-active' : ''} ${opt.value === 'm' ? 'is-male' : opt.value === 'f' ? 'is-female' : ''}`}
|
||||||
|
onClick={() => setVocalGender(opt.value)}
|
||||||
|
>
|
||||||
|
<span className="ms-gender-btn__icon">{opt.icon}</span>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Negative Tags (Suno only) */}
|
||||||
|
{provider === 'suno' && (
|
||||||
|
<div className="ms-param-group">
|
||||||
|
<label className="ms-param-label">Exclude Styles</label>
|
||||||
|
<div className="ms-negative-tags">
|
||||||
|
<div className="ms-negative-tags__presets">
|
||||||
|
{['screaming', 'autotune', 'distortion', 'whisper', 'falsetto', 'rap'].map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
type="button"
|
||||||
|
className={`ms-neg-chip ${negativeTags.includes(tag) ? 'is-active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setNegativeTags((prev) => {
|
||||||
|
const tags = prev.split(',').map(t => t.trim()).filter(Boolean);
|
||||||
|
if (tags.includes(tag)) return tags.filter(t => t !== tag).join(', ');
|
||||||
|
return [...tags, tag].join(', ');
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="ms-negative-tags__input"
|
||||||
|
placeholder="추가로 제외할 스타일을 입력..."
|
||||||
|
value={negativeTags}
|
||||||
|
onChange={(e) => setNegativeTags(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Style Weight / Audio Weight (Suno only) */}
|
||||||
|
{provider === 'suno' && (
|
||||||
|
<div className="ms-param-grid">
|
||||||
|
<div className="ms-param-group">
|
||||||
|
<div className="ms-param-row">
|
||||||
|
<label className="ms-param-label">Style Weight</label>
|
||||||
|
<span className="ms-param-value">{styleWeight}%</span>
|
||||||
|
</div>
|
||||||
|
<p className="ms-param-hint ms-param-hint--inline">Prompt ↔ Style 밸런스</p>
|
||||||
|
<input
|
||||||
|
type="range" min={0} max={100} value={styleWeight}
|
||||||
|
onChange={(e) => setStyleWeight(Number(e.target.value))}
|
||||||
|
className="ms-bpm-slider"
|
||||||
|
aria-label="Style Weight"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ms-param-group">
|
||||||
|
<div className="ms-param-row">
|
||||||
|
<label className="ms-param-label">Audio Weight</label>
|
||||||
|
<span className="ms-param-value">{audioWeight}%</span>
|
||||||
|
</div>
|
||||||
|
<p className="ms-param-hint ms-param-hint--inline">Original ↔ AI 밸런스</p>
|
||||||
|
<input
|
||||||
|
type="range" min={0} max={100} value={audioWeight}
|
||||||
|
onChange={(e) => setAudioWeight(Number(e.target.value))}
|
||||||
|
className="ms-bpm-slider"
|
||||||
|
aria-label="Audio Weight"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Step 5: Prompt */}
|
{/* Step 5: Prompt */}
|
||||||
@@ -1348,6 +1512,14 @@ export default function MusicStudio() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{coverArtModal && (
|
||||||
|
<CoverArtModal
|
||||||
|
images={coverArtModal.images}
|
||||||
|
onSelect={handleCoverSelect}
|
||||||
|
onClose={() => setCoverArtModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
40
src/pages/music/components/CoverArtModal.jsx
Normal file
40
src/pages/music/components/CoverArtModal.jsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const CoverArtModal = ({ images, onSelect, onClose }) => {
|
||||||
|
const [selected, setSelected] = useState(null);
|
||||||
|
|
||||||
|
if (!images || images.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ms-modal-overlay" onClick={onClose}>
|
||||||
|
<div className="ms-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="ms-modal__header">
|
||||||
|
<h3 className="ms-modal__title">Cover Art 선택</h3>
|
||||||
|
<button type="button" className="ms-modal__close" onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="ms-cover-grid">
|
||||||
|
{images.map((url, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
className={`ms-cover-option ${selected === idx ? 'is-selected' : ''}`}
|
||||||
|
onClick={() => setSelected(idx)}
|
||||||
|
>
|
||||||
|
<img src={url} alt={`Cover option ${idx + 1}`} className="ms-cover-option__img" />
|
||||||
|
<span className="ms-cover-option__label">Option {idx + 1}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="ms-modal__actions">
|
||||||
|
<button type="button" className="ms-btn ms-btn--accent" disabled={selected === null}
|
||||||
|
onClick={() => { if (selected !== null) onSelect(images[selected]); }}>
|
||||||
|
이 이미지 사용
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ms-btn ms-btn--ghost" onClick={onClose}>취소</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CoverArtModal;
|
||||||
Reference in New Issue
Block a user