diff --git a/src/api.js b/src/api.js index 3984890..d669ee7 100644 --- a/src/api.js +++ b/src/api.js @@ -544,6 +544,27 @@ export function putInstaPrompt(name, template, description = '') { return apiPut(`/api/insta/templates/prompts/${encodeURIComponent(name)}`, { template, description }); } +// ── insta-lab trends ── +export function getInstaTrends({ source, category, days = 1 } = {}) { + const q = new URLSearchParams(); + if (source) q.set('source', source); + if (category) q.set('category', category); + q.set('days', String(days)); + return apiGet(`/api/insta/trends?${q.toString()}`); +} + +export function instaCollectTrends(categories) { + return apiPost('/api/insta/trends/collect', categories ? { categories } : {}); +} + +export function getInstaPreferences() { + return apiGet('/api/insta/preferences'); +} + +export function putInstaPreferences(categories) { + return apiPut('/api/insta/preferences', { categories }); +} + // ── Agent Office ────────────────────────────────── export const getAgents = () => apiGet('/api/agent-office/agents'); export const getAgentDetail = (id) => apiGet(`/api/agent-office/agents/${id}`); diff --git a/src/pages/insta/InstaCards.css b/src/pages/insta/InstaCards.css index 624d968..367c8d0 100644 --- a/src/pages/insta/InstaCards.css +++ b/src/pages/insta/InstaCards.css @@ -100,3 +100,70 @@ /* 빈 상태 */ .ic-empty { text-align: center; padding: 40px 20px; color: rgba(255,255,255,.3); font-size: 0.85rem; } + +/* ── tabs ── */ +.ic-tabbar { display: flex; gap: 8px; border-bottom: 1px solid #e2e8f0; margin-bottom: 16px; } +.ic-tab { + background: transparent; border: 0; padding: 10px 16px; + cursor: pointer; font-size: 14px; font-weight: 600; + color: #64748b; border-bottom: 2px solid transparent; +} +.ic-tab.is-active { color: #ec4899; border-bottom-color: #ec4899; } + +/* ── trends grid ── */ +.ic-trends-grid { display: grid; gap: 16px; grid-template-columns: 1fr; } +@media (min-width: 1024px) { .ic-trends-grid { grid-template-columns: 320px 1fr; } } + +/* ── ic-panel base ── */ +.ic-panel { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; } +.ic-panel__title { font-size: 0.95rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin: 0 0 8px; } +.ic-panel__hint { font-size: 0.78rem; color: rgba(255,255,255,.4); margin: 0 0 10px; } + +/* ── focus panel ── */ +.ic-panel--focus .ic-focus__list { display: flex; flex-direction: column; gap: 10px; margin: 12px 0; } +.ic-focus__row { display: grid; grid-template-columns: 110px 1fr 50px; align-items: center; gap: 8px; } +.ic-focus__label { font-weight: 600; color: #475569; text-transform: capitalize; } +.ic-focus__slider { width: 100%; accent-color: #ec4899; } +.ic-focus__num { text-align: right; font-variant-numeric: tabular-nums; color: #475569; } +.ic-focus__add { display: flex; gap: 8px; margin-top: 12px; } +.ic-focus__add input { flex: 1; padding: 8px; border: 1px solid #cbd5e1; border-radius: 6px; background: rgba(255,255,255,.06); color: var(--text-primary, #e4e4e7); } +.ic-focus__add button { padding: 8px 14px; background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.12); border-radius: 6px; color: rgba(255,255,255,.7); cursor: pointer; font-size: 0.85rem; } +.ic-focus__save { + width: 100%; padding: 10px; margin-top: 12px; + background: #ec4899; color: #fff; border: 0; border-radius: 6px; cursor: pointer; + font-weight: 700; +} +.ic-focus__save:disabled { opacity: .5; cursor: not-allowed; } +.ic-focus__hint { margin-top: 12px; padding: 10px; background: rgba(245,158,11,.1); border-left: 3px solid #f59e0b; font-size: 12px; color: rgba(255,255,255,.6); line-height: 1.5; } +.ic-focus__hint code { background: rgba(0,0,0,.2); padding: 1px 4px; border-radius: 3px; } + +/* ── trends panel ── */ +.ic-trends__cols { display: grid; grid-template-columns: 1fr; gap: 16px; } +@media (min-width: 768px) { .ic-trends__cols { grid-template-columns: 1fr 1fr; } } +.ic-trends__col h4 { margin: 0 0 8px; font-size: 14px; color: rgba(255,255,255,.5); } +.ic-trend__group { margin-bottom: 14px; } +.ic-trend__group-head { font-size: 12px; font-weight: 700; text-transform: uppercase; margin-bottom: 4px; letter-spacing: 0.5px; } +.ic-trend__row { + display: grid; grid-template-columns: 10px 1fr 50px 36px; + align-items: center; gap: 8px; padding: 6px 4px; + border-bottom: 1px solid rgba(255,255,255,.06); +} +.ic-trend__cat-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } +.ic-trend__kw { font-weight: 500; color: var(--text-primary, #e4e4e7); font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ic-trend__score { text-align: right; color: rgba(255,255,255,.4); font-variant-numeric: tabular-nums; font-size: 12px; } +.ic-trend__make { background: #ec4899; border: 0; color: #fff; border-radius: 4px; cursor: pointer; padding: 4px; font-size: 14px; } +.ic-trend__make:hover { background: #db2777; } + +.ic-panel__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } +.ic-panel__actions { display: flex; gap: 8px; align-items: center; } +.ic-panel__actions button { padding: 6px 12px; background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.1); border-radius: 6px; color: rgba(255,255,255,.7); cursor: pointer; font-size: 0.8rem; } +.ic-panel__actions button:disabled { opacity: .5; cursor: not-allowed; } + +/* ── impact panel ── */ +.ic-impact__row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; } +.ic-impact__chip { + display: flex; align-items: baseline; gap: 6px; + padding: 6px 12px; background: rgba(255,255,255,.06); border-radius: 999px; +} +.ic-impact__cat { font-weight: 600; text-transform: capitalize; color: rgba(255,255,255,.6); font-size: 0.82rem; } +.ic-impact__count { color: #ec4899; font-weight: 700; font-size: 0.82rem; } diff --git a/src/pages/insta/InstaCards.jsx b/src/pages/insta/InstaCards.jsx index 90d0755..d7c7d06 100644 --- a/src/pages/insta/InstaCards.jsx +++ b/src/pages/insta/InstaCards.jsx @@ -14,6 +14,10 @@ import { getInstaTask, getInstaPrompt, putInstaPrompt, + getInstaTrends, + instaCollectTrends, + getInstaPreferences, + putInstaPreferences, } from '../../api'; import './InstaCards.css'; @@ -92,11 +96,225 @@ function TaskStatusBox({ task }) { ); } +/* ══════════════════════ Trends 탭 패널 1: AccountFocusPanel ══════════════ */ +function AccountFocusPanel() { + const [prefs, setPrefs] = useState([]); + const [draft, setDraft] = useState({}); + const [saving, setSaving] = useState(false); + const [newCat, setNewCat] = useState(''); + + const load = useCallback(async () => { + const data = await getInstaPreferences(); + setPrefs(data.categories || []); + const m = {}; + (data.categories || []).forEach(p => { m[p.category] = Math.round(p.weight * 100); }); + setDraft(m); + }, []); + + useEffect(() => { load(); }, [load]); + + const save = async () => { + setSaving(true); + try { + const payload = {}; + Object.entries(draft).forEach(([k, v]) => { payload[k] = (Number(v) || 0) / 100; }); + await putInstaPreferences(payload); + await load(); + } finally { setSaving(false); } + }; + + const addCat = () => { + const name = newCat.trim().toLowerCase(); + if (!name || draft[name] !== undefined) return; + setDraft({ ...draft, [name]: 0 }); + setNewCat(''); + }; + + return ( +
+

