import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import PullToRefresh from '../../components/PullToRefresh'; import { getInstaStatus, instaCollectNews, instaExtractKeywords, getInstaKeywords, createInstaSlate, getInstaSlates, getInstaSlate, renderInstaSlate, deleteInstaSlate, getInstaAssetUrl, getInstaTask, getInstaPrompt, putInstaPrompt, getInstaTrends, instaCollectTrends, getInstaPreferences, putInstaPreferences, } 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 || ''}
); } /* ══════════════════════ 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' && (
)}
); } /* ══════════════════════ 트리거 패널 ══════════════════════════════════════ */ 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']; const KEYWORDS_PER_PAGE = 10; function KeywordsPanel({ onCreateSlate }) { const [category, setCategory] = useState('전체'); const [keywords, setKeywords] = useState([]); const [creating, setCreating] = useState(null); // keyword_id being created const [page, setPage] = useState(0); const load = useCallback(() => { const cat = category === '전체' ? undefined : category; getInstaKeywords({ category: cat }).then((r) => setKeywords(r.items || [])).catch(() => {}); }, [category]); useEffect(() => { load(); }, [load]); useEffect(() => { setPage(0); }, [category]); // 카테고리 변경 시 첫 페이지로 // 동일 keyword 중복 제거(최고 score 1개만 유지) + score 내림차순 const deduped = useMemo(() => { const best = new Map(); for (const kw of keywords) { const name = (kw.keyword || '').trim(); if (!name) continue; const prev = best.get(name); if (!prev || (kw.score ?? 0) > (prev.score ?? 0)) best.set(name, kw); } return [...best.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); }, [keywords]); const totalPages = Math.max(1, Math.ceil(deduped.length / KEYWORDS_PER_PAGE)); const safePage = Math.min(page, totalPages - 1); const pageItems = deduped.slice(safePage * KEYWORDS_PER_PAGE, safePage * KEYWORDS_PER_PAGE + KEYWORDS_PER_PAGE); // 부모(InstaCards)의 handleCreateSlate에 위임 — progress 배너 + 스크롤 + 자동 미리보기 공통화 async function handleCreate(kw) { if (creating) return; setCreating(kw.id); try { await onCreateSlate?.({ keyword: kw.keyword, category: kw.category, keyword_id: kw.id, }); } finally { setCreating(null); } } return (

트렌딩 키워드

{/* 카테고리 필터 */}
{CATEGORIES.map((c) => ( ))}
{/* progress 표시는 상단 ic-slate-progress 배너에서 일괄 처리 */} {deduped.length === 0 ? (
키워드가 없습니다. 키워드 추출을 실행하세요.
) : ( <>
{pageItems.map((kw) => (
{kw.keyword} {kw.category} · {kw.articles_count ?? 0}건 {kw.score?.toFixed(1) ?? '-'}
))}
{totalPages > 1 && (
{safePage + 1} / {totalPages}
)} )}
); } /* ══════════════════════ 슬레이트 목록 ══════════════════════════════════ */ 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)} /> )}
); } /* ══════════════════════ 페이지 스트립 (chevron + indicator) ═══════════ */ function PagesStrip({ slateId, pageCount }) { const stripRef = useRef(null); const [activePage, setActivePage] = useState(1); const scrollToPage = useCallback((pageNo) => { const strip = stripRef.current; if (!strip) return; const next = Math.max(1, Math.min(pageCount, pageNo)); const child = strip.children[next - 1]; if (child) { child.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); setActivePage(next); } }, [pageCount]); // 스크롤/드래그 시 가운데 카드 감지 const onScroll = useCallback(() => { const strip = stripRef.current; if (!strip) return; const rect = strip.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; let best = 1, bestDist = Infinity; Array.from(strip.children).forEach((child, i) => { const cRect = child.getBoundingClientRect(); const cCenter = cRect.left + cRect.width / 2; const dist = Math.abs(cCenter - centerX); if (dist < bestDist) { bestDist = dist; best = i + 1; } }); if (best !== activePage) setActivePage(best); }, [activePage]); // 키보드 ←/→ useEffect(() => { const onKey = (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (e.key === 'ArrowLeft') { scrollToPage(activePage - 1); e.preventDefault(); } else if (e.key === 'ArrowRight') { scrollToPage(activePage + 1); e.preventDefault(); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [activePage, scrollToPage]); return (
{Array.from({ length: pageCount }, (_, i) => i + 1).map((page) => ( {`Page scrollToPage(page)} /> ))}
{activePage} / {pageCount}
); } /* ══════════════════════ 슬레이트 상세 ══════════════════════════════════ */ 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}
{/* 페이지 이미지 스트립 (캐러셀: chevron + indicator + ←/→ 키보드) */} {(slate.status === 'rendered' || slate.status === 'sent') ? ( ) : (
{slate.status === 'failed' ? '렌더 실패 — 재렌더를 시도하세요.' : '렌더링 전입니다.'}
)} {/* 캡션 */} {slate.suggested_caption && (
캡션
{slate.suggested_caption}
{slate.hashtags?.length > 0 && (
{slate.hashtags.join(' ')}
)}
)} {/* 커버 카피 (1/10) */} {slate.cover_copy && typeof slate.cover_copy === 'object' && (
🎯 커버 (1/10)
{slate.cover_copy.headline} {slate.cover_copy.body && (
{slate.cover_copy.body}
)} {slate.cover_copy.accent_color && (
accent: {slate.cover_copy.accent_color}
)}
)} {/* 본문 카피 8장 (2~9/10) */} {Array.isArray(slate.body_copies) && slate.body_copies.length > 0 && (
📝 본문 8장 (2~9/10)
{slate.body_copies.map((b, i) => (
0 ? '1px solid rgba(255,255,255,0.06)' : 'none', padding: '10px 0', }} > {i + 2}. {b?.headline || ''} {b?.body && (
{b.body}
)}
))}
)} {/* CTA 카피 (10/10) */} {slate.cta_copy && typeof slate.cta_copy === 'object' && (
📣 마무리 (10/10)
{slate.cta_copy.headline} {slate.cta_copy.body && (
{slate.cta_copy.body}
)} {slate.cta_copy.cta && (
CTA: {slate.cta_copy.cta}
)}
)}
); } /* ══════════════════════ 프롬프트 템플릿 에디터 ══════════════════════════ */ 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}
)}