diff --git a/CLAUDE.md b/CLAUDE.md index caa774c..2ec8822 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ | `/lab/day-calc` | `DayCalc` | 날짜 계산기 | | `/lab/music` | `MusicStudio` | AI 음악 생성 스튜디오 (Sonic Forge) | | `/todo` | `Todo` | 태스크 보드 | -| `/blog-lab` | `BlogMarketing` | 블로그 마케팅 수익화 대시보드 | +| `/insta` | `InstaCards` | 뉴스 키워드 발굴 → AI 카드 10장 자동 생성 → 인스타 업로드 | | `/agent-office` | `AgentOffice` | AI 에이전트 가상 오피스 (WebSocket + 채팅) | | `/portfolio` | `Portfolio` | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개) | @@ -113,9 +113,11 @@ proxy: { | 여행 | POST | `/api/travel/sync` | | 여행 | PUT | `/api/travel/albums/:album/cover`, `/api/travel/albums/:album/region` | | 여행 | PUT | `/api/travel/regions/:id` | -| 블로그마케팅 | POST | `/api/blog-marketing/research`, `/api/blog-marketing/generate` | -| 블로그마케팅 | GET | `/api/blog-marketing/posts`, `/api/blog-marketing/dashboard` | -| 블로그마케팅 | POST | `/api/blog-marketing/market/:id`, `/api/blog-marketing/review/:id` | +| 인스타 | GET | `/api/insta/status`, `/api/insta/news/articles`, `/api/insta/keywords`, `/api/insta/slates`, `/api/insta/slates/:id` | +| 인스타 | POST | `/api/insta/news/collect`, `/api/insta/keywords/extract`, `/api/insta/slates`, `/api/insta/slates/:id/render` | +| 인스타 | DELETE | `/api/insta/slates/:id` | +| 인스타 | GET/PUT | `/api/insta/templates/prompts/:name` | +| 인스타 | GET | `/api/insta/tasks/:task_id` | | 에이전트 | GET | `/api/agent-office/agents`, `/api/agent-office/states` | | 에이전트 | POST | `/api/agent-office/command`, `/api/agent-office/approve` | | 에이전트 | WS | `/api/agent-office/ws` | diff --git a/src/api.js b/src/api.js index a146af6..3984890 100644 --- a/src/api.js +++ b/src/api.js @@ -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 ────────────────────────────────── diff --git a/src/components/Icons.jsx b/src/components/Icons.jsx index 5a65614..3ffd126 100644 --- a/src/components/Icons.jsx +++ b/src/components/Icons.jsx @@ -125,3 +125,12 @@ export const IconBuilding = () => ); + +export const IconInsta = () => + svg( + <> + + + + + ); diff --git a/src/pages/blog-marketing/BlogMarketing.css b/src/pages/blog-marketing/BlogMarketing.css deleted file mode 100644 index 811c344..0000000 --- a/src/pages/blog-marketing/BlogMarketing.css +++ /dev/null @@ -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; } -} diff --git a/src/pages/blog-marketing/BlogMarketing.jsx b/src/pages/blog-marketing/BlogMarketing.jsx deleted file mode 100644 index 57ea783..0000000 --- a/src/pages/blog-marketing/BlogMarketing.jsx +++ /dev/null @@ -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 ( - -
-
-

Blog Lab

- {status && ( -
- - Naver {status.naver_api ? 'ON' : 'OFF'} - - - Claude {status.claude_api ? 'ON' : 'OFF'} - -
- )} -
- - - - {tab === 'dashboard' && } - {tab === 'research' && } - {tab === 'write' && } - {tab === 'posts' && } - - setTab('research')} label="키워드 분석" /> -
-
- ); -} - -/* ══════════════════════ Dashboard 탭 ═════════════════════════════════════ */ -function DashboardTab() { - const [data, setData] = useState(null); - - useEffect(() => { - getBlogMarketingDashboard().then(setData).catch(() => {}); - }, []); - - if (!data) return
로딩 중...
; - - return ( -
-
- - - - -
- - {data.top_posts?.length > 0 && ( -
-

Top 5 포스트 (수익 기준)

-
- {data.top_posts.map(p => ( -
- {p.title || '(제목 없음)'} - {fmtMoney(p.total_revenue)} -
- ))} -
-
- )} - - {data.monthly?.length > 0 && ( -
-

월별 수익

-
- {data.monthly.map(m => ( -
- {m.month} - - 클릭 {m.clicks} / 구매 {m.purchases} - - {fmtMoney(m.revenue)} -
- ))} -
-
- )} -
- ); -} - -function DashCard({ label, value, green }) { - return ( -
-
{label}
-
{value}
-
- ); -} - -/* ══════════════════════ 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 ( -
-
- setKeyword(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleSearch()} - disabled={!!poll.taskId} - /> - -
- - {poll.task && poll.task.status !== 'succeeded' && poll.task.status !== 'failed' && ( -
-
-
-
-
{poll.task.message || '처리 중...'}
-
- )} - -
- {analyses.length === 0 && !poll.taskId && ( -
아직 분석 결과가 없습니다. 키워드를 입력해 첫 분석을 시작하세요!
- )} - {analyses.map(a => ( -
-
- {a.keyword} - {fmtDate(a.created_at)} -
-
-
- 경쟁도 - {a.competition} -
-
- 기회 - {a.opportunity} -
-
- 블로그 - - {(a.blog_total || 0).toLocaleString()} - -
-
- 쇼핑 - - {(a.shop_total || 0).toLocaleString()} - -
- {a.avg_price != null && ( -
- 평균가 - - {fmtMoney(a.avg_price)} - -
- )} -
- - {expanded === a.id && a.top_products?.length > 0 && ( -
- 상위 상품: -
    - {a.top_products.map((p, i) => ( -
  • {p.title} — {fmtMoney(p.lprice)} ({p.mallName})
  • - ))} -
-
- )} - -
- - - -
-
- ))} -
-
- ); -} - -/* ══════════════════════ 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 ( -
-
-

아직 작성 중인 글이 없습니다.
Research 탭에서 키워드를 분석하고 글 생성을 시작하세요.

-
- ); - } - - return ( -
- {posts.length > 1 && ( -
- {posts.map(p => ( - - ))} -
- )} - - {isProcessing && activePoll && ( -
-
-
-
-
{activePoll.message || '처리 중...'}
-
- )} - - {post && ( - <> - {/* 브랜드커넥트 링크 섹션 */} -
-
-

