merge: InstaCards 모듈 분해 리팩토링 (1013→185줄 shell + components/11 + usePollTask + instaUtils)
This commit is contained in:
@@ -27,7 +27,7 @@
|
|||||||
| `/lab/day-calc` | `DayCalc` | 날짜 계산기 |
|
| `/lab/day-calc` | `DayCalc` | 날짜 계산기 |
|
||||||
| `/lab/music` | `MusicStudio` | AI 음악 생성 스튜디오 (Sonic Forge) |
|
| `/lab/music` | `MusicStudio` | AI 음악 생성 스튜디오 (Sonic Forge) |
|
||||||
| `/todo` | `Todo` | 태스크 보드 |
|
| `/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 뱃지) |
|
| `/agent-office` | `AgentOffice` | AI 에이전트 가상 오피스 (WebSocket + 채팅 + LogTab 5초 폴링 source 뱃지) |
|
||||||
| `/portfolio` | `Portfolio` | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개) |
|
| `/portfolio` | `Portfolio` | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개) |
|
||||||
| `/saju` | `Saju` | 호령 사주 v2 — 메인/입력 (mobile night-bg + desktop mt-wash 산수화, useViewportMode 1024px 분기) |
|
| `/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>/
|
src/pages/<feature>/
|
||||||
|
|||||||
@@ -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).
|
기존 기능의 대형 하위 컴포넌트도 같은 규칙으로 in-place 분해합니다. 예: `stock/components/PortfolioTab.jsx`(671줄) → 74줄 shell + `stock/components/portfolio/`(AddHoldingForm·PortfolioSummary·AssetHistoryChart·CashPanel·BrokerSection·HoldingRow·PriceSessionBadge + portfolioUtils).
|
||||||
|
|||||||
@@ -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`(26–34) 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`(45–82) 함수 본문 verbatim 이동, `import { useState, useEffect, useRef } from 'react';`(실제 사용 훅) + `import { getInstaTask } from '../../../api';` + `export default function usePollTask(onDone) { ... }`. 내부 `getInstaTask` 참조 그대로.
|
||||||
|
|
||||||
|
- [ ] **Step 6: components/StatusBadge.jsx 작성** — 현 `StatusBadge`(36–42) 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`(84–99) 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`(471–521) 함수 본문 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`(527–637) 함수 본문 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`(733–817) verbatim 이동. imports: `import React, { useState, useCallback, useEffect, useRef } from 'react';`(실제 사용) + `import { getInstaAssetUrl } from '../../../api';`. `export default PagesStrip;`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: components/SlateDetail.jsx 작성** — 현 `SlateDetail`(818–940) 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`(638–732) 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`(101–169) verbatim 이동. imports: `import React, { useState, useCallback, useEffect } from 'react';` + `import { getInstaPreferences, putInstaPreferences } from '../../../api';`(실제 사용). `export default AccountFocusPanel;`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: components/ExternalTrendsPanel.jsx 작성** — 현 `ExternalTrendsPanel`(175–267) 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`(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).
|
||||||
@@ -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, 최대·마지막).
|
||||||
@@ -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 PullToRefresh from '../../components/PullToRefresh';
|
||||||
import {
|
import {
|
||||||
getInstaStatus,
|
getInstaStatus,
|
||||||
instaCollectNews,
|
|
||||||
instaExtractKeywords,
|
|
||||||
getInstaKeywords,
|
|
||||||
createInstaSlate,
|
createInstaSlate,
|
||||||
getInstaSlates,
|
|
||||||
getInstaSlate,
|
|
||||||
renderInstaSlate,
|
|
||||||
deleteInstaSlate,
|
|
||||||
getInstaAssetUrl,
|
|
||||||
instaPackageUrl,
|
|
||||||
getInstaTask,
|
getInstaTask,
|
||||||
getInstaPrompt,
|
|
||||||
putInstaPrompt,
|
|
||||||
getInstaTrends,
|
|
||||||
instaCollectTrends,
|
|
||||||
getInstaPreferences,
|
|
||||||
putInstaPreferences,
|
|
||||||
} from '../../api';
|
} from '../../api';
|
||||||
import './InstaCards.css';
|
import './InstaCards.css';
|
||||||
|
import TriggerPanel from './components/TriggerPanel';
|
||||||
/* ────────────────────── 유틸 ────────────────────── */
|
import KeywordsPanel from './components/KeywordsPanel';
|
||||||
function fmtDate(iso) {
|
import SlatesPanel from './components/SlatesPanel';
|
||||||
if (!iso) return '';
|
import PromptTemplatesEditor from './components/PromptTemplatesEditor';
|
||||||
return new Date(iso).toLocaleDateString('ko-KR', {
|
import AccountFocusPanel from './components/AccountFocusPanel';
|
||||||
month: 'short',
|
import ExternalTrendsPanel from './components/ExternalTrendsPanel';
|
||||||
day: 'numeric',
|
import PreferenceImpactPanel from './components/PreferenceImpactPanel';
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ status }) {
|
|
||||||
return (
|
|
||||||
<span className={`ic-status-badge ic-status-badge--${status || 'draft'}`}>
|
|
||||||
{status || 'draft'}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ────────────────────── 폴링 훅 ────────────────────── */
|
|
||||||
function usePollTask(onDone) {
|
|
||||||
const [taskId, setTaskId] = useState(null);
|
|
||||||
const [task, setTask] = useState(null);
|
|
||||||
const timer = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!taskId) return;
|
|
||||||
let cancelled = false;
|
|
||||||
const poll = async () => {
|
|
||||||
try {
|
|
||||||
const t = await getInstaTask(taskId);
|
|
||||||
if (cancelled) return;
|
|
||||||
setTask(t);
|
|
||||||
if (t.status === 'succeeded' || t.status === 'failed') {
|
|
||||||
setTaskId(null);
|
|
||||||
onDone?.(t);
|
|
||||||
} else {
|
|
||||||
timer.current = setTimeout(poll, 3000);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) timer.current = setTimeout(poll, 3000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
poll();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
clearTimeout(timer.current);
|
|
||||||
};
|
|
||||||
}, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
return {
|
|
||||||
taskId,
|
|
||||||
task,
|
|
||||||
start: setTaskId,
|
|
||||||
clear: () => { setTaskId(null); setTask(null); },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ────────────────────── TaskStatusBox ────────────────────── */
|
|
||||||
function TaskStatusBox({ task }) {
|
|
||||||
if (!task) return null;
|
|
||||||
const pct = task.progress != null ? task.progress : (task.status === 'succeeded' ? 100 : 0);
|
|
||||||
return (
|
|
||||||
<div className="ic-task-status">
|
|
||||||
<div className="ic-task-status__label">
|
|
||||||
{task.status === 'succeeded' ? '완료' : task.status === 'failed' ? '실패' : '진행 중'}
|
|
||||||
</div>
|
|
||||||
<div className="ic-task-status__msg">{task.message || task.error || ''}</div>
|
|
||||||
<div className="ic-task-status__progress">
|
|
||||||
<div className="ic-task-status__fill" style={{ width: `${pct}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══════════════════════ Trends 탭 패널 1: AccountFocusPanel ══════════════ */
|
|
||||||
function AccountFocusPanel() {
|
|
||||||
const [prefs, setPrefs] = useState([]);
|
|
||||||
const [draft, setDraft] = useState({});
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [newCat, setNewCat] = useState('');
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
const data = await getInstaPreferences();
|
|
||||||
setPrefs(data.categories || []);
|
|
||||||
const m = {};
|
|
||||||
(data.categories || []).forEach(p => { m[p.category] = Math.round(p.weight * 100); });
|
|
||||||
setDraft(m);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const payload = {};
|
|
||||||
Object.entries(draft).forEach(([k, v]) => { payload[k] = (Number(v) || 0) / 100; });
|
|
||||||
await putInstaPreferences(payload);
|
|
||||||
await load();
|
|
||||||
} finally { setSaving(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const addCat = () => {
|
|
||||||
const name = newCat.trim().toLowerCase();
|
|
||||||
if (!name || draft[name] !== undefined) return;
|
|
||||||
setDraft({ ...draft, [name]: 0 });
|
|
||||||
setNewCat('');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="ic-panel ic-panel--focus">
|
|
||||||
<h3 className="ic-panel__title">🎯 이 계정의 주제 (카테고리 가중치)</h3>
|
|
||||||
<p className="ic-panel__hint">슬라이더는 각 카테고리에 자동 추출 키워드 비율을 결정합니다. 합계는 자동 정규화됩니다.</p>
|
|
||||||
<div className="ic-focus__list">
|
|
||||||
{Object.entries(draft).map(([cat, val]) => (
|
|
||||||
<div key={cat} className="ic-focus__row">
|
|
||||||
<label className="ic-focus__label">{cat}</label>
|
|
||||||
<input
|
|
||||||
type="range" min="0" max="100" value={val}
|
|
||||||
onChange={e => setDraft({ ...draft, [cat]: Number(e.target.value) })}
|
|
||||||
className="ic-focus__slider"
|
|
||||||
/>
|
|
||||||
<span className="ic-focus__num">{val}%</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="ic-focus__add">
|
|
||||||
<input
|
|
||||||
type="text" placeholder="신규 카테고리 (영문 소문자)"
|
|
||||||
value={newCat} onChange={e => setNewCat(e.target.value)}
|
|
||||||
/>
|
|
||||||
<button onClick={addCat}>+ 추가</button>
|
|
||||||
</div>
|
|
||||||
<button className="ic-focus__save" onClick={save} disabled={saving}>
|
|
||||||
{saving ? '저장 중...' : '저장'}
|
|
||||||
</button>
|
|
||||||
<div className="ic-focus__hint">
|
|
||||||
💡 신규 카테고리를 추가했다면 Cards 탭의 Prompt Templates Editor에서
|
|
||||||
<code>category_seeds</code>에 시드 키워드도 함께 정의해야 자동 추출에 반영됩니다.
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══════════════════════ Trends 탭 패널 2: ExternalTrendsPanel ══════════ */
|
|
||||||
const CATEGORY_COLORS = {
|
|
||||||
economy: '#0F62FE', psychology: '#A66CFF',
|
|
||||||
celebrity: '#FF5C8A', uncategorized: '#6B7280',
|
|
||||||
};
|
|
||||||
|
|
||||||
function ExternalTrendsPanel({ onCreateSlate }) {
|
|
||||||
const [naver, setNaver] = useState([]);
|
|
||||||
const [google, setGoogle] = useState([]);
|
|
||||||
const [lastFetched, setLastFetched] = useState(null);
|
|
||||||
const [collecting, setCollecting] = useState(false);
|
|
||||||
const [task, setTask] = useState(null);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
const [n, g] = await Promise.all([
|
|
||||||
getInstaTrends({ source: 'naver_popular', days: 2 }),
|
|
||||||
getInstaTrends({ source: 'youtube_trending', days: 2 }),
|
|
||||||
]);
|
|
||||||
setNaver(n.items || []);
|
|
||||||
setGoogle(g.items || []);
|
|
||||||
const all = [...(n.items || []), ...(g.items || [])];
|
|
||||||
if (all.length) {
|
|
||||||
const latest = all.map(t => t.suggested_at).sort().reverse()[0];
|
|
||||||
setLastFetched(latest);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
|
||||||
|
|
||||||
const trigger = async () => {
|
|
||||||
setCollecting(true);
|
|
||||||
try {
|
|
||||||
const { task_id } = await instaCollectTrends();
|
|
||||||
let st = null;
|
|
||||||
for (let i = 0; i < 60; i++) {
|
|
||||||
st = await getInstaTask(task_id);
|
|
||||||
setTask(st);
|
|
||||||
if (st.status === 'succeeded' || st.status === 'failed') break;
|
|
||||||
await new Promise(r => setTimeout(r, 3000));
|
|
||||||
}
|
|
||||||
await load();
|
|
||||||
} finally { setCollecting(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const groupByCat = (items) => {
|
|
||||||
const g = {};
|
|
||||||
items.forEach(it => { (g[it.category] = g[it.category] || []).push(it); });
|
|
||||||
return g;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderRow = (t) => (
|
|
||||||
<div className="ic-trend__row" key={`${t.source}-${t.id}`}>
|
|
||||||
<span className="ic-trend__cat-dot" style={{ background: CATEGORY_COLORS[t.category] || '#6B7280' }} />
|
|
||||||
<span className="ic-trend__kw">{t.keyword}</span>
|
|
||||||
<span className="ic-trend__score">{(t.score || 0).toFixed(2)}</span>
|
|
||||||
<button
|
|
||||||
className="ic-trend__make"
|
|
||||||
onClick={() => onCreateSlate?.({ keyword: t.keyword, category: t.category })}
|
|
||||||
>🎴</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const naverGrouped = groupByCat(naver);
|
|
||||||
return (
|
|
||||||
<section className="ic-panel ic-panel--trends">
|
|
||||||
<div className="ic-panel__head">
|
|
||||||
<h3 className="ic-panel__title">📈 외부 트렌드</h3>
|
|
||||||
<div className="ic-panel__actions">
|
|
||||||
<span className="ic-panel__hint">
|
|
||||||
{lastFetched ? `마지막 수집: ${fmtDate(lastFetched)}` : '아직 수집 없음'}
|
|
||||||
</span>
|
|
||||||
<button onClick={trigger} disabled={collecting}>
|
|
||||||
{collecting ? '수집 중...' : '🔄 수동 수집'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{task && <TaskStatusBox task={task} />}
|
|
||||||
<div className="ic-trends__cols">
|
|
||||||
<div className="ic-trends__col">
|
|
||||||
<h4>🔥 NAVER 인기</h4>
|
|
||||||
{Object.keys(naverGrouped).length === 0 && <p className="ic-empty">없음</p>}
|
|
||||||
{Object.entries(naverGrouped).map(([cat, items]) => (
|
|
||||||
<div key={cat} className="ic-trend__group">
|
|
||||||
<div className="ic-trend__group-head" style={{ color: CATEGORY_COLORS[cat] || '#6B7280' }}>{cat}</div>
|
|
||||||
{items.map(renderRow)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="ic-trends__col">
|
|
||||||
<h4>📺 YouTube 인기</h4>
|
|
||||||
{google.length === 0 && <p className="ic-empty">없음</p>}
|
|
||||||
{google.map(renderRow)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══════════════════════ Trends 탭 패널 3: PreferenceImpactPanel ══════ */
|
|
||||||
function PreferenceImpactPanel() {
|
|
||||||
const [prefs, setPrefs] = useState([]);
|
|
||||||
const TOTAL = 15;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
const data = await getInstaPreferences();
|
|
||||||
setPrefs(data.categories || []);
|
|
||||||
})();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const totalWeight = prefs.reduce((s, p) => s + (p.weight || 0), 0) || 1;
|
|
||||||
const breakdown = prefs.map(p => ({
|
|
||||||
category: p.category,
|
|
||||||
count: Math.round(TOTAL * (p.weight || 0) / totalWeight),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="ic-panel ic-panel--impact">
|
|
||||||
<h3 className="ic-panel__title">📊 다음 자동 추출 미리보기</h3>
|
|
||||||
<div className="ic-impact__row">
|
|
||||||
{breakdown.map(b => (
|
|
||||||
<div key={b.category} className="ic-impact__chip">
|
|
||||||
<span className="ic-impact__cat">{b.category}</span>
|
|
||||||
<span className="ic-impact__count">{b.count}개</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══════════════════════════════════════════════════════════════════════════ */
|
/* ══════════════════════════════════════════════════════════════════════════ */
|
||||||
export default function InstaCards() {
|
export default function InstaCards() {
|
||||||
@@ -466,548 +183,3 @@ export default function InstaCards() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ══════════════════════ 트리거 패널 ══════════════════════════════════════ */
|
|
||||||
function TriggerPanel() {
|
|
||||||
const collectPoll = usePollTask();
|
|
||||||
const keywordsPoll = usePollTask();
|
|
||||||
|
|
||||||
async function handleCollect() {
|
|
||||||
try {
|
|
||||||
const res = await instaCollectNews();
|
|
||||||
collectPoll.start(res.task_id);
|
|
||||||
} catch (e) {
|
|
||||||
alert('뉴스 수집 실패: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleKeywords() {
|
|
||||||
try {
|
|
||||||
const res = await instaExtractKeywords();
|
|
||||||
keywordsPoll.start(res.task_id);
|
|
||||||
} catch (e) {
|
|
||||||
alert('키워드 추출 실패: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const collectBusy = !!collectPoll.taskId;
|
|
||||||
const kwBusy = !!keywordsPoll.taskId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="ic-section">
|
|
||||||
<p className="ic-section__title">트리거</p>
|
|
||||||
<div className="ic-trigger-buttons">
|
|
||||||
<button
|
|
||||||
className="ic-btn ic-btn--primary"
|
|
||||||
onClick={handleCollect}
|
|
||||||
disabled={collectBusy}
|
|
||||||
>
|
|
||||||
{collectBusy && <span className="ic-spinner" />}
|
|
||||||
뉴스 수집
|
|
||||||
</button>
|
|
||||||
<TaskStatusBox task={collectPoll.task} />
|
|
||||||
<button
|
|
||||||
className="ic-btn ic-btn--secondary"
|
|
||||||
onClick={handleKeywords}
|
|
||||||
disabled={kwBusy}
|
|
||||||
>
|
|
||||||
{kwBusy && <span className="ic-spinner" />}
|
|
||||||
키워드 추출
|
|
||||||
</button>
|
|
||||||
<TaskStatusBox task={keywordsPoll.task} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══════════════════════ 키워드 목록 ══════════════════════════════════════ */
|
|
||||||
const CATEGORIES = ['전체', 'economy', 'psychology', 'celebrity'];
|
|
||||||
const KEYWORDS_PER_PAGE = 10;
|
|
||||||
|
|
||||||
function KeywordsPanel({ onCreateSlate }) {
|
|
||||||
const [category, setCategory] = useState('전체');
|
|
||||||
const [keywords, setKeywords] = useState([]);
|
|
||||||
const [creating, setCreating] = useState(null); // keyword_id being created
|
|
||||||
const [page, setPage] = useState(0);
|
|
||||||
|
|
||||||
const load = useCallback(() => {
|
|
||||||
const cat = category === '전체' ? undefined : category;
|
|
||||||
getInstaKeywords({ category: cat }).then((r) => setKeywords(r.items || [])).catch(() => {});
|
|
||||||
}, [category]);
|
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
|
||||||
useEffect(() => { setPage(0); }, [category]); // 카테고리 변경 시 첫 페이지로
|
|
||||||
|
|
||||||
// 동일 keyword 중복 제거(최고 score 1개만 유지) + score 내림차순
|
|
||||||
const deduped = useMemo(() => {
|
|
||||||
const best = new Map();
|
|
||||||
for (const kw of keywords) {
|
|
||||||
const name = (kw.keyword || '').trim();
|
|
||||||
if (!name) continue;
|
|
||||||
const prev = best.get(name);
|
|
||||||
if (!prev || (kw.score ?? 0) > (prev.score ?? 0)) best.set(name, kw);
|
|
||||||
}
|
|
||||||
return [...best.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
|
||||||
}, [keywords]);
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(deduped.length / KEYWORDS_PER_PAGE));
|
|
||||||
const safePage = Math.min(page, totalPages - 1);
|
|
||||||
const pageItems = deduped.slice(safePage * KEYWORDS_PER_PAGE, safePage * KEYWORDS_PER_PAGE + KEYWORDS_PER_PAGE);
|
|
||||||
|
|
||||||
// 부모(InstaCards)의 handleCreateSlate에 위임 — progress 배너 + 스크롤 + 자동 미리보기 공통화
|
|
||||||
async function handleCreate(kw) {
|
|
||||||
if (creating) return;
|
|
||||||
setCreating(kw.id);
|
|
||||||
try {
|
|
||||||
await onCreateSlate?.({
|
|
||||||
keyword: kw.keyword,
|
|
||||||
category: kw.category,
|
|
||||||
keyword_id: kw.id,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setCreating(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="ic-section">
|
|
||||||
<p className="ic-section__title">트렌딩 키워드</p>
|
|
||||||
|
|
||||||
{/* 카테고리 필터 */}
|
|
||||||
<div className="ic-filter">
|
|
||||||
{CATEGORIES.map((c) => (
|
|
||||||
<button
|
|
||||||
key={c}
|
|
||||||
className={`ic-filter-btn ${category === c ? 'ic-filter-btn--active' : ''}`}
|
|
||||||
onClick={() => setCategory(c)}
|
|
||||||
>
|
|
||||||
{c}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* progress 표시는 상단 ic-slate-progress 배너에서 일괄 처리 */}
|
|
||||||
|
|
||||||
{deduped.length === 0 ? (
|
|
||||||
<div className="ic-empty">키워드가 없습니다. 키워드 추출을 실행하세요.</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="ic-keywords">
|
|
||||||
{pageItems.map((kw) => (
|
|
||||||
<div key={kw.id} className="ic-keyword-row">
|
|
||||||
<span className="ic-keyword-row__kw">{kw.keyword}</span>
|
|
||||||
<span className="ic-keyword-row__meta">
|
|
||||||
{kw.category} · {kw.articles_count ?? 0}건
|
|
||||||
</span>
|
|
||||||
<span className="ic-keyword-row__score">{kw.score?.toFixed(1) ?? '-'}</span>
|
|
||||||
<button
|
|
||||||
className="ic-btn ic-btn--primary ic-btn--sm"
|
|
||||||
onClick={() => handleCreate(kw)}
|
|
||||||
disabled={!!creating}
|
|
||||||
>
|
|
||||||
{creating === kw.id ? <span className="ic-spinner" /> : '🎴'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="ic-keywords__pager">
|
|
||||||
<button
|
|
||||||
className="ic-pager-btn"
|
|
||||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
||||||
disabled={safePage === 0}
|
|
||||||
aria-label="이전 키워드"
|
|
||||||
>←</button>
|
|
||||||
<span className="ic-pager-info">{safePage + 1} / {totalPages}</span>
|
|
||||||
<button
|
|
||||||
className="ic-pager-btn"
|
|
||||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
|
||||||
disabled={safePage >= totalPages - 1}
|
|
||||||
aria-label="다음 키워드"
|
|
||||||
>→</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══════════════════════ 슬레이트 목록 ══════════════════════════════════ */
|
|
||||||
function SlatesPanel({ selectedId, onSelect }) {
|
|
||||||
const [slates, setSlates] = useState([]);
|
|
||||||
const [detail, setDetail] = useState(null);
|
|
||||||
|
|
||||||
const loadSlates = useCallback(() => {
|
|
||||||
getInstaSlates(50).then((r) => setSlates(r.items || [])).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { loadSlates(); }, [loadSlates]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedId) { setDetail(null); return; }
|
|
||||||
getInstaSlate(selectedId).then(setDetail).catch(() => setDetail(null));
|
|
||||||
}, [selectedId]);
|
|
||||||
|
|
||||||
function handleSelect(id) {
|
|
||||||
onSelect(id === selectedId ? null : id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(id) {
|
|
||||||
if (!confirm('슬레이트를 삭제하시겠습니까?')) return;
|
|
||||||
try {
|
|
||||||
await deleteInstaSlate(id);
|
|
||||||
if (selectedId === id) onSelect(null);
|
|
||||||
loadSlates();
|
|
||||||
} catch (e) {
|
|
||||||
alert('삭제 실패: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRender(id) {
|
|
||||||
try {
|
|
||||||
const res = await renderInstaSlate(id);
|
|
||||||
// Re-render is fire-and-forget from the panel; user can refresh detail
|
|
||||||
alert('재렌더 요청 완료 (task: ' + res.task_id + ')');
|
|
||||||
setTimeout(loadSlates, 3000);
|
|
||||||
} catch (e) {
|
|
||||||
alert('재렌더 실패: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="ic-section">
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 14 }}>
|
|
||||||
<p className="ic-section__title" style={{ margin: 0, flex: 1 }}>슬레이트 목록</p>
|
|
||||||
<button className="ic-btn ic-btn--secondary ic-btn--sm" onClick={loadSlates}>↻ 새로고침</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{slates.length === 0 ? (
|
|
||||||
<div className="ic-empty">슬레이트가 없습니다. 카드를 생성해 보세요.</div>
|
|
||||||
) : (
|
|
||||||
<div className="ic-slates-grid">
|
|
||||||
{slates.map((s) => (
|
|
||||||
<div
|
|
||||||
key={s.id}
|
|
||||||
className={`ic-slate-card ${selectedId === s.id ? 'ic-slate-card--active' : ''}`}
|
|
||||||
onClick={() => handleSelect(s.id)}
|
|
||||||
>
|
|
||||||
{s.status === 'rendered' || s.status === 'sent' ? (
|
|
||||||
<img
|
|
||||||
className="ic-slate-thumb"
|
|
||||||
src={getInstaAssetUrl(s.id, 1)}
|
|
||||||
alt={s.keyword}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="ic-slate-thumb--placeholder">🎴</div>
|
|
||||||
)}
|
|
||||||
<div className="ic-slate-card__info">
|
|
||||||
<div className="ic-slate-card__kw">{s.keyword}</div>
|
|
||||||
<div className="ic-slate-card__meta">
|
|
||||||
<span className="ic-slate-card__date">{fmtDate(s.created_at)}</span>
|
|
||||||
<StatusBadge status={s.status} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 슬레이트 상세 */}
|
|
||||||
{detail && (
|
|
||||||
<SlateDetail
|
|
||||||
slate={detail}
|
|
||||||
onDelete={() => handleDelete(detail.id)}
|
|
||||||
onRender={() => handleRender(detail.id)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══════════════════════ 페이지 스트립 (chevron + indicator) ═══════════ */
|
|
||||||
function PagesStrip({ slateId, pageCount }) {
|
|
||||||
const stripRef = useRef(null);
|
|
||||||
const [activePage, setActivePage] = useState(1);
|
|
||||||
|
|
||||||
const scrollToPage = useCallback((pageNo) => {
|
|
||||||
const strip = stripRef.current;
|
|
||||||
if (!strip) return;
|
|
||||||
const next = Math.max(1, Math.min(pageCount, pageNo));
|
|
||||||
const child = strip.children[next - 1];
|
|
||||||
if (child) {
|
|
||||||
child.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
|
||||||
setActivePage(next);
|
|
||||||
}
|
|
||||||
}, [pageCount]);
|
|
||||||
|
|
||||||
// 스크롤/드래그 시 가운데 카드 감지
|
|
||||||
const onScroll = useCallback(() => {
|
|
||||||
const strip = stripRef.current;
|
|
||||||
if (!strip) return;
|
|
||||||
const rect = strip.getBoundingClientRect();
|
|
||||||
const centerX = rect.left + rect.width / 2;
|
|
||||||
let best = 1, bestDist = Infinity;
|
|
||||||
Array.from(strip.children).forEach((child, i) => {
|
|
||||||
const cRect = child.getBoundingClientRect();
|
|
||||||
const cCenter = cRect.left + cRect.width / 2;
|
|
||||||
const dist = Math.abs(cCenter - centerX);
|
|
||||||
if (dist < bestDist) { bestDist = dist; best = i + 1; }
|
|
||||||
});
|
|
||||||
if (best !== activePage) setActivePage(best);
|
|
||||||
}, [activePage]);
|
|
||||||
|
|
||||||
// 키보드 ←/→
|
|
||||||
useEffect(() => {
|
|
||||||
const onKey = (e) => {
|
|
||||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
||||||
if (e.key === 'ArrowLeft') { scrollToPage(activePage - 1); e.preventDefault(); }
|
|
||||||
else if (e.key === 'ArrowRight') { scrollToPage(activePage + 1); e.preventDefault(); }
|
|
||||||
};
|
|
||||||
window.addEventListener('keydown', onKey);
|
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
|
||||||
}, [activePage, scrollToPage]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="ic-pages-wrap">
|
|
||||||
<button
|
|
||||||
className="ic-pages-nav ic-pages-nav--prev"
|
|
||||||
onClick={() => scrollToPage(activePage - 1)}
|
|
||||||
disabled={activePage <= 1}
|
|
||||||
aria-label="이전 페이지"
|
|
||||||
type="button"
|
|
||||||
>‹</button>
|
|
||||||
|
|
||||||
<div className="ic-pages-strip" ref={stripRef} onScroll={onScroll}>
|
|
||||||
{Array.from({ length: pageCount }, (_, i) => i + 1).map((page) => (
|
|
||||||
<img
|
|
||||||
key={page}
|
|
||||||
className={`ic-page-img ${activePage === page ? 'is-active' : ''}`}
|
|
||||||
src={getInstaAssetUrl(slateId, page)}
|
|
||||||
alt={`Page ${page}`}
|
|
||||||
loading="lazy"
|
|
||||||
onClick={() => scrollToPage(page)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="ic-pages-nav ic-pages-nav--next"
|
|
||||||
onClick={() => scrollToPage(activePage + 1)}
|
|
||||||
disabled={activePage >= pageCount}
|
|
||||||
aria-label="다음 페이지"
|
|
||||||
type="button"
|
|
||||||
>›</button>
|
|
||||||
|
|
||||||
<div className="ic-pages-indicator-row">
|
|
||||||
<span className="ic-pages-indicator">
|
|
||||||
<span className="ic-pages-indicator__current">{activePage}</span>
|
|
||||||
<span className="ic-pages-indicator__sep">/</span>
|
|
||||||
<span className="ic-pages-indicator__total">{pageCount}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══════════════════════ 슬레이트 상세 ══════════════════════════════════ */
|
|
||||||
function SlateDetail({ slate, onDelete, onRender }) {
|
|
||||||
const pages = slate.assets || [];
|
|
||||||
const pageCount = pages.length > 0 ? pages.length : 10;
|
|
||||||
|
|
||||||
function copyCaption() {
|
|
||||||
const text = [slate.suggested_caption, slate.hashtags?.join(' ')].filter(Boolean).join('\n\n');
|
|
||||||
navigator.clipboard.writeText(text).then(() => alert('클립보드에 복사되었습니다!'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="ic-detail">
|
|
||||||
<div className="ic-detail__header">
|
|
||||||
<div className="ic-detail__title">
|
|
||||||
{slate.keyword}
|
|
||||||
<span style={{ marginLeft: 8 }}><StatusBadge status={slate.status} /></span>
|
|
||||||
</div>
|
|
||||||
<div className="ic-detail__actions">
|
|
||||||
<button className="ic-btn ic-btn--secondary ic-btn--sm" onClick={onRender}>재렌더</button>
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
73
src/pages/insta/components/AccountFocusPanel.jsx
Normal file
73
src/pages/insta/components/AccountFocusPanel.jsx
Normal 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;
|
||||||
104
src/pages/insta/components/ExternalTrendsPanel.jsx
Normal file
104
src/pages/insta/components/ExternalTrendsPanel.jsx
Normal 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;
|
||||||
118
src/pages/insta/components/KeywordsPanel.jsx
Normal file
118
src/pages/insta/components/KeywordsPanel.jsx
Normal 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;
|
||||||
89
src/pages/insta/components/PagesStrip.jsx
Normal file
89
src/pages/insta/components/PagesStrip.jsx
Normal 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;
|
||||||
37
src/pages/insta/components/PreferenceImpactPanel.jsx
Normal file
37
src/pages/insta/components/PreferenceImpactPanel.jsx
Normal 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;
|
||||||
80
src/pages/insta/components/PromptTemplatesEditor.jsx
Normal file
80
src/pages/insta/components/PromptTemplatesEditor.jsx
Normal 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;
|
||||||
129
src/pages/insta/components/SlateDetail.jsx
Normal file
129
src/pages/insta/components/SlateDetail.jsx
Normal 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;
|
||||||
102
src/pages/insta/components/SlatesPanel.jsx
Normal file
102
src/pages/insta/components/SlatesPanel.jsx
Normal 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;
|
||||||
11
src/pages/insta/components/StatusBadge.jsx
Normal file
11
src/pages/insta/components/StatusBadge.jsx
Normal 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;
|
||||||
20
src/pages/insta/components/TaskStatusBox.jsx
Normal file
20
src/pages/insta/components/TaskStatusBox.jsx
Normal 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;
|
||||||
59
src/pages/insta/components/TriggerPanel.jsx
Normal file
59
src/pages/insta/components/TriggerPanel.jsx
Normal 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;
|
||||||
41
src/pages/insta/hooks/usePollTask.js
Normal file
41
src/pages/insta/hooks/usePollTask.js
Normal 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); },
|
||||||
|
};
|
||||||
|
}
|
||||||
10
src/pages/insta/instaUtils.js
Normal file
10
src/pages/insta/instaUtils.js
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
15
src/pages/insta/instaUtils.test.js
Normal file
15
src/pages/insta/instaUtils.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user