feat(insta): replace Blog Lab page with Insta cards UI
/blog-lab → /insta route. New InstaCards page consumes insta-lab API (news/keywords/slates + 10-page card preview + prompt template editor). 25개 blog-marketing API helper 제거, 13개 insta helper 추가.
This commit is contained in:
525
src/pages/insta/InstaCards.jsx
Normal file
525
src/pages/insta/InstaCards.jsx
Normal file
@@ -0,0 +1,525 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import {
|
||||
getInstaStatus,
|
||||
instaCollectNews,
|
||||
instaExtractKeywords,
|
||||
getInstaKeywords,
|
||||
createInstaSlate,
|
||||
getInstaSlates,
|
||||
getInstaSlate,
|
||||
renderInstaSlate,
|
||||
deleteInstaSlate,
|
||||
getInstaAssetUrl,
|
||||
getInstaTask,
|
||||
getInstaPrompt,
|
||||
putInstaPrompt,
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════ */
|
||||
export default function InstaCards() {
|
||||
const [status, setStatus] = useState(null);
|
||||
const [selectedSlateId, setSelectedSlateId] = useState(null);
|
||||
|
||||
const loadStatus = useCallback(() => {
|
||||
return getInstaStatus().then(setStatus).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
}, [loadStatus]);
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={loadStatus}>
|
||||
<div className="ic">
|
||||
{/* 헤더 + 상태 배너 */}
|
||||
<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={() => setSelectedSlateId(null)} />
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 슬레이트 목록 + 상세 */}
|
||||
<div>
|
||||
<SlatesPanel
|
||||
selectedId={selectedSlateId}
|
||||
onSelect={setSelectedSlateId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PromptTemplatesEditor />
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
/* ══════════════════════ 트리거 패널 ══════════════════════════════════════ */
|
||||
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'];
|
||||
|
||||
function KeywordsPanel({ onCreateSlate }) {
|
||||
const [category, setCategory] = useState('전체');
|
||||
const [keywords, setKeywords] = useState([]);
|
||||
const [creating, setCreating] = useState(null); // keyword_id being created
|
||||
const slatePoll = usePollTask((t) => {
|
||||
if (t.status === 'succeeded') onCreateSlate?.();
|
||||
setCreating(null);
|
||||
});
|
||||
|
||||
const load = useCallback(() => {
|
||||
const cat = category === '전체' ? undefined : category;
|
||||
getInstaKeywords({ category: cat }).then((r) => setKeywords(r.items || [])).catch(() => {});
|
||||
}, [category]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function handleCreate(kw) {
|
||||
if (creating) return;
|
||||
setCreating(kw.id);
|
||||
try {
|
||||
const res = await createInstaSlate({
|
||||
keyword: kw.keyword,
|
||||
category: kw.category,
|
||||
keyword_id: kw.id,
|
||||
});
|
||||
slatePoll.start(res.task_id);
|
||||
} catch (e) {
|
||||
alert('카드 생성 실패: ' + e.message);
|
||||
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>
|
||||
|
||||
{slatePoll.task && <TaskStatusBox task={slatePoll.task} />}
|
||||
|
||||
{keywords.length === 0 ? (
|
||||
<div className="ic-empty">키워드가 없습니다. 키워드 추출을 실행하세요.</div>
|
||||
) : (
|
||||
<div className="ic-keywords">
|
||||
{keywords.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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ══════════════════════ 슬레이트 상세 ══════════════════════════════════ */
|
||||
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>
|
||||
|
||||
{/* 페이지 이미지 스트립 */}
|
||||
{(slate.status === 'rendered' || slate.status === 'sent') ? (
|
||||
<div className="ic-pages-strip">
|
||||
{Array.from({ length: pageCount }, (_, i) => i + 1).map((page) => (
|
||||
<img
|
||||
key={page}
|
||||
className="ic-page-img"
|
||||
src={getInstaAssetUrl(slate.id, page)}
|
||||
alt={`Page ${page}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 커버 카피 / 바디 카피 */}
|
||||
{slate.cover_copy && (
|
||||
<div className="ic-caption-box">
|
||||
<div className="ic-caption-box__label">커버 카피</div>
|
||||
<div className="ic-caption-text">{slate.cover_copy}</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user