merge: InstaCards 모듈 분해 리팩토링 (1013→185줄 shell + components/11 + usePollTask + instaUtils)

This commit is contained in:
2026-07-10 14:14:40 +09:00
19 changed files with 1278 additions and 839 deletions

View File

@@ -27,7 +27,7 @@
| `/lab/day-calc` | `DayCalc` | 날짜 계산기 |
| `/lab/music` | `MusicStudio` | AI 음악 생성 스튜디오 (Sonic Forge) |
| `/todo` | `Todo` | 태스크 보드 |
| `/insta` | `InstaCards` | 뉴스 키워드 발굴 → AI 카드 10장 자동 생성 → 인스타 업로드 |
| `/insta` | `InstaCards` | 뉴스 키워드 발굴 → AI 카드 10장 자동 생성 → 인스타 업로드<br>• **(모듈 분해: shell(185줄) + components/11 + hooks/usePollTask + instaUtils. Cards/Trends 2탭)** |
| `/agent-office` | `AgentOffice` | AI 에이전트 가상 오피스 (WebSocket + 채팅 + LogTab 5초 폴링 source 뱃지) |
| `/portfolio` | `Portfolio` | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개) |
| `/saju` | `Saju` | 호령 사주 v2 — 메인/입력 (mobile night-bg + desktop mt-wash 산수화, useViewportMode 1024px 분기) |
@@ -245,7 +245,7 @@ npm run preview # 빌드 결과물 미리보기
## 기능 모듈 구조 (컨벤션)
대형 기능 페이지는 단일 파일이 아니라 아래 모듈 구조로 분해한다 (레퍼런스: `src/pages/listings/`, `src/pages/subscription/`, `src/pages/realestate/`).
대형 기능 페이지는 단일 파일이 아니라 아래 모듈 구조로 분해한다 (레퍼런스: `src/pages/listings/`, `src/pages/subscription/`, `src/pages/realestate/`, `src/pages/insta/`).
```
src/pages/<feature>/

View File

@@ -227,6 +227,6 @@ apiPost('/api/travel/sync');
### 기능 모듈 구조
대형 기능 페이지는 `src/pages/<feature>/` 아래 **shell(`<Feature>.jsx`) + `components/` + `hooks/` + `<feature>Utils.js`(+테스트)** 로 책임을 분해합니다. 파일당 단일 책임·권장 ≤300줄, 순수 로직은 유틸로 뽑아 유닛 테스트합니다. (레퍼런스: `listings`, `subscription`, `realestate`)
대형 기능 페이지는 `src/pages/<feature>/` 아래 **shell(`<Feature>.jsx`) + `components/` + `hooks/` + `<feature>Utils.js`(+테스트)** 로 책임을 분해합니다. 파일당 단일 책임·권장 ≤300줄, 순수 로직은 유틸로 뽑아 유닛 테스트합니다. (레퍼런스: `listings`, `subscription`, `realestate`, `insta`)
기존 기능의 대형 하위 컴포넌트도 같은 규칙으로 in-place 분해합니다. 예: `stock/components/PortfolioTab.jsx`(671줄) → 74줄 shell + `stock/components/portfolio/`(AddHoldingForm·PortfolioSummary·AssetHistoryChart·CashPanel·BrokerSection·HoldingRow·PriceSessionBadge + portfolioUtils).

View File

@@ -0,0 +1,281 @@
# InstaCards.jsx 분해 리팩토링 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** `src/pages/insta/InstaCards.jsx`(1013줄)를 shell(~140줄) + `components/`11 + `hooks/usePollTask` + `instaUtils`(+test)로 동작 보존 분해한다.
**Architecture:** behavior-preserving 순수 추출. 각 컴포넌트/훅/헬퍼를 현 정의 그대로(verbatim) 새 파일로 옮기고 import로 연결. 로직/JSX/스타일/prop shape 불변. 컴포넌트를 이름으로 찾아(정의 시그니처 기준) 옮기며, 매 task 후 build+lint+전체 test green(회귀 0)을 유지한다.
**Tech Stack:** React 18 + Vite + Vitest + @testing-library/react. 설계: `docs/superpowers/specs/2026-07-10-fe-module-refactor-instacards-design.md`.
## Global Constraints
- **behavior-preserving**: 옮기는 코드는 verbatim. 로직/JSX/className/prop 참조/스타일 변경 금지. 리팩토링은 "정의를 파일로 이동 + import 연결"만.
- **line 번호는 pre-refactor(1013줄) 기준 힌트**. 추출이 진행되면 InstaCards.jsx 줄이 바뀌므로 **컴포넌트/함수 이름·시그니처로 위치를 찾을 것**(예: `function KeywordsPanel({ onCreateSlate }) {`).
- **import 경로**: `components/*.jsx`·`hooks/*.js` → api `'../../../api'`, utils `'../instaUtils'`, 형제 컴포넌트 `'./X'`, 훅 `'../hooks/usePollTask'`. shell(`InstaCards.jsx`) → 컴포넌트 `'./components/X'`, 훅 `'./hooks/usePollTask'`, utils `'./instaUtils'`, `PullToRefresh` `'../../components/PullToRefresh'`, CSS `'./InstaCards.css'`.
- **import 최소·완전**: 각 파일은 실제 참조하는 심볼만 import. 매 task에서 shell의 이제 안 쓰는 api/컴포넌트 import 제거. `npm run lint`(no-unused-vars)와 `npm run build`가 누락·미사용을 검출 → 둘 다 0 에러로 몰 것.
- **각 컴포넌트는 default export**.
- **검증 게이트(매 task)**: `npm run test:run` 전체 green(회귀 0, 시작 165) + `npm run lint` 신규/수정 파일 0 에러 + `npm run build` `✓ built`.
- 단일 사용 상수 co-locate: `CATEGORIES`/`KEYWORDS_PER_PAGE` → KeywordsPanel.jsx, `PROMPT_NAMES` → PromptTemplatesEditor.jsx, `CATEGORY_COLORS` → ExternalTrendsPanel.jsx. 다중 사용 `fmtDate` → instaUtils.js.
---
## 파일 구조 (분해 후)
```
src/pages/insta/
├── InstaCards.jsx # shell(~140줄): 탭바+상태헤더+progress배너+handleCreateSlate+2탭 조합
├── InstaCards.css # 변경 없음
├── instaUtils.js # fmtDate
├── instaUtils.test.js # fmtDate 유닛
├── hooks/usePollTask.js # { taskId, task, start, clear }
└── components/
├── StatusBadge.jsx TaskStatusBox.jsx TriggerPanel.jsx KeywordsPanel.jsx
├── SlatesPanel.jsx SlateDetail.jsx PagesStrip.jsx
└── AccountFocusPanel.jsx ExternalTrendsPanel.jsx PreferenceImpactPanel.jsx PromptTemplatesEditor.jsx
```
**shell이 직접 렌더하는 7 컴포넌트**: TriggerPanel, KeywordsPanel, SlatesPanel, PromptTemplatesEditor, AccountFocusPanel, ExternalTrendsPanel, PreferenceImpactPanel. (StatusBadge/TaskStatusBox/SlateDetail/PagesStrip은 다른 컴포넌트가 렌더 → shell import 아님.)
**컴포넌트 참조 그래프**: TriggerPanel→usePollTask+TaskStatusBox / ExternalTrendsPanel→TaskStatusBox+CATEGORY_COLORS+fmtDate / SlatesPanel→SlateDetail+StatusBadge+fmtDate / SlateDetail→PagesStrip+StatusBadge / PromptTemplatesEditor→fmtDate+PROMPT_NAMES.
---
### Task 1: 기반 — instaUtils(+test) + usePollTask 훅 + 리프 컴포넌트(StatusBadge, TaskStatusBox)
**Files:**
- Create: `src/pages/insta/instaUtils.js`, `src/pages/insta/instaUtils.test.js`
- Create: `src/pages/insta/hooks/usePollTask.js`
- Create: `src/pages/insta/components/StatusBadge.jsx`, `src/pages/insta/components/TaskStatusBox.jsx`
- Modify: `src/pages/insta/InstaCards.jsx` (정의 4개 제거 + import)
**Interfaces:**
- Produces: `fmtDate(iso) → string`; `usePollTask(onDone?) → { taskId, task, start(id), clear() }`; `StatusBadge({ status })`; `TaskStatusBox({ task })` (모두 default export, usePollTask는 named 아님 default).
- [ ] **Step 1: instaUtils.test.js 작성(RED)**
```js
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);
});
});
```
- [ ] **Step 2: RED 확인** — Run: `npx vitest run src/pages/insta/instaUtils.test.js`. Expected: FAIL(`fmtDate`/모듈 미존재).
- [ ] **Step 3: instaUtils.js 작성(GREEN)** — 현 `InstaCards.jsx``fmtDate`(2634) verbatim, `export` 부여:
```js
// 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',
});
}
```
- [ ] **Step 4: GREEN 확인** — Run: `npx vitest run src/pages/insta/instaUtils.test.js`. Expected: PASS(2 tests).
- [ ] **Step 5: hooks/usePollTask.js 작성** — 현 `InstaCards.jsx``usePollTask`(4582) 함수 본문 verbatim 이동, `import { useState, useEffect, useRef } from 'react';`(실제 사용 훅) + `import { getInstaTask } from '../../../api';` + `export default function usePollTask(onDone) { ... }`. 내부 `getInstaTask` 참조 그대로.
- [ ] **Step 6: components/StatusBadge.jsx 작성** — 현 `StatusBadge`(3642) verbatim: `import React from 'react'; const StatusBadge = ({ status }) => (<span className={\`ic-status-badge ic-status-badge--${status || 'draft'}\`}>{status || 'draft'}</span>); export default StatusBadge;` (JSX는 원본과 동일하게).
- [ ] **Step 7: components/TaskStatusBox.jsx 작성** — 현 `TaskStatusBox`(8499) verbatim 이동. `import React from 'react';` + 원본이 참조하는 심볼(예: 상태 텍스트/StatusBadge? — 원본 본문 확인해 실제 참조만 import; 순수 표현이면 React만). `export default TaskStatusBox;`.
- [ ] **Step 8: InstaCards.jsx 수정**`fmtDate`/`usePollTask`/`StatusBadge`/`TaskStatusBox` 인라인 정의 4개 제거. 상단에 추가:
```jsx
import { fmtDate } from './instaUtils';
import usePollTask from './hooks/usePollTask';
import StatusBadge from './components/StatusBadge';
import TaskStatusBox from './components/TaskStatusBox';
```
아직 인라인인 컴포넌트(TriggerPanel/ExternalTrendsPanel/SlatesPanel/SlateDetail/PromptTemplatesEditor)가 이 심볼들을 참조 → 동일 모듈 scope에서 import된 심볼 사용(동작 보존). api import 블록에서 이제 shell 파일 어디서도 안 쓰는 것이 있으면 제거(단 아직 인라인 컴포넌트가 쓰는 것은 유지). `getInstaTask`는 usePollTask로 이동했지만 shell `handleCreateSlate`가 여전히 직접 사용 → 유지.
- [ ] **Step 9: 검증** — Run: `npm run test:run`(전체 green, ≥165+2=167), `npm run lint`(0 에러), `npm run build`(`✓ built`).
- [ ] **Step 10: Commit**
```bash
git add src/pages/insta/instaUtils.js src/pages/insta/instaUtils.test.js src/pages/insta/hooks/usePollTask.js src/pages/insta/components/StatusBadge.jsx src/pages/insta/components/TaskStatusBox.jsx src/pages/insta/InstaCards.jsx
git commit -m "refactor(insta): instaUtils(fmtDate)+usePollTask 훅+StatusBadge/TaskStatusBox 추출"
```
---
### Task 2: Cards 탭 좌측 — TriggerPanel + KeywordsPanel
**Files:**
- Create: `src/pages/insta/components/TriggerPanel.jsx`, `src/pages/insta/components/KeywordsPanel.jsx`
- Modify: `src/pages/insta/InstaCards.jsx`
**Interfaces:**
- Consumes: `usePollTask`(Task1), `TaskStatusBox`(Task1).
- Produces: `TriggerPanel()`, `KeywordsPanel({ onCreateSlate })` (default export).
- [ ] **Step 1: components/TriggerPanel.jsx 작성** — 현 `TriggerPanel`(471521) 함수 본문 verbatim 이동. imports:
```jsx
import React from 'react';
import usePollTask from '../hooks/usePollTask';
import TaskStatusBox from './TaskStatusBox';
import { instaCollectNews, instaExtractKeywords } from '../../../api';
```
내부 `usePollTask()`×2, `TaskStatusBox`, `instaCollectNews`/`instaExtractKeywords` 참조 그대로. `export default TriggerPanel;`.
- [ ] **Step 2: components/KeywordsPanel.jsx 작성** — 현 `KeywordsPanel`(527637) 함수 본문 verbatim 이동 + **상수 `CATEGORIES`(524)·`KEYWORDS_PER_PAGE`(525)를 이 파일 상단에 co-locate**. imports:
```jsx
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { getInstaKeywords } from '../../../api';
```
(실제 사용 훅/헬퍼만 — 원본이 useState/useEffect/useCallback/useMemo와 getInstaKeywords, onCreateSlate prop 사용.) 상수:
```jsx
const CATEGORIES = ['전체', 'economy', 'psychology', 'celebrity'];
const KEYWORDS_PER_PAGE = 10;
```
`export default KeywordsPanel;`.
- [ ] **Step 3: InstaCards.jsx 수정**`TriggerPanel`/`KeywordsPanel` 인라인 정의 + `CATEGORIES`/`KEYWORDS_PER_PAGE` 상수 제거. 추가:
```jsx
import TriggerPanel from './components/TriggerPanel';
import KeywordsPanel from './components/KeywordsPanel';
```
이제 shell에서 안 쓰는 api import 제거: `instaCollectNews`/`instaExtractKeywords`(TriggerPanel로 이동), `getInstaKeywords`(KeywordsPanel로 이동)가 shell 다른 곳에서 안 쓰이면 shell import에서 삭제. `TaskStatusBox` shell import는 아직 인라인 ExternalTrendsPanel(245)이 사용 → **유지**. `usePollTask` shell import는 이제 shell 인라인에서 미사용(TriggerPanel 이동) → **제거**.
- [ ] **Step 4: 검증**`npm run test:run`(green), `npm run lint`(0), `npm run build`(✓).
- [ ] **Step 5: Commit**
```bash
git add src/pages/insta/components/TriggerPanel.jsx src/pages/insta/components/KeywordsPanel.jsx src/pages/insta/InstaCards.jsx
git commit -m "refactor(insta): TriggerPanel/KeywordsPanel 추출 (Cards 탭 좌측)"
```
---
### Task 3: Cards 탭 우측 (중첩 트리) — SlatesPanel + SlateDetail + PagesStrip
**Files:**
- Create: `src/pages/insta/components/SlatesPanel.jsx`, `src/pages/insta/components/SlateDetail.jsx`, `src/pages/insta/components/PagesStrip.jsx`
- Modify: `src/pages/insta/InstaCards.jsx`
**Interfaces:**
- Consumes: `StatusBadge`(Task1), `fmtDate`(Task1).
- Produces: `SlatesPanel({ selectedId, onSelect })`, `SlateDetail({ slate, onDelete, onRender })`, `PagesStrip({ slateId, pageCount })` (default export).
- [ ] **Step 1: components/PagesStrip.jsx 작성** — 현 `PagesStrip`(733817) verbatim 이동. imports: `import React, { useState, useCallback, useEffect, useRef } from 'react';`(실제 사용) + `import { getInstaAssetUrl } from '../../../api';`. `export default PagesStrip;`.
- [ ] **Step 2: components/SlateDetail.jsx 작성** — 현 `SlateDetail`(818940) verbatim 이동. imports:
```jsx
import React from 'react';
import StatusBadge from './StatusBadge';
import PagesStrip from './PagesStrip';
import { getInstaAssetUrl, instaPackageUrl } from '../../../api';
```
(실제 사용 심볼만 — 원본 본문이 참조하는 것으로 확정; React 훅 사용 시 추가.) 내부 `<StatusBadge>`(832)·`<PagesStrip>`(845) 참조 그대로. `export default SlateDetail;`.
- [ ] **Step 3: components/SlatesPanel.jsx 작성** — 현 `SlatesPanel`(638732) verbatim 이동. imports:
```jsx
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';
```
(실제 사용만 — 원본이 `<StatusBadge>`(711)·`<SlateDetail>`(722)·`fmtDate`(710)·slate 목록/상세/렌더/삭제 api 사용. onDelete/onRender 핸들러가 renderInstaSlate/deleteInstaSlate를 어디서 호출하는지 원본 따라 배치.) `export default SlatesPanel;`.
- [ ] **Step 4: InstaCards.jsx 수정**`SlatesPanel`/`SlateDetail`/`PagesStrip` 인라인 정의 제거. 추가: `import SlatesPanel from './components/SlatesPanel';`. (SlateDetail/PagesStrip은 shell이 직접 렌더 안 함 → shell import 아님.) 이제 shell에서 미사용: `StatusBadge` import(SlatesPanel/SlateDetail 이동으로 shell 인라인 미사용) → **제거**; 관련 api import(getInstaSlates/getInstaSlate/getInstaAssetUrl/renderInstaSlate/deleteInstaSlate/instaPackageUrl 등)가 shell 인라인에서 미사용이면 **제거**.
- [ ] **Step 5: 검증**`npm run test:run`(green), `npm run lint`(0), `npm run build`(✓).
- [ ] **Step 6: Commit**
```bash
git add src/pages/insta/components/SlatesPanel.jsx src/pages/insta/components/SlateDetail.jsx src/pages/insta/components/PagesStrip.jsx src/pages/insta/InstaCards.jsx
git commit -m "refactor(insta): SlatesPanel/SlateDetail/PagesStrip 추출 (Cards 탭 우측)"
```
---
### Task 4: Trends 탭 3패널 + PromptTemplatesEditor + shell 확정
**Files:**
- Create: `src/pages/insta/components/AccountFocusPanel.jsx`, `src/pages/insta/components/ExternalTrendsPanel.jsx`, `src/pages/insta/components/PreferenceImpactPanel.jsx`, `src/pages/insta/components/PromptTemplatesEditor.jsx`
- Modify: `src/pages/insta/InstaCards.jsx`
**Interfaces:**
- Consumes: `TaskStatusBox`(Task1), `fmtDate`(Task1).
- Produces: `AccountFocusPanel()`, `ExternalTrendsPanel({ onCreateSlate })`, `PreferenceImpactPanel()`, `PromptTemplatesEditor()` (default export).
- [ ] **Step 1: components/AccountFocusPanel.jsx 작성** — 현 `AccountFocusPanel`(101169) verbatim 이동. imports: `import React, { useState, useCallback, useEffect } from 'react';` + `import { getInstaPreferences, putInstaPreferences } from '../../../api';`(실제 사용). `export default AccountFocusPanel;`.
- [ ] **Step 2: components/ExternalTrendsPanel.jsx 작성** — 현 `ExternalTrendsPanel`(175267) verbatim 이동 + **상수 `CATEGORY_COLORS`(170)를 이 파일 상단에 co-locate**. imports:
```jsx
import React, { useState, useCallback, useEffect } from 'react';
import TaskStatusBox from './TaskStatusBox';
import { fmtDate } from '../instaUtils';
import { getInstaTrends, instaCollectTrends, getInstaTask } from '../../../api';
```
(실제 사용만 — 원본이 `<TaskStatusBox>`(245)·`fmtDate`(238)·`CATEGORY_COLORS`(221,252)·trends api·인라인 폴링 getInstaTask 사용.) 상수 `const CATEGORY_COLORS = { ... };`(원본 170 블록 verbatim). `export default ExternalTrendsPanel;`.
- [ ] **Step 3: components/PreferenceImpactPanel.jsx 작성** — 현 `PreferenceImpactPanel`(268300) verbatim 이동. imports: `import React, { useState, useEffect } from 'react';` + `import { getInstaPreferences } from '../../../api';`(실제 사용). `export default PreferenceImpactPanel;`.
- [ ] **Step 4: components/PromptTemplatesEditor.jsx 작성** — 현 `PromptTemplatesEditor`(9431013) 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 14 + 각 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).

View File

@@ -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`(3643), `TaskStatusBox`(8499)
- 훅: `usePollTask`(4582) — `{ taskId, task, start(taskId), clear() }` 반환, TriggerPanel이 사용
- 패널: `AccountFocusPanel`(101169), `ExternalTrendsPanel`(175267), `PreferenceImpactPanel`(268300), `TriggerPanel`(471521), `KeywordsPanel`(527637), `SlatesPanel`(638732), `PagesStrip`(733817), `SlateDetail`(818940), `PromptTemplatesEditor`(9431013)
- 메인 `InstaCards()` shell(301468, ~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, 최대·마지막).

View File

@@ -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 (
<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>
);
}
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() {
</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>
<a className="ic-btn ic-btn--secondary ic-btn--sm" href={instaPackageUrl(slate.id)} download>
📦 패키지 다운로드 (10 + 캡션)
</a>
<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>
);
}

View File

@@ -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 (
<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>
);
}
export default AccountFocusPanel;

View File

@@ -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) => (
<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>
);
}
export default ExternalTrendsPanel;

View File

@@ -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 (
<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>
);
}
export default KeywordsPanel;

View File

@@ -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 (
<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>
);
}
export default PagesStrip;

View File

@@ -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 (
<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 PreferenceImpactPanel;

View File

@@ -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 (
<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>
);
}
export default PromptTemplatesEditor;

View File

@@ -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 (
<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>
<a className="ic-btn ic-btn--secondary ic-btn--sm" href={instaPackageUrl(slate.id)} download>
📦 패키지 다운로드 (10 + 캡션)
</a>
<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>
);
}
export default SlateDetail;

View File

@@ -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 (
<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>
);
}
export default SlatesPanel;

View File

@@ -0,0 +1,11 @@
import React from 'react';
function StatusBadge({ status }) {
return (
<span className={`ic-status-badge ic-status-badge--${status || 'draft'}`}>
{status || 'draft'}
</span>
);
}
export default StatusBadge;

View File

@@ -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 (
<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 TaskStatusBox;

View File

@@ -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 (
<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>
);
}
export default TriggerPanel;

View File

@@ -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); },
};
}

View File

@@ -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',
});
}

View File

@@ -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);
});
});