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' && (
<>
{/* 헤더 + 상태 배너 */}
{/* 왼쪽: 트리거 + 키워드 */}
{/* 오른쪽: 슬레이트 목록 + 상세 */}
>
)}
{/* ── 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}
{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) => (

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 (
{/* 페이지 이미지 스트립 (캐러셀: 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}
)}
))}
);
}