브랜드커넥트 링크 ({links.length})

- -
- - {showLinkForm && ( -
- setLinkForm(p => ({ ...p, url: e.target.value }))} - style={{ fontSize: '0.85rem' }} - /> - setLinkForm(p => ({ ...p, product_name: e.target.value }))} - style={{ fontSize: '0.85rem' }} - /> - setLinkForm(p => ({ ...p, description: e.target.value }))} - style={{ fontSize: '0.85rem' }} - /> - setLinkForm(p => ({ ...p, placement_hint: e.target.value }))} - style={{ fontSize: '0.85rem' }} - /> - -
- )} - - {links.length > 0 && ( -
- {links.map(l => ( -
-
- {l.product_name} - {l.description && {l.description}} -
- -
- ))} -
- )} -
- -
-
-
{post.title || '(제목 없음)'}
- - {post.status} - -
-
- {post.tags?.length > 0 && ( -
- {post.tags.map((t, i) => #{t})} -
- )} -
- - {post.review_detail && post.review_score != null && ( -
-

품질 리뷰 결과

-
- {Object.entries(post.review_detail.scores || {}).map(([k, v]) => ( -
- {k} - {v} -
- ))} -
-
- 총점: {post.review_score}/60 {post.review_detail.pass ? '(통과)' : '(미달)'} -
- {post.review_detail.feedback && ( -
{post.review_detail.feedback}
- )} -
- )} - -
- {post.status === 'draft' && ( - - )} - - - -
- - )} -
- ); -} - -/* ══════════════════════ 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 ( -
-
- {filters.map(f => ( - - ))} -
- -
- {posts.length === 0 &&
포스트가 없습니다.
} - {posts.map(p => ( -
-
- {p.title || '(제목 없음)'} - - {p.status} - -
- {p.excerpt &&
{p.excerpt}
} -
- {p.review_score != null && 리뷰: {p.review_score}/60} - {p.naver_url && 네이버 링크} - {fmtDate(p.created_at)} -
-
- - {p.status !== 'published' && ( - - )} - -
-
- ))} -
- - {publishModal && ( -
setPublishModal(null)}> -
e.stopPropagation()}> -

네이버 블로그 발행

-

- 본문을 네이버 블로그에 붙여넣기한 후, 발행된 URL을 입력하세요. -

- setNaverUrl(e.target.value)} - /> -
- - -
-
-
- )} -
- ); -} diff --git a/src/pages/insta/InstaCards.css b/src/pages/insta/InstaCards.css new file mode 100644 index 0000000..624d968 --- /dev/null +++ b/src/pages/insta/InstaCards.css @@ -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; } diff --git a/src/pages/insta/InstaCards.jsx b/src/pages/insta/InstaCards.jsx new file mode 100644 index 0000000..90d0755 --- /dev/null +++ b/src/pages/insta/InstaCards.jsx @@ -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 ( + + {status || 'draft'} + + ); +} + +/* ────────────────────── 폴링 훅 ────────────────────── */ +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 ( +
+
+ {task.status === 'succeeded' ? '완료' : task.status === 'failed' ? '실패' : '진행 중'} +
+
{task.message || task.error || ''}
+
+
+
+
+ ); +} + +/* ══════════════════════════════════════════════════════════════════════════ */ +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 ( + +
+ {/* 헤더 + 상태 배너 */} +
+

Insta Cards

+ {status && ( +
+ + Naver {status.naver_api ? 'ON' : 'OFF'} + + + AI {status.anthropic_api ? 'ON' : 'OFF'} + +
+ )} +
+ +
+ {/* 왼쪽: 트리거 + 키워드 */} +
+ +
+ setSelectedSlateId(null)} /> +
+ + {/* 오른쪽: 슬레이트 목록 + 상세 */} +
+ +
+
+ + +
+ + ); +} + +/* ══════════════════════ 트리거 패널 ══════════════════════════════════════ */ +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 ( +
+

트리거

+
+ + + + +
+
+ ); +} + +/* ══════════════════════ 키워드 목록 ══════════════════════════════════════ */ +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 ( +
+

트렌딩 키워드

+ + {/* 카테고리 필터 */} +
+ {CATEGORIES.map((c) => ( + + ))} +
+ + {slatePoll.task && } + + {keywords.length === 0 ? ( +
키워드가 없습니다. 키워드 추출을 실행하세요.
+ ) : ( +
+ {keywords.map((kw) => ( +
+ {kw.keyword} + + {kw.category} · {kw.articles_count ?? 0}건 + + {kw.score?.toFixed(1) ?? '-'} + +
+ ))} +
+ )} +
+ ); +} + +/* ══════════════════════ 슬레이트 목록 ══════════════════════════════════ */ +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 ( +
+
+
+

슬레이트 목록

+ +
+ + {slates.length === 0 ? ( +
슬레이트가 없습니다. 카드를 생성해 보세요.
+ ) : ( +
+ {slates.map((s) => ( +
handleSelect(s.id)} + > + {s.status === 'rendered' || s.status === 'sent' ? ( + {s.keyword} + ) : ( +
🎴
+ )} +
+
{s.keyword}
+
+ {fmtDate(s.created_at)} + +
+
+
+ ))} +
+ )} +
+ + {/* 슬레이트 상세 */} + {detail && ( + handleDelete(detail.id)} + onRender={() => handleRender(detail.id)} + /> + )} +
+ ); +} + +/* ══════════════════════ 슬레이트 상세 ══════════════════════════════════ */ +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 ( +
+
+
+ {slate.keyword} + +
+
+ + +
+
+ + {/* 페이지 이미지 스트립 */} + {(slate.status === 'rendered' || slate.status === 'sent') ? ( +
+ {Array.from({ length: pageCount }, (_, i) => i + 1).map((page) => ( + {`Page + ))} +
+ ) : ( +
+ {slate.status === 'failed' ? '렌더 실패 — 재렌더를 시도하세요.' : '렌더링 전입니다.'} +
+ )} + + {/* 캡션 */} + {slate.suggested_caption && ( +
+
+ 캡션 + +
+
{slate.suggested_caption}
+ {slate.hashtags?.length > 0 && ( +
+ {slate.hashtags.join(' ')} +
+ )} +
+ )} + + {/* 커버 카피 / 바디 카피 */} + {slate.cover_copy && ( +
+
커버 카피
+
{slate.cover_copy}
+
+ )} +
+ ); +} + +/* ══════════════════════ 프롬프트 템플릿 에디터 ══════════════════════════ */ +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 ( +
+

프롬프트 템플릿

+ {PROMPT_NAMES.map((name) => ( +
+
+ {name} + {prompts[name]?.updated_at && ( + + 최종 수정: {fmtDate(prompts[name].updated_at)} + + )} +
+ {prompts[name]?.description && ( +
+ {prompts[name].description} +
+ )} +