import React, { useState, useEffect, useCallback } from 'react'; import PullToRefresh from '../../components/PullToRefresh'; import { getInstaStatus, createInstaSlate, getInstaTask, getInstaPrompt, putInstaPrompt, getInstaTrends, instaCollectTrends, getInstaPreferences, putInstaPreferences, } from '../../api'; import './InstaCards.css'; import { fmtDate } from './instaUtils'; import TaskStatusBox from './components/TaskStatusBox'; import TriggerPanel from './components/TriggerPanel'; import KeywordsPanel from './components/KeywordsPanel'; import SlatesPanel from './components/SlatesPanel'; /* ══════════════════════ 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: 'youtube_trending', 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)}
))}

📺 YouTube 인기

{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); /* ── 카드 생성 progress (Trends 탭 클릭 + Cards 탭 양쪽 모두 사용) ── * null = idle * { keyword, status: 'starting'|'processing'|'succeeded'|'failed', message?, slate_id?, error? } */ const [slateProgress, setSlateProgress] = 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(() => {}); }, []); useEffect(() => { loadStatus(); }, [loadStatus]); /* ── handleCreateSlate: 키워드 → 카피 + 이미지 추론 → 자동 미리보기 ── * 1. createInstaSlate 호출 → task_id * 2. getInstaTask로 폴링 (3초 간격, 최대 8분 = Claude 카피 + Playwright 10장 렌더) * 3. 완료 시 Cards 탭으로 자동 전환 + 슬레이트 선택 → SlateDetail이 카피·이미지 미리보기 */ const handleCreateSlate = useCallback(async ({ keyword, category, keyword_id } = {}) => { if (!keyword || !category) { alert('keyword + category 필수'); return; } setSlateProgress({ keyword, status: 'starting', message: '카드 생성 시작...' }); // 상단 progress 배너가 보이도록 스크롤 (Trends/Cards 어느 탭의 어느 위치에서 눌렀든) if (typeof window !== 'undefined') { window.scrollTo({ top: 0, behavior: 'smooth' }); } try { const { task_id } = await createInstaSlate({ keyword, category, keyword_id }); let st = null; // 최대 8분 (3초 × 160) 폴링 for (let i = 0; i < 160; i++) { st = await getInstaTask(task_id); setSlateProgress({ keyword, status: st.status, message: st.message || `진행률 ${st.progress}%`, }); if (st.status === 'succeeded' || st.status === 'failed') break; await new Promise(r => setTimeout(r, 3000)); } if (st && st.status === 'succeeded' && st.result_id) { // 완료 — Cards 탭으로 자동 이동해서 SlateDetail 보여주기 setSlateProgress({ keyword, status: 'succeeded', message: '완료', slate_id: st.result_id, }); setSelectedSlateId(st.result_id); switchTab('cards'); // 3초 후 progress 배너 자동 dismiss setTimeout(() => setSlateProgress(null), 3000); } else { setSlateProgress({ keyword, status: 'failed', error: (st && st.error) || '시간 초과 또는 알 수 없는 오류', }); } } catch (e) { setSlateProgress({ keyword, status: 'failed', error: e.message }); } }, []); return (
{/* ── 탭 바 ── */}
{/* ── 카드 생성 progress 배너 (양 탭 공통) ── */} {slateProgress && (
slateProgress.status !== 'processing' && slateProgress.status !== 'starting' && setSlateProgress(null)} > {slateProgress.status === 'starting' && '⏳'} {slateProgress.status === 'processing' && '🎨'} {slateProgress.status === 'succeeded' && '✅'} {slateProgress.status === 'failed' && '⚠️'} {' '} {slateProgress.keyword} {' — '} {slateProgress.status === 'failed' ? `실패: ${slateProgress.error}` : slateProgress.message} {(slateProgress.status === 'starting' || slateProgress.status === 'processing') && ( · Claude로 10페이지 카피 추론 + Playwright로 카드 10장 생성 중 (3~7분) )}
)} {/* ── Cards 탭 (기존 5-패널) ── */} {activeTab === 'cards' && ( <>
{/* 헤더 + 상태 배너 */}

Insta Cards

{status && (
Naver {status.naver_api ? 'ON' : 'OFF'} AI {status.anthropic_api ? 'ON' : 'OFF'}
)}
{/* 왼쪽: 트리거 + 키워드 */}
{/* 오른쪽: 슬레이트 목록 + 상세 */}
)} {/* ── Trends 탭 (3 new panels) ── */} {activeTab === 'trends' && (
)}
); } /* ══════════════════════ 프롬프트 템플릿 에디터 ══════════════════════════ */ 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}
)}