🎯 이 계정의 주제 (카테고리 가중치)

+

슬라이더는 각 카테고리에 자동 추출 키워드 비율을 결정합니다. 합계는 자동 정규화됩니다.

+
+ {Object.entries(draft).map(([cat, val]) => ( +
+ + setDraft({ ...draft, [cat]: Number(e.target.value) })} + className="ic-focus__slider" + /> + {val}% +
+ ))} +
+
+ setNewCat(e.target.value)} + /> + +
+ +
+ 💡 신규 카테고리를 추가했다면 Cards 탭의 Prompt Templates Editor에서 + category_seeds에 시드 키워드도 함께 정의해야 자동 추출에 반영됩니다. +
+
+ ); +} + +/* ══════════════════════ Trends 탭 패널 2: ExternalTrendsPanel ══════════ */ +const CATEGORY_COLORS = { + economy: '#0F62FE', psychology: '#A66CFF', + celebrity: '#FF5C8A', uncategorized: '#6B7280', +}; + +function ExternalTrendsPanel({ onCreateSlate }) { + const [naver, setNaver] = useState([]); + const [google, setGoogle] = useState([]); + const [lastFetched, setLastFetched] = useState(null); + const [collecting, setCollecting] = useState(false); + const [task, setTask] = useState(null); + + const load = useCallback(async () => { + const [n, g] = await Promise.all([ + getInstaTrends({ source: 'naver_popular', days: 2 }), + getInstaTrends({ source: 'google_trends', days: 2 }), + ]); + setNaver(n.items || []); + setGoogle(g.items || []); + const all = [...(n.items || []), ...(g.items || [])]; + if (all.length) { + const latest = all.map(t => t.suggested_at).sort().reverse()[0]; + setLastFetched(latest); + } + }, []); + + useEffect(() => { load(); }, [load]); + + const trigger = async () => { + setCollecting(true); + try { + const { task_id } = await instaCollectTrends(); + let st = null; + for (let i = 0; i < 60; i++) { + st = await getInstaTask(task_id); + setTask(st); + if (st.status === 'succeeded' || st.status === 'failed') break; + await new Promise(r => setTimeout(r, 3000)); + } + await load(); + } finally { setCollecting(false); } + }; + + const groupByCat = (items) => { + const g = {}; + items.forEach(it => { (g[it.category] = g[it.category] || []).push(it); }); + return g; + }; + + const renderRow = (t) => ( +
+ + {t.keyword} + {(t.score || 0).toFixed(2)} + +
+ ); + + const naverGrouped = groupByCat(naver); + return ( +
+
+

📈 외부 트렌드

+
+ + {lastFetched ? `마지막 수집: ${fmtDate(lastFetched)}` : '아직 수집 없음'} + + +
+
+ {task && } +
+
+

🔥 NAVER 인기

+ {Object.keys(naverGrouped).length === 0 &&

없음

} + {Object.entries(naverGrouped).map(([cat, items]) => ( +
+
{cat}
+ {items.map(renderRow)} +
+ ))} +
+
+

🌐 Google Trends

+ {google.length === 0 &&

없음

} + {google.map(renderRow)} +
+
+
+ ); +} + +/* ══════════════════════ Trends 탭 패널 3: PreferenceImpactPanel ══════ */ +function PreferenceImpactPanel() { + const [prefs, setPrefs] = useState([]); + const TOTAL = 15; + + useEffect(() => { + (async () => { + const data = await getInstaPreferences(); + setPrefs(data.categories || []); + })(); + }, []); + + const totalWeight = prefs.reduce((s, p) => s + (p.weight || 0), 0) || 1; + const breakdown = prefs.map(p => ({ + category: p.category, + count: Math.round(TOTAL * (p.weight || 0) / totalWeight), + })); + + return ( +
+

📊 다음 자동 추출 미리보기

+
+ {breakdown.map(b => ( +
+ {b.category} + {b.count}개 +
+ ))} +
+
+ ); +} + /* ══════════════════════════════════════════════════════════════════════════ */ export default function InstaCards() { const [status, setStatus] = useState(null); const [selectedSlateId, setSelectedSlateId] = useState(null); + /* ── 탭 상태 (URL 동기화) ── */ + const [activeTab, setActiveTab] = useState(() => { + const u = new URL(window.location.href); + return u.searchParams.get('tab') === 'trends' ? 'trends' : 'cards'; + }); + + const switchTab = (next) => { + setActiveTab(next); + const u = new URL(window.location.href); + if (next === 'cards') u.searchParams.delete('tab'); + else u.searchParams.set('tab', next); + window.history.replaceState({}, '', u.toString()); + }; + const loadStatus = useCallback(() => { return getInstaStatus().then(setStatus).catch(() => {}); }, []); @@ -105,44 +323,82 @@ export default function InstaCards() { loadStatus(); }, [loadStatus]); + /* ── handleCreateSlate: 키워드 → 슬레이트 생성 (Trends 탭에서도 공유) ── */ + const handleCreateSlate = useCallback(async ({ keyword, category, keyword_id } = {}) => { + try { + await createInstaSlate({ keyword, category, keyword_id }); + setSelectedSlateId(null); + } catch (e) { + alert('카드 생성 실패: ' + e.message); + } + }, []); + return ( - -
- {/* 헤더 + 상태 배너 */} -
-

Insta Cards

- {status && ( -
- - Naver {status.naver_api ? 'ON' : 'OFF'} - - - AI {status.anthropic_api ? 'ON' : 'OFF'} - -
- )} -
- -
- {/* 왼쪽: 트리거 + 키워드 */} -
- -
- setSelectedSlateId(null)} /> -
- - {/* 오른쪽: 슬레이트 목록 + 상세 */} -
- -
-
- - +
+ {/* ── 탭 바 ── */} +
+ +
- + + {/* ── Cards 탭 (기존 5-패널) ── */} + {activeTab === 'cards' && ( + <> + +
+ {/* 헤더 + 상태 배너 */} +
+

Insta Cards

+ {status && ( +
+ + Naver {status.naver_api ? 'ON' : 'OFF'} + + + AI {status.anthropic_api ? 'ON' : 'OFF'} + +
+ )} +
+ +
+ {/* 왼쪽: 트리거 + 키워드 */} +
+ +
+ setSelectedSlateId(null)} /> +
+ + {/* 오른쪽: 슬레이트 목록 + 상세 */} +
+ +
+
+ + +
+ + + )} + + {/* ── Trends 탭 (3 new panels) ── */} + {activeTab === 'trends' && ( +
+ + + +
+ )} +
); }