`(245)·`fmtDate`(238)·`CATEGORY_COLORS`(221,252)·trends api·인라인 폴링 getInstaTask 사용.) 상수 `const CATEGORY_COLORS = { ... };`(원본 170 블록 verbatim). `export default ExternalTrendsPanel;`.
+
+- [ ] **Step 3: components/PreferenceImpactPanel.jsx 작성** — 현 `PreferenceImpactPanel`(268–300) verbatim 이동. imports: `import React, { useState, useEffect } from 'react';` + `import { getInstaPreferences } from '../../../api';`(실제 사용). `export default PreferenceImpactPanel;`.
+
+- [ ] **Step 4: components/PromptTemplatesEditor.jsx 작성** — 현 `PromptTemplatesEditor`(943–1013) verbatim 이동 + **상수 `PROMPT_NAMES`(941)를 이 파일 상단에 co-locate**. imports:
+```jsx
+import React, { useState, useEffect } from 'react';
+import { fmtDate } from '../instaUtils';
+import { getInstaPrompt, putInstaPrompt } from '../../../api';
+```
+(실제 사용만 — 원본이 `fmtDate`(984)·prompt api·`PROMPT_NAMES` 사용.) 상수 `const PROMPT_NAMES = ['slate_writer', 'category_seeds'];`. `export default PromptTemplatesEditor;`.
+
+- [ ] **Step 5: InstaCards.jsx 최종 확정(shell)** — 4개 인라인 정의 + `CATEGORY_COLORS`/`PROMPT_NAMES` 상수 제거. 추가:
+```jsx
+import AccountFocusPanel from './components/AccountFocusPanel';
+import ExternalTrendsPanel from './components/ExternalTrendsPanel';
+import PreferenceImpactPanel from './components/PreferenceImpactPanel';
+import PromptTemplatesEditor from './components/PromptTemplatesEditor';
+```
+**shell import 최종 정리** — shell이 실제 쓰는 것만 남긴다:
+ - React: `import React, { useState, useEffect, useCallback } from 'react';`(shell이 실제 사용하는 훅만; useRef/useMemo는 shell 미사용이면 제거)
+ - `import PullToRefresh from '../../components/PullToRefresh';`(유지)
+ - 7 컴포넌트 import(TriggerPanel/KeywordsPanel/SlatesPanel/PromptTemplatesEditor/AccountFocusPanel/ExternalTrendsPanel/PreferenceImpactPanel)
+ - api: shell이 직접 쓰는 것만 — `import { getInstaStatus, createInstaSlate, getInstaTask } from '../../../api';`(loadStatus=getInstaStatus, handleCreateSlate=createInstaSlate+getInstaTask). 나머지 api·`TaskStatusBox`·`StatusBadge`·`usePollTask`·`fmtDate` import 전부 제거(모두 컴포넌트로 이동).
+ - `import './InstaCards.css';`(유지)
+결과 `InstaCards.jsx`는 shell(~140줄).
+
+- [ ] **Step 6: 검증** — Run: `npm run test:run`(전체 green, 회귀 0), `npm run lint`(0 에러, 미사용 import 0), `npm run build`(`✓ built`), `wc -l src/pages/insta/InstaCards.jsx`(~140줄 확인).
+
+- [ ] **Step 7: Commit**
+```bash
+git add src/pages/insta/components/AccountFocusPanel.jsx src/pages/insta/components/ExternalTrendsPanel.jsx src/pages/insta/components/PreferenceImpactPanel.jsx src/pages/insta/components/PromptTemplatesEditor.jsx src/pages/insta/InstaCards.jsx
+git commit -m "refactor(insta): Trends 3패널+PromptTemplatesEditor 추출 → InstaCards shell 확정"
+```
+
+- [ ] **Step 8: Manual verification (dev server)** — `npm run dev` → `http://localhost:3007/insta`. 확인: 2탭(Cards/Trends) 전환 + URL `?tab=trends` 동기화 / 트리거(뉴스 수집·키워드 추출 버튼 + 스피너 + TaskStatusBox) / 키워드 목록·카테고리·페이지네이션 / 슬레이트 생성(키워드 클릭 → progress 배너 → 완료 시 Cards 탭 자동 전환 + 상세) / 슬레이트 목록·상세(SlateDetail)·페이지 스트립(PagesStrip 스크롤·키보드)·삭제·렌더 / 프롬프트 편집(저장) / Trends 3패널(AccountFocus·ExternalTrends 수집·PreferenceImpact)이 리팩토링 전과 **동일 동작**.
+
+---
+
+## Self-Review 결과
+
+**Spec coverage** (설계 §1–§7):
+- §3 file map(instaUtils/usePollTask/11 컴포넌트/shell) → Task 1(utils+hook+2리프) / Task 2(2) / Task 3(3) / Task 4(4+shell) ✅ (11 = 2+2+3+4)
+- §4 shell 책임(탭·progress·handleCreateSlate 유지, 7 컴포넌트 조합) → Task 4 Step 5 ✅
+- §5 테스트(instaUtils fmtDate 유닛 + build/lint/전체/수동) → Task 1 Step 1–4 + 각 task 검증 + Task 4 Step 8 ✅
+- §6 완료기준 → 각 task 검증 + Task 4 line count ✅
+- §3 상수 배치(CATEGORIES/KEYWORDS_PER_PAGE→KeywordsPanel, PROMPT_NAMES→PromptTemplatesEditor, CATEGORY_COLORS→ExternalTrendsPanel co-locate, fmtDate→instaUtils) → 각 Task 명시 ✅
+
+**Placeholder scan:** fmtDate 테스트/구현 완전 명시. 리프트 컴포넌트는 "현 정의(이름·line 힌트) verbatim + 명시 import/export/상수 co-locate"로 지정(원본 코드 존재 → 재현 대신 위치+연결 지정, 서브프로젝트 1~3 동일 컨벤션). 일부 컴포넌트 import는 "원본이 참조하는 심볼만(build/lint 검출)"으로 위임 — 리프트 특성상 정확 목록은 원본 참조와 동일. ✅
+
+**Type consistency:** usePollTask 반환 `{ taskId, task, start, clear }` ↔ TriggerPanel 사용 일치. 컴포넌트 props(status/task/onCreateSlate/selectedId/onSelect/slate/onDelete/onRender/slateId/pageCount) ↔ shell/부모 전달 일치. 경로(components→`../../../api`·`../instaUtils`·`./sibling`·`../hooks/usePollTask`) 일관. StatusBadge/TaskStatusBox는 Task1 추출 → 소비처(Task2/3/4)가 직접 import, shell은 소비 인라인 남아있는 동안만 브릿지 import 유지 후 제거. ✅
+
+**참고:** Task 순서 의존(Task1 리프/훅/유틸 → Task2/3/4가 import). behavior-preserving라 컴포넌트 RED는 "신규 파일 미존재"로 성립, GREEN은 추출 후 전체 회귀 0. fmtDate만 명시적 TDD(RED→GREEN).
diff --git a/docs/superpowers/specs/2026-07-10-fe-module-refactor-instacards-design.md b/docs/superpowers/specs/2026-07-10-fe-module-refactor-instacards-design.md
new file mode 100644
index 0000000..f1750f3
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-10-fe-module-refactor-instacards-design.md
@@ -0,0 +1,98 @@
+# InstaCards.jsx 분해 리팩토링 — 설계 (FE 모듈 sweep 서브프로젝트 4)
+
+- **작성일**: 2026-07-10
+- **역할/저장소**: FE (`web-ui`)
+- **범위**: FE 대형 컴포넌트 분해 sweep의 **4번째 서브프로젝트**. 컨벤션은 sub-project 1~3에서 확립됨(web-ui/CLAUDE.md "기능 모듈 구조") — 이번은 적용만.
+- **성격**: **behavior-preserving 순수 추출**. 로직/JSX/스타일 변경 없음.
+
+---
+
+## 1. 배경 & 목표
+
+`src/pages/insta/InstaCards.jsx`(1013줄, `/insta` 뉴스 키워드 → AI 카드 자동 생성 페이지)를 기능 모듈 구조로 동작 보존 분해한다. 단일 파일에 **메인 shell + 11개 표현/패널 컴포넌트 + 1개 훅(`usePollTask`) + 헬퍼/상수**가 몰려 있다. 각 컴포넌트는 대부분 로컬 상태 + API를 직접 보유하는 자족적 패널이라 그대로 `components/`로 lift 한다.
+
+비목표: 동작/스타일 변경, 신규 기능, 컴포넌트 내부 재분해(SlateDetail/KeywordsPanel 등), `handleCreateSlate`의 훅 추출.
+
+---
+
+## 2. 현 구조 맵 (InstaCards.jsx 1013줄)
+
+- 헬퍼/상수: `fmtDate`(26), `CATEGORY_COLORS`(170), `CATEGORIES`(524), `KEYWORDS_PER_PAGE`(525), `PROMPT_NAMES`(941)
+- 소형 표현: `StatusBadge`(36–43), `TaskStatusBox`(84–99)
+- 훅: `usePollTask`(45–82) — `{ taskId, task, start(taskId), clear() }` 반환, TriggerPanel이 사용
+- 패널: `AccountFocusPanel`(101–169), `ExternalTrendsPanel`(175–267), `PreferenceImpactPanel`(268–300), `TriggerPanel`(471–521), `KeywordsPanel`(527–637), `SlatesPanel`(638–732), `PagesStrip`(733–817), `SlateDetail`(818–940), `PromptTemplatesEditor`(943–1013)
+- 메인 `InstaCards()` shell(301–468, ~168줄): status 상태 + 탭(cards/trends, URL 동기화 `switchTab`) + `slateProgress` 배너 + `handleCreateSlate`(createInstaSlate → getInstaTask 인라인 폴링 최대 8분 → 완료 시 cards 탭 전환 + 슬레이트 선택) + 2탭 레이아웃 조합
+
+**컴포넌트 중첩**: `SlatesPanel` → `SlateDetail` → `PagesStrip`. `TriggerPanel` → `usePollTask` + `TaskStatusBox`.
+
+의존: `PullToRefresh`(src/components), `../../api` 헬퍼(getInstaStatus/createInstaSlate/getInstaTask/getInstaKeywords/getInstaSlates/getInstaSlate/renderInstaSlate/deleteInstaSlate/getInstaAssetUrl/instaPackageUrl/getInstaPrompt/putInstaPrompt/getInstaTrends/instaCollectTrends/getInstaPreferences/putInstaPreferences/instaCollectNews/instaExtractKeywords).
+
+---
+
+## 3. 목표 file map
+
+```
+src/pages/insta/
+├── InstaCards.jsx # shell(~140줄): 탭바 + 상태헤더 + progress배너 + handleCreateSlate + 2탭 레이아웃 조합
+├── InstaCards.css # 변경 없음 (shell에서 import 유지)
+├── instaUtils.js # fmtDate(순수) — 다중 컴포넌트 공유 헬퍼. (CATEGORY_COLORS는 단일 사용이라 ExternalTrendsPanel에 co-locate)
+├── instaUtils.test.js # fmtDate 유닛 (신규)
+├── hooks/
+│ └── usePollTask.js # 태스크 폴링 훅 { taskId, task, start, clear }
+└── components/
+ ├── StatusBadge.jsx # ({ status })
+ ├── TaskStatusBox.jsx # ({ task })
+ ├── TriggerPanel.jsx # () — usePollTask×2 + TaskStatusBox
+ ├── KeywordsPanel.jsx # ({ onCreateSlate }) — CATEGORIES/KEYWORDS_PER_PAGE co-locate
+ ├── SlatesPanel.jsx # ({ selectedId, onSelect }) — SlateDetail 렌더
+ ├── SlateDetail.jsx # ({ slate, onDelete, onRender }) — PagesStrip 렌더
+ ├── PagesStrip.jsx # ({ slateId, pageCount })
+ ├── AccountFocusPanel.jsx # ()
+ ├── ExternalTrendsPanel.jsx# ({ onCreateSlate })
+ ├── PreferenceImpactPanel.jsx # ()
+ └── PromptTemplatesEditor.jsx # () — PROMPT_NAMES co-locate
+```
+
+**import 경로 규칙:**
+- `components/*.jsx`·`hooks/*.js` → api `'../../../api'`; utils `'../instaUtils'`; 형제 컴포넌트/훅은 `'./TaskStatusBox'`·`'../hooks/usePollTask'` 등 상대 경로.
+- `shell(InstaCards.jsx)` → 하위 `'./components/*'`, 훅 `'./hooks/usePollTask'`, utils `'./instaUtils'`, CSS `'./InstaCards.css'`, `PullToRefresh` `'../../components/PullToRefresh'`(기존 유지).
+- 각 컴포넌트가 실제 사용하는 심볼만 import(빌드/lint가 누락·미사용 검출).
+
+**상수 배치:** `fmtDate` + `CATEGORY_COLORS`(다중 컴포넌트 사용 가능성 → 공유) = instaUtils. `CATEGORIES`/`KEYWORDS_PER_PAGE`(KeywordsPanel 단독) → KeywordsPanel.jsx co-locate. `PROMPT_NAMES`(PromptTemplatesEditor 단독) → PromptTemplatesEditor.jsx co-locate. (구현 시 실제 사용처가 단일이면 co-locate, 2+면 instaUtils로 승격.)
+
+---
+
+## 4. shell 책임 (분해 후)
+
+현 `InstaCards()`의 조합/오케스트레이션만 남긴다(동작 보존):
+- 상태: `status`, `selectedSlateId`, `slateProgress`, `activeTab`.
+- `switchTab`(URL 동기화), `loadStatus`(getInstaStatus), `useEffect` 로드.
+- `handleCreateSlate`(createInstaSlate → getInstaTask 인라인 폴링 → cards 탭 전환 + 슬레이트 선택 + progress 배너) — **shell 유지**, `onCreateSlate` prop으로 KeywordsPanel·ExternalTrendsPanel에 전달.
+- 렌더: 탭바 + progress 배너 + Cards 탭(PullToRefresh > 헤더 + TriggerPanel + KeywordsPanel + SlatesPanel + PromptTemplatesEditor) + Trends 탭(AccountFocusPanel + ExternalTrendsPanel + PreferenceImpactPanel).
+
+---
+
+## 5. 테스트 (안전망 — 현재 InstaCards 테스트 0)
+
+1. **`instaUtils.test.js`(신규, 필수)** — `fmtDate`(현 구현: falsy → `''`; 유효 ISO → `new Date(iso).toLocaleDateString('ko-KR', {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'})`): falsy 3케이스(`''`/`null`/`undefined`) → `''` 고정; 유효 ISO → **비어있지 않은 문자열**(타임존/로케일 의존이라 정확 포맷 대신 non-empty smoke). 회귀 안전망.
+2. 나머지 표현/패널 컴포넌트는 로컬 상태+API 위주 → 이번 범위 스모크 최소화(추출 검증은 build+lint+수동). StatusBadge 스모크(선택) 가능.
+3. **검증 게이트:** 각 단계 `npm run build` + `npm run test:run` 전체 green(회귀 0) + `npm run lint` 신규/수정 파일 0 에러. 최종 개발서버 `/insta`: 2탭(Cards/Trends) 전환·URL 동기화, 트리거(뉴스 수집·키워드 추출), 키워드 목록·페이지네이션, 슬레이트 생성(progress 배너 → 완료 자동 전환)·목록·상세(SlateDetail)·페이지 스트립(PagesStrip)·삭제·렌더, 프롬프트 편집, Trends 3패널이 리팩토링 전과 동일 동작.
+
+---
+
+## 6. 완료 기준
+
+- [ ] InstaCards.jsx가 shell(~140줄) + hooks/usePollTask + instaUtils(+test) + components/11로 분해, 동작/스타일 불변.
+- [ ] `instaUtils.test.js` 통과, 전체 `npm run test:run` green.
+- [ ] `npm run lint`·`npm run build` 통과.
+- [ ] `/insta` 수동 검증 동일 동작(2탭·트리거·키워드·슬레이트 생성/상세/렌더/삭제·프롬프트·Trends).
+
+---
+
+## 7. 리스크 / 오픈 이슈
+
+- **테스트 부재 컴포넌트 리팩토링**: 순수 추출 + build/lint/유닛/수동으로 위험 최소화.
+- **컴포넌트 중첩 wiring**: SlatesPanel→SlateDetail→PagesStrip, TriggerPanel→usePollTask/TaskStatusBox 상대 import 경로 정확성(빌드가 검출).
+- **상수 배치 판정**: 실제 사용처 수에 따라 co-locate vs instaUtils 승격(구현 시 grep 확인).
+- **후속 follow-up**: SlateDetail(~122줄)·KeywordsPanel(~110줄) 내부 분할 여지 기록.
+- **다음 서브프로젝트**: MusicStudio(1936, 최대·마지막).
diff --git a/src/pages/insta/InstaCards.jsx b/src/pages/insta/InstaCards.jsx
index 244e7bc..3cae41e 100644
--- a/src/pages/insta/InstaCards.jsx
+++ b/src/pages/insta/InstaCards.jsx
@@ -1,301 +1,18 @@
-import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
import PullToRefresh from '../../components/PullToRefresh';
import {
getInstaStatus,
- instaCollectNews,
- instaExtractKeywords,
- getInstaKeywords,
createInstaSlate,
- getInstaSlates,
- getInstaSlate,
- renderInstaSlate,
- deleteInstaSlate,
- getInstaAssetUrl,
- instaPackageUrl,
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}개
-
- ))}
-
-
- );
-}
+import TriggerPanel from './components/TriggerPanel';
+import KeywordsPanel from './components/KeywordsPanel';
+import SlatesPanel from './components/SlatesPanel';
+import PromptTemplatesEditor from './components/PromptTemplatesEditor';
+import AccountFocusPanel from './components/AccountFocusPanel';
+import ExternalTrendsPanel from './components/ExternalTrendsPanel';
+import PreferenceImpactPanel from './components/PreferenceImpactPanel';
/* ══════════════════════════════════════════════════════════════════════════ */
export default function InstaCards() {
@@ -466,548 +183,3 @@ export default function InstaCards() {
);
}
-
-/* ══════════════════════ 트리거 패널 ══════════════════════════════════════ */
-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 (
-
-
-
- {slate.keyword}
-
-
-
-
-
- {/* 페이지 이미지 스트립 (캐러셀: 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}
-
- )}
-
- ))}
-
- );
-}
diff --git a/src/pages/insta/components/AccountFocusPanel.jsx b/src/pages/insta/components/AccountFocusPanel.jsx
new file mode 100644
index 0000000..8205343
--- /dev/null
+++ b/src/pages/insta/components/AccountFocusPanel.jsx
@@ -0,0 +1,73 @@
+import React, { useState, useCallback, useEffect } from 'react';
+import { getInstaPreferences, putInstaPreferences } from '../../../api';
+
+/* ══════════════════════ 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에 시드 키워드도 함께 정의해야 자동 추출에 반영됩니다.
+
+
+ );
+}
+
+export default AccountFocusPanel;
diff --git a/src/pages/insta/components/ExternalTrendsPanel.jsx b/src/pages/insta/components/ExternalTrendsPanel.jsx
new file mode 100644
index 0000000..dfcfbf9
--- /dev/null
+++ b/src/pages/insta/components/ExternalTrendsPanel.jsx
@@ -0,0 +1,104 @@
+import React, { useState, useCallback, useEffect } from 'react';
+import TaskStatusBox from './TaskStatusBox';
+import { fmtDate } from '../instaUtils';
+import { getInstaTrends, instaCollectTrends, getInstaTask } from '../../../api';
+
+/* ══════════════════════ 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)}
+
+
+
+ );
+}
+
+export default ExternalTrendsPanel;
diff --git a/src/pages/insta/components/KeywordsPanel.jsx b/src/pages/insta/components/KeywordsPanel.jsx
new file mode 100644
index 0000000..96029aa
--- /dev/null
+++ b/src/pages/insta/components/KeywordsPanel.jsx
@@ -0,0 +1,118 @@
+import React, { useState, useEffect, useCallback, useMemo } from 'react';
+import { getInstaKeywords } from '../../../api';
+
+/* ══════════════════════ 키워드 목록 ══════════════════════════════════════ */
+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}
+
+
+ )}
+ >
+ )}
+
+ );
+}
+
+export default KeywordsPanel;
diff --git a/src/pages/insta/components/PagesStrip.jsx b/src/pages/insta/components/PagesStrip.jsx
new file mode 100644
index 0000000..5441442
--- /dev/null
+++ b/src/pages/insta/components/PagesStrip.jsx
@@ -0,0 +1,89 @@
+import React, { useState, useEffect, useCallback, useRef } from 'react';
+import { getInstaAssetUrl } from '../../../api';
+
+/* ══════════════════════ 페이지 스트립 (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}
+
+
+
+ );
+}
+
+export default PagesStrip;
diff --git a/src/pages/insta/components/PreferenceImpactPanel.jsx b/src/pages/insta/components/PreferenceImpactPanel.jsx
new file mode 100644
index 0000000..0ba3463
--- /dev/null
+++ b/src/pages/insta/components/PreferenceImpactPanel.jsx
@@ -0,0 +1,37 @@
+import React, { useState, useEffect } from 'react';
+import { getInstaPreferences } from '../../../api';
+
+/* ══════════════════════ 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 PreferenceImpactPanel;
diff --git a/src/pages/insta/components/PromptTemplatesEditor.jsx b/src/pages/insta/components/PromptTemplatesEditor.jsx
new file mode 100644
index 0000000..92db623
--- /dev/null
+++ b/src/pages/insta/components/PromptTemplatesEditor.jsx
@@ -0,0 +1,80 @@
+import React, { useState, useEffect } from 'react';
+import { fmtDate } from '../instaUtils';
+import { getInstaPrompt, putInstaPrompt } from '../../../api';
+
+/* ══════════════════════ 프롬프트 템플릿 에디터 ══════════════════════════ */
+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}
+
+ )}
+
+ ))}
+
+ );
+}
+
+export default PromptTemplatesEditor;
diff --git a/src/pages/insta/components/SlateDetail.jsx b/src/pages/insta/components/SlateDetail.jsx
new file mode 100644
index 0000000..db6d339
--- /dev/null
+++ b/src/pages/insta/components/SlateDetail.jsx
@@ -0,0 +1,129 @@
+import React from 'react';
+import StatusBadge from './StatusBadge';
+import PagesStrip from './PagesStrip';
+import { instaPackageUrl } from '../../../api';
+
+/* ══════════════════════ 슬레이트 상세 ══════════════════════════════════ */
+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 (
+
+
+
+ {slate.keyword}
+
+
+
+
+
+ {/* 페이지 이미지 스트립 (캐러셀: 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}
+
+ )}
+
+
+ )}
+
+ );
+}
+
+export default SlateDetail;
diff --git a/src/pages/insta/components/SlatesPanel.jsx b/src/pages/insta/components/SlatesPanel.jsx
new file mode 100644
index 0000000..9350a71
--- /dev/null
+++ b/src/pages/insta/components/SlatesPanel.jsx
@@ -0,0 +1,102 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import StatusBadge from './StatusBadge';
+import SlateDetail from './SlateDetail';
+import { fmtDate } from '../instaUtils';
+import { getInstaSlates, getInstaSlate, getInstaAssetUrl, renderInstaSlate, deleteInstaSlate } from '../../../api';
+
+/* ══════════════════════ 슬레이트 목록 ══════════════════════════════════ */
+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)}
+ />
+ )}
+
+ );
+}
+
+export default SlatesPanel;
diff --git a/src/pages/insta/components/StatusBadge.jsx b/src/pages/insta/components/StatusBadge.jsx
new file mode 100644
index 0000000..bbedab0
--- /dev/null
+++ b/src/pages/insta/components/StatusBadge.jsx
@@ -0,0 +1,11 @@
+import React from 'react';
+
+function StatusBadge({ status }) {
+ return (
+
+ {status || 'draft'}
+
+ );
+}
+
+export default StatusBadge;
diff --git a/src/pages/insta/components/TaskStatusBox.jsx b/src/pages/insta/components/TaskStatusBox.jsx
new file mode 100644
index 0000000..64a63f6
--- /dev/null
+++ b/src/pages/insta/components/TaskStatusBox.jsx
@@ -0,0 +1,20 @@
+import React from 'react';
+
+/* ────────────────────── 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 || ''}
+
+
+ );
+}
+
+export default TaskStatusBox;
diff --git a/src/pages/insta/components/TriggerPanel.jsx b/src/pages/insta/components/TriggerPanel.jsx
new file mode 100644
index 0000000..3cecf20
--- /dev/null
+++ b/src/pages/insta/components/TriggerPanel.jsx
@@ -0,0 +1,59 @@
+import React from 'react';
+import usePollTask from '../hooks/usePollTask';
+import TaskStatusBox from './TaskStatusBox';
+import { instaCollectNews, instaExtractKeywords } from '../../../api';
+
+/* ══════════════════════ 트리거 패널 ══════════════════════════════════════ */
+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 (
+
+
트리거
+
+
+
+
+
+
+
+ );
+}
+
+export default TriggerPanel;
diff --git a/src/pages/insta/hooks/usePollTask.js b/src/pages/insta/hooks/usePollTask.js
new file mode 100644
index 0000000..e0391d7
--- /dev/null
+++ b/src/pages/insta/hooks/usePollTask.js
@@ -0,0 +1,41 @@
+import { useState, useEffect, useRef } from 'react';
+import { getInstaTask } from '../../../api';
+
+/* ────────────────────── 폴링 훅 ────────────────────── */
+export default 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); },
+ };
+}
diff --git a/src/pages/insta/instaUtils.js b/src/pages/insta/instaUtils.js
new file mode 100644
index 0000000..201bb9f
--- /dev/null
+++ b/src/pages/insta/instaUtils.js
@@ -0,0 +1,10 @@
+// src/pages/insta/instaUtils.js
+export function fmtDate(iso) {
+ if (!iso) return '';
+ return new Date(iso).toLocaleDateString('ko-KR', {
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+}
diff --git a/src/pages/insta/instaUtils.test.js b/src/pages/insta/instaUtils.test.js
new file mode 100644
index 0000000..2ae6bb0
--- /dev/null
+++ b/src/pages/insta/instaUtils.test.js
@@ -0,0 +1,15 @@
+import { describe, it, expect } from 'vitest';
+import { fmtDate } from './instaUtils';
+
+describe('instaUtils.fmtDate', () => {
+ it('returns empty string for falsy input', () => {
+ expect(fmtDate('')).toBe('');
+ expect(fmtDate(null)).toBe('');
+ expect(fmtDate(undefined)).toBe('');
+ });
+ it('returns a non-empty string for a valid ISO date (locale/tz-dependent format)', () => {
+ const out = fmtDate('2026-07-10T09:30:00Z');
+ expect(typeof out).toBe('string');
+ expect(out.length).toBeGreaterThan(0);
+ });
+});