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