Files
web-page/src/pages/insta/InstaCards.jsx

465 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<section className="ic-panel ic-panel--focus">
<h3 className="ic-panel__title">🎯 계정의 주제 (카테고리 가중치)</h3>
<p className="ic-panel__hint">슬라이더는 카테고리에 자동 추출 키워드 비율을 결정합니다. 합계는 자동 정규화됩니다.</p>
<div className="ic-focus__list">
{Object.entries(draft).map(([cat, val]) => (
<div key={cat} className="ic-focus__row">
<label className="ic-focus__label">{cat}</label>
<input
type="range" min="0" max="100" value={val}
onChange={e => setDraft({ ...draft, [cat]: Number(e.target.value) })}
className="ic-focus__slider"
/>
<span className="ic-focus__num">{val}%</span>
</div>
))}
</div>
<div className="ic-focus__add">
<input
type="text" placeholder="신규 카테고리 (영문 소문자)"
value={newCat} onChange={e => setNewCat(e.target.value)}
/>
<button onClick={addCat}>+ 추가</button>
</div>
<button className="ic-focus__save" onClick={save} disabled={saving}>
{saving ? '저장 중...' : '저장'}
</button>
<div className="ic-focus__hint">
💡 신규 카테고리를 추가했다면 Cards 탭의 Prompt Templates Editor에서
<code>category_seeds</code> .
</div>
</section>
);
}
/* ══════════════════════ 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) => (
<div className="ic-trend__row" key={`${t.source}-${t.id}`}>
<span className="ic-trend__cat-dot" style={{ background: CATEGORY_COLORS[t.category] || '#6B7280' }} />
<span className="ic-trend__kw">{t.keyword}</span>
<span className="ic-trend__score">{(t.score || 0).toFixed(2)}</span>
<button
className="ic-trend__make"
onClick={() => onCreateSlate?.({ keyword: t.keyword, category: t.category })}
>🎴</button>
</div>
);
const naverGrouped = groupByCat(naver);
return (
<section className="ic-panel ic-panel--trends">
<div className="ic-panel__head">
<h3 className="ic-panel__title">📈 외부 트렌드</h3>
<div className="ic-panel__actions">
<span className="ic-panel__hint">
{lastFetched ? `마지막 수집: ${fmtDate(lastFetched)}` : '아직 수집 없음'}
</span>
<button onClick={trigger} disabled={collecting}>
{collecting ? '수집 중...' : '🔄 수동 수집'}
</button>
</div>
</div>
{task && <TaskStatusBox task={task} />}
<div className="ic-trends__cols">
<div className="ic-trends__col">
<h4>🔥 NAVER 인기</h4>
{Object.keys(naverGrouped).length === 0 && <p className="ic-empty">없음</p>}
{Object.entries(naverGrouped).map(([cat, items]) => (
<div key={cat} className="ic-trend__group">
<div className="ic-trend__group-head" style={{ color: CATEGORY_COLORS[cat] || '#6B7280' }}>{cat}</div>
{items.map(renderRow)}
</div>
))}
</div>
<div className="ic-trends__col">
<h4>📺 YouTube 인기</h4>
{google.length === 0 && <p className="ic-empty">없음</p>}
{google.map(renderRow)}
</div>
</div>
</section>
);
}
/* ══════════════════════ 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 (
<section className="ic-panel ic-panel--impact">
<h3 className="ic-panel__title">📊 다음 자동 추출 미리보기</h3>
<div className="ic-impact__row">
{breakdown.map(b => (
<div key={b.category} className="ic-impact__chip">
<span className="ic-impact__cat">{b.category}</span>
<span className="ic-impact__count">{b.count}</span>
</div>
))}
</div>
</section>
);
}
/* ══════════════════════════════════════════════════════════════════════════ */
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 (
<div className="ic">
{/* ── 탭 바 ── */}
<div className="ic-tabbar">
<button
className={`ic-tab ${activeTab === 'cards' ? 'is-active' : ''}`}
onClick={() => switchTab('cards')}
>🎴 Cards</button>
<button
className={`ic-tab ${activeTab === 'trends' ? 'is-active' : ''}`}
onClick={() => switchTab('trends')}
>📈 Trends</button>
</div>
{/* ── 카드 생성 progress 배너 (양 탭 공통) ── */}
{slateProgress && (
<div
className={`ic-slate-progress ic-slate-progress--${slateProgress.status}`}
onClick={() => slateProgress.status !== 'processing' && slateProgress.status !== 'starting' && setSlateProgress(null)}
>
{slateProgress.status === 'starting' && '⏳'}
{slateProgress.status === 'processing' && '🎨'}
{slateProgress.status === 'succeeded' && '✅'}
{slateProgress.status === 'failed' && '⚠️'}
{' '}
<strong>{slateProgress.keyword}</strong>
{' — '}
{slateProgress.status === 'failed'
? `실패: ${slateProgress.error}`
: slateProgress.message}
{(slateProgress.status === 'starting' || slateProgress.status === 'processing') && (
<span className="ic-slate-progress__hint"> · Claude로 10페이지 카피 추론 + Playwright로 카드 10 생성 (3~7)</span>
)}
</div>
)}
{/* ── Cards 탭 (기존 5-패널) ── */}
{activeTab === 'cards' && (
<>
<PullToRefresh onRefresh={loadStatus}>
<div>
{/* 헤더 + 상태 배너 */}
<header className="ic-header">
<h1>Insta Cards</h1>
{status && (
<div className="ic-status-badges">
<span className={`ic-badge ${status.naver_api ? 'ic-badge--on' : 'ic-badge--off'}`}>
Naver {status.naver_api ? 'ON' : 'OFF'}
</span>
<span className={`ic-badge ${status.anthropic_api ? 'ic-badge--on' : 'ic-badge--off'}`}>
AI {status.anthropic_api ? 'ON' : 'OFF'}
</span>
</div>
)}
</header>
<div className="ic-layout">
{/* 왼쪽: 트리거 + 키워드 */}
<div>
<TriggerPanel />
<div style={{ height: 16 }} />
<KeywordsPanel onCreateSlate={handleCreateSlate} />
</div>
{/* 오른쪽: 슬레이트 목록 + 상세 */}
<div>
<SlatesPanel
selectedId={selectedSlateId}
onSelect={setSelectedSlateId}
/>
</div>
</div>
<PromptTemplatesEditor />
</div>
</PullToRefresh>
</>
)}
{/* ── Trends 탭 (3 new panels) ── */}
{activeTab === 'trends' && (
<div className="ic-trends-grid">
<AccountFocusPanel />
<ExternalTrendsPanel onCreateSlate={handleCreateSlate} />
<PreferenceImpactPanel />
</div>
)}
</div>
);
}
/* ══════════════════════ 프롬프트 템플릿 에디터 ══════════════════════════ */
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 (
<div className="ic-prompt-editor" style={{ marginTop: 24 }}>
<p className="ic-prompt-editor__title">프롬프트 템플릿</p>
{PROMPT_NAMES.map((name) => (
<div key={name} className="ic-prompt-block">
<div className="ic-prompt-block__head">
<span className="ic-prompt-block__name">{name}</span>
{prompts[name]?.updated_at && (
<span className="ic-prompt-block__date">
최종 수정: {fmtDate(prompts[name].updated_at)}
</span>
)}
</div>
{prompts[name]?.description && (
<div style={{ fontSize: '0.75rem', color: 'rgba(255,255,255,.4)', marginBottom: 6 }}>
{prompts[name].description}
</div>
)}
<textarea
className="ic-prompt-textarea"
value={drafts[name] ?? ''}
onChange={(e) => setDrafts((prev) => ({ ...prev, [name]: e.target.value }))}
placeholder={`${name} 템플릿을 입력하세요...`}
/>
<div className="ic-prompt-save-row">
<button
className="ic-btn ic-btn--primary ic-btn--sm"
onClick={() => handleSave(name)}
disabled={saving[name]}
>
{saving[name] ? <span className="ic-spinner" /> : null}
저장
</button>
</div>
</div>
))}
</div>
);
}