KeywordsPanel이 전체 목록을 세로로 길게 표시하던 것을, 동일 keyword 중복 제거(최고 score 유지)·score 내림차순 후 페이지당 10개만 렌더하고 이전(←)/다음(→) 페이저로 탐색하도록 변경. 카테고리 변경 시 첫 페이지 리셋. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1010 lines
36 KiB
JavaScript
1010 lines
36 KiB
JavaScript
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 (
|
||
<span className={`ic-status-badge ic-status-badge--${status || 'draft'}`}>
|
||
{status || 'draft'}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/* ────────────────────── 폴링 훅 ────────────────────── */
|
||
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 (
|
||
<div className="ic-task-status">
|
||
<div className="ic-task-status__label">
|
||
{task.status === 'succeeded' ? '완료' : task.status === 'failed' ? '실패' : '진행 중'}
|
||
</div>
|
||
<div className="ic-task-status__msg">{task.message || task.error || ''}</div>
|
||
<div className="ic-task-status__progress">
|
||
<div className="ic-task-status__fill" style={{ width: `${pct}%` }} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ══════════════════════ 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>
|
||
);
|
||
}
|
||
|
||
/* ══════════════════════ 트리거 패널 ══════════════════════════════════════ */
|
||
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 (
|
||
<div className="ic-section">
|
||
<p className="ic-section__title">트리거</p>
|
||
<div className="ic-trigger-buttons">
|
||
<button
|
||
className="ic-btn ic-btn--primary"
|
||
onClick={handleCollect}
|
||
disabled={collectBusy}
|
||
>
|
||
{collectBusy && <span className="ic-spinner" />}
|
||
뉴스 수집
|
||
</button>
|
||
<TaskStatusBox task={collectPoll.task} />
|
||
<button
|
||
className="ic-btn ic-btn--secondary"
|
||
onClick={handleKeywords}
|
||
disabled={kwBusy}
|
||
>
|
||
{kwBusy && <span className="ic-spinner" />}
|
||
키워드 추출
|
||
</button>
|
||
<TaskStatusBox task={keywordsPoll.task} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ══════════════════════ 키워드 목록 ══════════════════════════════════════ */
|
||
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 (
|
||
<div className="ic-section">
|
||
<p className="ic-section__title">트렌딩 키워드</p>
|
||
|
||
{/* 카테고리 필터 */}
|
||
<div className="ic-filter">
|
||
{CATEGORIES.map((c) => (
|
||
<button
|
||
key={c}
|
||
className={`ic-filter-btn ${category === c ? 'ic-filter-btn--active' : ''}`}
|
||
onClick={() => setCategory(c)}
|
||
>
|
||
{c}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* progress 표시는 상단 ic-slate-progress 배너에서 일괄 처리 */}
|
||
|
||
{deduped.length === 0 ? (
|
||
<div className="ic-empty">키워드가 없습니다. 키워드 추출을 실행하세요.</div>
|
||
) : (
|
||
<>
|
||
<div className="ic-keywords">
|
||
{pageItems.map((kw) => (
|
||
<div key={kw.id} className="ic-keyword-row">
|
||
<span className="ic-keyword-row__kw">{kw.keyword}</span>
|
||
<span className="ic-keyword-row__meta">
|
||
{kw.category} · {kw.articles_count ?? 0}건
|
||
</span>
|
||
<span className="ic-keyword-row__score">{kw.score?.toFixed(1) ?? '-'}</span>
|
||
<button
|
||
className="ic-btn ic-btn--primary ic-btn--sm"
|
||
onClick={() => handleCreate(kw)}
|
||
disabled={!!creating}
|
||
>
|
||
{creating === kw.id ? <span className="ic-spinner" /> : '🎴'}
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{totalPages > 1 && (
|
||
<div className="ic-keywords__pager">
|
||
<button
|
||
className="ic-pager-btn"
|
||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||
disabled={safePage === 0}
|
||
aria-label="이전 키워드"
|
||
>←</button>
|
||
<span className="ic-pager-info">{safePage + 1} / {totalPages}</span>
|
||
<button
|
||
className="ic-pager-btn"
|
||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||
disabled={safePage >= totalPages - 1}
|
||
aria-label="다음 키워드"
|
||
>→</button>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ══════════════════════ 슬레이트 목록 ══════════════════════════════════ */
|
||
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 (
|
||
<div>
|
||
<div className="ic-section">
|
||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 14 }}>
|
||
<p className="ic-section__title" style={{ margin: 0, flex: 1 }}>슬레이트 목록</p>
|
||
<button className="ic-btn ic-btn--secondary ic-btn--sm" onClick={loadSlates}>↻ 새로고침</button>
|
||
</div>
|
||
|
||
{slates.length === 0 ? (
|
||
<div className="ic-empty">슬레이트가 없습니다. 카드를 생성해 보세요.</div>
|
||
) : (
|
||
<div className="ic-slates-grid">
|
||
{slates.map((s) => (
|
||
<div
|
||
key={s.id}
|
||
className={`ic-slate-card ${selectedId === s.id ? 'ic-slate-card--active' : ''}`}
|
||
onClick={() => handleSelect(s.id)}
|
||
>
|
||
{s.status === 'rendered' || s.status === 'sent' ? (
|
||
<img
|
||
className="ic-slate-thumb"
|
||
src={getInstaAssetUrl(s.id, 1)}
|
||
alt={s.keyword}
|
||
loading="lazy"
|
||
/>
|
||
) : (
|
||
<div className="ic-slate-thumb--placeholder">🎴</div>
|
||
)}
|
||
<div className="ic-slate-card__info">
|
||
<div className="ic-slate-card__kw">{s.keyword}</div>
|
||
<div className="ic-slate-card__meta">
|
||
<span className="ic-slate-card__date">{fmtDate(s.created_at)}</span>
|
||
<StatusBadge status={s.status} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 슬레이트 상세 */}
|
||
{detail && (
|
||
<SlateDetail
|
||
slate={detail}
|
||
onDelete={() => handleDelete(detail.id)}
|
||
onRender={() => handleRender(detail.id)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ══════════════════════ 페이지 스트립 (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 (
|
||
<div className="ic-pages-wrap">
|
||
<button
|
||
className="ic-pages-nav ic-pages-nav--prev"
|
||
onClick={() => scrollToPage(activePage - 1)}
|
||
disabled={activePage <= 1}
|
||
aria-label="이전 페이지"
|
||
type="button"
|
||
>‹</button>
|
||
|
||
<div className="ic-pages-strip" ref={stripRef} onScroll={onScroll}>
|
||
{Array.from({ length: pageCount }, (_, i) => i + 1).map((page) => (
|
||
<img
|
||
key={page}
|
||
className={`ic-page-img ${activePage === page ? 'is-active' : ''}`}
|
||
src={getInstaAssetUrl(slateId, page)}
|
||
alt={`Page ${page}`}
|
||
loading="lazy"
|
||
onClick={() => scrollToPage(page)}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
<button
|
||
className="ic-pages-nav ic-pages-nav--next"
|
||
onClick={() => scrollToPage(activePage + 1)}
|
||
disabled={activePage >= pageCount}
|
||
aria-label="다음 페이지"
|
||
type="button"
|
||
>›</button>
|
||
|
||
<div className="ic-pages-indicator-row">
|
||
<span className="ic-pages-indicator">
|
||
<span className="ic-pages-indicator__current">{activePage}</span>
|
||
<span className="ic-pages-indicator__sep">/</span>
|
||
<span className="ic-pages-indicator__total">{pageCount}</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ══════════════════════ 슬레이트 상세 ══════════════════════════════════ */
|
||
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 (
|
||
<div className="ic-detail">
|
||
<div className="ic-detail__header">
|
||
<div className="ic-detail__title">
|
||
{slate.keyword}
|
||
<span style={{ marginLeft: 8 }}><StatusBadge status={slate.status} /></span>
|
||
</div>
|
||
<div className="ic-detail__actions">
|
||
<button className="ic-btn ic-btn--secondary ic-btn--sm" onClick={onRender}>재렌더</button>
|
||
<button className="ic-btn ic-btn--danger ic-btn--sm" onClick={onDelete}>삭제</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 페이지 이미지 스트립 (캐러셀: chevron + indicator + ←/→ 키보드) */}
|
||
{(slate.status === 'rendered' || slate.status === 'sent') ? (
|
||
<PagesStrip slateId={slate.id} pageCount={pageCount} />
|
||
) : (
|
||
<div className="ic-empty" style={{ padding: '20px 0' }}>
|
||
{slate.status === 'failed' ? '렌더 실패 — 재렌더를 시도하세요.' : '렌더링 전입니다.'}
|
||
</div>
|
||
)}
|
||
|
||
{/* 캡션 */}
|
||
{slate.suggested_caption && (
|
||
<div className="ic-caption-box">
|
||
<div className="ic-caption-box__label">
|
||
캡션
|
||
<button
|
||
className="ic-btn ic-btn--secondary ic-btn--sm"
|
||
style={{ marginLeft: 8 }}
|
||
onClick={copyCaption}
|
||
>
|
||
복사
|
||
</button>
|
||
</div>
|
||
<div className="ic-caption-text">{slate.suggested_caption}</div>
|
||
{slate.hashtags?.length > 0 && (
|
||
<div className="ic-hashtags" style={{ marginTop: 8 }}>
|
||
{slate.hashtags.join(' ')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 커버 카피 (1/10) */}
|
||
{slate.cover_copy && typeof slate.cover_copy === 'object' && (
|
||
<div className="ic-caption-box">
|
||
<div className="ic-caption-box__label">🎯 커버 (1/10)</div>
|
||
<div className="ic-caption-text">
|
||
<strong>{slate.cover_copy.headline}</strong>
|
||
{slate.cover_copy.body && (
|
||
<div style={{ marginTop: 6, opacity: 0.85, whiteSpace: 'pre-wrap' }}>
|
||
{slate.cover_copy.body}
|
||
</div>
|
||
)}
|
||
{slate.cover_copy.accent_color && (
|
||
<div style={{ marginTop: 6, fontSize: '0.72rem', opacity: 0.5 }}>
|
||
accent: <code>{slate.cover_copy.accent_color}</code>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 본문 카피 8장 (2~9/10) */}
|
||
{Array.isArray(slate.body_copies) && slate.body_copies.length > 0 && (
|
||
<div className="ic-caption-box">
|
||
<div className="ic-caption-box__label">📝 본문 8장 (2~9/10)</div>
|
||
{slate.body_copies.map((b, i) => (
|
||
<div
|
||
key={i}
|
||
style={{
|
||
borderTop: i > 0 ? '1px solid rgba(255,255,255,0.06)' : 'none',
|
||
padding: '10px 0',
|
||
}}
|
||
>
|
||
<strong>{i + 2}. {b?.headline || ''}</strong>
|
||
{b?.body && (
|
||
<div style={{ marginTop: 4, opacity: 0.85, whiteSpace: 'pre-wrap' }}>
|
||
{b.body}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* CTA 카피 (10/10) */}
|
||
{slate.cta_copy && typeof slate.cta_copy === 'object' && (
|
||
<div className="ic-caption-box">
|
||
<div className="ic-caption-box__label">📣 마무리 (10/10)</div>
|
||
<div className="ic-caption-text">
|
||
<strong>{slate.cta_copy.headline}</strong>
|
||
{slate.cta_copy.body && (
|
||
<div style={{ marginTop: 6, opacity: 0.85, whiteSpace: 'pre-wrap' }}>
|
||
{slate.cta_copy.body}
|
||
</div>
|
||
)}
|
||
{slate.cta_copy.cta && (
|
||
<div style={{ marginTop: 8, color: '#ec4899', fontWeight: 700 }}>
|
||
CTA: {slate.cta_copy.cta}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</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>
|
||
);
|
||
}
|