Blog Lab 페이지 추가 (블로그 마케팅 수익화)
4탭 구성: Dashboard, Research, Write, Posts - BlogMarketing.jsx/css: 키워드 분석, AI 글 생성, 품질 리뷰, 발행 관리 - api.js: blog-marketing API 함수 15개 추가 - routes.jsx + Icons.jsx: Blog Lab 네비게이션 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
83
src/api.js
83
src/api.js
@@ -423,3 +423,86 @@ export function deleteBlogPost(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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = () =>
|
||||
svg(
|
||||
<>
|
||||
|
||||
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; }
|
||||
}
|
||||
566
src/pages/blog-marketing/BlogMarketing.jsx
Normal file
566
src/pages/blog-marketing/BlogMarketing.jsx
Normal file
@@ -0,0 +1,566 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
getBlogMarketingStatus,
|
||||
startResearch,
|
||||
getResearchHistory,
|
||||
getResearchDetail,
|
||||
deleteResearch,
|
||||
getBlogMarketingTask,
|
||||
startGenerate,
|
||||
startReview,
|
||||
startRegenerate,
|
||||
getBlogMarketingPosts,
|
||||
getBlogMarketingPost,
|
||||
deleteBlogMarketingPost,
|
||||
publishBlogMarketingPost,
|
||||
getBlogMarketingDashboard,
|
||||
getBlogMarketingCommissions,
|
||||
addBlogMarketingCommission,
|
||||
deleteBlogMarketingCommission,
|
||||
} 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 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 loadDrafts = useCallback(() => {
|
||||
getBlogMarketingPosts('draft', 20).then(r => {
|
||||
const drafts = r.posts || [];
|
||||
setPosts(drafts);
|
||||
if (drafts.length > 0 && !selected) setSelected(drafts[0].id);
|
||||
}).catch(() => {});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => { loadDrafts(); }, [loadDrafts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) { setPost(null); return; }
|
||||
getBlogMarketingPost(selected).then(setPost).catch(() => {});
|
||||
}, [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 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 handleCopy = () => {
|
||||
if (!post) return;
|
||||
navigator.clipboard.writeText(post.body).then(() => alert('본문이 클립보드에 복사되었습니다!'));
|
||||
};
|
||||
|
||||
const activePoll = reviewPoll.task || regenPoll.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) || `Draft #${p.id}`}
|
||||
</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-preview">
|
||||
<div className="bm-preview__title">{post.title || '(제목 없음)'}</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}/50 {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">
|
||||
<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) => {
|
||||
navigator.clipboard.writeText(body).then(() => alert('복사 완료!'));
|
||||
};
|
||||
|
||||
const filters = [
|
||||
{ id: '', label: '전체' },
|
||||
{ id: 'draft', label: 'Draft' },
|
||||
{ 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}/50</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)}>삭<EFBFBD><EFBFBD></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>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IconMusic,
|
||||
IconLab,
|
||||
IconTodo,
|
||||
IconBlogMarketing,
|
||||
} from './components/Icons';
|
||||
|
||||
const Home = lazy(() => import('./pages/home/Home'));
|
||||
@@ -24,6 +25,7 @@ const SwordStream = lazy(() => import('./pages/effect-lab/SwordStream'));
|
||||
const DayCalc = lazy(() => import('./pages/effect-lab/DayCalc'));
|
||||
const Todo = lazy(() => import('./pages/todo/Todo'));
|
||||
const MusicStudio = lazy(() => import('./pages/music/MusicStudio'));
|
||||
const BlogMarketing = lazy(() => import('./pages/blog-marketing/BlogMarketing'));
|
||||
|
||||
export const navLinks = [
|
||||
{
|
||||
@@ -89,6 +91,15 @@ export const navLinks = [
|
||||
icon: <IconMusic />,
|
||||
accent: '#f5a623',
|
||||
},
|
||||
{
|
||||
id: 'blog-lab',
|
||||
label: 'Blog Lab',
|
||||
path: '/blog-lab',
|
||||
subtitle: 'MONETIZE',
|
||||
description: 'AI 블로그 마케팅으로 수익을 만드는 연구소',
|
||||
icon: <IconBlogMarketing />,
|
||||
accent: '#10b981',
|
||||
},
|
||||
{
|
||||
id: 'lab',
|
||||
label: 'Lab',
|
||||
@@ -158,6 +169,10 @@ export const appRoutes = [
|
||||
path: 'music',
|
||||
element: <MusicStudio />,
|
||||
},
|
||||
{
|
||||
path: 'blog-lab',
|
||||
element: <BlogMarketing />,
|
||||
},
|
||||
{
|
||||
path: 'todo',
|
||||
element: <Todo />,
|
||||
|
||||
Reference in New Issue
Block a user