Compare commits
24 Commits
c4f67e7d34
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
| 95d74c1913 | |||
| d8bc6af062 | |||
| 226e368347 | |||
| 310679de61 | |||
| 916d16c235 | |||
| 96a5d97ff7 | |||
| 2ef43b070a | |||
| 7fc2d3aaf7 | |||
| b215a93c89 | |||
| 1f00866694 | |||
| 0849c70644 | |||
| 7a591bb0f1 | |||
| 312677e624 | |||
| 6786f8c883 | |||
| 45b74e672a | |||
| bf5c7ba54e | |||
| 8af2824c12 | |||
| ff0ee3757c | |||
| 0eb55fe731 | |||
| 5dadd4bf2c | |||
| 5cf60e7ee6 | |||
| 74f043bf29 | |||
| e8e45391ae | |||
| c9e29bdad9 |
198
src/api.js
198
src/api.js
@@ -312,6 +312,84 @@ export function removeVocals(payload) {
|
|||||||
return apiPost('/api/music/vocal-removal', payload);
|
return apiPost('/api/music/vocal-removal', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 저장된 가사 CRUD ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /api/music/lyrics/library → { lyrics: [{ id, title, text, prompt, created_at, updated_at }] }
|
||||||
|
export function getSavedLyrics() {
|
||||||
|
return apiGet('/api/music/lyrics/library');
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/music/lyrics/library body: { title, text, prompt }
|
||||||
|
export function saveLyrics(data) {
|
||||||
|
return apiPost('/api/music/lyrics/library', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/music/lyrics/library/:id body: { title?, text?, prompt? }
|
||||||
|
export function updateLyrics(id, data) {
|
||||||
|
return apiPut(`/api/music/lyrics/library/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/music/lyrics/library/:id
|
||||||
|
export function deleteLyrics(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 2 API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// POST /api/music/wav body: { suno_task_id, suno_id, track_id }
|
||||||
|
export function convertToWav(payload) {
|
||||||
|
return apiPost('/api/music/wav', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/music/stem-split body: { suno_task_id, suno_id, track_id }
|
||||||
|
export function splitStems(payload) {
|
||||||
|
return apiPost('/api/music/stem-split', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/music/timestamped-lyrics?task_id=...&suno_id=...
|
||||||
|
export function getTimestampedLyrics(taskId, sunoId) {
|
||||||
|
return apiGet(`/api/music/timestamped-lyrics?task_id=${encodeURIComponent(taskId)}&suno_id=${encodeURIComponent(sunoId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/music/style-boost body: { content }
|
||||||
|
export function generateStyleBoost(content) {
|
||||||
|
return apiPost('/api/music/style-boost', { content });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 3 API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// POST /api/music/upload-cover
|
||||||
|
export function uploadAndCover(payload) {
|
||||||
|
return apiPost('/api/music/upload-cover', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/music/upload-extend
|
||||||
|
export function uploadAndExtend(payload) {
|
||||||
|
return apiPost('/api/music/upload-extend', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/music/add-vocals
|
||||||
|
export function addVocals(payload) {
|
||||||
|
return apiPost('/api/music/add-vocals', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/music/add-instrumental
|
||||||
|
export function addInstrumental(payload) {
|
||||||
|
return apiPost('/api/music/add-instrumental', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/music/video
|
||||||
|
export function generateVideo(payload) {
|
||||||
|
return apiPost('/api/music/video', payload);
|
||||||
|
}
|
||||||
|
|
||||||
// ── 로또 고도화 API ────────────────────────────────────────────────────────────
|
// ── 로또 고도화 API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// GET /api/lotto/stats/performance
|
// GET /api/lotto/stats/performance
|
||||||
@@ -401,3 +479,123 @@ export function deleteBlogPost(id) {
|
|||||||
return apiDelete(`/api/blog/posts/${id}`);
|
return apiDelete(`/api/blog/posts/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 블로그 마케팅 API ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getBlogMarketingStatus() {
|
||||||
|
return apiGet('/api/blog-marketing/status');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startResearch(keyword) {
|
||||||
|
return apiPost('/api/blog-marketing/research', { keyword });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getResearchHistory(limit = 30) {
|
||||||
|
return apiGet(`/api/blog-marketing/research/history?limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getResearchDetail(id) {
|
||||||
|
return apiGet(`/api/blog-marketing/research/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteResearch(id) {
|
||||||
|
return apiDelete(`/api/blog-marketing/research/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlogMarketingTask(taskId) {
|
||||||
|
return apiGet(`/api/blog-marketing/task/${encodeURIComponent(taskId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startGenerate(keywordId) {
|
||||||
|
return apiPost('/api/blog-marketing/generate', { keyword_id: keywordId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startReview(postId) {
|
||||||
|
return apiPost(`/api/blog-marketing/review/${postId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startRegenerate(postId) {
|
||||||
|
return apiPost(`/api/blog-marketing/regenerate/${postId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlogMarketingPosts(status, limit = 50) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (status) qs.set('status', status);
|
||||||
|
if (limit) qs.set('limit', String(limit));
|
||||||
|
const q = qs.toString();
|
||||||
|
return apiGet(`/api/blog-marketing/posts${q ? '?' + q : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlogMarketingPost(id) {
|
||||||
|
return apiGet(`/api/blog-marketing/posts/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBlogMarketingPost(id, data) {
|
||||||
|
return apiPut(`/api/blog-marketing/posts/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBlogMarketingPost(id) {
|
||||||
|
return apiDelete(`/api/blog-marketing/posts/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishBlogMarketingPost(id, naverUrl) {
|
||||||
|
return apiPost(`/api/blog-marketing/posts/${id}/publish`, { naver_url: naverUrl || '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlogMarketingCommissions(postId) {
|
||||||
|
const qs = postId ? `?post_id=${postId}` : '';
|
||||||
|
return apiGet(`/api/blog-marketing/commissions${qs}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addBlogMarketingCommission(data) {
|
||||||
|
return apiPost('/api/blog-marketing/commissions', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBlogMarketingCommission(id, data) {
|
||||||
|
return apiPut(`/api/blog-marketing/commissions/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBlogMarketingCommission(id) {
|
||||||
|
return apiDelete(`/api/blog-marketing/commissions/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlogMarketingDashboard() {
|
||||||
|
return apiGet('/api/blog-marketing/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마케터 단계
|
||||||
|
export function startMarket(postId) {
|
||||||
|
return apiPost(`/api/blog-marketing/market/${postId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 브랜드커넥트 링크 CRUD
|
||||||
|
export function getBrandLinks(params = {}) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.post_id) qs.set('post_id', String(params.post_id));
|
||||||
|
if (params.keyword_id) qs.set('keyword_id', String(params.keyword_id));
|
||||||
|
const q = qs.toString();
|
||||||
|
return apiGet(`/api/blog-marketing/links${q ? '?' + q : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBrandLink(data) {
|
||||||
|
return apiPost('/api/blog-marketing/links', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBrandLink(id, data) {
|
||||||
|
return apiPut(`/api/blog-marketing/links/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBrandLink(id) {
|
||||||
|
return apiDelete(`/api/blog-marketing/links/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agent Office ──────────────────────────────────
|
||||||
|
export const getAgents = () => apiGet('/api/agent-office/agents');
|
||||||
|
export const getAgentDetail = (id) => apiGet(`/api/agent-office/agents/${id}`);
|
||||||
|
export const updateAgentConfig = (id, body) => apiPut(`/api/agent-office/agents/${id}`, body);
|
||||||
|
export const getAgentTasks = (id, limit=20) => apiGet(`/api/agent-office/agents/${id}/tasks?limit=${limit}`);
|
||||||
|
export const getAgentLogs = (id, limit=50) => apiGet(`/api/agent-office/agents/${id}/logs?limit=${limit}`);
|
||||||
|
export const getPendingTasks = () => apiGet('/api/agent-office/tasks/pending');
|
||||||
|
export const sendAgentCommand = (agent, action, params={}) => apiPost('/api/agent-office/command', { agent, action, params });
|
||||||
|
export const approveAgentTask = (agent, task_id, approved, feedback='') => apiPost('/api/agent-office/approve', { agent, task_id, approved, feedback });
|
||||||
|
export const getAgentStates = () => apiGet('/api/agent-office/states');
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,17 @@ export const IconSubscription = () =>
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const IconBlogMarketing = () =>
|
||||||
|
svg(
|
||||||
|
<>
|
||||||
|
<path d="M4 4h16v16H4z" />
|
||||||
|
<path d="M8 8h8" />
|
||||||
|
<path d="M8 12h5" />
|
||||||
|
<circle cx="17" cy="15" r="2.5" fill="currentColor" strokeWidth="0" />
|
||||||
|
<path d="M15.5 13l3 4" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
export const IconBuilding = () =>
|
export const IconBuilding = () =>
|
||||||
svg(
|
svg(
|
||||||
<>
|
<>
|
||||||
|
|||||||
331
src/pages/agent-office/AgentOffice.css
Normal file
331
src/pages/agent-office/AgentOffice.css
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
.ao-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
background: #0d0d1a;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border-bottom: 1px solid #2a2a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-title {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: #8b5cf6;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.ao-dot--on { background: #34d399; }
|
||||||
|
.ao-dot--off { background: #f87171; }
|
||||||
|
|
||||||
|
.ao-workspace {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-canvas-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-agent-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
border-radius: 20px;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-agent-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.ao-agent-chip:hover { border-color: #8b5cf6; }
|
||||||
|
.ao-agent-chip--selected { border-color: #8b5cf6; background: rgba(139, 92, 246, 0.15); }
|
||||||
|
.ao-agent-chip--alert { animation: ao-pulse 1s infinite; }
|
||||||
|
|
||||||
|
@keyframes ao-pulse {
|
||||||
|
0%, 100% { border-color: #fbbf24; }
|
||||||
|
50% { border-color: #f59e0b; box-shadow: 0 0 8px rgba(251, 191, 36, 0.4); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-chip-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.ao-chip-dot--idle { background: #666; }
|
||||||
|
.ao-chip-dot--working { background: #818cf8; }
|
||||||
|
.ao-chip-dot--waiting { background: #fbbf24; }
|
||||||
|
.ao-chip-dot--reporting { background: #34d399; }
|
||||||
|
.ao-chip-dot--break { background: #a78bfa; }
|
||||||
|
|
||||||
|
.ao-chip-badge {
|
||||||
|
background: #f87171;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-pending-count {
|
||||||
|
color: #fbbf24;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-chat-panel {
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
top: 60px;
|
||||||
|
width: 340px;
|
||||||
|
max-height: calc(100% - 80px);
|
||||||
|
background: rgba(26, 26, 46, 0.95);
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #2a2a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-chat-title {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-chat-state {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.ao-chat-state--idle { background: #333; }
|
||||||
|
.ao-chat-state--working { background: #3730a3; }
|
||||||
|
.ao-chat-state--waiting { background: #92400e; }
|
||||||
|
.ao-chat-state--break { background: #4c1d95; }
|
||||||
|
|
||||||
|
.ao-chat-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ao-chat-close:hover { color: #fff; }
|
||||||
|
|
||||||
|
.ao-chat-detail {
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-chat-approval {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(251, 191, 36, 0.1);
|
||||||
|
border-top: 1px solid #2a2a4a;
|
||||||
|
border-bottom: 1px solid #2a2a4a;
|
||||||
|
}
|
||||||
|
.ao-chat-approval p {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: #fbbf24;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.ao-chat-approval-btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-btn {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.ao-btn--approve { background: #065f46; color: #34d399; }
|
||||||
|
.ao-btn--approve:hover { background: #047857; }
|
||||||
|
.ao-btn--reject { background: #7f1d1d; color: #f87171; }
|
||||||
|
.ao-btn--reject:hover { background: #991b1b; }
|
||||||
|
.ao-btn--send { background: #4c1d95; color: #c4b5fd; }
|
||||||
|
.ao-btn--send:hover { background: #5b21b6; }
|
||||||
|
|
||||||
|
.ao-chat-commands {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-cmd-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.ao-cmd-btn:hover { border-color: #8b5cf6; background: rgba(139, 92, 246, 0.1); }
|
||||||
|
|
||||||
|
.ao-chat-input-area {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px 12px;
|
||||||
|
}
|
||||||
|
.ao-chat-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #111;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.ao-chat-input:focus { border-color: #8b5cf6; outline: none; }
|
||||||
|
|
||||||
|
.ao-chat-result {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-top: 1px solid #2a2a4a;
|
||||||
|
}
|
||||||
|
.ao-chat-result h4 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.ao-chat-result pre {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #aaa;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-history-panel {
|
||||||
|
position: absolute;
|
||||||
|
left: 16px;
|
||||||
|
top: 60px;
|
||||||
|
width: 340px;
|
||||||
|
max-height: calc(100% - 80px);
|
||||||
|
background: rgba(26, 26, 46, 0.95);
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-history-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #2a2a4a;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-history-list { padding: 8px; }
|
||||||
|
.ao-history-empty { text-align: center; color: #666; padding: 20px; }
|
||||||
|
|
||||||
|
.ao-history-item {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #1a1a2e;
|
||||||
|
}
|
||||||
|
.ao-history-item:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.ao-history-item-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.ao-history-type { font-size: 0.85rem; color: #ccc; }
|
||||||
|
.ao-history-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.ao-history-time {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.ao-history-detail {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.ao-history-detail summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #8b5cf6;
|
||||||
|
}
|
||||||
|
.ao-history-detail pre {
|
||||||
|
color: #aaa;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border-top: 1px solid #2a2a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ao-tool-btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.ao-tool-btn:hover { border-color: #8b5cf6; color: #e0e0e0; }
|
||||||
85
src/pages/agent-office/AgentOffice.jsx
Normal file
85
src/pages/agent-office/AgentOffice.jsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import React, { useRef, useState, useCallback, useEffect } from 'react';
|
||||||
|
import { useAgentManager } from './hooks/useAgentManager';
|
||||||
|
import { useOfficeCanvas } from './hooks/useOfficeCanvas';
|
||||||
|
import ChatPanel from './components/ChatPanel';
|
||||||
|
import TaskHistory from './components/TaskHistory';
|
||||||
|
import './AgentOffice.css';
|
||||||
|
|
||||||
|
export function Component() {
|
||||||
|
const canvasContainerRef = useRef(null);
|
||||||
|
const [selectedAgent, setSelectedAgent] = useState(null);
|
||||||
|
const [showHistory, setShowHistory] = useState(null);
|
||||||
|
|
||||||
|
const { agents, pendingTasks, connected, sendCommand, sendApproval } = useAgentManager();
|
||||||
|
|
||||||
|
const handleAgentClick = useCallback((agentId) => {
|
||||||
|
setSelectedAgent(prev => prev === agentId ? null : agentId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { updateAgentState, moveAgent } = useOfficeCanvas(canvasContainerRef, handleAgentClick);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
for (const [id, info] of Object.entries(agents)) {
|
||||||
|
updateAgentState(id, info.state, info.detail);
|
||||||
|
}
|
||||||
|
}, [agents, updateAgentState]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ao-page">
|
||||||
|
<div className="ao-header">
|
||||||
|
<h1 className="ao-title">Agent Office</h1>
|
||||||
|
<div className="ao-status">
|
||||||
|
<span className={`ao-dot ${connected ? 'ao-dot--on' : 'ao-dot--off'}`} />
|
||||||
|
{connected ? 'Connected' : 'Disconnected'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ao-workspace">
|
||||||
|
<div className="ao-canvas-container" ref={canvasContainerRef} />
|
||||||
|
|
||||||
|
<div className="ao-agent-bar">
|
||||||
|
{Object.entries(agents).map(([id, info]) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
className={`ao-agent-chip ${info.state === 'waiting' ? 'ao-agent-chip--alert' : ''} ${selectedAgent === id ? 'ao-agent-chip--selected' : ''}`}
|
||||||
|
onClick={() => handleAgentClick(id)}
|
||||||
|
>
|
||||||
|
<span className={`ao-chip-dot ao-chip-dot--${info.state}`} />
|
||||||
|
{id}
|
||||||
|
{info.state === 'waiting' && <span className="ao-chip-badge">!</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{pendingTasks.length > 0 && (
|
||||||
|
<span className="ao-pending-count">{pendingTasks.length} pending</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedAgent && (
|
||||||
|
<ChatPanel
|
||||||
|
agentId={selectedAgent}
|
||||||
|
agentState={agents[selectedAgent]}
|
||||||
|
onCommand={sendCommand}
|
||||||
|
onApproval={sendApproval}
|
||||||
|
onClose={() => setSelectedAgent(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showHistory && (
|
||||||
|
<TaskHistory
|
||||||
|
agentId={showHistory}
|
||||||
|
onClose={() => setShowHistory(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ao-toolbar">
|
||||||
|
{Object.keys(agents).map(id => (
|
||||||
|
<button key={id} className="ao-tool-btn"
|
||||||
|
onClick={() => setShowHistory(prev => prev === id ? null : id)}>
|
||||||
|
📋 {id} 이력
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/pages/agent-office/assets/office-map.json
Normal file
45
src/pages/agent-office/assets/office-map.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"tileSize": 32,
|
||||||
|
"cols": 20,
|
||||||
|
"rows": 14,
|
||||||
|
"layers": {
|
||||||
|
"floor": [
|
||||||
|
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"furniture": [
|
||||||
|
{"type": "desk", "x": 2, "y": 1, "label": "Stock"},
|
||||||
|
{"type": "desk", "x": 7, "y": 1, "label": "Music"},
|
||||||
|
{"type": "desk", "x": 12, "y": 1, "label": "Claude"},
|
||||||
|
{"type": "desk", "x": 17, "y": 1, "label": "(빈)"},
|
||||||
|
{"type": "table", "x": 8, "y": 6, "w": 4, "h": 2, "label": "회의 테이블"},
|
||||||
|
{"type": "sofa", "x": 1, "y": 10, "label": "휴게실"},
|
||||||
|
{"type": "coffee", "x": 3, "y": 10, "label": "☕"},
|
||||||
|
{"type": "desk", "x": 14, "y": 10, "w": 5, "h": 2, "label": "CEO"}
|
||||||
|
],
|
||||||
|
"waypoints": {
|
||||||
|
"stock_desk": {"x": 2, "y": 2},
|
||||||
|
"music_desk": {"x": 7, "y": 2},
|
||||||
|
"claude_desk": {"x": 12, "y": 2},
|
||||||
|
"meeting_table": {"x": 9, "y": 7},
|
||||||
|
"break_room": {"x": 2, "y": 11},
|
||||||
|
"ceo_desk": {"x": 16, "y": 11}
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"1": "#3a3a50",
|
||||||
|
"2": "#4a3a2a"
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/pages/agent-office/canvas/AgentSprite.js
Normal file
84
src/pages/agent-office/canvas/AgentSprite.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { drawAgent, getAnimSpeed } from './SpriteSheet';
|
||||||
|
|
||||||
|
export class AgentSprite {
|
||||||
|
constructor(agentId, waypoints) {
|
||||||
|
this.agentId = agentId;
|
||||||
|
this.waypoints = waypoints;
|
||||||
|
this.state = 'idle';
|
||||||
|
this.detail = '';
|
||||||
|
|
||||||
|
const deskKey = `${agentId}_desk`;
|
||||||
|
const desk = waypoints[deskKey] || { x: 5, y: 3 };
|
||||||
|
this.x = desk.x;
|
||||||
|
this.y = desk.y;
|
||||||
|
this.targetX = desk.x;
|
||||||
|
this.targetY = desk.y;
|
||||||
|
this.deskPos = { x: desk.x, y: desk.y };
|
||||||
|
|
||||||
|
this.frameIndex = 0;
|
||||||
|
this._lastFrameTime = 0;
|
||||||
|
this._moveSpeed = 0.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(newState, detail = '') {
|
||||||
|
this.state = newState;
|
||||||
|
this.detail = detail;
|
||||||
|
this.frameIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
moveTo(target) {
|
||||||
|
const wp = this.waypoints[target];
|
||||||
|
if (wp) {
|
||||||
|
this.targetX = wp.x;
|
||||||
|
this.targetY = wp.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
moveToDesk() {
|
||||||
|
this.targetX = this.deskPos.x;
|
||||||
|
this.targetY = this.deskPos.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(now) {
|
||||||
|
const speed = getAnimSpeed(this.state);
|
||||||
|
if (now - this._lastFrameTime > speed) {
|
||||||
|
this.frameIndex++;
|
||||||
|
this._lastFrameTime = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx = this.targetX - this.x;
|
||||||
|
const dy = this.targetY - this.y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (dist > 0.1) {
|
||||||
|
const step = Math.min(this._moveSpeed, dist);
|
||||||
|
this.x += (dx / dist) * step;
|
||||||
|
this.y += (dy / dist) * step;
|
||||||
|
} else {
|
||||||
|
this.x = this.targetX;
|
||||||
|
this.y = this.targetY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draw(ctx, renderInfo) {
|
||||||
|
const { scale, offsetX, offsetY, tileSize } = renderInfo;
|
||||||
|
const canvasX = offsetX + this.x * tileSize * scale + (tileSize * scale) / 2;
|
||||||
|
const canvasY = offsetY + this.y * tileSize * scale + (tileSize * scale) / 2;
|
||||||
|
|
||||||
|
const isMoving = Math.abs(this.targetX - this.x) > 0.1 || Math.abs(this.targetY - this.y) > 0.1;
|
||||||
|
const drawState = isMoving ? 'walk' : this.state;
|
||||||
|
|
||||||
|
drawAgent(ctx, this.agentId, canvasX, canvasY, drawState, this.frameIndex, scale * 1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
hitTest(canvasX, canvasY, renderInfo) {
|
||||||
|
const { scale, offsetX, offsetY, tileSize } = renderInfo;
|
||||||
|
const cx = offsetX + this.x * tileSize * scale + (tileSize * scale) / 2;
|
||||||
|
const cy = offsetY + this.y * tileSize * scale + (tileSize * scale) / 2;
|
||||||
|
const hitW = 20 * scale;
|
||||||
|
const hitH = 30 * scale;
|
||||||
|
|
||||||
|
return canvasX >= cx - hitW && canvasX <= cx + hitW &&
|
||||||
|
canvasY >= cy - hitH && canvasY <= cy + hitH;
|
||||||
|
}
|
||||||
|
}
|
||||||
129
src/pages/agent-office/canvas/OfficeRenderer.js
Normal file
129
src/pages/agent-office/canvas/OfficeRenderer.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { drawTileMap } from './TileMap';
|
||||||
|
import { AgentSprite } from './AgentSprite';
|
||||||
|
import { getCharLabel } from './SpriteSheet';
|
||||||
|
|
||||||
|
const STATUS_ICONS = {
|
||||||
|
idle: null,
|
||||||
|
working: null,
|
||||||
|
waiting: '❗',
|
||||||
|
reporting: '📋',
|
||||||
|
break: '☕',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class OfficeRenderer {
|
||||||
|
constructor(canvas, mapData) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext('2d');
|
||||||
|
this.mapData = mapData;
|
||||||
|
this.renderInfo = null;
|
||||||
|
this.agents = {};
|
||||||
|
this._animId = null;
|
||||||
|
this._onClick = null;
|
||||||
|
|
||||||
|
const agentIds = ['stock', 'music'];
|
||||||
|
for (const id of agentIds) {
|
||||||
|
this.agents[id] = new AgentSprite(id, mapData.waypoints);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this._loop = this._loop.bind(this);
|
||||||
|
this._animId = requestAnimationFrame(this._loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this._animId) {
|
||||||
|
cancelAnimationFrame(this._animId);
|
||||||
|
this._animId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resize(width, height) {
|
||||||
|
this.canvas.width = width;
|
||||||
|
this.canvas.height = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOnClick(handler) {
|
||||||
|
this._onClick = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClick(canvasX, canvasY) {
|
||||||
|
if (!this.renderInfo) return null;
|
||||||
|
|
||||||
|
for (const [id, sprite] of Object.entries(this.agents)) {
|
||||||
|
if (sprite.hitTest(canvasX, canvasY, this.renderInfo)) {
|
||||||
|
if (this._onClick) this._onClick(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAgentState(agentId, state, detail) {
|
||||||
|
const sprite = this.agents[agentId];
|
||||||
|
if (sprite) {
|
||||||
|
sprite.setState(state, detail);
|
||||||
|
if (state === 'idle' || state === 'working' || state === 'waiting') {
|
||||||
|
sprite.moveToDesk();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
moveAgent(agentId, target) {
|
||||||
|
const sprite = this.agents[agentId];
|
||||||
|
if (sprite) {
|
||||||
|
sprite.moveTo(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_loop(timestamp) {
|
||||||
|
const { ctx, canvas, mapData } = this;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.fillStyle = '#1a1a2e';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
this.renderInfo = drawTileMap(ctx, mapData, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
for (const sprite of Object.values(this.agents)) {
|
||||||
|
sprite.update(now);
|
||||||
|
sprite.draw(ctx, this.renderInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, sprite] of Object.entries(this.agents)) {
|
||||||
|
this._drawOverlay(ctx, sprite, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._animId = requestAnimationFrame(this._loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawOverlay(ctx, sprite, agentId) {
|
||||||
|
if (!this.renderInfo) return;
|
||||||
|
const { scale, offsetX, offsetY, tileSize } = this.renderInfo;
|
||||||
|
const cx = offsetX + sprite.x * tileSize * scale + (tileSize * scale) / 2;
|
||||||
|
const cy = offsetY + sprite.y * tileSize * scale - 10 * scale;
|
||||||
|
|
||||||
|
const icon = STATUS_ICONS[sprite.state];
|
||||||
|
if (icon) {
|
||||||
|
ctx.font = `${14 * scale}px serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(icon, cx, cy - 15 * scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.7)';
|
||||||
|
ctx.font = `${8 * scale}px monospace`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(getCharLabel(agentId), cx, cy + 30 * scale + 30);
|
||||||
|
|
||||||
|
if (sprite.detail && (sprite.state === 'working' || sprite.state === 'waiting')) {
|
||||||
|
const bubbleY = cy - 25 * scale;
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||||||
|
const textW = ctx.measureText(sprite.detail).width;
|
||||||
|
ctx.fillRect(cx - textW / 2 - 6, bubbleY - 10, textW + 12, 16);
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.font = `${7 * scale}px monospace`;
|
||||||
|
ctx.fillText(sprite.detail, cx, bubbleY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/pages/agent-office/canvas/SpriteSheet.js
Normal file
89
src/pages/agent-office/canvas/SpriteSheet.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
const PIXEL_CHARS = {
|
||||||
|
stock: { body: '#4488cc', accent: '#cc4444', label: '주식', hair: '#332222' },
|
||||||
|
music: { body: '#44aa88', accent: '#ffaa00', label: '음악', hair: '#443322' },
|
||||||
|
claude: { body: '#8855cc', accent: '#cc88ff', label: 'Claude', hair: '#554466' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANIM_FRAMES = {
|
||||||
|
idle: { frames: 2, speed: 800 },
|
||||||
|
working: { frames: 4, speed: 200 },
|
||||||
|
waiting: { frames: 2, speed: 400 },
|
||||||
|
break: { frames: 2, speed: 1000 },
|
||||||
|
walk: { frames: 4, speed: 150 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function drawAgent(ctx, agentId, x, y, state, frameIndex, scale = 2) {
|
||||||
|
const char = PIXEL_CHARS[agentId] || PIXEL_CHARS.claude;
|
||||||
|
const s = scale;
|
||||||
|
const anim = ANIM_FRAMES[state] || ANIM_FRAMES.idle;
|
||||||
|
const frame = frameIndex % anim.frames;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(x, y);
|
||||||
|
|
||||||
|
// Shadow
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.2)';
|
||||||
|
ctx.fillRect(-4 * s, 14 * s, 8 * s, 2 * s);
|
||||||
|
|
||||||
|
// Body
|
||||||
|
ctx.fillStyle = char.body;
|
||||||
|
ctx.fillRect(-3 * s, 2 * s, 6 * s, 8 * s);
|
||||||
|
|
||||||
|
// Head
|
||||||
|
ctx.fillStyle = '#ffcc99';
|
||||||
|
ctx.fillRect(-3 * s, -4 * s, 6 * s, 6 * s);
|
||||||
|
|
||||||
|
// Hair
|
||||||
|
ctx.fillStyle = char.hair;
|
||||||
|
ctx.fillRect(-3 * s, -5 * s, 6 * s, 2 * s);
|
||||||
|
|
||||||
|
// Eyes
|
||||||
|
ctx.fillStyle = '#222';
|
||||||
|
const eyeOffset = state === 'break' && frame === 1 ? 0 : 1;
|
||||||
|
ctx.fillRect(-2 * s, -1 * s, 1 * s, eyeOffset * s);
|
||||||
|
ctx.fillRect(1 * s, -1 * s, 1 * s, eyeOffset * s);
|
||||||
|
|
||||||
|
// Legs
|
||||||
|
ctx.fillStyle = '#335';
|
||||||
|
const legSpread = state === 'walk' ? (frame % 2 === 0 ? 1 : -1) : 0;
|
||||||
|
ctx.fillRect(-2 * s, 10 * s, 2 * s, 4 * s);
|
||||||
|
ctx.fillRect(0 + legSpread * s, 10 * s, 2 * s, 4 * s);
|
||||||
|
|
||||||
|
// Accent
|
||||||
|
ctx.fillStyle = char.accent;
|
||||||
|
if (agentId === 'stock') {
|
||||||
|
ctx.fillRect(0, 2 * s, 1 * s, 5 * s);
|
||||||
|
} else if (agentId === 'music') {
|
||||||
|
ctx.fillRect(-4 * s, -4 * s, 1 * s, 4 * s);
|
||||||
|
ctx.fillRect(3 * s, -4 * s, 1 * s, 4 * s);
|
||||||
|
ctx.fillRect(-4 * s, -5 * s, 8 * s, 1 * s);
|
||||||
|
} else if (agentId === 'claude') {
|
||||||
|
ctx.globalAlpha = 0.3 + 0.2 * Math.sin(Date.now() / 500);
|
||||||
|
ctx.fillRect(-4 * s, -6 * s, 8 * s, 1 * s);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Working: typing hands
|
||||||
|
if (state === 'working') {
|
||||||
|
ctx.fillStyle = '#ffcc99';
|
||||||
|
const handY = 6 * s + (frame % 2) * s;
|
||||||
|
ctx.fillRect(-4 * s, handY, 1 * s, 2 * s);
|
||||||
|
ctx.fillRect(3 * s, handY, 1 * s, 2 * s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waiting wobble
|
||||||
|
if (state === 'waiting') {
|
||||||
|
const wobble = Math.sin(Date.now() / 200) * s;
|
||||||
|
ctx.translate(wobble, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAnimSpeed(state) {
|
||||||
|
return (ANIM_FRAMES[state] || ANIM_FRAMES.idle).speed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCharLabel(agentId) {
|
||||||
|
return (PIXEL_CHARS[agentId] || {}).label || agentId;
|
||||||
|
}
|
||||||
90
src/pages/agent-office/canvas/TileMap.js
Normal file
90
src/pages/agent-office/canvas/TileMap.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
const WALL_COLOR = '#2a2a3a';
|
||||||
|
const DESK_COLOR = '#6b5b3a';
|
||||||
|
const DESK_TOP = '#8b7b5a';
|
||||||
|
const TABLE_COLOR = '#5a4a2a';
|
||||||
|
const SOFA_COLOR = '#884444';
|
||||||
|
const MONITOR_COLOR = '#224466';
|
||||||
|
const MONITOR_SCREEN = '#44aacc';
|
||||||
|
|
||||||
|
export function drawTileMap(ctx, mapData, width, height) {
|
||||||
|
const { tileSize, cols, rows, layers, furniture, colors } = mapData;
|
||||||
|
const scaleX = width / (cols * tileSize);
|
||||||
|
const scaleY = height / (rows * tileSize);
|
||||||
|
const scale = Math.min(scaleX, scaleY);
|
||||||
|
|
||||||
|
const offsetX = (width - cols * tileSize * scale) / 2;
|
||||||
|
const offsetY = (height - rows * tileSize * scale) / 2;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(offsetX, offsetY);
|
||||||
|
ctx.scale(scale, scale);
|
||||||
|
|
||||||
|
const floor = layers.floor;
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
const tile = floor[r][c];
|
||||||
|
ctx.fillStyle = colors[String(tile)] || '#3a3a50';
|
||||||
|
ctx.fillRect(c * tileSize, r * tileSize, tileSize, tileSize);
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
|
||||||
|
ctx.strokeRect(c * tileSize, r * tileSize, tileSize, tileSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = WALL_COLOR;
|
||||||
|
ctx.fillRect(0, 0, cols * tileSize, 4);
|
||||||
|
|
||||||
|
for (const f of furniture) {
|
||||||
|
const fx = f.x * tileSize;
|
||||||
|
const fy = f.y * tileSize;
|
||||||
|
const fw = (f.w || 2) * tileSize;
|
||||||
|
const fh = (f.h || 2) * tileSize;
|
||||||
|
|
||||||
|
if (f.type === 'desk') {
|
||||||
|
ctx.fillStyle = DESK_COLOR;
|
||||||
|
ctx.fillRect(fx, fy, fw, fh);
|
||||||
|
ctx.fillStyle = DESK_TOP;
|
||||||
|
ctx.fillRect(fx + 2, fy + 2, fw - 4, 6);
|
||||||
|
const mx = fx + fw / 2 - 8;
|
||||||
|
ctx.fillStyle = MONITOR_COLOR;
|
||||||
|
ctx.fillRect(mx, fy + 4, 16, 12);
|
||||||
|
ctx.fillStyle = MONITOR_SCREEN;
|
||||||
|
ctx.fillRect(mx + 2, fy + 6, 12, 8);
|
||||||
|
if (f.label) {
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||||||
|
ctx.font = '8px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(f.label, fx + fw / 2, fy + fh + 12);
|
||||||
|
}
|
||||||
|
} else if (f.type === 'table') {
|
||||||
|
ctx.fillStyle = TABLE_COLOR;
|
||||||
|
ctx.fillRect(fx, fy, fw, fh);
|
||||||
|
ctx.fillStyle = '#7a6a4a';
|
||||||
|
ctx.fillRect(fx + 4, fy + 4, fw - 8, fh - 8);
|
||||||
|
} else if (f.type === 'sofa') {
|
||||||
|
ctx.fillStyle = SOFA_COLOR;
|
||||||
|
ctx.fillRect(fx, fy, 48, 32);
|
||||||
|
ctx.fillStyle = '#aa5555';
|
||||||
|
ctx.fillRect(fx + 4, fy + 4, 40, 24);
|
||||||
|
} else if (f.type === 'coffee') {
|
||||||
|
ctx.fillStyle = '#664422';
|
||||||
|
ctx.fillRect(fx + 8, fy + 8, 16, 20);
|
||||||
|
ctx.fillStyle = '#886644';
|
||||||
|
ctx.fillRect(fx + 6, fy + 6, 20, 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
return { scale, offsetX, offsetY, tileSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function worldToTile(mapData, renderInfo, canvasX, canvasY) {
|
||||||
|
const { scale, offsetX, offsetY, tileSize } = renderInfo;
|
||||||
|
const wx = (canvasX - offsetX) / scale;
|
||||||
|
const wy = (canvasY - offsetY) / scale;
|
||||||
|
return { col: Math.floor(wx / tileSize), row: Math.floor(wy / tileSize), worldX: wx, worldY: wy };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tileToCanvas(mapData, renderInfo, col, row) {
|
||||||
|
const { scale, offsetX, offsetY, tileSize } = renderInfo;
|
||||||
|
return { x: offsetX + col * tileSize * scale + (tileSize * scale) / 2, y: offsetY + row * tileSize * scale + (tileSize * scale) / 2 };
|
||||||
|
}
|
||||||
106
src/pages/agent-office/components/ChatPanel.jsx
Normal file
106
src/pages/agent-office/components/ChatPanel.jsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const AGENT_COMMANDS = {
|
||||||
|
stock: [
|
||||||
|
{ action: 'fetch_news', label: '뉴스 수집', icon: '📰' },
|
||||||
|
{ action: 'list_alerts', label: '알람 목록', icon: '🔔' },
|
||||||
|
],
|
||||||
|
music: [
|
||||||
|
{ action: 'compose', label: '작곡 시작', icon: '🎵', needsInput: true },
|
||||||
|
{ action: 'credits', label: '크레딧 확인', icon: '💳' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChatPanel = ({ agentId, agentState, onCommand, onApproval, onClose }) => {
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [activeCommand, setActiveCommand] = useState(null);
|
||||||
|
|
||||||
|
const commands = AGENT_COMMANDS[agentId] || [];
|
||||||
|
const state = agentState || {};
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (!input.trim() || !activeCommand) return;
|
||||||
|
const params = activeCommand === 'compose'
|
||||||
|
? { prompt: input }
|
||||||
|
: { message: input };
|
||||||
|
onCommand(agentId, activeCommand, params);
|
||||||
|
setInput('');
|
||||||
|
setActiveCommand(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickAction = (cmd) => {
|
||||||
|
if (cmd.needsInput) {
|
||||||
|
setActiveCommand(cmd.action);
|
||||||
|
} else {
|
||||||
|
onCommand(agentId, cmd.action, {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ao-chat-panel">
|
||||||
|
<div className="ao-chat-header">
|
||||||
|
<span className="ao-chat-title">
|
||||||
|
{agentId === 'stock' ? '주식 트레이더' :
|
||||||
|
agentId === 'music' ? '음악 프로듀서' : agentId}
|
||||||
|
</span>
|
||||||
|
<span className={`ao-chat-state ao-chat-state--${state.state || 'idle'}`}>
|
||||||
|
{state.state || 'idle'}
|
||||||
|
</span>
|
||||||
|
<button className="ao-chat-close" onClick={onClose}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.detail && (
|
||||||
|
<div className="ao-chat-detail">{state.detail}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.state === 'waiting' && state.taskId && (
|
||||||
|
<div className="ao-chat-approval">
|
||||||
|
<p>승인 대기 중인 작업이 있습니다</p>
|
||||||
|
<div className="ao-chat-approval-btns">
|
||||||
|
<button className="ao-btn ao-btn--approve"
|
||||||
|
onClick={() => onApproval(agentId, state.taskId, true)}>
|
||||||
|
✅ 승인
|
||||||
|
</button>
|
||||||
|
<button className="ao-btn ao-btn--reject"
|
||||||
|
onClick={() => onApproval(agentId, state.taskId, false)}>
|
||||||
|
❌ 거절
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="ao-chat-commands">
|
||||||
|
{commands.map(cmd => (
|
||||||
|
<button key={cmd.action} className="ao-cmd-btn"
|
||||||
|
onClick={() => handleQuickAction(cmd)}>
|
||||||
|
<span>{cmd.icon}</span> {cmd.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeCommand && (
|
||||||
|
<div className="ao-chat-input-area">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="ao-chat-input"
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleSend()}
|
||||||
|
placeholder={activeCommand === 'compose' ? '프롬프트 입력...' : '메시지 입력...'}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button className="ao-btn ao-btn--send" onClick={handleSend}>전송</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.lastResult && (
|
||||||
|
<div className="ao-chat-result">
|
||||||
|
<h4>최근 결과</h4>
|
||||||
|
<pre>{JSON.stringify(state.lastResult, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatPanel;
|
||||||
62
src/pages/agent-office/components/TaskHistory.jsx
Normal file
62
src/pages/agent-office/components/TaskHistory.jsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { getAgentTasks } from '../../../api';
|
||||||
|
|
||||||
|
const STATUS_BADGE = {
|
||||||
|
pending: { label: '대기', color: '#fbbf24' },
|
||||||
|
approved: { label: '승인됨', color: '#60a5fa' },
|
||||||
|
working: { label: '진행중', color: '#818cf8' },
|
||||||
|
succeeded: { label: '완료', color: '#34d399' },
|
||||||
|
failed: { label: '실패', color: '#f87171' },
|
||||||
|
rejected: { label: '거절됨', color: '#fb923c' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const TaskHistory = ({ agentId, onClose }) => {
|
||||||
|
const [tasks, setTasks] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!agentId) return;
|
||||||
|
setLoading(true);
|
||||||
|
getAgentTasks(agentId, 30)
|
||||||
|
.then(data => setTasks(data.tasks || []))
|
||||||
|
.catch(() => setTasks([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [agentId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ao-history-panel">
|
||||||
|
<div className="ao-history-header">
|
||||||
|
<span>작업 이력 — {agentId}</span>
|
||||||
|
<button className="ao-chat-close" onClick={onClose}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="ao-history-list">
|
||||||
|
{loading && <p className="ao-history-empty">로딩 중...</p>}
|
||||||
|
{!loading && tasks.length === 0 && <p className="ao-history-empty">이력 없음</p>}
|
||||||
|
{tasks.map(task => {
|
||||||
|
const badge = STATUS_BADGE[task.status] || STATUS_BADGE.pending;
|
||||||
|
return (
|
||||||
|
<div key={task.id} className="ao-history-item">
|
||||||
|
<div className="ao-history-item-header">
|
||||||
|
<span className="ao-history-type">{task.task_type}</span>
|
||||||
|
<span className="ao-history-badge" style={{ background: badge.color }}>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="ao-history-time">
|
||||||
|
{task.created_at?.replace('T', ' ').slice(0, 19)}
|
||||||
|
</div>
|
||||||
|
{task.result_data && (
|
||||||
|
<details className="ao-history-detail">
|
||||||
|
<summary>결과 보기</summary>
|
||||||
|
<pre>{JSON.stringify(task.result_data, null, 2)}</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TaskHistory;
|
||||||
88
src/pages/agent-office/hooks/useAgentManager.js
Normal file
88
src/pages/agent-office/hooks/useAgentManager.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
export function useAgentManager() {
|
||||||
|
const [agents, setAgents] = useState({});
|
||||||
|
const [pendingTasks, setPendingTasks] = useState([]);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const wsRef = useRef(null);
|
||||||
|
const reconnectTimer = useRef(null);
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/api/agent-office/ws`;
|
||||||
|
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setConnected(true);
|
||||||
|
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setConnected(false);
|
||||||
|
reconnectTimer.current = setTimeout(connect, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => { ws.close(); };
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'init': {
|
||||||
|
const agentMap = {};
|
||||||
|
for (const a of msg.agents) {
|
||||||
|
agentMap[a.agent_id] = { state: a.state, detail: a.detail };
|
||||||
|
}
|
||||||
|
setAgents(agentMap);
|
||||||
|
setPendingTasks(msg.pending || []);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'agent_state':
|
||||||
|
setAgents(prev => ({
|
||||||
|
...prev,
|
||||||
|
[msg.agent]: { state: msg.state, detail: msg.detail, taskId: msg.task_id },
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
case 'task_complete':
|
||||||
|
setAgents(prev => ({
|
||||||
|
...prev,
|
||||||
|
[msg.agent]: { ...prev[msg.agent], lastResult: msg.result },
|
||||||
|
}));
|
||||||
|
setPendingTasks(prev => prev.filter(id => id !== msg.task_id));
|
||||||
|
break;
|
||||||
|
case 'command_result':
|
||||||
|
setAgents(prev => ({
|
||||||
|
...prev,
|
||||||
|
[msg.agent]: { ...prev[msg.agent], lastCommand: msg.result },
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
connect();
|
||||||
|
return () => {
|
||||||
|
if (wsRef.current) wsRef.current.close();
|
||||||
|
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||||
|
};
|
||||||
|
}, [connect]);
|
||||||
|
|
||||||
|
const sendCommand = useCallback((agent, action, params = {}) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify({ type: 'command', agent, action, params }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendApproval = useCallback((agent, taskId, approved) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify({ type: 'approval', agent, task_id: taskId, approved }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { agents, pendingTasks, connected, sendCommand, sendApproval };
|
||||||
|
}
|
||||||
62
src/pages/agent-office/hooks/useOfficeCanvas.js
Normal file
62
src/pages/agent-office/hooks/useOfficeCanvas.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { OfficeRenderer } from '../canvas/OfficeRenderer';
|
||||||
|
import officeMap from '../assets/office-map.json';
|
||||||
|
|
||||||
|
export function useOfficeCanvas(containerRef, onAgentClick) {
|
||||||
|
const rendererRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.style.display = 'block';
|
||||||
|
canvas.style.width = '100%';
|
||||||
|
canvas.style.height = '100%';
|
||||||
|
canvas.style.imageRendering = 'pixelated';
|
||||||
|
containerRef.current.appendChild(canvas);
|
||||||
|
|
||||||
|
const renderer = new OfficeRenderer(canvas, officeMap);
|
||||||
|
rendererRef.current = renderer;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
renderer.resize(rect.width, rect.height);
|
||||||
|
};
|
||||||
|
|
||||||
|
resize();
|
||||||
|
renderer.start();
|
||||||
|
|
||||||
|
renderer.setOnClick((agentId) => {
|
||||||
|
if (onAgentClick) onAgentClick(agentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClick = (e) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
renderer.handleClick(x, y);
|
||||||
|
};
|
||||||
|
|
||||||
|
canvas.addEventListener('click', handleClick);
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
renderer.stop();
|
||||||
|
canvas.removeEventListener('click', handleClick);
|
||||||
|
window.removeEventListener('resize', resize);
|
||||||
|
if (containerRef.current && canvas.parentNode === containerRef.current) {
|
||||||
|
containerRef.current.removeChild(canvas);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [containerRef, onAgentClick]);
|
||||||
|
|
||||||
|
const updateAgentState = useCallback((agentId, state, detail) => {
|
||||||
|
rendererRef.current?.updateAgentState(agentId, state, detail);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const moveAgent = useCallback((agentId, target) => {
|
||||||
|
rendererRef.current?.moveAgent(agentId, target);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { updateAgentState, moveAgent };
|
||||||
|
}
|
||||||
138
src/pages/blog-marketing/BlogMarketing.css
Normal file
138
src/pages/blog-marketing/BlogMarketing.css
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/* ── Blog Marketing ─────────────────────────────────────────────────────── */
|
||||||
|
.bm { max-width: 1100px; margin: 0 auto; padding: 24px 16px 80px; }
|
||||||
|
|
||||||
|
/* 헤더 */
|
||||||
|
.bm-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
|
||||||
|
.bm-header h1 { font-size: 1.5rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin: 0; }
|
||||||
|
.bm-status { display: flex; gap: 8px; margin-left: auto; }
|
||||||
|
.bm-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 99px; background: rgba(16,185,129,.15); color: #10b981; }
|
||||||
|
.bm-badge--off { background: rgba(239,68,68,.12); color: #ef4444; }
|
||||||
|
|
||||||
|
/* 탭 바 */
|
||||||
|
.bm-tabs { display: flex; gap: 4px; border-bottom: 1px solid rgba(255,255,255,.08); margin-bottom: 20px; }
|
||||||
|
.bm-tab { padding: 8px 16px; font-size: 0.85rem; background: none; border: none; color: rgba(255,255,255,.45); cursor: pointer; border-bottom: 2px solid transparent; transition: all .15s; }
|
||||||
|
.bm-tab:hover { color: rgba(255,255,255,.7); }
|
||||||
|
.bm-tab--active { color: #10b981; border-bottom-color: #10b981; }
|
||||||
|
|
||||||
|
/* ── Dashboard 탭 ─────────────────────────────────────────────────────────── */
|
||||||
|
.bm-dash-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px; }
|
||||||
|
.bm-dash-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; }
|
||||||
|
.bm-dash-card__label { font-size: 0.75rem; color: rgba(255,255,255,.4); margin-bottom: 4px; }
|
||||||
|
.bm-dash-card__value { font-size: 1.4rem; font-weight: 700; color: var(--text-primary, #e4e4e7); }
|
||||||
|
.bm-dash-card__value--green { color: #10b981; }
|
||||||
|
|
||||||
|
.bm-dash-section { margin-bottom: 24px; }
|
||||||
|
.bm-dash-section h3 { font-size: 0.9rem; font-weight: 600; color: rgba(255,255,255,.6); margin-bottom: 12px; }
|
||||||
|
|
||||||
|
.bm-top-posts { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.bm-top-post { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; background: rgba(255,255,255,.03); border-radius: 8px; }
|
||||||
|
.bm-top-post__title { font-size: 0.85rem; color: var(--text-primary, #e4e4e7); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.bm-top-post__rev { font-size: 0.85rem; font-weight: 600; color: #10b981; margin-left: 12px; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ── Research 탭 ──────────────────────────────────────────────────────────── */
|
||||||
|
.bm-research-form { display: flex; gap: 8px; margin-bottom: 20px; }
|
||||||
|
.bm-research-input { flex: 1; padding: 10px 14px; border-radius: 8px; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.04); color: var(--text-primary, #e4e4e7); font-size: 0.9rem; outline: none; }
|
||||||
|
.bm-research-input:focus { border-color: #10b981; }
|
||||||
|
.bm-research-input::placeholder { color: rgba(255,255,255,.25); }
|
||||||
|
|
||||||
|
.bm-btn { padding: 8px 18px; border-radius: 8px; border: none; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all .15s; display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.bm-btn--primary { background: #10b981; color: #fff; }
|
||||||
|
.bm-btn--primary:hover { background: #059669; }
|
||||||
|
.bm-btn--primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.bm-btn--secondary { background: rgba(255,255,255,.08); color: rgba(255,255,255,.7); }
|
||||||
|
.bm-btn--secondary:hover { background: rgba(255,255,255,.12); }
|
||||||
|
.bm-btn--danger { background: rgba(239,68,68,.15); color: #ef4444; }
|
||||||
|
.bm-btn--danger:hover { background: rgba(239,68,68,.25); }
|
||||||
|
.bm-btn--sm { padding: 4px 10px; font-size: 0.75rem; }
|
||||||
|
|
||||||
|
.bm-spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: bm-spin .6s linear infinite; display: inline-block; }
|
||||||
|
@keyframes bm-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* 분석 카드 */
|
||||||
|
.bm-analyses { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.bm-analysis-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; }
|
||||||
|
.bm-analysis-card__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||||
|
.bm-analysis-card__keyword { font-size: 1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); }
|
||||||
|
.bm-analysis-card__date { font-size: 0.7rem; color: rgba(255,255,255,.3); }
|
||||||
|
.bm-analysis-card__scores { display: flex; gap: 16px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||||
|
.bm-score { text-align: center; }
|
||||||
|
.bm-score__label { font-size: 0.65rem; color: rgba(255,255,255,.4); display: block; margin-bottom: 2px; }
|
||||||
|
.bm-score__value { font-size: 1.1rem; font-weight: 700; }
|
||||||
|
.bm-score__value--high { color: #10b981; }
|
||||||
|
.bm-score__value--mid { color: #fbbf24; }
|
||||||
|
.bm-score__value--low { color: #ef4444; }
|
||||||
|
.bm-analysis-card__summary { font-size: 0.8rem; color: rgba(255,255,255,.5); line-height: 1.5; }
|
||||||
|
.bm-analysis-card__actions { display: flex; gap: 8px; margin-top: 12px; }
|
||||||
|
|
||||||
|
/* ── Write 탭 ─────────────────────────────────────────────────────────────── */
|
||||||
|
.bm-write-empty { text-align: center; padding: 60px 20px; color: rgba(255,255,255,.3); }
|
||||||
|
.bm-write-empty p { font-size: 0.85rem; margin-top: 8px; }
|
||||||
|
|
||||||
|
.bm-progress { margin-bottom: 20px; }
|
||||||
|
.bm-progress__bar { height: 4px; background: rgba(255,255,255,.08); border-radius: 2px; overflow: hidden; margin-bottom: 6px; }
|
||||||
|
.bm-progress__fill { height: 100%; background: #10b981; border-radius: 2px; transition: width .3s; }
|
||||||
|
.bm-progress__text { font-size: 0.75rem; color: rgba(255,255,255,.4); }
|
||||||
|
|
||||||
|
.bm-preview { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 20px; margin-bottom: 16px; }
|
||||||
|
.bm-preview__title { font-size: 1.1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin-bottom: 12px; }
|
||||||
|
.bm-preview__body { font-size: 0.85rem; color: rgba(255,255,255,.6); line-height: 1.7; max-height: 400px; overflow-y: auto; }
|
||||||
|
.bm-preview__body h1, .bm-preview__body h2, .bm-preview__body h3 { color: var(--text-primary, #e4e4e7); margin: 16px 0 8px; }
|
||||||
|
.bm-preview__body table { width: 100%; border-collapse: collapse; margin: 12px 0; }
|
||||||
|
.bm-preview__body th, .bm-preview__body td { border: 1px solid rgba(255,255,255,.1); padding: 6px 10px; font-size: 0.8rem; }
|
||||||
|
.bm-preview__body th { background: rgba(255,255,255,.06); }
|
||||||
|
.bm-preview__tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 12px; }
|
||||||
|
.bm-tag { font-size: 0.7rem; padding: 2px 8px; border-radius: 4px; background: rgba(16,185,129,.12); color: #10b981; }
|
||||||
|
|
||||||
|
.bm-review-box { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; margin-bottom: 16px; }
|
||||||
|
.bm-review-box h4 { font-size: 0.85rem; font-weight: 600; color: var(--text-primary, #e4e4e7); margin-bottom: 10px; }
|
||||||
|
.bm-review-scores { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||||
|
.bm-review-score { text-align: center; min-width: 60px; }
|
||||||
|
.bm-review-score__label { font-size: 0.65rem; color: rgba(255,255,255,.4); display: block; }
|
||||||
|
.bm-review-score__val { font-size: 1rem; font-weight: 700; }
|
||||||
|
.bm-review-total { font-size: 0.85rem; font-weight: 700; margin-bottom: 6px; }
|
||||||
|
.bm-review-total--pass { color: #10b981; }
|
||||||
|
.bm-review-total--fail { color: #ef4444; }
|
||||||
|
.bm-review-feedback { font-size: 0.8rem; color: rgba(255,255,255,.5); line-height: 1.5; }
|
||||||
|
|
||||||
|
.bm-write-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* ── Posts 탭 ─────────────────────────────────────────────────────────────── */
|
||||||
|
.bm-posts-filter { display: flex; gap: 4px; margin-bottom: 16px; }
|
||||||
|
.bm-filter-btn { padding: 4px 12px; border-radius: 6px; border: none; font-size: 0.75rem; background: rgba(255,255,255,.06); color: rgba(255,255,255,.5); cursor: pointer; transition: all .15s; }
|
||||||
|
.bm-filter-btn--active { background: rgba(16,185,129,.15); color: #10b981; }
|
||||||
|
|
||||||
|
.bm-posts-list { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.bm-post-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 14px 16px; }
|
||||||
|
.bm-post-card__top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 6px; }
|
||||||
|
.bm-post-card__title { font-size: 0.9rem; font-weight: 600; color: var(--text-primary, #e4e4e7); flex: 1; }
|
||||||
|
.bm-post-card__status { font-size: 0.65rem; padding: 2px 8px; border-radius: 4px; font-weight: 600; white-space: nowrap; margin-left: 8px; }
|
||||||
|
.bm-post-card__status--draft { background: rgba(255,255,255,.08); color: rgba(255,255,255,.5); }
|
||||||
|
.bm-post-card__status--reviewed { background: rgba(96,165,250,.15); color: #60a5fa; }
|
||||||
|
.bm-post-card__status--published { background: rgba(16,185,129,.15); color: #10b981; }
|
||||||
|
.bm-post-card__excerpt { font-size: 0.8rem; color: rgba(255,255,255,.4); margin-bottom: 8px; line-height: 1.4; }
|
||||||
|
.bm-post-card__meta { font-size: 0.7rem; color: rgba(255,255,255,.25); display: flex; gap: 12px; }
|
||||||
|
.bm-post-card__actions { display: flex; gap: 6px; margin-top: 10px; }
|
||||||
|
|
||||||
|
/* 발행 모달 */
|
||||||
|
.bm-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 100; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.bm-modal { background: #1e1e24; border: 1px solid rgba(255,255,255,.1); border-radius: 14px; padding: 24px; width: 90%; max-width: 440px; }
|
||||||
|
.bm-modal h3 { font-size: 1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin-bottom: 12px; }
|
||||||
|
.bm-modal__input { width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.04); color: var(--text-primary, #e4e4e7); font-size: 0.85rem; outline: none; margin-bottom: 14px; }
|
||||||
|
.bm-modal__input:focus { border-color: #10b981; }
|
||||||
|
.bm-modal__buttons { display: flex; gap: 8px; justify-content: flex-end; }
|
||||||
|
|
||||||
|
/* ── 공통 빈 상태 ─────────────────────────────────────────────────────────── */
|
||||||
|
.bm-empty { text-align: center; padding: 48px 20px; color: rgba(255,255,255,.25); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
/* ── 모바일 ───────────────────────────────────────────────────────────────── */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.bm { padding: 16px 10px 60px; }
|
||||||
|
.bm-header h1 { font-size: 1.2rem; }
|
||||||
|
.bm-status { display: none; }
|
||||||
|
.bm-tab { padding: 6px 10px; font-size: 0.8rem; }
|
||||||
|
.bm-dash-cards { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.bm-research-form { flex-direction: column; }
|
||||||
|
.bm-analysis-card__scores { gap: 10px; }
|
||||||
|
.bm-write-actions { flex-direction: column; }
|
||||||
|
.bm-post-card__actions { flex-wrap: wrap; }
|
||||||
|
}
|
||||||
696
src/pages/blog-marketing/BlogMarketing.jsx
Normal file
696
src/pages/blog-marketing/BlogMarketing.jsx
Normal file
@@ -0,0 +1,696 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
getBlogMarketingStatus,
|
||||||
|
startResearch,
|
||||||
|
getResearchHistory,
|
||||||
|
getResearchDetail,
|
||||||
|
deleteResearch,
|
||||||
|
getBlogMarketingTask,
|
||||||
|
startGenerate,
|
||||||
|
startReview,
|
||||||
|
startRegenerate,
|
||||||
|
startMarket,
|
||||||
|
getBlogMarketingPosts,
|
||||||
|
getBlogMarketingPost,
|
||||||
|
deleteBlogMarketingPost,
|
||||||
|
publishBlogMarketingPost,
|
||||||
|
getBlogMarketingDashboard,
|
||||||
|
getBlogMarketingCommissions,
|
||||||
|
addBlogMarketingCommission,
|
||||||
|
deleteBlogMarketingCommission,
|
||||||
|
getBrandLinks,
|
||||||
|
createBrandLink,
|
||||||
|
deleteBrandLink,
|
||||||
|
} from '../../api';
|
||||||
|
import './BlogMarketing.css';
|
||||||
|
|
||||||
|
/* ────────────────────── 유틸 ────────────────────── */
|
||||||
|
function fmtDate(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
return new Date(iso).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
function fmtMoney(n) {
|
||||||
|
if (n == null) return '-';
|
||||||
|
return n.toLocaleString('ko-KR') + '원';
|
||||||
|
}
|
||||||
|
function copyHtmlToClipboard(html) {
|
||||||
|
const blob = new Blob([html], { type: 'text/html' });
|
||||||
|
const plainBlob = new Blob([html.replace(/<[^>]*>/g, '')], { type: 'text/plain' });
|
||||||
|
navigator.clipboard.write([
|
||||||
|
new ClipboardItem({ 'text/html': blob, 'text/plain': plainBlob }),
|
||||||
|
]).then(() => alert('본문이 클립보드에 복사되었습니다! (서식 포함)'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreColor(v, max = 100) {
|
||||||
|
const r = v / max;
|
||||||
|
if (r >= 0.6) return 'bm-score__value--high';
|
||||||
|
if (r >= 0.3) return 'bm-score__value--mid';
|
||||||
|
return 'bm-score__value--low';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ────────────────────── 폴링 훅 ────────────────────── */
|
||||||
|
function usePollTask(onDone) {
|
||||||
|
const [taskId, setTaskId] = useState(null);
|
||||||
|
const [task, setTask] = useState(null);
|
||||||
|
const timer = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!taskId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const t = await getBlogMarketingTask(taskId);
|
||||||
|
if (cancelled) return;
|
||||||
|
setTask(t);
|
||||||
|
if (t.status === 'succeeded' || t.status === 'failed') {
|
||||||
|
setTaskId(null);
|
||||||
|
onDone?.(t);
|
||||||
|
} else {
|
||||||
|
timer.current = setTimeout(poll, 1500);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) timer.current = setTimeout(poll, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
poll();
|
||||||
|
return () => { cancelled = true; clearTimeout(timer.current); };
|
||||||
|
}, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return { taskId, task, start: setTaskId, clear: () => { setTaskId(null); setTask(null); } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
export default function BlogMarketing() {
|
||||||
|
const [tab, setTab] = useState('dashboard');
|
||||||
|
const [status, setStatus] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getBlogMarketingStatus().then(setStatus).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'dashboard', label: 'Dashboard' },
|
||||||
|
{ id: 'research', label: 'Research' },
|
||||||
|
{ id: 'write', label: 'Write' },
|
||||||
|
{ id: 'posts', label: 'Posts' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bm">
|
||||||
|
<header className="bm-header">
|
||||||
|
<h1>Blog Lab</h1>
|
||||||
|
{status && (
|
||||||
|
<div className="bm-status">
|
||||||
|
<span className={`bm-badge ${status.naver_api ? '' : 'bm-badge--off'}`}>
|
||||||
|
Naver {status.naver_api ? 'ON' : 'OFF'}
|
||||||
|
</span>
|
||||||
|
<span className={`bm-badge ${status.claude_api ? '' : 'bm-badge--off'}`}>
|
||||||
|
Claude {status.claude_api ? 'ON' : 'OFF'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav className="bm-tabs">
|
||||||
|
{tabs.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
className={`bm-tab ${tab === t.id ? 'bm-tab--active' : ''}`}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{tab === 'dashboard' && <DashboardTab />}
|
||||||
|
{tab === 'research' && <ResearchTab onGenerate={(id) => { setTab('write'); }} />}
|
||||||
|
{tab === 'write' && <WriteTab />}
|
||||||
|
{tab === 'posts' && <PostsTab />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════ Dashboard 탭 ═════════════════════════════════════ */
|
||||||
|
function DashboardTab() {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getBlogMarketingDashboard().then(setData).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!data) return <div className="bm-empty">로딩 중...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="bm-dash-cards">
|
||||||
|
<DashCard label="총 포스트" value={data.total_posts} />
|
||||||
|
<DashCard label="발행 완료" value={data.published_posts} />
|
||||||
|
<DashCard label="총 클릭" value={data.total_clicks.toLocaleString()} />
|
||||||
|
<DashCard label="총 수익" value={fmtMoney(data.total_revenue)} green />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.top_posts?.length > 0 && (
|
||||||
|
<div className="bm-dash-section">
|
||||||
|
<h3>Top 5 포스트 (수익 기준)</h3>
|
||||||
|
<div className="bm-top-posts">
|
||||||
|
{data.top_posts.map(p => (
|
||||||
|
<div key={p.id} className="bm-top-post">
|
||||||
|
<span className="bm-top-post__title">{p.title || '(제목 없음)'}</span>
|
||||||
|
<span className="bm-top-post__rev">{fmtMoney(p.total_revenue)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.monthly?.length > 0 && (
|
||||||
|
<div className="bm-dash-section">
|
||||||
|
<h3>월별 수익</h3>
|
||||||
|
<div className="bm-top-posts">
|
||||||
|
{data.monthly.map(m => (
|
||||||
|
<div key={m.month} className="bm-top-post">
|
||||||
|
<span className="bm-top-post__title">{m.month}</span>
|
||||||
|
<span style={{ fontSize: '0.8rem', color: 'rgba(255,255,255,.4)', marginRight: 12 }}>
|
||||||
|
클릭 {m.clicks} / 구매 {m.purchases}
|
||||||
|
</span>
|
||||||
|
<span className="bm-top-post__rev">{fmtMoney(m.revenue)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashCard({ label, value, green }) {
|
||||||
|
return (
|
||||||
|
<div className="bm-dash-card">
|
||||||
|
<div className="bm-dash-card__label">{label}</div>
|
||||||
|
<div className={`bm-dash-card__value ${green ? 'bm-dash-card__value--green' : ''}`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════ Research 탭 ══════════════════════════════════════ */
|
||||||
|
function ResearchTab() {
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [analyses, setAnalyses] = useState([]);
|
||||||
|
const [expanded, setExpanded] = useState(null);
|
||||||
|
|
||||||
|
const loadHistory = useCallback(() => {
|
||||||
|
getResearchHistory(30).then(r => setAnalyses(r.analyses || [])).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadHistory(); }, [loadHistory]);
|
||||||
|
|
||||||
|
const poll = usePollTask((t) => {
|
||||||
|
if (t.status === 'succeeded') loadHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
if (!keyword.trim() || poll.taskId) return;
|
||||||
|
try {
|
||||||
|
const { task_id } = await startResearch(keyword.trim());
|
||||||
|
poll.start(task_id);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
if (!confirm('이 분석을 삭제할까요?')) return;
|
||||||
|
await deleteResearch(id);
|
||||||
|
setAnalyses(prev => prev.filter(a => a.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerate = async (analysisId) => {
|
||||||
|
try {
|
||||||
|
const { task_id } = await startGenerate(analysisId);
|
||||||
|
alert(`글 생성 시작! (task: ${task_id.slice(0, 8)})\nWrite 탭에서 확인하세요.`);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="bm-research-form">
|
||||||
|
<input
|
||||||
|
className="bm-research-input"
|
||||||
|
placeholder="분석할 키워드를 입력하세요 (예: 무선 이어폰 추천)"
|
||||||
|
value={keyword}
|
||||||
|
onChange={e => setKeyword(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||||
|
disabled={!!poll.taskId}
|
||||||
|
/>
|
||||||
|
<button className="bm-btn bm-btn--primary" onClick={handleSearch} disabled={!!poll.taskId}>
|
||||||
|
{poll.taskId ? <><span className="bm-spinner" /> 분석 중...</> : '분석'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{poll.task && poll.task.status !== 'succeeded' && poll.task.status !== 'failed' && (
|
||||||
|
<div className="bm-progress">
|
||||||
|
<div className="bm-progress__bar">
|
||||||
|
<div className="bm-progress__fill" style={{ width: `${poll.task.progress || 0}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="bm-progress__text">{poll.task.message || '처리 중...'}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bm-analyses">
|
||||||
|
{analyses.length === 0 && !poll.taskId && (
|
||||||
|
<div className="bm-empty">아직 분석 결과가 없습니다. 키워드를 입력해 첫 분석을 시작하세요!</div>
|
||||||
|
)}
|
||||||
|
{analyses.map(a => (
|
||||||
|
<div key={a.id} className="bm-analysis-card">
|
||||||
|
<div className="bm-analysis-card__header">
|
||||||
|
<span className="bm-analysis-card__keyword">{a.keyword}</span>
|
||||||
|
<span className="bm-analysis-card__date">{fmtDate(a.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bm-analysis-card__scores">
|
||||||
|
<div className="bm-score">
|
||||||
|
<span className="bm-score__label">경쟁도</span>
|
||||||
|
<span className={`bm-score__value ${scoreColor(a.competition)}`}>{a.competition}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bm-score">
|
||||||
|
<span className="bm-score__label">기회</span>
|
||||||
|
<span className={`bm-score__value ${scoreColor(a.opportunity)}`}>{a.opportunity}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bm-score">
|
||||||
|
<span className="bm-score__label">블로그</span>
|
||||||
|
<span className="bm-score__value" style={{ color: 'rgba(255,255,255,.6)' }}>
|
||||||
|
{(a.blog_total || 0).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bm-score">
|
||||||
|
<span className="bm-score__label">쇼핑</span>
|
||||||
|
<span className="bm-score__value" style={{ color: 'rgba(255,255,255,.6)' }}>
|
||||||
|
{(a.shop_total || 0).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{a.avg_price != null && (
|
||||||
|
<div className="bm-score">
|
||||||
|
<span className="bm-score__label">평균가</span>
|
||||||
|
<span className="bm-score__value" style={{ color: 'rgba(255,255,255,.6)' }}>
|
||||||
|
{fmtMoney(a.avg_price)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded === a.id && a.top_products?.length > 0 && (
|
||||||
|
<div className="bm-analysis-card__summary">
|
||||||
|
<strong>상위 상품:</strong>
|
||||||
|
<ul style={{ margin: '4px 0 0 16px', padding: 0 }}>
|
||||||
|
{a.top_products.map((p, i) => (
|
||||||
|
<li key={i}>{p.title} — {fmtMoney(p.lprice)} ({p.mallName})</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bm-analysis-card__actions">
|
||||||
|
<button className="bm-btn bm-btn--primary bm-btn--sm" onClick={() => handleGenerate(a.id)}>
|
||||||
|
글 생성
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="bm-btn bm-btn--secondary bm-btn--sm"
|
||||||
|
onClick={() => setExpanded(expanded === a.id ? null : a.id)}
|
||||||
|
>
|
||||||
|
{expanded === a.id ? '접기' : '상세'}
|
||||||
|
</button>
|
||||||
|
<button className="bm-btn bm-btn--danger bm-btn--sm" onClick={() => handleDelete(a.id)}>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════ Write 탭 ═════════════════════════════════════════ */
|
||||||
|
function WriteTab() {
|
||||||
|
const [posts, setPosts] = useState([]);
|
||||||
|
const [selected, setSelected] = useState(null);
|
||||||
|
const [post, setPost] = useState(null);
|
||||||
|
|
||||||
|
// 브랜드 링크 상태
|
||||||
|
const [links, setLinks] = useState([]);
|
||||||
|
const [showLinkForm, setShowLinkForm] = useState(false);
|
||||||
|
const [linkForm, setLinkForm] = useState({ url: '', product_name: '', description: '', placement_hint: '' });
|
||||||
|
|
||||||
|
const loadPosts = useCallback(() => {
|
||||||
|
Promise.all([
|
||||||
|
getBlogMarketingPosts('draft', 20),
|
||||||
|
getBlogMarketingPosts('marketed', 20),
|
||||||
|
]).then(([draftRes, marketedRes]) => {
|
||||||
|
const all = [...(draftRes.posts || []), ...(marketedRes.posts || [])];
|
||||||
|
all.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||||
|
setPosts(all);
|
||||||
|
if (all.length > 0 && !selected) setSelected(all[0].id);
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => { loadPosts(); }, [loadPosts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected) { setPost(null); setLinks([]); return; }
|
||||||
|
getBlogMarketingPost(selected).then(setPost).catch(() => {});
|
||||||
|
getBrandLinks({ post_id: selected }).then(r => setLinks(r.links || [])).catch(() => setLinks([]));
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
|
const reviewPoll = usePollTask((t) => {
|
||||||
|
if (t.status === 'succeeded' && t.result_id) {
|
||||||
|
getBlogMarketingPost(t.result_id).then(setPost).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const regenPoll = usePollTask((t) => {
|
||||||
|
if (t.status === 'succeeded' && t.result_id) {
|
||||||
|
getBlogMarketingPost(t.result_id).then(setPost).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const marketPoll = usePollTask((t) => {
|
||||||
|
if (t.status === 'succeeded' && t.result_id) {
|
||||||
|
getBlogMarketingPost(t.result_id).then(setPost).catch(() => {});
|
||||||
|
loadPosts();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleReview = async () => {
|
||||||
|
if (!post) return;
|
||||||
|
try {
|
||||||
|
const { task_id } = await startReview(post.id);
|
||||||
|
reviewPoll.start(task_id);
|
||||||
|
} catch (e) { alert(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegenerate = async () => {
|
||||||
|
if (!post) return;
|
||||||
|
try {
|
||||||
|
const { task_id } = await startRegenerate(post.id);
|
||||||
|
regenPoll.start(task_id);
|
||||||
|
} catch (e) { alert(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarket = async () => {
|
||||||
|
if (!post) return;
|
||||||
|
if (links.length === 0) {
|
||||||
|
alert('마케터 실행 전 브랜드커넥트 링크를 먼저 추가하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { task_id } = await startMarket(post.id);
|
||||||
|
marketPoll.start(task_id);
|
||||||
|
} catch (e) { alert(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
if (!post) return;
|
||||||
|
copyHtmlToClipboard(post.body);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddLink = async () => {
|
||||||
|
if (!linkForm.url.trim() || !linkForm.product_name.trim()) {
|
||||||
|
alert('URL과 상품명은 필수입니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await createBrandLink({ ...linkForm, post_id: selected });
|
||||||
|
setLinkForm({ url: '', product_name: '', description: '', placement_hint: '' });
|
||||||
|
setShowLinkForm(false);
|
||||||
|
getBrandLinks({ post_id: selected }).then(r => setLinks(r.links || [])).catch(() => {});
|
||||||
|
} catch (e) { alert(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteLink = async (linkId) => {
|
||||||
|
if (!confirm('이 링크를 삭제할까요?')) return;
|
||||||
|
await deleteBrandLink(linkId);
|
||||||
|
setLinks(prev => prev.filter(l => l.id !== linkId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const activePoll = reviewPoll.task || regenPoll.task || marketPoll.task;
|
||||||
|
const isProcessing = activePoll && activePoll.status !== 'succeeded' && activePoll.status !== 'failed';
|
||||||
|
|
||||||
|
if (posts.length === 0 && !post) {
|
||||||
|
return (
|
||||||
|
<div className="bm-write-empty">
|
||||||
|
<div style={{ fontSize: '2rem', marginBottom: 8 }}>✍</div>
|
||||||
|
<p>아직 작성 중인 글이 없습니다.<br />Research 탭에서 키워드를 분석하고 글 생성을 시작하세요.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{posts.length > 1 && (
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||||
|
{posts.map(p => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
className={`bm-filter-btn ${selected === p.id ? 'bm-filter-btn--active' : ''}`}
|
||||||
|
onClick={() => setSelected(p.id)}
|
||||||
|
>
|
||||||
|
{p.title?.slice(0, 20) || `${p.status === 'marketed' ? 'Marketed' : 'Draft'} #${p.id}`}
|
||||||
|
{p.status === 'marketed' && <span style={{ marginLeft: 4, fontSize: '0.7rem', color: '#f59e0b' }}>[M]</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isProcessing && activePoll && (
|
||||||
|
<div className="bm-progress">
|
||||||
|
<div className="bm-progress__bar">
|
||||||
|
<div className="bm-progress__fill" style={{ width: `${activePoll.progress || 0}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="bm-progress__text">{activePoll.message || '처리 중...'}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{post && (
|
||||||
|
<>
|
||||||
|
{/* 브랜드커넥트 링크 섹션 */}
|
||||||
|
<div className="bm-links-section" style={{ marginBottom: 16, padding: 12, background: 'rgba(255,255,255,0.04)', borderRadius: 8 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||||
|
<h4 style={{ margin: 0, fontSize: '0.9rem' }}>브랜드커넥트 링크 ({links.length})</h4>
|
||||||
|
<button className="bm-btn bm-btn--secondary bm-btn--sm" onClick={() => setShowLinkForm(!showLinkForm)}>
|
||||||
|
{showLinkForm ? '취소' : '+ 링크 추가'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showLinkForm && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12, padding: 12, background: 'rgba(0,0,0,0.2)', borderRadius: 6 }}>
|
||||||
|
<input
|
||||||
|
className="bm-research-input"
|
||||||
|
placeholder="제휴 링크 URL (필수)"
|
||||||
|
value={linkForm.url}
|
||||||
|
onChange={e => setLinkForm(p => ({ ...p, url: e.target.value }))}
|
||||||
|
style={{ fontSize: '0.85rem' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="bm-research-input"
|
||||||
|
placeholder="상품명 (필수)"
|
||||||
|
value={linkForm.product_name}
|
||||||
|
onChange={e => setLinkForm(p => ({ ...p, product_name: e.target.value }))}
|
||||||
|
style={{ fontSize: '0.85rem' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="bm-research-input"
|
||||||
|
placeholder="상품 설명 (선택)"
|
||||||
|
value={linkForm.description}
|
||||||
|
onChange={e => setLinkForm(p => ({ ...p, description: e.target.value }))}
|
||||||
|
style={{ fontSize: '0.85rem' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="bm-research-input"
|
||||||
|
placeholder="배치 힌트 (선택, 예: 본문 중간 자연스럽게)"
|
||||||
|
value={linkForm.placement_hint}
|
||||||
|
onChange={e => setLinkForm(p => ({ ...p, placement_hint: e.target.value }))}
|
||||||
|
style={{ fontSize: '0.85rem' }}
|
||||||
|
/>
|
||||||
|
<button className="bm-btn bm-btn--primary bm-btn--sm" onClick={handleAddLink}>등록</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{links.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{links.map(l => (
|
||||||
|
<div key={l.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 8px', background: 'rgba(255,255,255,0.03)', borderRadius: 4, fontSize: '0.8rem' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<strong>{l.product_name}</strong>
|
||||||
|
{l.description && <span style={{ marginLeft: 8, color: 'rgba(255,255,255,.4)' }}>{l.description}</span>}
|
||||||
|
</div>
|
||||||
|
<button className="bm-btn bm-btn--danger bm-btn--sm" onClick={() => handleDeleteLink(l.id)} style={{ fontSize: '0.7rem', padding: '2px 6px' }}>삭제</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bm-preview">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div className="bm-preview__title">{post.title || '(제목 없음)'}</div>
|
||||||
|
<span className={`bm-post-card__status bm-post-card__status--${post.status}`} style={{ fontSize: '0.75rem' }}>
|
||||||
|
{post.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bm-preview__body" dangerouslySetInnerHTML={{ __html: post.body }} />
|
||||||
|
{post.tags?.length > 0 && (
|
||||||
|
<div className="bm-preview__tags">
|
||||||
|
{post.tags.map((t, i) => <span key={i} className="bm-tag">#{t}</span>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{post.review_detail && post.review_score != null && (
|
||||||
|
<div className="bm-review-box">
|
||||||
|
<h4>품질 리뷰 결과</h4>
|
||||||
|
<div className="bm-review-scores">
|
||||||
|
{Object.entries(post.review_detail.scores || {}).map(([k, v]) => (
|
||||||
|
<div key={k} className="bm-review-score">
|
||||||
|
<span className="bm-review-score__label">{k}</span>
|
||||||
|
<span className={`bm-review-score__val ${scoreColor(v, 10)}`}>{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={`bm-review-total ${post.review_detail.pass ? 'bm-review-total--pass' : 'bm-review-total--fail'}`}>
|
||||||
|
총점: {post.review_score}/60 {post.review_detail.pass ? '(통과)' : '(미달)'}
|
||||||
|
</div>
|
||||||
|
{post.review_detail.feedback && (
|
||||||
|
<div className="bm-review-feedback">{post.review_detail.feedback}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bm-write-actions">
|
||||||
|
{post.status === 'draft' && (
|
||||||
|
<button className="bm-btn bm-btn--primary" onClick={handleMarket} disabled={isProcessing} title={links.length === 0 ? '브랜드 링크를 먼저 추가하세요' : ''}>
|
||||||
|
{marketPoll.taskId ? <><span className="bm-spinner" /> 마케팅 중...</> : '마케터 실행'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="bm-btn bm-btn--primary" onClick={handleReview} disabled={isProcessing}>
|
||||||
|
{reviewPoll.taskId ? <><span className="bm-spinner" /> 리뷰 중...</> : '품질 리뷰'}
|
||||||
|
</button>
|
||||||
|
<button className="bm-btn bm-btn--secondary" onClick={handleRegenerate} disabled={isProcessing}>
|
||||||
|
{regenPoll.taskId ? <><span className="bm-spinner" /> 재생성 중...</> : '재생성'}
|
||||||
|
</button>
|
||||||
|
<button className="bm-btn bm-btn--secondary" onClick={handleCopy}>
|
||||||
|
본문 복사
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════ Posts 탭 ═════════════════════════════════════════ */
|
||||||
|
function PostsTab() {
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
const [posts, setPosts] = useState([]);
|
||||||
|
const [publishModal, setPublishModal] = useState(null);
|
||||||
|
const [naverUrl, setNaverUrl] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
getBlogMarketingPosts(filter || undefined).then(r => setPosts(r.posts || [])).catch(() => {});
|
||||||
|
}, [filter]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
if (!confirm('이 포스트를 삭제할까요?')) return;
|
||||||
|
await deleteBlogMarketingPost(id);
|
||||||
|
setPosts(prev => prev.filter(p => p.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePublish = async () => {
|
||||||
|
if (!publishModal) return;
|
||||||
|
await publishBlogMarketingPost(publishModal, naverUrl);
|
||||||
|
setPublishModal(null);
|
||||||
|
setNaverUrl('');
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = (body) => {
|
||||||
|
copyHtmlToClipboard(body);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filters = [
|
||||||
|
{ id: '', label: '전체' },
|
||||||
|
{ id: 'draft', label: 'Draft' },
|
||||||
|
{ id: 'marketed', label: 'Marketed' },
|
||||||
|
{ id: 'reviewed', label: 'Reviewed' },
|
||||||
|
{ id: 'published', label: 'Published' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="bm-posts-filter">
|
||||||
|
{filters.map(f => (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
className={`bm-filter-btn ${filter === f.id ? 'bm-filter-btn--active' : ''}`}
|
||||||
|
onClick={() => setFilter(f.id)}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bm-posts-list">
|
||||||
|
{posts.length === 0 && <div className="bm-empty">포스트가 없습니다.</div>}
|
||||||
|
{posts.map(p => (
|
||||||
|
<div key={p.id} className="bm-post-card">
|
||||||
|
<div className="bm-post-card__top">
|
||||||
|
<span className="bm-post-card__title">{p.title || '(제목 없음)'}</span>
|
||||||
|
<span className={`bm-post-card__status bm-post-card__status--${p.status}`}>
|
||||||
|
{p.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{p.excerpt && <div className="bm-post-card__excerpt">{p.excerpt}</div>}
|
||||||
|
<div className="bm-post-card__meta">
|
||||||
|
{p.review_score != null && <span>리뷰: {p.review_score}/60</span>}
|
||||||
|
{p.naver_url && <a href={p.naver_url} target="_blank" rel="noreferrer" style={{ color: '#10b981' }}>네이버 링크</a>}
|
||||||
|
<span>{fmtDate(p.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bm-post-card__actions">
|
||||||
|
<button className="bm-btn bm-btn--secondary bm-btn--sm" onClick={() => handleCopy(p.body)}>복사</button>
|
||||||
|
{p.status !== 'published' && (
|
||||||
|
<button className="bm-btn bm-btn--primary bm-btn--sm" onClick={() => { setPublishModal(p.id); setNaverUrl(''); }}>
|
||||||
|
발행
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="bm-btn bm-btn--danger bm-btn--sm" onClick={() => handleDelete(p.id)}>삭제</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{publishModal && (
|
||||||
|
<div className="bm-modal-overlay" onClick={() => setPublishModal(null)}>
|
||||||
|
<div className="bm-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3>네이버 블로그 발행</h3>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'rgba(255,255,255,.4)', marginBottom: 12 }}>
|
||||||
|
본문을 네이버 블로그에 붙여넣기한 후, 발행된 URL을 입력하세요.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
className="bm-modal__input"
|
||||||
|
placeholder="https://blog.naver.com/..."
|
||||||
|
value={naverUrl}
|
||||||
|
onChange={e => setNaverUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="bm-modal__buttons">
|
||||||
|
<button className="bm-btn bm-btn--secondary" onClick={() => setPublishModal(null)}>취소</button>
|
||||||
|
<button className="bm-btn bm-btn--primary" onClick={handlePublish}>발행 완료</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,17 @@ const LAB_ITEMS = [
|
|||||||
icon: '📅',
|
icon: '📅',
|
||||||
status: 'live',
|
status: 'live',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'agent-office',
|
||||||
|
path: '/agent-office',
|
||||||
|
title: 'Agent Office',
|
||||||
|
category: 'AI · 자동화',
|
||||||
|
desc: 'AI 에이전트들이 사무실에서 자동으로 작업하는 가상 오피스',
|
||||||
|
tags: ['Canvas 2D', 'WebSocket', 'AI Agent', 'Telegram'],
|
||||||
|
accent: '#8b5cf6',
|
||||||
|
icon: '🏢',
|
||||||
|
status: 'wip',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_LABEL = {
|
const STATUS_LABEL = {
|
||||||
|
|||||||
@@ -2110,6 +2110,311 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════
|
||||||
|
LYRICS TAB
|
||||||
|
═══════════════════════════════════════════════════ */
|
||||||
|
.ms-lyrics-tab {
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-lyrics-tab__head {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-lyrics-tab__title {
|
||||||
|
font-family: var(--ms-ff-disp);
|
||||||
|
font-size: 28px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--ms-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-lyrics-tab__desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ms-muted);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Input area ── */
|
||||||
|
.ms-lyrics-tab__form {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-lyrics-tab__input-wrap {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--ms-surface);
|
||||||
|
border: 1px solid var(--ms-line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-lyrics-tab__input-wrap:focus-within {
|
||||||
|
border-color: var(--ms-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-lyrics-tab__input {
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: var(--ms-text);
|
||||||
|
font-family: var(--ms-ff-body);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-lyrics-tab__input::placeholder {
|
||||||
|
color: var(--ms-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-lyrics-tab__input-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-lyrics-tab__count {
|
||||||
|
font-family: var(--ms-ff-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--ms-dim);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Accent button ── */
|
||||||
|
.ms-btn--accent {
|
||||||
|
padding: 8px 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--ms-ff-body);
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--ms-accent);
|
||||||
|
color: #0c0b09;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s, transform 0.15s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-btn--accent:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-btn--accent:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-btn--accent.is-loading {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-btn__spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid rgba(12,11,9,0.2);
|
||||||
|
border-top-color: #0c0b09;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: ms-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ms-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty state ── */
|
||||||
|
.ms-lyrics-tab__empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 20px;
|
||||||
|
border: 1px dashed var(--ms-line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--ms-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-lyrics-tab__empty-icon {
|
||||||
|
font-size: 40px;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-lyrics-tab__empty p {
|
||||||
|
color: var(--ms-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-lyrics-tab__empty-hint {
|
||||||
|
font-family: var(--ms-ff-mono);
|
||||||
|
font-size: 10px !important;
|
||||||
|
color: var(--ms-dim) !important;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading state ── */
|
||||||
|
.ms-lyrics-tab__loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px 20px;
|
||||||
|
border: 1px solid var(--ms-line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--ms-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-lyrics-tab__loading p {
|
||||||
|
color: var(--ms-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--ms-ff-mono);
|
||||||
|
margin: 12px 0 0;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-lyrics-tab__loading-bar {
|
||||||
|
height: 3px;
|
||||||
|
width: 60%;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--ms-accent), transparent);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: ms-shimmer 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Results list ── */
|
||||||
|
.ms-lyrics-tab__results {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Lyrics Card ── */
|
||||||
|
.ms-lyrics-card {
|
||||||
|
background: var(--ms-surface);
|
||||||
|
border: 1px solid var(--ms-line);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-lyrics-card:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--ms-accent) 50%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-lyrics-card__header {
|
||||||
|
padding: 14px 16px 10px;
|
||||||
|
border-bottom: 1px solid var(--ms-line-2);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-lyrics-card__title {
|
||||||
|
font-family: var(--ms-ff-disp);
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--ms-text);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-lyrics-card__prompt {
|
||||||
|
font-family: var(--ms-ff-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--ms-dim);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-lyrics-card__text {
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--ms-ff-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: rgba(255,255,255,0.75);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-lyrics-card__date {
|
||||||
|
font-family: var(--ms-ff-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--ms-dim);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-lyrics-card__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px 12px;
|
||||||
|
border-top: 1px solid var(--ms-line-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 수정 모드 ── */
|
||||||
|
.ms-lyrics-card.is-editing {
|
||||||
|
border-color: var(--ms-accent);
|
||||||
|
box-shadow: 0 0 16px rgba(245, 166, 35, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-lyrics-card__title-input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--ms-surface2);
|
||||||
|
border: 1px solid var(--ms-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-family: var(--ms-ff-disp);
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--ms-text);
|
||||||
|
outline: none;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-lyrics-card__title-input:focus {
|
||||||
|
border-color: var(--ms-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-lyrics-card__text-input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--ms-surface2);
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--ms-line-2);
|
||||||
|
border-bottom: 1px solid var(--ms-line-2);
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-family: var(--ms-ff-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: rgba(255,255,255,0.85);
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 200px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-btn--danger-text {
|
||||||
|
color: #e85c3a !important;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-btn--danger-text:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-btn--accent.ms-btn--sm {
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════
|
||||||
REDUCED MOTION
|
REDUCED MOTION
|
||||||
═══════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════ */
|
||||||
@@ -2121,3 +2426,173 @@
|
|||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Phase 1: Credits Badge ─────────────────────────────── */
|
||||||
|
.ms-credits-badge {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 6px 14px; border-radius: 20px;
|
||||||
|
background: rgba(245, 166, 35, 0.1);
|
||||||
|
border: 1px solid rgba(245, 166, 35, 0.25);
|
||||||
|
font-family: 'Courier Prime', monospace;
|
||||||
|
font-size: 0.85rem; color: var(--ms-accent);
|
||||||
|
}
|
||||||
|
.ms-credits-badge__icon { font-size: 1rem; }
|
||||||
|
.ms-credits-badge__value { font-weight: 700; font-size: 1.1rem; }
|
||||||
|
.ms-credits-badge__label { color: var(--ms-muted); font-size: 0.75rem; text-transform: uppercase; }
|
||||||
|
.ms-credits-badge.is-low {
|
||||||
|
background: rgba(231, 76, 60, 0.15);
|
||||||
|
border-color: rgba(231, 76, 60, 0.4);
|
||||||
|
color: #e74c3c;
|
||||||
|
animation: pulse-badge 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse-badge {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stem Modal ─────────────────────────────────────────── */
|
||||||
|
.ms-modal--wide { max-width: 680px; }
|
||||||
|
.ms-modal__subtitle { font-size: 0.78rem; color: var(--ms-muted); font-family: 'Courier Prime', monospace; }
|
||||||
|
.ms-stem-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||||
|
.ms-stem-card {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||||
|
padding: 12px 8px; border-radius: 10px;
|
||||||
|
background: var(--ms-surface2); border: 1px solid var(--ms-line);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.ms-stem-card.is-playing { border-color: var(--ms-accent); background: rgba(245,166,35,0.08); }
|
||||||
|
.ms-stem-card__icon { font-size: 1.4rem; }
|
||||||
|
.ms-stem-card__name {
|
||||||
|
font-family: 'Courier Prime', monospace; font-size: 0.72rem;
|
||||||
|
color: var(--ms-muted); text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.ms-stem-card__actions { display: flex; gap: 6px; }
|
||||||
|
|
||||||
|
/* ── Synced Lyrics Player ───────────────────────────────── */
|
||||||
|
.ms-synced-player {
|
||||||
|
background: var(--ms-surface); border: 1px solid var(--ms-line);
|
||||||
|
border-radius: 12px; padding: 16px; margin-top: 12px;
|
||||||
|
}
|
||||||
|
.ms-synced-player__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||||
|
.ms-synced-player__title { font-family: 'Bebas Neue', sans-serif; font-size: 1.1rem; color: var(--ms-text); }
|
||||||
|
.ms-synced-player__audio { width: 100%; margin-bottom: 12px; }
|
||||||
|
.ms-synced-player__lyrics { line-height: 1.8; font-family: 'Syne', sans-serif; font-size: 0.95rem; }
|
||||||
|
.ms-synced-word { color: var(--ms-dim); transition: color 0.15s; }
|
||||||
|
.ms-synced-word.is-active { color: var(--synced-accent, var(--ms-accent)); font-weight: 600; }
|
||||||
|
.ms-synced-word.is-past { color: var(--ms-muted); }
|
||||||
|
|
||||||
|
/* ── Style Boost Button ─────────────────────────────────── */
|
||||||
|
.ms-style-boost-btn { margin-left: auto; }
|
||||||
|
.ms-style-boost-btn.is-loading { opacity: 0.6; }
|
||||||
|
|
||||||
|
/* ── Remix Tab ──────────────────────────────────────────── */
|
||||||
|
.ms-remix-tab { display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
.ms-remix-tab__header { }
|
||||||
|
.ms-remix-tab__title { font-family: 'Bebas Neue', sans-serif; font-size: 1.8rem; color: var(--ms-text); }
|
||||||
|
.ms-remix-tab__desc { font-size: 0.85rem; color: var(--ms-muted); }
|
||||||
|
|
||||||
|
.ms-remix-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||||
|
.ms-remix-card {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||||
|
padding: 20px 12px; border-radius: 12px; cursor: pointer;
|
||||||
|
background: var(--ms-surface); border: 1px solid var(--ms-line);
|
||||||
|
transition: all 0.2s; text-align: center;
|
||||||
|
}
|
||||||
|
.ms-remix-card:hover { border-color: var(--ms-accent); background: var(--ms-surface2); }
|
||||||
|
.ms-remix-card.is-active { border-color: var(--ms-accent); background: rgba(245,166,35,0.08); }
|
||||||
|
.ms-remix-card__icon { font-size: 2rem; }
|
||||||
|
.ms-remix-card__label { font-family: 'Bebas Neue', sans-serif; font-size: 1.1rem; color: var(--ms-text); }
|
||||||
|
.ms-remix-card__desc { font-size: 0.72rem; color: var(--ms-muted); font-family: 'Courier Prime', monospace; }
|
||||||
|
|
||||||
|
.ms-remix-params {
|
||||||
|
display: flex; flex-direction: column; gap: 12px;
|
||||||
|
padding: 16px; border-radius: 12px;
|
||||||
|
background: var(--ms-surface); border: 1px solid var(--ms-line);
|
||||||
|
}
|
||||||
|
.ms-remix-submit { align-self: flex-start; margin-top: 8px; }
|
||||||
|
|||||||
@@ -7,11 +7,24 @@ import {
|
|||||||
getMusicProviders,
|
getMusicProviders,
|
||||||
getMusicStatus,
|
getMusicStatus,
|
||||||
getMusicModels,
|
getMusicModels,
|
||||||
getMusicCredits,
|
|
||||||
extendMusicTrack,
|
extendMusicTrack,
|
||||||
removeVocals,
|
removeVocals,
|
||||||
|
generateCoverImage,
|
||||||
|
convertToWav,
|
||||||
|
splitStems,
|
||||||
|
getTimestampedLyrics,
|
||||||
|
generateStyleBoost,
|
||||||
|
generateVideo,
|
||||||
} from '../../api';
|
} from '../../api';
|
||||||
import './MusicStudio.css';
|
import './MusicStudio.css';
|
||||||
|
import AudioPlayer from './components/AudioPlayer';
|
||||||
|
import { fmtTime } from './components/AudioPlayer';
|
||||||
|
import CreditsBadge from './components/CreditsBadge';
|
||||||
|
import CoverArtModal from './components/CoverArtModal';
|
||||||
|
import LyricsTab from './components/LyricsTab';
|
||||||
|
import StemModal from './components/StemModal';
|
||||||
|
import SyncedLyricsPlayer from './components/SyncedLyricsPlayer';
|
||||||
|
import RemixTab from './components/RemixTab';
|
||||||
|
|
||||||
/* ─────────────────────────────────────────────
|
/* ─────────────────────────────────────────────
|
||||||
데이터 상수
|
데이터 상수
|
||||||
@@ -79,12 +92,6 @@ const SIM_STEPS = [
|
|||||||
{ msg: 'Track ready!', pct: 100 },
|
{ msg: 'Track ready!', pct: 100 },
|
||||||
];
|
];
|
||||||
|
|
||||||
/* ─────────────────────────────────────────────
|
|
||||||
유틸
|
|
||||||
───────────────────────────────────────────── */
|
|
||||||
const pad = (n) => String(Math.floor(n)).padStart(2, '0');
|
|
||||||
const fmtTime = (s) => `${pad(s / 60)}:${pad(s % 60)}`;
|
|
||||||
|
|
||||||
/* ─────────────────────────────────────────────
|
/* ─────────────────────────────────────────────
|
||||||
Loading Skeleton
|
Loading Skeleton
|
||||||
───────────────────────────────────────────── */
|
───────────────────────────────────────────── */
|
||||||
@@ -257,125 +264,6 @@ const GenerationProgress = ({ progress, stepMsg }) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ─────────────────────────────────────────────
|
|
||||||
Audio Player (실제 <audio> 기반)
|
|
||||||
───────────────────────────────────────────── */
|
|
||||||
const AudioPlayer = ({ audioUrl, totalSec, accentColor }) => {
|
|
||||||
const audioRef = useRef(null);
|
|
||||||
const [playing, setPlaying] = useState(false);
|
|
||||||
const [elapsed, setElapsed] = useState(0);
|
|
||||||
const [duration, setDuration] = useState(totalSec ?? 0);
|
|
||||||
const [volume, setVolume] = useState(1);
|
|
||||||
|
|
||||||
/* 실제 오디오가 없으면 가짜 타이머로 폴백 */
|
|
||||||
const isFake = !audioUrl;
|
|
||||||
const timerRef = useRef(null);
|
|
||||||
|
|
||||||
const total = duration || totalSec || 60;
|
|
||||||
|
|
||||||
const togglePlay = () => {
|
|
||||||
if (isFake) {
|
|
||||||
if (playing) {
|
|
||||||
clearInterval(timerRef.current);
|
|
||||||
setPlaying(false);
|
|
||||||
} else {
|
|
||||||
setPlaying(true);
|
|
||||||
timerRef.current = setInterval(() => {
|
|
||||||
setElapsed((e) => {
|
|
||||||
if (e >= total - 1) {
|
|
||||||
clearInterval(timerRef.current);
|
|
||||||
setPlaying(false);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return e + 1;
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const el = audioRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
playing ? el.pause() : el.play();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSeek = (e) => {
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
|
||||||
const ratio = (e.clientX - rect.left) / rect.width;
|
|
||||||
const newTime = ratio * total;
|
|
||||||
if (!isFake && audioRef.current) {
|
|
||||||
audioRef.current.currentTime = newTime;
|
|
||||||
}
|
|
||||||
setElapsed(newTime);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVolumeChange = (e) => {
|
|
||||||
const v = Number(e.target.value);
|
|
||||||
setVolume(v);
|
|
||||||
if (!isFake && audioRef.current) audioRef.current.volume = v;
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => () => clearInterval(timerRef.current), []);
|
|
||||||
|
|
||||||
const progress = (elapsed / total) * 100;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="ms-audio-player" style={{ '--player-accent': accentColor }}>
|
|
||||||
{!isFake && (
|
|
||||||
<audio
|
|
||||||
ref={audioRef}
|
|
||||||
src={audioUrl}
|
|
||||||
onLoadedMetadata={(e) => setDuration(e.target.duration)}
|
|
||||||
onTimeUpdate={(e) => setElapsed(e.target.currentTime)}
|
|
||||||
onPlay={() => setPlaying(true)}
|
|
||||||
onPause={() => setPlaying(false)}
|
|
||||||
onEnded={() => { setPlaying(false); setElapsed(0); }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`ms-player__play ${playing ? 'is-playing' : ''}`}
|
|
||||||
onClick={togglePlay}
|
|
||||||
aria-label={playing ? '일시정지' : '재생'}
|
|
||||||
>
|
|
||||||
{playing ? (
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
||||||
<rect x="3" y="2" width="4" height="12" rx="1" />
|
|
||||||
<rect x="9" y="2" width="4" height="12" rx="1" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
||||||
<path d="M4 2l10 6-10 6V2z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="ms-player__timeline">
|
|
||||||
<div className="ms-player__bar" onClick={handleSeek} role="slider"
|
|
||||||
aria-label="재생 위치" aria-valuenow={Math.round(elapsed)} aria-valuemin={0} aria-valuemax={Math.round(total)}>
|
|
||||||
<div className="ms-player__fill" style={{ width: `${progress}%` }} />
|
|
||||||
<div className="ms-player__thumb" style={{ left: `${progress}%` }} />
|
|
||||||
</div>
|
|
||||||
<div className="ms-player__times">
|
|
||||||
<span>{fmtTime(elapsed)}</span>
|
|
||||||
<span>{fmtTime(total)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ms-volume">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden>
|
|
||||||
<path d="M2 5h2.5l3-3v10l-3-3H2V5zm8.5-1.5a4.5 4.5 0 010 7" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round"/>
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
type="range" min={0} max={1} step={0.02} value={volume}
|
|
||||||
onChange={handleVolumeChange}
|
|
||||||
className="ms-volume__slider"
|
|
||||||
aria-label="볼륨"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ─────────────────────────────────────────────
|
/* ─────────────────────────────────────────────
|
||||||
Track Result Card
|
Track Result Card
|
||||||
───────────────────────────────────────────── */
|
───────────────────────────────────────────── */
|
||||||
@@ -447,7 +335,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, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, 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() : '';
|
||||||
@@ -508,29 +397,35 @@ 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>
|
||||||
|
<button type="button" onClick={() => { onWavConvert(track); setMenuOpen(false); }}
|
||||||
|
disabled={isGenerating}>📀 WAV Download</button>
|
||||||
|
<button type="button" onClick={() => { onStemSplit(track); setMenuOpen(false); }}
|
||||||
|
disabled={isGenerating}>🎛 12 Stems (50cr)</button>
|
||||||
|
<button type="button" onClick={() => { onSyncedLyrics(track); setMenuOpen(false); }}
|
||||||
|
disabled={isGenerating || !track.lyrics}>📝 Synced Lyrics</button>
|
||||||
|
<button type="button" onClick={() => { onVideoGenerate(track); setMenuOpen(false); }}
|
||||||
|
disabled={isGenerating}>🎬 Music Video</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!hasSunoId && track.audio_url && (
|
{!hasSunoId && track.audio_url && (
|
||||||
@@ -550,7 +445,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, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, isGenerating, loading }) => {
|
||||||
const [playingId, setPlayingId] = useState(null);
|
const [playingId, setPlayingId] = useState(null);
|
||||||
|
|
||||||
const handlePlay = (track) => {
|
const handlePlay = (track) => {
|
||||||
@@ -599,6 +494,11 @@ 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}
|
||||||
|
onWavConvert={onWavConvert}
|
||||||
|
onStemSplit={onStemSplit}
|
||||||
|
onSyncedLyrics={onSyncedLyrics}
|
||||||
|
onVideoGenerate={onVideoGenerate}
|
||||||
isGenerating={isGenerating}
|
isGenerating={isGenerating}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -635,7 +535,20 @@ export default function MusicStudio() {
|
|||||||
const [lyricsLoading, setLyricsLoading] = useState(false);
|
const [lyricsLoading, setLyricsLoading] = useState(false);
|
||||||
const [model, setModel] = useState('V4');
|
const [model, setModel] = useState('V4');
|
||||||
const [models, setModels] = useState([]);
|
const [models, setModels] = useState([]);
|
||||||
const [credits, setCredits] = useState(null);
|
|
||||||
|
/* ── 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 }
|
||||||
|
|
||||||
|
/* ── Phase 2 상태 ── */
|
||||||
|
const [stemModal, setStemModal] = useState(null); // { stems: {} }
|
||||||
|
const [syncedLyrics, setSyncedLyrics] = useState(null); // { audioUrl, words }
|
||||||
|
const [styleBoostLoading, setStyleBoostLoading] = useState(false);
|
||||||
|
|
||||||
/* ── 생성 상태 ── */
|
/* ── 생성 상태 ── */
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
@@ -678,14 +591,11 @@ export default function MusicStudio() {
|
|||||||
.catch(() => setProviderError(true));
|
.catch(() => setProviderError(true));
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
/* ── 모델 & 크레딧 로드 ── */
|
/* ── 모델 로드 ── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getMusicModels()
|
getMusicModels()
|
||||||
.then((data) => setModels(data.models ?? []))
|
.then((data) => setModels(data.models ?? []))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
getMusicCredits()
|
|
||||||
.then((data) => setCredits(data))
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/* ── 가사 AI 생성 ── */
|
/* ── 가사 AI 생성 ── */
|
||||||
@@ -836,6 +746,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,
|
||||||
} : {}),
|
} : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -924,6 +838,224 @@ 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── WAV 변환 핸들러 ── */
|
||||||
|
const handleWavConvert = async (track) => {
|
||||||
|
if (!track.task_id || !track.suno_id || isGenerating) return;
|
||||||
|
setTab('create');
|
||||||
|
setIsGenerating(true);
|
||||||
|
setTrack(null);
|
||||||
|
setGenProgress(0);
|
||||||
|
setGenStep('WAV 변환 요청 중…');
|
||||||
|
setGenError(null);
|
||||||
|
try {
|
||||||
|
const res = await convertToWav({
|
||||||
|
suno_task_id: track.task_id,
|
||||||
|
suno_id: track.suno_id,
|
||||||
|
track_id: track.id,
|
||||||
|
});
|
||||||
|
if (res?.task_id) {
|
||||||
|
taskIdRef.current = res.task_id;
|
||||||
|
setGenStep('WAV 변환 처리 중…');
|
||||||
|
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 wavUrl = status.audio_url;
|
||||||
|
if (wavUrl) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = wavUrl;
|
||||||
|
a.download = `${track.title || 'track'}.wav`;
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
setGenStep('WAV 변환 완료!');
|
||||||
|
} else if (status.status === 'failed') {
|
||||||
|
clearInterval(pollRef.current);
|
||||||
|
setIsGenerating(false);
|
||||||
|
setGenError(`WAV 변환 실패: ${status.error ?? '알 수 없는 오류'}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
clearInterval(pollRef.current);
|
||||||
|
setIsGenerating(false);
|
||||||
|
setGenError('WAV 변환 상태 조회 실패');
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setIsGenerating(false);
|
||||||
|
setGenError('WAV 변환에 실패했습니다');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 12스템 분리 핸들러 ── */
|
||||||
|
const handleStemSplit = async (track) => {
|
||||||
|
if (!track.task_id || !track.suno_id || isGenerating) return;
|
||||||
|
setTab('create');
|
||||||
|
setIsGenerating(true);
|
||||||
|
setTrack(null);
|
||||||
|
setGenProgress(0);
|
||||||
|
setGenStep('12스템 분리 요청 중…');
|
||||||
|
setGenError(null);
|
||||||
|
try {
|
||||||
|
const res = await splitStems({
|
||||||
|
suno_task_id: track.task_id,
|
||||||
|
suno_id: track.suno_id,
|
||||||
|
track_id: track.id,
|
||||||
|
});
|
||||||
|
if (res?.task_id) {
|
||||||
|
taskIdRef.current = res.task_id;
|
||||||
|
setGenStep('12스템 분리 처리 중 (약 2~3분)…');
|
||||||
|
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 stems = JSON.parse(status.audio_url || '{}');
|
||||||
|
setStemModal({ stems });
|
||||||
|
} 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('12스템 분리에 실패했습니다');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 타임스탬프 가사 핸들러 ── */
|
||||||
|
const handleSyncedLyrics = async (track) => {
|
||||||
|
if (!track.task_id || !track.suno_id) return;
|
||||||
|
try {
|
||||||
|
const result = await getTimestampedLyrics(track.task_id, track.suno_id);
|
||||||
|
if (result?.alignedWords || result?.aligned_words) {
|
||||||
|
setSyncedLyrics({
|
||||||
|
audioUrl: track.audio_url,
|
||||||
|
words: result.alignedWords || result.aligned_words,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setGenError('타임스탬프 가사 조회에 실패했습니다');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 스타일 부스트 핸들러 ── */
|
||||||
|
const handleStyleBoost = async () => {
|
||||||
|
if (!genre || styleBoostLoading) return;
|
||||||
|
setStyleBoostLoading(true);
|
||||||
|
try {
|
||||||
|
const content = [
|
||||||
|
GENRES.find(g => g.id === genre)?.label,
|
||||||
|
...moods.map(id => MOODS.find(m => m.id === id)?.label).filter(Boolean),
|
||||||
|
].join(', ');
|
||||||
|
const result = await generateStyleBoost(content);
|
||||||
|
if (result?.result) {
|
||||||
|
setPrompt(result.result);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
finally { setStyleBoostLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 뮤직비디오 핸들러 ── */
|
||||||
|
const handleVideoGenerate = async (track) => {
|
||||||
|
if (!track.task_id || !track.suno_id || isGenerating) return;
|
||||||
|
setTab('create');
|
||||||
|
setIsGenerating(true);
|
||||||
|
setTrack(null);
|
||||||
|
setGenProgress(0);
|
||||||
|
setGenStep('뮤직비디오 생성 요청 중…');
|
||||||
|
setGenError(null);
|
||||||
|
try {
|
||||||
|
const res = await generateVideo({
|
||||||
|
suno_task_id: track.task_id,
|
||||||
|
suno_id: track.suno_id,
|
||||||
|
track_id: track.id,
|
||||||
|
});
|
||||||
|
if (res?.task_id) {
|
||||||
|
taskIdRef.current = res.task_id;
|
||||||
|
startPolling(res.task_id, `${track.title} (Video)`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setIsGenerating(false);
|
||||||
|
setGenError('뮤직비디오 생성에 실패했습니다');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleNewTrack = () => {
|
const handleNewTrack = () => {
|
||||||
setTrack(null);
|
setTrack(null);
|
||||||
setGenProgress(0);
|
setGenProgress(0);
|
||||||
@@ -949,14 +1081,7 @@ export default function MusicStudio() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ms-header__right">
|
<div className="ms-header__right">
|
||||||
{credits && (
|
<CreditsBadge />
|
||||||
<div className="ms-credits">
|
|
||||||
<span className="ms-credits__label">Credits</span>
|
|
||||||
<span className="ms-credits__value">
|
|
||||||
{credits.credits_left ?? credits.remaining ?? '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<SonicRadar isGenerating={isGenerating} accentColor={accentColor} />
|
<SonicRadar isGenerating={isGenerating} accentColor={accentColor} />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -970,6 +1095,13 @@ export default function MusicStudio() {
|
|||||||
>
|
>
|
||||||
<span className="ms-tab__icon">⚗</span> Create
|
<span className="ms-tab__icon">⚗</span> Create
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ms-tab ${tab === 'lyrics' ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setTab('lyrics')}
|
||||||
|
>
|
||||||
|
<span className="ms-tab__icon">🎤</span> Lyrics
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`ms-tab ${tab === 'library' ? 'is-active' : ''}`}
|
className={`ms-tab ${tab === 'library' ? 'is-active' : ''}`}
|
||||||
@@ -980,6 +1112,13 @@ export default function MusicStudio() {
|
|||||||
<span className="ms-tab__badge">{library.length}</span>
|
<span className="ms-tab__badge">{library.length}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ms-tab ${tab === 'remix' ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setTab('remix')}
|
||||||
|
>
|
||||||
|
<span className="ms-tab__icon">🔄</span> Remix
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* ═══ LIBRARY TAB ═══ */}
|
{/* ═══ LIBRARY TAB ═══ */}
|
||||||
@@ -991,6 +1130,34 @@ export default function MusicStudio() {
|
|||||||
onRefresh={loadLibrary}
|
onRefresh={loadLibrary}
|
||||||
onExtend={handleExtend}
|
onExtend={handleExtend}
|
||||||
onVocalRemoval={handleVocalRemoval}
|
onVocalRemoval={handleVocalRemoval}
|
||||||
|
onCoverArt={handleCoverArt}
|
||||||
|
onWavConvert={handleWavConvert}
|
||||||
|
onStemSplit={handleStemSplit}
|
||||||
|
onSyncedLyrics={handleSyncedLyrics}
|
||||||
|
onVideoGenerate={handleVideoGenerate}
|
||||||
|
isGenerating={isGenerating}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ═══ LYRICS TAB ═══ */}
|
||||||
|
{tab === 'lyrics' && (
|
||||||
|
<LyricsTab onUseInCreate={(text) => { setLyrics(text); setInstrumental(false); setProvider('suno'); setTab('create'); }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ═══ REMIX TAB ═══ */}
|
||||||
|
{tab === 'remix' && (
|
||||||
|
<RemixTab
|
||||||
|
onTaskStarted={(taskId, title) => {
|
||||||
|
setTab('create');
|
||||||
|
setIsGenerating(true);
|
||||||
|
setTrack(null);
|
||||||
|
setGenProgress(0);
|
||||||
|
setGenStep(`${title} 처리 중…`);
|
||||||
|
setGenError(null);
|
||||||
|
taskIdRef.current = taskId;
|
||||||
|
startPolling(taskId, title);
|
||||||
|
}}
|
||||||
|
model={model}
|
||||||
isGenerating={isGenerating}
|
isGenerating={isGenerating}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1065,6 +1232,17 @@ export default function MusicStudio() {
|
|||||||
<span className="ms-section__step">01</span>
|
<span className="ms-section__step">01</span>
|
||||||
<h2 className="ms-section__title">Genre</h2>
|
<h2 className="ms-section__title">Genre</h2>
|
||||||
<span className="ms-section__hint">장르를 선택하세요</span>
|
<span className="ms-section__hint">장르를 선택하세요</span>
|
||||||
|
{provider === 'suno' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ms-btn ms-btn--ghost ms-btn--sm ms-style-boost-btn ${styleBoostLoading ? 'is-loading' : ''}`}
|
||||||
|
onClick={handleStyleBoost}
|
||||||
|
disabled={styleBoostLoading || !genre}
|
||||||
|
title="현재 설정으로 최적 스타일 프롬프트 생성"
|
||||||
|
>
|
||||||
|
{styleBoostLoading ? '생성 중...' : '✨ Style Boost'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button type="button" className={`ms-desc-toggle ${isOpen('genre') ? 'is-open' : ''}`} onClick={() => toggleDesc('genre')} aria-label="설명 펼치기">ℹ</button>
|
<button type="button" className={`ms-desc-toggle ${isOpen('genre') ? 'is-open' : ''}`} onClick={() => toggleDesc('genre')} aria-label="설명 펼치기">ℹ</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={`ms-desc-wrap ${isOpen('genre') ? 'is-open' : ''}`}>
|
<div className={`ms-desc-wrap ${isOpen('genre') ? 'is-open' : ''}`}>
|
||||||
@@ -1278,6 +1456,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 */}
|
||||||
@@ -1469,6 +1737,29 @@ export default function MusicStudio() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{coverArtModal && (
|
||||||
|
<CoverArtModal
|
||||||
|
images={coverArtModal.images}
|
||||||
|
onSelect={handleCoverSelect}
|
||||||
|
onClose={() => setCoverArtModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ═══ Stem Modal ═══ */}
|
||||||
|
{stemModal && (
|
||||||
|
<StemModal stems={stemModal.stems} onClose={() => setStemModal(null)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ═══ Synced Lyrics Player ═══ */}
|
||||||
|
{syncedLyrics && (
|
||||||
|
<SyncedLyricsPlayer
|
||||||
|
audioUrl={syncedLyrics.audioUrl}
|
||||||
|
alignedWords={syncedLyrics.words}
|
||||||
|
onClose={() => setSyncedLyrics(null)}
|
||||||
|
accentColor={accentColor}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
128
src/pages/music/components/AudioPlayer.jsx
Normal file
128
src/pages/music/components/AudioPlayer.jsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import React, { useRef, useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────
|
||||||
|
유틸
|
||||||
|
───────────────────────────────────────────── */
|
||||||
|
const pad = (n) => String(Math.floor(n)).padStart(2, '0');
|
||||||
|
export const fmtTime = (s) => `${pad(s / 60)}:${pad(s % 60)}`;
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────
|
||||||
|
Audio Player (실제 <audio> 기반)
|
||||||
|
───────────────────────────────────────────── */
|
||||||
|
const AudioPlayer = ({ audioUrl, totalSec, accentColor }) => {
|
||||||
|
const audioRef = useRef(null);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(totalSec ?? 0);
|
||||||
|
const [volume, setVolume] = useState(1);
|
||||||
|
|
||||||
|
/* 실제 오디오가 없으면 가짜 타이머로 폴백 */
|
||||||
|
const isFake = !audioUrl;
|
||||||
|
const timerRef = useRef(null);
|
||||||
|
|
||||||
|
const total = duration || totalSec || 60;
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (isFake) {
|
||||||
|
if (playing) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
setPlaying(false);
|
||||||
|
} else {
|
||||||
|
setPlaying(true);
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setElapsed((e) => {
|
||||||
|
if (e >= total - 1) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
setPlaying(false);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return e + 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const el = audioRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
playing ? el.pause() : el.play();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeek = (e) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const ratio = (e.clientX - rect.left) / rect.width;
|
||||||
|
const newTime = ratio * total;
|
||||||
|
if (!isFake && audioRef.current) {
|
||||||
|
audioRef.current.currentTime = newTime;
|
||||||
|
}
|
||||||
|
setElapsed(newTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeChange = (e) => {
|
||||||
|
const v = Number(e.target.value);
|
||||||
|
setVolume(v);
|
||||||
|
if (!isFake && audioRef.current) audioRef.current.volume = v;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => () => clearInterval(timerRef.current), []);
|
||||||
|
|
||||||
|
const progress = (elapsed / total) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ms-audio-player" style={{ '--player-accent': accentColor }}>
|
||||||
|
{!isFake && (
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={audioUrl}
|
||||||
|
onLoadedMetadata={(e) => setDuration(e.target.duration)}
|
||||||
|
onTimeUpdate={(e) => setElapsed(e.target.currentTime)}
|
||||||
|
onPlay={() => setPlaying(true)}
|
||||||
|
onPause={() => setPlaying(false)}
|
||||||
|
onEnded={() => { setPlaying(false); setElapsed(0); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ms-player__play ${playing ? 'is-playing' : ''}`}
|
||||||
|
onClick={togglePlay}
|
||||||
|
aria-label={playing ? '일시정지' : '재생'}
|
||||||
|
>
|
||||||
|
{playing ? (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<rect x="3" y="2" width="4" height="12" rx="1" />
|
||||||
|
<rect x="9" y="2" width="4" height="12" rx="1" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M4 2l10 6-10 6V2z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="ms-player__timeline">
|
||||||
|
<div className="ms-player__bar" onClick={handleSeek} role="slider"
|
||||||
|
aria-label="재생 위치" aria-valuenow={Math.round(elapsed)} aria-valuemin={0} aria-valuemax={Math.round(total)}>
|
||||||
|
<div className="ms-player__fill" style={{ width: `${progress}%` }} />
|
||||||
|
<div className="ms-player__thumb" style={{ left: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="ms-player__times">
|
||||||
|
<span>{fmtTime(elapsed)}</span>
|
||||||
|
<span>{fmtTime(total)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ms-volume">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden>
|
||||||
|
<path d="M2 5h2.5l3-3v10l-3-3H2V5zm8.5-1.5a4.5 4.5 0 010 7" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="range" min={0} max={1} step={0.02} value={volume}
|
||||||
|
onChange={handleVolumeChange}
|
||||||
|
className="ms-volume__slider"
|
||||||
|
aria-label="볼륨"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AudioPlayer;
|
||||||
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;
|
||||||
36
src/pages/music/components/CreditsBadge.jsx
Normal file
36
src/pages/music/components/CreditsBadge.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { getMusicCredits } from '../../../api';
|
||||||
|
|
||||||
|
const CreditsBadge = () => {
|
||||||
|
const [credits, setCredits] = useState(null);
|
||||||
|
|
||||||
|
const fetchCredits = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await getMusicCredits();
|
||||||
|
setCredits(data);
|
||||||
|
} catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCredits();
|
||||||
|
const interval = setInterval(fetchCredits, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchCredits]);
|
||||||
|
|
||||||
|
if (!credits) return null;
|
||||||
|
|
||||||
|
const remaining = credits.credits_left ?? credits.remaining ?? credits.data ?? null;
|
||||||
|
if (remaining == null) return null;
|
||||||
|
|
||||||
|
const isLow = remaining <= 10;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`ms-credits-badge ${isLow ? 'is-low' : ''}`}>
|
||||||
|
<span className="ms-credits-badge__icon">⚡</span>
|
||||||
|
<span className="ms-credits-badge__value">{remaining}</span>
|
||||||
|
<span className="ms-credits-badge__label">credits</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreditsBadge;
|
||||||
245
src/pages/music/components/LyricsTab.jsx
Normal file
245
src/pages/music/components/LyricsTab.jsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
generateMusicLyrics,
|
||||||
|
getSavedLyrics,
|
||||||
|
saveLyrics,
|
||||||
|
updateLyrics,
|
||||||
|
deleteLyrics,
|
||||||
|
} from '../../../api';
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────
|
||||||
|
Lyrics Tab
|
||||||
|
───────────────────────────────────────────── */
|
||||||
|
const LyricsTab = ({ onUseInCreate }) => {
|
||||||
|
const [lyrPrompt, setLyrPrompt] = useState('');
|
||||||
|
const [lyrLoading, setLyrLoading] = useState(false);
|
||||||
|
const [lyrError, setLyrError] = useState(null);
|
||||||
|
const [copied, setCopied] = useState(null); // id
|
||||||
|
const [saved, setSaved] = useState([]); // DB에 저장된 가사
|
||||||
|
const [loadingSaved, setLoadingSaved] = useState(true);
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
const [editTitle, setEditTitle] = useState('');
|
||||||
|
const [editText, setEditText] = useState('');
|
||||||
|
|
||||||
|
/* ── 저장된 가사 로드 ── */
|
||||||
|
useEffect(() => {
|
||||||
|
setLoadingSaved(true);
|
||||||
|
getSavedLyrics()
|
||||||
|
.then((data) => setSaved(data.lyrics ?? []))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoadingSaved(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ── AI 생성 → 즉시 저장 ── */
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
if (!lyrPrompt.trim() || lyrLoading) return;
|
||||||
|
setLyrLoading(true);
|
||||||
|
setLyrError(null);
|
||||||
|
try {
|
||||||
|
const res = await generateMusicLyrics(lyrPrompt.trim());
|
||||||
|
if (res?.text) {
|
||||||
|
const record = await saveLyrics({
|
||||||
|
title: res.title || '',
|
||||||
|
text: res.text,
|
||||||
|
prompt: lyrPrompt.trim(),
|
||||||
|
});
|
||||||
|
setSaved((prev) => [record, ...prev]);
|
||||||
|
} else {
|
||||||
|
setLyrError('가사 생성 결과가 없습니다');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setLyrError(e.message || '가사 생성에 실패했습니다');
|
||||||
|
} finally {
|
||||||
|
setLyrLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 복사 ── */
|
||||||
|
const handleCopy = (text, id) => {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
setCopied(id);
|
||||||
|
setTimeout(() => setCopied(null), 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 삭제 ── */
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
try {
|
||||||
|
await deleteLyrics(id);
|
||||||
|
setSaved((prev) => prev.filter((l) => l.id !== id));
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 수정 시작 ── */
|
||||||
|
const startEdit = (item) => {
|
||||||
|
setEditingId(item.id);
|
||||||
|
setEditTitle(item.title);
|
||||||
|
setEditText(item.text);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 수정 저장 ── */
|
||||||
|
const handleSaveEdit = async () => {
|
||||||
|
if (editingId == null) return;
|
||||||
|
try {
|
||||||
|
const updated = await updateLyrics(editingId, { title: editTitle, text: editText });
|
||||||
|
setSaved((prev) => prev.map((l) => l.id === editingId ? updated : l));
|
||||||
|
setEditingId(null);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 수정 취소 ── */
|
||||||
|
const cancelEdit = () => setEditingId(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ms-lyrics-tab">
|
||||||
|
<div className="ms-lyrics-tab__form">
|
||||||
|
<div className="ms-lyrics-tab__head">
|
||||||
|
<h2 className="ms-lyrics-tab__title">AI Lyrics Generator</h2>
|
||||||
|
<p className="ms-lyrics-tab__desc">
|
||||||
|
원하는 분위기, 주제, 스타일을 설명하면 AI가 가사를 작성합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ms-lyrics-tab__input-wrap">
|
||||||
|
<textarea
|
||||||
|
className="ms-lyrics-tab__input"
|
||||||
|
placeholder="예: 비 오는 밤, 혼자 걷는 도시의 거리를 배경으로 한 감성적인 발라드 가사"
|
||||||
|
value={lyrPrompt}
|
||||||
|
onChange={(e) => setLyrPrompt(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
maxLength={200}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleGenerate(); } }}
|
||||||
|
/>
|
||||||
|
<div className="ms-lyrics-tab__input-footer">
|
||||||
|
<span className="ms-lyrics-tab__count">{lyrPrompt.length}/200</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ms-btn ms-btn--accent ${lyrLoading ? 'is-loading' : ''}`}
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={!lyrPrompt.trim() || lyrLoading}
|
||||||
|
>
|
||||||
|
{lyrLoading ? (
|
||||||
|
<><span className="ms-btn__spinner" /> 생성 중...</>
|
||||||
|
) : (
|
||||||
|
'✨ 가사 생성'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lyrError && (
|
||||||
|
<div className="ms-error-banner">
|
||||||
|
<span>⚠ {lyrError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lyrLoading && (
|
||||||
|
<div className="ms-lyrics-tab__loading">
|
||||||
|
<div className="ms-lyrics-tab__loading-bar" />
|
||||||
|
<p>AI가 가사를 작성하고 있습니다...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 저장된 가사 목록 */}
|
||||||
|
{loadingSaved && (
|
||||||
|
<div className="ms-lyrics-tab__loading">
|
||||||
|
<div className="ms-lyrics-tab__loading-bar" />
|
||||||
|
<p>저장된 가사를 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loadingSaved && saved.length === 0 && !lyrLoading && (
|
||||||
|
<div className="ms-lyrics-tab__empty">
|
||||||
|
<span className="ms-lyrics-tab__empty-icon">🎤</span>
|
||||||
|
<p>저장된 가사가 없습니다</p>
|
||||||
|
<p className="ms-lyrics-tab__empty-hint">
|
||||||
|
프롬프트를 입력하면 AI가 [Verse], [Chorus] 등 섹션이 포함된 가사를 작성합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="ms-lyrics-tab__results">
|
||||||
|
{saved.map((item) => (
|
||||||
|
<div key={item.id} className={`ms-lyrics-card ${editingId === item.id ? 'is-editing' : ''}`}>
|
||||||
|
<div className="ms-lyrics-card__header">
|
||||||
|
{editingId === item.id ? (
|
||||||
|
<input
|
||||||
|
className="ms-lyrics-card__title-input"
|
||||||
|
value={editTitle}
|
||||||
|
onChange={(e) => setEditTitle(e.target.value)}
|
||||||
|
placeholder="제목"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{item.title && <h3 className="ms-lyrics-card__title">{item.title}</h3>}
|
||||||
|
<span className="ms-lyrics-card__prompt">{item.prompt}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="ms-lyrics-card__date">
|
||||||
|
{item.created_at ? new Date(item.created_at).toLocaleDateString('ko-KR') : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingId === item.id ? (
|
||||||
|
<textarea
|
||||||
|
className="ms-lyrics-card__text-input"
|
||||||
|
value={editText}
|
||||||
|
onChange={(e) => setEditText(e.target.value)}
|
||||||
|
rows={12}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<pre className="ms-lyrics-card__text">{item.text}</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="ms-lyrics-card__actions">
|
||||||
|
{editingId === item.id ? (
|
||||||
|
<>
|
||||||
|
<button type="button" className="ms-btn ms-btn--accent ms-btn--sm" onClick={handleSaveEdit}>
|
||||||
|
✓ 저장
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm" onClick={cancelEdit}>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||||
|
onClick={() => handleCopy(item.text, item.id)}
|
||||||
|
>
|
||||||
|
{copied === item.id ? '✓ 복사됨' : '📋 복사'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||||
|
onClick={() => onUseInCreate(item.text)}
|
||||||
|
>
|
||||||
|
🎵 Create에서 사용
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||||
|
onClick={() => startEdit(item)}
|
||||||
|
>
|
||||||
|
✏️ 수정
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ms-btn ms-btn--ghost ms-btn--sm ms-btn--danger-text"
|
||||||
|
onClick={() => handleDelete(item.id)}
|
||||||
|
>
|
||||||
|
🗑 삭제
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LyricsTab;
|
||||||
193
src/pages/music/components/RemixTab.jsx
Normal file
193
src/pages/music/components/RemixTab.jsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { uploadAndCover, uploadAndExtend, addVocals, addInstrumental } from '../../../api';
|
||||||
|
|
||||||
|
const REMIX_ACTIONS = [
|
||||||
|
{ id: 'cover', label: 'AI Cover', icon: '🎨', desc: '외부 음원을 Suno AI 스타일로 리메이크' },
|
||||||
|
{ id: 'extend', label: 'Extend', icon: '⏩', desc: '외부 음원을 이어서 확장' },
|
||||||
|
{ id: 'add-vocals', label: 'Add Vocals', icon: '🎤', desc: '인스트루멘탈에 AI 보컬 입히기' },
|
||||||
|
{ id: 'add-instrumental', label: 'Add Instrumental', icon: '🎹', desc: '보컬에 AI 반주 입히기' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const RemixTab = ({ onTaskStarted, model, isGenerating }) => {
|
||||||
|
const [uploadUrl, setUploadUrl] = useState('');
|
||||||
|
const [activeAction, setActiveAction] = useState(null);
|
||||||
|
|
||||||
|
// 각 액션별 파라미터
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [style, setStyle] = useState('');
|
||||||
|
const [prompt, setPrompt] = useState('');
|
||||||
|
const [tags, setTags] = useState('');
|
||||||
|
const [negativeTags, setNegativeTags] = useState('');
|
||||||
|
const [vocalGender, setVocalGender] = useState(null);
|
||||||
|
const [continueAt, setContinueAt] = useState(0);
|
||||||
|
const [instrumental, setInstrumental] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!uploadUrl || !activeAction || isGenerating) return;
|
||||||
|
|
||||||
|
let apiCall;
|
||||||
|
let payload = {};
|
||||||
|
|
||||||
|
switch (activeAction) {
|
||||||
|
case 'cover':
|
||||||
|
apiCall = uploadAndCover;
|
||||||
|
payload = {
|
||||||
|
upload_url: uploadUrl, model, custom_mode: true,
|
||||||
|
instrumental, prompt, style, title,
|
||||||
|
vocal_gender: vocalGender || undefined,
|
||||||
|
negative_tags: negativeTags || undefined,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'extend':
|
||||||
|
apiCall = uploadAndExtend;
|
||||||
|
payload = {
|
||||||
|
upload_url: uploadUrl, model,
|
||||||
|
default_param_flag: !prompt,
|
||||||
|
continue_at: continueAt || undefined,
|
||||||
|
prompt, style, title, instrumental,
|
||||||
|
vocal_gender: vocalGender || undefined,
|
||||||
|
negative_tags: negativeTags || undefined,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'add-vocals':
|
||||||
|
apiCall = addVocals;
|
||||||
|
payload = {
|
||||||
|
upload_url: uploadUrl, prompt, title, style,
|
||||||
|
negative_tags: negativeTags,
|
||||||
|
vocal_gender: vocalGender || undefined,
|
||||||
|
model: 'V4_5PLUS',
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'add-instrumental':
|
||||||
|
apiCall = addInstrumental;
|
||||||
|
payload = {
|
||||||
|
upload_url: uploadUrl, title, tags,
|
||||||
|
negative_tags: negativeTags,
|
||||||
|
vocal_gender: vocalGender || undefined,
|
||||||
|
model: 'V4_5PLUS',
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiCall(payload);
|
||||||
|
if (res?.task_id) {
|
||||||
|
onTaskStarted(res.task_id, `Remix: ${REMIX_ACTIONS.find(a => a.id === activeAction)?.label}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 에러는 부모 컴포넌트에서 처리
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ms-remix-tab">
|
||||||
|
<div className="ms-remix-tab__header">
|
||||||
|
<h2 className="ms-remix-tab__title">Remix Studio</h2>
|
||||||
|
<p className="ms-remix-tab__desc">외부 음원을 AI로 리메이크, 확장, 보컬/반주 추가</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ms-param-group">
|
||||||
|
<label className="ms-param-label">Audio URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
className="ms-negative-tags__input"
|
||||||
|
placeholder="리믹스할 오디오 파일 URL (예: /media/music/track.mp3)"
|
||||||
|
value={uploadUrl}
|
||||||
|
onChange={(e) => setUploadUrl(e.target.value)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ms-remix-actions">
|
||||||
|
{REMIX_ACTIONS.map((action) => (
|
||||||
|
<button
|
||||||
|
key={action.id}
|
||||||
|
type="button"
|
||||||
|
className={`ms-remix-card ${activeAction === action.id ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setActiveAction(activeAction === action.id ? null : action.id)}
|
||||||
|
>
|
||||||
|
<span className="ms-remix-card__icon">{action.icon}</span>
|
||||||
|
<span className="ms-remix-card__label">{action.label}</span>
|
||||||
|
<span className="ms-remix-card__desc">{action.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeAction && (
|
||||||
|
<div className="ms-remix-params">
|
||||||
|
{/* 공통 파라미터 */}
|
||||||
|
<div className="ms-param-group">
|
||||||
|
<label className="ms-param-label">Title</label>
|
||||||
|
<input type="text" className="ms-negative-tags__input" value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)} placeholder="곡 제목" style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(activeAction === 'cover' || activeAction === 'extend' || activeAction === 'add-vocals') && (
|
||||||
|
<div className="ms-param-group">
|
||||||
|
<label className="ms-param-label">Prompt / Lyrics</label>
|
||||||
|
<textarea className="ms-prompt" value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)} rows={3}
|
||||||
|
placeholder="가사 또는 스타일 설명" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(activeAction === 'cover' || activeAction === 'extend' || activeAction === 'add-vocals') && (
|
||||||
|
<div className="ms-param-group">
|
||||||
|
<label className="ms-param-label">Style</label>
|
||||||
|
<input type="text" className="ms-negative-tags__input" value={style}
|
||||||
|
onChange={(e) => setStyle(e.target.value)} placeholder="예: Pop, Energetic, Piano" style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeAction === 'add-instrumental' && (
|
||||||
|
<div className="ms-param-group">
|
||||||
|
<label className="ms-param-label">Tags (스타일/특성)</label>
|
||||||
|
<input type="text" className="ms-negative-tags__input" value={tags}
|
||||||
|
onChange={(e) => setTags(e.target.value)} placeholder="예: acoustic, warm, dreamy" style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeAction === 'extend' && (
|
||||||
|
<div className="ms-param-group">
|
||||||
|
<label className="ms-param-label">Continue At (초)</label>
|
||||||
|
<input type="number" className="ms-negative-tags__input" value={continueAt}
|
||||||
|
onChange={(e) => setContinueAt(Number(e.target.value))} min={0} style={{ width: '120px' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="ms-param-group">
|
||||||
|
<label className="ms-param-label">Exclude Styles</label>
|
||||||
|
<input type="text" className="ms-negative-tags__input" value={negativeTags}
|
||||||
|
onChange={(e) => setNegativeTags(e.target.value)} placeholder="제외할 스타일" style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ms-param-group">
|
||||||
|
<label className="ms-param-label">Vocal Gender</label>
|
||||||
|
<div className="ms-gender-toggle">
|
||||||
|
{[{ value: null, label: 'Auto' }, { value: 'm', label: 'Male' }, { value: 'f', label: 'Female' }].map((opt) => (
|
||||||
|
<button key={opt.label} type="button"
|
||||||
|
className={`ms-gender-btn ${vocalGender === opt.value ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setVocalGender(opt.value)}>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ms-btn ms-btn--accent ms-remix-submit"
|
||||||
|
disabled={!uploadUrl || isGenerating}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
{isGenerating ? 'Processing...' : `Start ${REMIX_ACTIONS.find(a => a.id === activeAction)?.label}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RemixTab;
|
||||||
55
src/pages/music/components/StemModal.jsx
Normal file
55
src/pages/music/components/StemModal.jsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const STEM_ICONS = {
|
||||||
|
vocal: '🎤', backing_vocals: '🎶', drums: '🥁', bass: '🎸',
|
||||||
|
guitar: '🎸', keyboard: '🎹', strings: '🎻', brass: '🎺',
|
||||||
|
woodwinds: '🪈', percussion: '🪘', synth: '🎛', fx: '✨',
|
||||||
|
};
|
||||||
|
|
||||||
|
const StemModal = ({ stems, onClose }) => {
|
||||||
|
const [playingStem, setPlayingStem] = useState(null);
|
||||||
|
|
||||||
|
if (!stems || Object.keys(stems).length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ms-modal-overlay" onClick={onClose}>
|
||||||
|
<div className="ms-modal ms-modal--wide" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="ms-modal__header">
|
||||||
|
<h3 className="ms-modal__title">12 Stems</h3>
|
||||||
|
<span className="ms-modal__subtitle">각 스템을 개별 재생 및 다운로드할 수 있습니다</span>
|
||||||
|
<button type="button" className="ms-modal__close" onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="ms-stem-grid">
|
||||||
|
{Object.entries(stems).map(([name, url]) => {
|
||||||
|
if (!url) return null;
|
||||||
|
const isPlaying = playingStem === name;
|
||||||
|
return (
|
||||||
|
<div key={name} className={`ms-stem-card ${isPlaying ? 'is-playing' : ''}`}>
|
||||||
|
<span className="ms-stem-card__icon">{STEM_ICONS[name] || '🎵'}</span>
|
||||||
|
<span className="ms-stem-card__name">{name.replace(/_/g, ' ')}</span>
|
||||||
|
<div className="ms-stem-card__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ms-btn--icon"
|
||||||
|
onClick={() => setPlayingStem(isPlaying ? null : name)}
|
||||||
|
>
|
||||||
|
{isPlaying ? '■' : '▶'}
|
||||||
|
</button>
|
||||||
|
<a href={url} download className="ms-btn--icon" aria-label="다운로드">↓</a>
|
||||||
|
</div>
|
||||||
|
{isPlaying && (
|
||||||
|
<audio src={url} autoPlay onEnded={() => setPlayingStem(null)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="ms-modal__actions">
|
||||||
|
<button type="button" className="ms-btn ms-btn--ghost" onClick={onClose}>닫기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StemModal;
|
||||||
51
src/pages/music/components/SyncedLyricsPlayer.jsx
Normal file
51
src/pages/music/components/SyncedLyricsPlayer.jsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const SyncedLyricsPlayer = ({ audioUrl, alignedWords, onClose, accentColor }) => {
|
||||||
|
const audioRef = useRef(null);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = audioRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const handler = () => setCurrentTime(el.currentTime);
|
||||||
|
el.addEventListener('timeupdate', handler);
|
||||||
|
return () => el.removeEventListener('timeupdate', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!alignedWords || alignedWords.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ms-synced-player" style={{ '--synced-accent': accentColor }}>
|
||||||
|
<div className="ms-synced-player__header">
|
||||||
|
<h4 className="ms-synced-player__title">Synced Lyrics</h4>
|
||||||
|
<button type="button" className="ms-modal__close" onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={audioUrl}
|
||||||
|
onPlay={() => setPlaying(true)}
|
||||||
|
onPause={() => setPlaying(false)}
|
||||||
|
onEnded={() => setPlaying(false)}
|
||||||
|
controls
|
||||||
|
className="ms-synced-player__audio"
|
||||||
|
/>
|
||||||
|
<div className="ms-synced-player__lyrics">
|
||||||
|
{alignedWords.map((word, idx) => {
|
||||||
|
const isActive = currentTime >= word.startS && currentTime < word.endS;
|
||||||
|
const isPast = currentTime >= word.endS;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className={`ms-synced-word ${isActive ? 'is-active' : ''} ${isPast ? 'is-past' : ''}`}
|
||||||
|
>
|
||||||
|
{word.word}{' '}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SyncedLyricsPlayer;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import {
|
|||||||
IconMusic,
|
IconMusic,
|
||||||
IconLab,
|
IconLab,
|
||||||
IconTodo,
|
IconTodo,
|
||||||
|
IconBlogMarketing,
|
||||||
} from './components/Icons';
|
} from './components/Icons';
|
||||||
|
|
||||||
const Home = lazy(() => import('./pages/home/Home'));
|
const Home = lazy(() => import('./pages/home/Home'));
|
||||||
@@ -17,13 +18,13 @@ const Lotto = lazy(() => import('./pages/lotto/Lotto'));
|
|||||||
const Travel = lazy(() => import('./pages/travel/Travel'));
|
const Travel = lazy(() => import('./pages/travel/Travel'));
|
||||||
const Stock = lazy(() => import('./pages/stock/Stock'));
|
const Stock = lazy(() => import('./pages/stock/Stock'));
|
||||||
const StockTrade = lazy(() => import('./pages/stock/StockTrade'));
|
const StockTrade = lazy(() => import('./pages/stock/StockTrade'));
|
||||||
const RealEstate = lazy(() => import('./pages/realestate/RealEstate'));
|
|
||||||
const Subscription = lazy(() => import('./pages/subscription/Subscription'));
|
const Subscription = lazy(() => import('./pages/subscription/Subscription'));
|
||||||
const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab'));
|
const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab'));
|
||||||
const SwordStream = lazy(() => import('./pages/effect-lab/SwordStream'));
|
const SwordStream = lazy(() => import('./pages/effect-lab/SwordStream'));
|
||||||
const DayCalc = lazy(() => import('./pages/effect-lab/DayCalc'));
|
const DayCalc = lazy(() => import('./pages/effect-lab/DayCalc'));
|
||||||
const Todo = lazy(() => import('./pages/todo/Todo'));
|
const Todo = lazy(() => import('./pages/todo/Todo'));
|
||||||
const MusicStudio = lazy(() => import('./pages/music/MusicStudio'));
|
const MusicStudio = lazy(() => import('./pages/music/MusicStudio'));
|
||||||
|
const BlogMarketing = lazy(() => import('./pages/blog-marketing/BlogMarketing'));
|
||||||
|
|
||||||
export const navLinks = [
|
export const navLinks = [
|
||||||
{
|
{
|
||||||
@@ -67,7 +68,7 @@ export const navLinks = [
|
|||||||
label: 'Realestate',
|
label: 'Realestate',
|
||||||
path: '/realestate',
|
path: '/realestate',
|
||||||
subtitle: '부동산',
|
subtitle: '부동산',
|
||||||
description: '청약 자격 비교, 일정 관리, 관심 단지 정보를 관리하는 공간',
|
description: '청약 공고 자동 수집, 매칭, 프로필 기반 자격 분석',
|
||||||
icon: <IconBuilding />,
|
icon: <IconBuilding />,
|
||||||
accent: '#f43f5e',
|
accent: '#f43f5e',
|
||||||
},
|
},
|
||||||
@@ -89,6 +90,15 @@ export const navLinks = [
|
|||||||
icon: <IconMusic />,
|
icon: <IconMusic />,
|
||||||
accent: '#f5a623',
|
accent: '#f5a623',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'blog-lab',
|
||||||
|
label: 'Blog Lab',
|
||||||
|
path: '/blog-lab',
|
||||||
|
subtitle: 'MONETIZE',
|
||||||
|
description: 'AI 블로그 마케팅으로 수익을 만드는 연구소',
|
||||||
|
icon: <IconBlogMarketing />,
|
||||||
|
accent: '#10b981',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'lab',
|
id: 'lab',
|
||||||
label: 'Lab',
|
label: 'Lab',
|
||||||
@@ -107,6 +117,15 @@ export const navLinks = [
|
|||||||
icon: <IconTodo />,
|
icon: <IconTodo />,
|
||||||
accent: '#f472b6',
|
accent: '#f472b6',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'agent-office',
|
||||||
|
label: 'Agent Office',
|
||||||
|
path: '/agent-office',
|
||||||
|
subtitle: 'AI LAB',
|
||||||
|
description: 'AI 에이전트 사무실',
|
||||||
|
icon: <span style={{fontSize:'1.2em'}}>🏢</span>,
|
||||||
|
accent: '#8b5cf6',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const appRoutes = [
|
export const appRoutes = [
|
||||||
@@ -134,10 +153,6 @@ export const appRoutes = [
|
|||||||
path: 'realestate',
|
path: 'realestate',
|
||||||
element: <Subscription />,
|
element: <Subscription />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'realestate/property',
|
|
||||||
element: <RealEstate />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'travel',
|
path: 'travel',
|
||||||
element: <Travel />,
|
element: <Travel />,
|
||||||
@@ -158,8 +173,16 @@ export const appRoutes = [
|
|||||||
path: 'music',
|
path: 'music',
|
||||||
element: <MusicStudio />,
|
element: <MusicStudio />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'blog-lab',
|
||||||
|
element: <BlogMarketing />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'todo',
|
path: 'todo',
|
||||||
element: <Todo />,
|
element: <Todo />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'agent-office',
|
||||||
|
lazy: () => import('./pages/agent-office/AgentOffice'),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user