feat(insta): replace Blog Lab page with Insta cards UI

/blog-lab → /insta route. New InstaCards page consumes insta-lab API
(news/keywords/slates + 10-page card preview + prompt template editor).
25개 blog-marketing API helper 제거, 13개 insta helper 추가.
This commit is contained in:
2026-05-16 02:47:19 +09:00
parent f261a80d52
commit a8e411ec22
8 changed files with 689 additions and 955 deletions

View File

@@ -479,113 +479,69 @@ export function deleteBlogPost(id) {
return apiDelete(`/api/blog/posts/${id}`);
}
// ── 블로그 마케팅 API ────────────────────────────────────────────────────────
// ── insta-lab ────────────────────────────────────────────────────────────────
export function getBlogMarketingStatus() {
return apiGet('/api/blog-marketing/status');
export function getInstaStatus() {
return apiGet('/api/insta/status');
}
export function startResearch(keyword) {
return apiPost('/api/blog-marketing/research', { keyword });
export function instaCollectNews(categories) {
return apiPost('/api/insta/news/collect', categories ? { categories } : {});
}
export function getResearchHistory(limit = 30) {
return apiGet(`/api/blog-marketing/research/history?limit=${limit}`);
export function getInstaArticles({ category, days = 7 } = {}) {
const q = new URLSearchParams();
if (category) q.set('category', category);
q.set('days', String(days));
return apiGet(`/api/insta/news/articles?${q.toString()}`);
}
export function getResearchDetail(id) {
return apiGet(`/api/blog-marketing/research/${id}`);
export function instaExtractKeywords(categories) {
return apiPost('/api/insta/keywords/extract', categories ? { categories } : {});
}
export function deleteResearch(id) {
return apiDelete(`/api/blog-marketing/research/${id}`);
export function getInstaKeywords({ category, used } = {}) {
const q = new URLSearchParams();
if (category) q.set('category', category);
if (used !== undefined) q.set('used', used ? 'true' : 'false');
const qs = q.toString();
return apiGet(`/api/insta/keywords${qs ? '?' + qs : ''}`);
}
export function getBlogMarketingTask(taskId) {
return apiGet(`/api/blog-marketing/task/${encodeURIComponent(taskId)}`);
export function createInstaSlate({ keyword, category, keyword_id }) {
return apiPost('/api/insta/slates', { keyword, category, keyword_id });
}
export function startGenerate(keywordId) {
return apiPost('/api/blog-marketing/generate', { keyword_id: keywordId });
export function getInstaSlates(limit = 50) {
return apiGet(`/api/insta/slates?limit=${limit}`);
}
export function startReview(postId) {
return apiPost(`/api/blog-marketing/review/${postId}`);
export function getInstaSlate(id) {
return apiGet(`/api/insta/slates/${id}`);
}
export function startRegenerate(postId) {
return apiPost(`/api/blog-marketing/regenerate/${postId}`);
export function renderInstaSlate(id) {
return apiPost(`/api/insta/slates/${id}/render`);
}
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 deleteInstaSlate(id) {
return apiDelete(`/api/insta/slates/${id}`);
}
export function getBlogMarketingPost(id) {
return apiGet(`/api/blog-marketing/posts/${id}`);
export function getInstaAssetUrl(slateId, page) {
return `/api/insta/slates/${slateId}/assets/${page}`;
}
export function updateBlogMarketingPost(id, data) {
return apiPut(`/api/blog-marketing/posts/${id}`, data);
export function getInstaTask(taskId) {
return apiGet(`/api/insta/tasks/${encodeURIComponent(taskId)}`);
}
export function deleteBlogMarketingPost(id) {
return apiDelete(`/api/blog-marketing/posts/${id}`);
export function getInstaPrompt(name) {
return apiGet(`/api/insta/templates/prompts/${encodeURIComponent(name)}`);
}
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}`);
export function putInstaPrompt(name, template, description = '') {
return apiPut(`/api/insta/templates/prompts/${encodeURIComponent(name)}`, { template, description });
}
// ── Agent Office ──────────────────────────────────

View File

@@ -125,3 +125,12 @@ export const IconBuilding = () =>
<rect x="11" y="16" width="3" height="3" />
</>
);
export const IconInsta = () =>
svg(
<>
<rect x="2" y="2" width="20" height="20" rx="5" />
<circle cx="12" cy="12" r="4" />
<circle cx="17.5" cy="6.5" r="1" fill="currentColor" strokeWidth="0" />
</>
);

View File

@@ -1,154 +0,0 @@
/* ── 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: 768px) {
.bm-tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.bm-tabs > * {
flex-shrink: 0;
white-space: nowrap;
}
}
@media (max-width: 480px) {
.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: 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; }
}
@media (prefers-reduced-motion: reduce) {
.bm-spinner { animation: none; }
}

View File

@@ -1,706 +0,0 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import PullToRefresh from '../../components/PullToRefresh';
import FAB from '../../components/FAB';
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);
const loadStatus = useCallback(() => {
return getBlogMarketingStatus().then(setStatus).catch(() => {});
}, []);
useEffect(() => {
loadStatus();
}, [loadStatus]);
const tabs = [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'research', label: 'Research' },
{ id: 'write', label: 'Write' },
{ id: 'posts', label: 'Posts' },
];
return (
<PullToRefresh onRefresh={loadStatus}>
<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 />}
{tab === 'write' && <WriteTab />}
{tab === 'posts' && <PostsTab />}
<FAB onClick={() => setTab('research')} label="키워드 분석" />
</div>
</PullToRefresh>
);
}
/* ══════════════════════ 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 }}>&#9997;</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>
);
}

View File

@@ -0,0 +1,102 @@
/* ── InstaCards ──────────────────────────────────────────────────────────── */
.ic { max-width: 1100px; margin: 0 auto; padding: 24px 16px 80px; }
/* 헤더 */
.ic-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.ic-header h1 { font-size: 1.5rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin: 0; }
.ic-status-badges { display: flex; gap: 8px; margin-left: auto; }
.ic-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 99px; background: rgba(236,72,153,.15); color: #ec4899; }
.ic-badge--on { background: rgba(16,185,129,.15); color: #10b981; }
.ic-badge--off { background: rgba(239,68,68,.12); color: #ef4444; }
/* 버튼 공통 */
.ic-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; }
.ic-btn--primary { background: #ec4899; color: #fff; }
.ic-btn--primary:hover { background: #db2777; }
.ic-btn--primary:disabled { opacity: .5; cursor: not-allowed; }
.ic-btn--secondary { background: rgba(255,255,255,.08); color: rgba(255,255,255,.7); }
.ic-btn--secondary:hover { background: rgba(255,255,255,.12); }
.ic-btn--secondary:disabled { opacity: .5; cursor: not-allowed; }
.ic-btn--danger { background: rgba(239,68,68,.15); color: #ef4444; }
.ic-btn--danger:hover { background: rgba(239,68,68,.25); }
.ic-btn--sm { padding: 4px 10px; font-size: 0.75rem; }
.ic-spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: ic-spin .6s linear infinite; display: inline-block; }
@keyframes ic-spin { to { transform: rotate(360deg); } }
/* 레이아웃: 모바일 1컬럼, 데스크탑 2컬럼 */
.ic-layout { display: grid; grid-template-columns: 1fr; gap: 20px; }
@media (min-width: 768px) {
.ic-layout { grid-template-columns: 320px 1fr; }
}
/* 섹션 카드 */
.ic-section { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; }
.ic-section__title { font-size: 0.85rem; font-weight: 700; color: rgba(255,255,255,.6); text-transform: uppercase; letter-spacing: .05em; margin: 0 0 14px; }
/* 트리거 패널 */
.ic-trigger-buttons { display: flex; flex-direction: column; gap: 10px; }
.ic-task-status { margin-top: 10px; padding: 10px 12px; background: rgba(255,255,255,.03); border-radius: 8px; font-size: 0.8rem; }
.ic-task-status__label { color: rgba(255,255,255,.4); margin-bottom: 4px; }
.ic-task-status__msg { color: var(--text-primary, #e4e4e7); }
.ic-task-status__progress { margin-top: 6px; height: 3px; background: rgba(255,255,255,.08); border-radius: 2px; }
.ic-task-status__fill { height: 100%; background: #ec4899; border-radius: 2px; transition: width .3s; }
/* 카테고리 필터 */
.ic-filter { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.ic-filter-btn { padding: 4px 12px; border-radius: 99px; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.04); color: rgba(255,255,255,.5); font-size: 0.75rem; cursor: pointer; transition: all .15s; }
.ic-filter-btn--active { background: rgba(236,72,153,.18); border-color: #ec4899; color: #ec4899; }
/* 키워드 목록 */
.ic-keywords { display: flex; flex-direction: column; gap: 8px; }
.ic-keyword-row { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: rgba(255,255,255,.03); border-radius: 8px; }
.ic-keyword-row__kw { font-size: 0.9rem; font-weight: 600; color: var(--text-primary, #e4e4e7); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ic-keyword-row__meta { font-size: 0.72rem; color: rgba(255,255,255,.35); white-space: nowrap; }
.ic-keyword-row__score { font-size: 0.75rem; font-weight: 700; color: #ec4899; min-width: 36px; text-align: right; }
/* 슬레이트 그리드 */
.ic-slates-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
.ic-slate-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 10px; overflow: hidden; cursor: pointer; transition: border-color .15s; }
.ic-slate-card:hover { border-color: rgba(236,72,153,.4); }
.ic-slate-card--active { border-color: #ec4899; }
.ic-slate-thumb { width: 100%; aspect-ratio: 4/5; object-fit: cover; background: rgba(255,255,255,.06); display: block; }
.ic-slate-thumb--placeholder { width: 100%; aspect-ratio: 4/5; background: rgba(255,255,255,.04); display: flex; align-items: center; justify-content: center; font-size: 1.8rem; }
.ic-slate-card__info { padding: 8px; }
.ic-slate-card__kw { font-size: 0.78rem; font-weight: 600; color: var(--text-primary, #e4e4e7); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ic-slate-card__meta { display: flex; align-items: center; justify-content: space-between; margin-top: 4px; }
.ic-slate-card__date { font-size: 0.65rem; color: rgba(255,255,255,.3); }
/* 상태 뱃지 */
.ic-status-badge { font-size: 0.65rem; padding: 1px 6px; border-radius: 99px; font-weight: 600; }
.ic-status-badge--draft { background: rgba(161,161,170,.15); color: #a1a1aa; }
.ic-status-badge--rendered { background: rgba(96,165,250,.15); color: #60a5fa; }
.ic-status-badge--sent { background: rgba(16,185,129,.15); color: #10b981; }
.ic-status-badge--failed { background: rgba(239,68,68,.12); color: #ef4444; }
/* 슬레이트 상세 패널 */
.ic-detail { margin-top: 20px; padding: 16px; background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; }
.ic-detail__header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
.ic-detail__title { font-size: 1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); flex: 1; }
.ic-detail__actions { display: flex; gap: 8px; }
.ic-pages-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 8px; margin-bottom: 14px; scroll-snap-type: x mandatory; }
.ic-page-img { width: 120px; flex-shrink: 0; aspect-ratio: 4/5; border-radius: 6px; object-fit: cover; scroll-snap-align: start; border: 1px solid rgba(255,255,255,.08); background: rgba(255,255,255,.04); }
.ic-caption-box { background: rgba(255,255,255,.03); border-radius: 8px; padding: 12px; margin-bottom: 10px; }
.ic-caption-box__label { font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,.4); text-transform: uppercase; margin-bottom: 6px; }
.ic-caption-text { font-size: 0.85rem; color: var(--text-primary, #e4e4e7); line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
.ic-hashtags { font-size: 0.8rem; color: #60a5fa; line-height: 1.8; word-break: break-all; }
/* 프롬프트 에디터 */
.ic-prompt-editor { margin-top: 20px; }
.ic-prompt-editor__title { font-size: 0.85rem; font-weight: 700; color: rgba(255,255,255,.5); margin-bottom: 12px; text-transform: uppercase; }
.ic-prompt-block { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 10px; padding: 14px; margin-bottom: 12px; }
.ic-prompt-block__head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.ic-prompt-block__name { font-size: 0.8rem; font-weight: 700; color: rgba(255,255,255,.7); flex: 1; }
.ic-prompt-block__date { font-size: 0.68rem; color: rgba(255,255,255,.3); }
.ic-prompt-textarea { width: 100%; min-height: 140px; background: rgba(0,0,0,.3); border: 1px solid rgba(255,255,255,.1); border-radius: 6px; color: var(--text-primary, #e4e4e7); font-size: 0.8rem; font-family: monospace; line-height: 1.5; padding: 10px; resize: vertical; box-sizing: border-box; outline: none; }
.ic-prompt-textarea:focus { border-color: #ec4899; }
.ic-prompt-save-row { display: flex; justify-content: flex-end; margin-top: 8px; }
/* 빈 상태 */
.ic-empty { text-align: center; padding: 40px 20px; color: rgba(255,255,255,.3); font-size: 0.85rem; }

View File

@@ -0,0 +1,525 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import PullToRefresh from '../../components/PullToRefresh';
import {
getInstaStatus,
instaCollectNews,
instaExtractKeywords,
getInstaKeywords,
createInstaSlate,
getInstaSlates,
getInstaSlate,
renderInstaSlate,
deleteInstaSlate,
getInstaAssetUrl,
getInstaTask,
getInstaPrompt,
putInstaPrompt,
} from '../../api';
import './InstaCards.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 StatusBadge({ status }) {
return (
<span className={`ic-status-badge ic-status-badge--${status || 'draft'}`}>
{status || 'draft'}
</span>
);
}
/* ────────────────────── 폴링 훅 ────────────────────── */
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 getInstaTask(taskId);
if (cancelled) return;
setTask(t);
if (t.status === 'succeeded' || t.status === 'failed') {
setTaskId(null);
onDone?.(t);
} else {
timer.current = setTimeout(poll, 3000);
}
} 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); },
};
}
/* ────────────────────── TaskStatusBox ────────────────────── */
function TaskStatusBox({ task }) {
if (!task) return null;
const pct = task.progress != null ? task.progress : (task.status === 'succeeded' ? 100 : 0);
return (
<div className="ic-task-status">
<div className="ic-task-status__label">
{task.status === 'succeeded' ? '완료' : task.status === 'failed' ? '실패' : '진행 중'}
</div>
<div className="ic-task-status__msg">{task.message || task.error || ''}</div>
<div className="ic-task-status__progress">
<div className="ic-task-status__fill" style={{ width: `${pct}%` }} />
</div>
</div>
);
}
/* ══════════════════════════════════════════════════════════════════════════ */
export default function InstaCards() {
const [status, setStatus] = useState(null);
const [selectedSlateId, setSelectedSlateId] = useState(null);
const loadStatus = useCallback(() => {
return getInstaStatus().then(setStatus).catch(() => {});
}, []);
useEffect(() => {
loadStatus();
}, [loadStatus]);
return (
<PullToRefresh onRefresh={loadStatus}>
<div className="ic">
{/* 헤더 + 상태 배너 */}
<header className="ic-header">
<h1>Insta Cards</h1>
{status && (
<div className="ic-status-badges">
<span className={`ic-badge ${status.naver_api ? 'ic-badge--on' : 'ic-badge--off'}`}>
Naver {status.naver_api ? 'ON' : 'OFF'}
</span>
<span className={`ic-badge ${status.anthropic_api ? 'ic-badge--on' : 'ic-badge--off'}`}>
AI {status.anthropic_api ? 'ON' : 'OFF'}
</span>
</div>
)}
</header>
<div className="ic-layout">
{/* 왼쪽: 트리거 + 키워드 */}
<div>
<TriggerPanel />
<div style={{ height: 16 }} />
<KeywordsPanel onCreateSlate={() => setSelectedSlateId(null)} />
</div>
{/* 오른쪽: 슬레이트 목록 + 상세 */}
<div>
<SlatesPanel
selectedId={selectedSlateId}
onSelect={setSelectedSlateId}
/>
</div>
</div>
<PromptTemplatesEditor />
</div>
</PullToRefresh>
);
}
/* ══════════════════════ 트리거 패널 ══════════════════════════════════════ */
function TriggerPanel() {
const collectPoll = usePollTask();
const keywordsPoll = usePollTask();
async function handleCollect() {
try {
const res = await instaCollectNews();
collectPoll.start(res.task_id);
} catch (e) {
alert('뉴스 수집 실패: ' + e.message);
}
}
async function handleKeywords() {
try {
const res = await instaExtractKeywords();
keywordsPoll.start(res.task_id);
} catch (e) {
alert('키워드 추출 실패: ' + e.message);
}
}
const collectBusy = !!collectPoll.taskId;
const kwBusy = !!keywordsPoll.taskId;
return (
<div className="ic-section">
<p className="ic-section__title">트리거</p>
<div className="ic-trigger-buttons">
<button
className="ic-btn ic-btn--primary"
onClick={handleCollect}
disabled={collectBusy}
>
{collectBusy && <span className="ic-spinner" />}
뉴스 수집
</button>
<TaskStatusBox task={collectPoll.task} />
<button
className="ic-btn ic-btn--secondary"
onClick={handleKeywords}
disabled={kwBusy}
>
{kwBusy && <span className="ic-spinner" />}
키워드 추출
</button>
<TaskStatusBox task={keywordsPoll.task} />
</div>
</div>
);
}
/* ══════════════════════ 키워드 목록 ══════════════════════════════════════ */
const CATEGORIES = ['전체', 'economy', 'psychology', 'celebrity'];
function KeywordsPanel({ onCreateSlate }) {
const [category, setCategory] = useState('전체');
const [keywords, setKeywords] = useState([]);
const [creating, setCreating] = useState(null); // keyword_id being created
const slatePoll = usePollTask((t) => {
if (t.status === 'succeeded') onCreateSlate?.();
setCreating(null);
});
const load = useCallback(() => {
const cat = category === '전체' ? undefined : category;
getInstaKeywords({ category: cat }).then((r) => setKeywords(r.items || [])).catch(() => {});
}, [category]);
useEffect(() => { load(); }, [load]);
async function handleCreate(kw) {
if (creating) return;
setCreating(kw.id);
try {
const res = await createInstaSlate({
keyword: kw.keyword,
category: kw.category,
keyword_id: kw.id,
});
slatePoll.start(res.task_id);
} catch (e) {
alert('카드 생성 실패: ' + e.message);
setCreating(null);
}
}
return (
<div className="ic-section">
<p className="ic-section__title">트렌딩 키워드</p>
{/* 카테고리 필터 */}
<div className="ic-filter">
{CATEGORIES.map((c) => (
<button
key={c}
className={`ic-filter-btn ${category === c ? 'ic-filter-btn--active' : ''}`}
onClick={() => setCategory(c)}
>
{c}
</button>
))}
</div>
{slatePoll.task && <TaskStatusBox task={slatePoll.task} />}
{keywords.length === 0 ? (
<div className="ic-empty">키워드가 없습니다. 키워드 추출을 실행하세요.</div>
) : (
<div className="ic-keywords">
{keywords.map((kw) => (
<div key={kw.id} className="ic-keyword-row">
<span className="ic-keyword-row__kw">{kw.keyword}</span>
<span className="ic-keyword-row__meta">
{kw.category} · {kw.articles_count ?? 0}
</span>
<span className="ic-keyword-row__score">{kw.score?.toFixed(1) ?? '-'}</span>
<button
className="ic-btn ic-btn--primary ic-btn--sm"
onClick={() => handleCreate(kw)}
disabled={!!creating}
>
{creating === kw.id ? <span className="ic-spinner" /> : '🎴'}
</button>
</div>
))}
</div>
)}
</div>
);
}
/* ══════════════════════ 슬레이트 목록 ══════════════════════════════════ */
function SlatesPanel({ selectedId, onSelect }) {
const [slates, setSlates] = useState([]);
const [detail, setDetail] = useState(null);
const loadSlates = useCallback(() => {
getInstaSlates(50).then((r) => setSlates(r.items || [])).catch(() => {});
}, []);
useEffect(() => { loadSlates(); }, [loadSlates]);
useEffect(() => {
if (!selectedId) { setDetail(null); return; }
getInstaSlate(selectedId).then(setDetail).catch(() => setDetail(null));
}, [selectedId]);
function handleSelect(id) {
onSelect(id === selectedId ? null : id);
}
async function handleDelete(id) {
if (!confirm('슬레이트를 삭제하시겠습니까?')) return;
try {
await deleteInstaSlate(id);
if (selectedId === id) onSelect(null);
loadSlates();
} catch (e) {
alert('삭제 실패: ' + e.message);
}
}
async function handleRender(id) {
try {
const res = await renderInstaSlate(id);
// Re-render is fire-and-forget from the panel; user can refresh detail
alert('재렌더 요청 완료 (task: ' + res.task_id + ')');
setTimeout(loadSlates, 3000);
} catch (e) {
alert('재렌더 실패: ' + e.message);
}
}
return (
<div>
<div className="ic-section">
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 14 }}>
<p className="ic-section__title" style={{ margin: 0, flex: 1 }}>슬레이트 목록</p>
<button className="ic-btn ic-btn--secondary ic-btn--sm" onClick={loadSlates}> 새로고침</button>
</div>
{slates.length === 0 ? (
<div className="ic-empty">슬레이트가 없습니다. 카드를 생성해 보세요.</div>
) : (
<div className="ic-slates-grid">
{slates.map((s) => (
<div
key={s.id}
className={`ic-slate-card ${selectedId === s.id ? 'ic-slate-card--active' : ''}`}
onClick={() => handleSelect(s.id)}
>
{s.status === 'rendered' || s.status === 'sent' ? (
<img
className="ic-slate-thumb"
src={getInstaAssetUrl(s.id, 1)}
alt={s.keyword}
loading="lazy"
/>
) : (
<div className="ic-slate-thumb--placeholder">🎴</div>
)}
<div className="ic-slate-card__info">
<div className="ic-slate-card__kw">{s.keyword}</div>
<div className="ic-slate-card__meta">
<span className="ic-slate-card__date">{fmtDate(s.created_at)}</span>
<StatusBadge status={s.status} />
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* 슬레이트 상세 */}
{detail && (
<SlateDetail
slate={detail}
onDelete={() => handleDelete(detail.id)}
onRender={() => handleRender(detail.id)}
/>
)}
</div>
);
}
/* ══════════════════════ 슬레이트 상세 ══════════════════════════════════ */
function SlateDetail({ slate, onDelete, onRender }) {
const pages = slate.assets || [];
const pageCount = pages.length > 0 ? pages.length : 10;
function copyCaption() {
const text = [slate.suggested_caption, slate.hashtags?.join(' ')].filter(Boolean).join('\n\n');
navigator.clipboard.writeText(text).then(() => alert('클립보드에 복사되었습니다!'));
}
return (
<div className="ic-detail">
<div className="ic-detail__header">
<div className="ic-detail__title">
{slate.keyword}
<span style={{ marginLeft: 8 }}><StatusBadge status={slate.status} /></span>
</div>
<div className="ic-detail__actions">
<button className="ic-btn ic-btn--secondary ic-btn--sm" onClick={onRender}>재렌더</button>
<button className="ic-btn ic-btn--danger ic-btn--sm" onClick={onDelete}>삭제</button>
</div>
</div>
{/* 페이지 이미지 스트립 */}
{(slate.status === 'rendered' || slate.status === 'sent') ? (
<div className="ic-pages-strip">
{Array.from({ length: pageCount }, (_, i) => i + 1).map((page) => (
<img
key={page}
className="ic-page-img"
src={getInstaAssetUrl(slate.id, page)}
alt={`Page ${page}`}
loading="lazy"
/>
))}
</div>
) : (
<div className="ic-empty" style={{ padding: '20px 0' }}>
{slate.status === 'failed' ? '렌더 실패 — 재렌더를 시도하세요.' : '렌더링 전입니다.'}
</div>
)}
{/* 캡션 */}
{slate.suggested_caption && (
<div className="ic-caption-box">
<div className="ic-caption-box__label">
캡션
<button
className="ic-btn ic-btn--secondary ic-btn--sm"
style={{ marginLeft: 8 }}
onClick={copyCaption}
>
복사
</button>
</div>
<div className="ic-caption-text">{slate.suggested_caption}</div>
{slate.hashtags?.length > 0 && (
<div className="ic-hashtags" style={{ marginTop: 8 }}>
{slate.hashtags.join(' ')}
</div>
)}
</div>
)}
{/* 커버 카피 / 바디 카피 */}
{slate.cover_copy && (
<div className="ic-caption-box">
<div className="ic-caption-box__label">커버 카피</div>
<div className="ic-caption-text">{slate.cover_copy}</div>
</div>
)}
</div>
);
}
/* ══════════════════════ 프롬프트 템플릿 에디터 ══════════════════════════ */
const PROMPT_NAMES = ['slate_writer', 'category_seeds'];
function PromptTemplatesEditor() {
const [prompts, setPrompts] = useState({});
const [drafts, setDrafts] = useState({});
const [saving, setSaving] = useState({});
useEffect(() => {
PROMPT_NAMES.forEach((name) => {
getInstaPrompt(name)
.then((p) => {
setPrompts((prev) => ({ ...prev, [name]: p }));
setDrafts((prev) => ({ ...prev, [name]: p.template }));
})
.catch(() => {
setPrompts((prev) => ({ ...prev, [name]: null }));
setDrafts((prev) => ({ ...prev, [name]: '' }));
});
});
}, []);
async function handleSave(name) {
setSaving((prev) => ({ ...prev, [name]: true }));
try {
const updated = await putInstaPrompt(name, drafts[name] || '', prompts[name]?.description || '');
setPrompts((prev) => ({ ...prev, [name]: updated }));
alert(`${name} 저장 완료`);
} catch (e) {
alert('저장 실패: ' + e.message);
} finally {
setSaving((prev) => ({ ...prev, [name]: false }));
}
}
return (
<div className="ic-prompt-editor" style={{ marginTop: 24 }}>
<p className="ic-prompt-editor__title">프롬프트 템플릿</p>
{PROMPT_NAMES.map((name) => (
<div key={name} className="ic-prompt-block">
<div className="ic-prompt-block__head">
<span className="ic-prompt-block__name">{name}</span>
{prompts[name]?.updated_at && (
<span className="ic-prompt-block__date">
최종 수정: {fmtDate(prompts[name].updated_at)}
</span>
)}
</div>
{prompts[name]?.description && (
<div style={{ fontSize: '0.75rem', color: 'rgba(255,255,255,.4)', marginBottom: 6 }}>
{prompts[name].description}
</div>
)}
<textarea
className="ic-prompt-textarea"
value={drafts[name] ?? ''}
onChange={(e) => setDrafts((prev) => ({ ...prev, [name]: e.target.value }))}
placeholder={`${name} 템플릿을 입력하세요...`}
/>
<div className="ic-prompt-save-row">
<button
className="ic-btn ic-btn--primary ic-btn--sm"
onClick={() => handleSave(name)}
disabled={saving[name]}
>
{saving[name] ? <span className="ic-spinner" /> : null}
저장
</button>
</div>
</div>
))}
</div>
);
}

View File

@@ -9,7 +9,7 @@ import {
IconMusic,
IconLab,
IconTodo,
IconBlogMarketing,
IconInsta,
IconPortfolio,
} from './components/Icons';
@@ -26,7 +26,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'));
const InstaCards = lazy(() => import('./pages/insta/InstaCards'));
const Portfolio = lazy(() => import('./pages/portfolio/Portfolio'));
export const navLinks = [
@@ -103,13 +103,13 @@ export const navLinks = [
accent: '#f43f5e',
},
{
id: 'blog-lab',
label: 'Blog Lab',
path: '/blog-lab',
subtitle: 'MONETIZE',
description: 'AI 블로그 마케팅으로 수익을 만드는 연구소',
icon: <IconBlogMarketing />,
accent: '#10b981',
id: 'insta',
label: 'Insta',
path: '/insta',
subtitle: 'CARD FEED',
description: '뉴스에서 키워드 발굴 → AI 카드 10장 자동 생성 → 인스타 업로드',
icon: <IconInsta />,
accent: '#ec4899',
},
{
id: 'todo',
@@ -190,8 +190,8 @@ export const appRoutes = [
element: <MusicStudio />,
},
{
path: 'blog-lab',
element: <BlogMarketing />,
path: 'insta',
element: <InstaCards />,
},
{
path: 'todo',