From 44fd42316135a972e3de2cac7b0e15d5af6fd9cd Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 10 Jul 2026 11:21:27 +0900 Subject: [PATCH 1/8] =?UTF-8?q?docs(refactor):=20InstaCards=20=EB=B6=84?= =?UTF-8?q?=ED=95=B4=20=EC=84=A4=EA=B3=84=20(=EC=84=9C=EB=B8=8C=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=A0=9D=ED=8A=B8=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS --- ...10-fe-module-refactor-instacards-design.md | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-10-fe-module-refactor-instacards-design.md diff --git a/docs/superpowers/specs/2026-07-10-fe-module-refactor-instacards-design.md b/docs/superpowers/specs/2026-07-10-fe-module-refactor-instacards-design.md new file mode 100644 index 0000000..4cd6e2c --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-fe-module-refactor-instacards-design.md @@ -0,0 +1,98 @@ +# InstaCards.jsx 분해 리팩토링 — 설계 (FE 모듈 sweep 서브프로젝트 4) + +- **작성일**: 2026-07-10 +- **역할/저장소**: FE (`web-ui`) +- **범위**: FE 대형 컴포넌트 분해 sweep의 **4번째 서브프로젝트**. 컨벤션은 sub-project 1~3에서 확립됨(web-ui/CLAUDE.md "기능 모듈 구조") — 이번은 적용만. +- **성격**: **behavior-preserving 순수 추출**. 로직/JSX/스타일 변경 없음. + +--- + +## 1. 배경 & 목표 + +`src/pages/insta/InstaCards.jsx`(1013줄, `/insta` 뉴스 키워드 → AI 카드 자동 생성 페이지)를 기능 모듈 구조로 동작 보존 분해한다. 단일 파일에 **메인 shell + 11개 표현/패널 컴포넌트 + 1개 훅(`usePollTask`) + 헬퍼/상수**가 몰려 있다. 각 컴포넌트는 대부분 로컬 상태 + API를 직접 보유하는 자족적 패널이라 그대로 `components/`로 lift 한다. + +비목표: 동작/스타일 변경, 신규 기능, 컴포넌트 내부 재분해(SlateDetail/KeywordsPanel 등), `handleCreateSlate`의 훅 추출. + +--- + +## 2. 현 구조 맵 (InstaCards.jsx 1013줄) + +- 헬퍼/상수: `fmtDate`(26), `CATEGORY_COLORS`(170), `CATEGORIES`(524), `KEYWORDS_PER_PAGE`(525), `PROMPT_NAMES`(941) +- 소형 표현: `StatusBadge`(36–43), `TaskStatusBox`(84–99) +- 훅: `usePollTask`(45–82) — `{ taskId, task, start(taskId), clear() }` 반환, TriggerPanel이 사용 +- 패널: `AccountFocusPanel`(101–169), `ExternalTrendsPanel`(175–267), `PreferenceImpactPanel`(268–300), `TriggerPanel`(471–521), `KeywordsPanel`(527–637), `SlatesPanel`(638–732), `PagesStrip`(733–817), `SlateDetail`(818–940), `PromptTemplatesEditor`(943–1013) +- 메인 `InstaCards()` shell(301–468, ~168줄): status 상태 + 탭(cards/trends, URL 동기화 `switchTab`) + `slateProgress` 배너 + `handleCreateSlate`(createInstaSlate → getInstaTask 인라인 폴링 최대 8분 → 완료 시 cards 탭 전환 + 슬레이트 선택) + 2탭 레이아웃 조합 + +**컴포넌트 중첩**: `SlatesPanel` → `SlateDetail` → `PagesStrip`. `TriggerPanel` → `usePollTask` + `TaskStatusBox`. + +의존: `PullToRefresh`(src/components), `../../api` 헬퍼(getInstaStatus/createInstaSlate/getInstaTask/getInstaKeywords/getInstaSlates/getInstaSlate/renderInstaSlate/deleteInstaSlate/getInstaAssetUrl/instaPackageUrl/getInstaPrompt/putInstaPrompt/getInstaTrends/instaCollectTrends/getInstaPreferences/putInstaPreferences/instaCollectNews/instaExtractKeywords). + +--- + +## 3. 목표 file map + +``` +src/pages/insta/ +├── InstaCards.jsx # shell(~140줄): 탭바 + 상태헤더 + progress배너 + handleCreateSlate + 2탭 레이아웃 조합 +├── InstaCards.css # 변경 없음 (shell에서 import 유지) +├── instaUtils.js # fmtDate(순수) + 다중 사용 상수(CATEGORY_COLORS) +├── 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, 최대·마지막). From 25a8be6e238f989d3484496034f8604742c518d5 Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 10 Jul 2026 11:29:17 +0900 Subject: [PATCH 2/8] =?UTF-8?q?docs(refactor):=20InstaCards=20=EB=B6=84?= =?UTF-8?q?=ED=95=B4=20=EA=B5=AC=ED=98=84=20=EA=B3=84=ED=9A=8D=20(4=20task?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS --- ...026-07-10-fe-module-refactor-instacards.md | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-10-fe-module-refactor-instacards.md diff --git a/docs/superpowers/plans/2026-07-10-fe-module-refactor-instacards.md b/docs/superpowers/plans/2026-07-10-fe-module-refactor-instacards.md new file mode 100644 index 0000000..0cfb272 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-fe-module-refactor-instacards.md @@ -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 }) => ({status || 'draft'}); 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 훅 사용 시 추가.) 내부 ``(832)·``(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'; +``` +(실제 사용만 — 원본이 ``(711)·``(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'; +``` +(실제 사용만 — 원본이 ``(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). From dfe621362d13e3939e723f2caf070f42b0165a1b Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 10 Jul 2026 13:31:23 +0900 Subject: [PATCH 3/8] =?UTF-8?q?refactor(insta):=20instaUtils(fmtDate)+useP?= =?UTF-8?q?ollTask=20=ED=9B=85+StatusBadge/TaskStatusBox=20=EC=B6=94?= =?UTF-8?q?=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/insta/InstaCards.jsx | 79 +------------------- src/pages/insta/components/StatusBadge.jsx | 11 +++ src/pages/insta/components/TaskStatusBox.jsx | 20 +++++ src/pages/insta/hooks/usePollTask.js | 41 ++++++++++ src/pages/insta/instaUtils.js | 10 +++ src/pages/insta/instaUtils.test.js | 15 ++++ 6 files changed, 101 insertions(+), 75 deletions(-) create mode 100644 src/pages/insta/components/StatusBadge.jsx create mode 100644 src/pages/insta/components/TaskStatusBox.jsx create mode 100644 src/pages/insta/hooks/usePollTask.js create mode 100644 src/pages/insta/instaUtils.js create mode 100644 src/pages/insta/instaUtils.test.js diff --git a/src/pages/insta/InstaCards.jsx b/src/pages/insta/InstaCards.jsx index 244e7bc..f60d3e4 100644 --- a/src/pages/insta/InstaCards.jsx +++ b/src/pages/insta/InstaCards.jsx @@ -21,81 +21,10 @@ import { putInstaPreferences, } from '../../api'; import './InstaCards.css'; - -/* ────────────────────── 유틸 ────────────────────── */ -function fmtDate(iso) { - if (!iso) return ''; - return new Date(iso).toLocaleDateString('ko-KR', { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); -} - -function StatusBadge({ status }) { - return ( - - {status || 'draft'} - - ); -} - -/* ────────────────────── 폴링 훅 ────────────────────── */ -function usePollTask(onDone) { - const [taskId, setTaskId] = useState(null); - const [task, setTask] = useState(null); - const timer = useRef(null); - - useEffect(() => { - if (!taskId) return; - let cancelled = false; - const poll = async () => { - try { - const t = await getInstaTask(taskId); - if (cancelled) return; - setTask(t); - if (t.status === 'succeeded' || t.status === 'failed') { - setTaskId(null); - onDone?.(t); - } else { - timer.current = setTimeout(poll, 3000); - } - } catch { - if (!cancelled) timer.current = setTimeout(poll, 3000); - } - }; - poll(); - return () => { - cancelled = true; - clearTimeout(timer.current); - }; - }, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps - - return { - taskId, - task, - start: setTaskId, - clear: () => { setTaskId(null); setTask(null); }, - }; -} - -/* ────────────────────── TaskStatusBox ────────────────────── */ -function TaskStatusBox({ task }) { - if (!task) return null; - const pct = task.progress != null ? task.progress : (task.status === 'succeeded' ? 100 : 0); - return ( -
-
- {task.status === 'succeeded' ? '완료' : task.status === 'failed' ? '실패' : '진행 중'} -
-
{task.message || task.error || ''}
-
-
-
-
- ); -} +import { fmtDate } from './instaUtils'; +import usePollTask from './hooks/usePollTask'; +import StatusBadge from './components/StatusBadge'; +import TaskStatusBox from './components/TaskStatusBox'; /* ══════════════════════ Trends 탭 패널 1: AccountFocusPanel ══════════════ */ function AccountFocusPanel() { diff --git a/src/pages/insta/components/StatusBadge.jsx b/src/pages/insta/components/StatusBadge.jsx new file mode 100644 index 0000000..bbedab0 --- /dev/null +++ b/src/pages/insta/components/StatusBadge.jsx @@ -0,0 +1,11 @@ +import React from 'react'; + +function StatusBadge({ status }) { + return ( + + {status || 'draft'} + + ); +} + +export default StatusBadge; diff --git a/src/pages/insta/components/TaskStatusBox.jsx b/src/pages/insta/components/TaskStatusBox.jsx new file mode 100644 index 0000000..64a63f6 --- /dev/null +++ b/src/pages/insta/components/TaskStatusBox.jsx @@ -0,0 +1,20 @@ +import React from 'react'; + +/* ────────────────────── TaskStatusBox ────────────────────── */ +function TaskStatusBox({ task }) { + if (!task) return null; + const pct = task.progress != null ? task.progress : (task.status === 'succeeded' ? 100 : 0); + return ( +
+
+ {task.status === 'succeeded' ? '완료' : task.status === 'failed' ? '실패' : '진행 중'} +
+
{task.message || task.error || ''}
+
+
+
+
+ ); +} + +export default TaskStatusBox; diff --git a/src/pages/insta/hooks/usePollTask.js b/src/pages/insta/hooks/usePollTask.js new file mode 100644 index 0000000..e0391d7 --- /dev/null +++ b/src/pages/insta/hooks/usePollTask.js @@ -0,0 +1,41 @@ +import { useState, useEffect, useRef } from 'react'; +import { getInstaTask } from '../../../api'; + +/* ────────────────────── 폴링 훅 ────────────────────── */ +export default function usePollTask(onDone) { + const [taskId, setTaskId] = useState(null); + const [task, setTask] = useState(null); + const timer = useRef(null); + + useEffect(() => { + if (!taskId) return; + let cancelled = false; + const poll = async () => { + try { + const t = await getInstaTask(taskId); + if (cancelled) return; + setTask(t); + if (t.status === 'succeeded' || t.status === 'failed') { + setTaskId(null); + onDone?.(t); + } else { + timer.current = setTimeout(poll, 3000); + } + } catch { + if (!cancelled) timer.current = setTimeout(poll, 3000); + } + }; + poll(); + return () => { + cancelled = true; + clearTimeout(timer.current); + }; + }, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps + + return { + taskId, + task, + start: setTaskId, + clear: () => { setTaskId(null); setTask(null); }, + }; +} diff --git a/src/pages/insta/instaUtils.js b/src/pages/insta/instaUtils.js new file mode 100644 index 0000000..201bb9f --- /dev/null +++ b/src/pages/insta/instaUtils.js @@ -0,0 +1,10 @@ +// src/pages/insta/instaUtils.js +export function fmtDate(iso) { + if (!iso) return ''; + return new Date(iso).toLocaleDateString('ko-KR', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} diff --git a/src/pages/insta/instaUtils.test.js b/src/pages/insta/instaUtils.test.js new file mode 100644 index 0000000..2ae6bb0 --- /dev/null +++ b/src/pages/insta/instaUtils.test.js @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest'; +import { fmtDate } from './instaUtils'; + +describe('instaUtils.fmtDate', () => { + it('returns empty string for falsy input', () => { + expect(fmtDate('')).toBe(''); + expect(fmtDate(null)).toBe(''); + expect(fmtDate(undefined)).toBe(''); + }); + it('returns a non-empty string for a valid ISO date (locale/tz-dependent format)', () => { + const out = fmtDate('2026-07-10T09:30:00Z'); + expect(typeof out).toBe('string'); + expect(out.length).toBeGreaterThan(0); + }); +}); From a8d1c5dfca380ec56db0c0aeee2c2769166c6f59 Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 10 Jul 2026 13:38:37 +0900 Subject: [PATCH 4/8] =?UTF-8?q?refactor(insta):=20TriggerPanel/KeywordsPan?= =?UTF-8?q?el=20=EC=B6=94=EC=B6=9C=20(Cards=20=ED=83=AD=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/insta/InstaCards.jsx | 175 +------------------ src/pages/insta/components/KeywordsPanel.jsx | 118 +++++++++++++ src/pages/insta/components/TriggerPanel.jsx | 59 +++++++ 3 files changed, 180 insertions(+), 172 deletions(-) create mode 100644 src/pages/insta/components/KeywordsPanel.jsx create mode 100644 src/pages/insta/components/TriggerPanel.jsx diff --git a/src/pages/insta/InstaCards.jsx b/src/pages/insta/InstaCards.jsx index f60d3e4..3859da6 100644 --- a/src/pages/insta/InstaCards.jsx +++ b/src/pages/insta/InstaCards.jsx @@ -1,10 +1,7 @@ -import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import PullToRefresh from '../../components/PullToRefresh'; import { getInstaStatus, - instaCollectNews, - instaExtractKeywords, - getInstaKeywords, createInstaSlate, getInstaSlates, getInstaSlate, @@ -22,9 +19,10 @@ import { } from '../../api'; import './InstaCards.css'; import { fmtDate } from './instaUtils'; -import usePollTask from './hooks/usePollTask'; import StatusBadge from './components/StatusBadge'; import TaskStatusBox from './components/TaskStatusBox'; +import TriggerPanel from './components/TriggerPanel'; +import KeywordsPanel from './components/KeywordsPanel'; /* ══════════════════════ Trends 탭 패널 1: AccountFocusPanel ══════════════ */ function AccountFocusPanel() { @@ -396,173 +394,6 @@ export default function InstaCards() { ); } -/* ══════════════════════ 트리거 패널 ══════════════════════════════════════ */ -function TriggerPanel() { - const collectPoll = usePollTask(); - const keywordsPoll = usePollTask(); - - async function handleCollect() { - try { - const res = await instaCollectNews(); - collectPoll.start(res.task_id); - } catch (e) { - alert('뉴스 수집 실패: ' + e.message); - } - } - - async function handleKeywords() { - try { - const res = await instaExtractKeywords(); - keywordsPoll.start(res.task_id); - } catch (e) { - alert('키워드 추출 실패: ' + e.message); - } - } - - const collectBusy = !!collectPoll.taskId; - const kwBusy = !!keywordsPoll.taskId; - - return ( -
-

트리거

-
- - - - -
-
- ); -} - -/* ══════════════════════ 키워드 목록 ══════════════════════════════════════ */ -const CATEGORIES = ['전체', 'economy', 'psychology', 'celebrity']; -const KEYWORDS_PER_PAGE = 10; - -function KeywordsPanel({ onCreateSlate }) { - const [category, setCategory] = useState('전체'); - const [keywords, setKeywords] = useState([]); - const [creating, setCreating] = useState(null); // keyword_id being created - const [page, setPage] = useState(0); - - const load = useCallback(() => { - const cat = category === '전체' ? undefined : category; - getInstaKeywords({ category: cat }).then((r) => setKeywords(r.items || [])).catch(() => {}); - }, [category]); - - useEffect(() => { load(); }, [load]); - useEffect(() => { setPage(0); }, [category]); // 카테고리 변경 시 첫 페이지로 - - // 동일 keyword 중복 제거(최고 score 1개만 유지) + score 내림차순 - const deduped = useMemo(() => { - const best = new Map(); - for (const kw of keywords) { - const name = (kw.keyword || '').trim(); - if (!name) continue; - const prev = best.get(name); - if (!prev || (kw.score ?? 0) > (prev.score ?? 0)) best.set(name, kw); - } - return [...best.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); - }, [keywords]); - - const totalPages = Math.max(1, Math.ceil(deduped.length / KEYWORDS_PER_PAGE)); - const safePage = Math.min(page, totalPages - 1); - const pageItems = deduped.slice(safePage * KEYWORDS_PER_PAGE, safePage * KEYWORDS_PER_PAGE + KEYWORDS_PER_PAGE); - - // 부모(InstaCards)의 handleCreateSlate에 위임 — progress 배너 + 스크롤 + 자동 미리보기 공통화 - async function handleCreate(kw) { - if (creating) return; - setCreating(kw.id); - try { - await onCreateSlate?.({ - keyword: kw.keyword, - category: kw.category, - keyword_id: kw.id, - }); - } finally { - setCreating(null); - } - } - - return ( -
-

트렌딩 키워드

- - {/* 카테고리 필터 */} -
- {CATEGORIES.map((c) => ( - - ))} -
- - {/* progress 표시는 상단 ic-slate-progress 배너에서 일괄 처리 */} - - {deduped.length === 0 ? ( -
키워드가 없습니다. 키워드 추출을 실행하세요.
- ) : ( - <> -
- {pageItems.map((kw) => ( -
- {kw.keyword} - - {kw.category} · {kw.articles_count ?? 0}건 - - {kw.score?.toFixed(1) ?? '-'} - -
- ))} -
- - {totalPages > 1 && ( -
- - {safePage + 1} / {totalPages} - -
- )} - - )} -
- ); -} - /* ══════════════════════ 슬레이트 목록 ══════════════════════════════════ */ function SlatesPanel({ selectedId, onSelect }) { const [slates, setSlates] = useState([]); diff --git a/src/pages/insta/components/KeywordsPanel.jsx b/src/pages/insta/components/KeywordsPanel.jsx new file mode 100644 index 0000000..96029aa --- /dev/null +++ b/src/pages/insta/components/KeywordsPanel.jsx @@ -0,0 +1,118 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { getInstaKeywords } from '../../../api'; + +/* ══════════════════════ 키워드 목록 ══════════════════════════════════════ */ +const CATEGORIES = ['전체', 'economy', 'psychology', 'celebrity']; +const KEYWORDS_PER_PAGE = 10; + +function KeywordsPanel({ onCreateSlate }) { + const [category, setCategory] = useState('전체'); + const [keywords, setKeywords] = useState([]); + const [creating, setCreating] = useState(null); // keyword_id being created + const [page, setPage] = useState(0); + + const load = useCallback(() => { + const cat = category === '전체' ? undefined : category; + getInstaKeywords({ category: cat }).then((r) => setKeywords(r.items || [])).catch(() => {}); + }, [category]); + + useEffect(() => { load(); }, [load]); + useEffect(() => { setPage(0); }, [category]); // 카테고리 변경 시 첫 페이지로 + + // 동일 keyword 중복 제거(최고 score 1개만 유지) + score 내림차순 + const deduped = useMemo(() => { + const best = new Map(); + for (const kw of keywords) { + const name = (kw.keyword || '').trim(); + if (!name) continue; + const prev = best.get(name); + if (!prev || (kw.score ?? 0) > (prev.score ?? 0)) best.set(name, kw); + } + return [...best.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); + }, [keywords]); + + const totalPages = Math.max(1, Math.ceil(deduped.length / KEYWORDS_PER_PAGE)); + const safePage = Math.min(page, totalPages - 1); + const pageItems = deduped.slice(safePage * KEYWORDS_PER_PAGE, safePage * KEYWORDS_PER_PAGE + KEYWORDS_PER_PAGE); + + // 부모(InstaCards)의 handleCreateSlate에 위임 — progress 배너 + 스크롤 + 자동 미리보기 공통화 + async function handleCreate(kw) { + if (creating) return; + setCreating(kw.id); + try { + await onCreateSlate?.({ + keyword: kw.keyword, + category: kw.category, + keyword_id: kw.id, + }); + } finally { + setCreating(null); + } + } + + return ( +
+

트렌딩 키워드

+ + {/* 카테고리 필터 */} +
+ {CATEGORIES.map((c) => ( + + ))} +
+ + {/* progress 표시는 상단 ic-slate-progress 배너에서 일괄 처리 */} + + {deduped.length === 0 ? ( +
키워드가 없습니다. 키워드 추출을 실행하세요.
+ ) : ( + <> +
+ {pageItems.map((kw) => ( +
+ {kw.keyword} + + {kw.category} · {kw.articles_count ?? 0}건 + + {kw.score?.toFixed(1) ?? '-'} + +
+ ))} +
+ + {totalPages > 1 && ( +
+ + {safePage + 1} / {totalPages} + +
+ )} + + )} +
+ ); +} + +export default KeywordsPanel; diff --git a/src/pages/insta/components/TriggerPanel.jsx b/src/pages/insta/components/TriggerPanel.jsx new file mode 100644 index 0000000..3cecf20 --- /dev/null +++ b/src/pages/insta/components/TriggerPanel.jsx @@ -0,0 +1,59 @@ +import React from 'react'; +import usePollTask from '../hooks/usePollTask'; +import TaskStatusBox from './TaskStatusBox'; +import { instaCollectNews, instaExtractKeywords } from '../../../api'; + +/* ══════════════════════ 트리거 패널 ══════════════════════════════════════ */ +function TriggerPanel() { + const collectPoll = usePollTask(); + const keywordsPoll = usePollTask(); + + async function handleCollect() { + try { + const res = await instaCollectNews(); + collectPoll.start(res.task_id); + } catch (e) { + alert('뉴스 수집 실패: ' + e.message); + } + } + + async function handleKeywords() { + try { + const res = await instaExtractKeywords(); + keywordsPoll.start(res.task_id); + } catch (e) { + alert('키워드 추출 실패: ' + e.message); + } + } + + const collectBusy = !!collectPoll.taskId; + const kwBusy = !!keywordsPoll.taskId; + + return ( +
+

트리거

+
+ + + + +
+
+ ); +} + +export default TriggerPanel; From 06b6717eb9cbaa18cf277551649352b34b3f6180 Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 10 Jul 2026 13:45:00 +0900 Subject: [PATCH 5/8] =?UTF-8?q?refactor(insta):=20SlatesPanel/SlateDetail/?= =?UTF-8?q?PagesStrip=20=EC=B6=94=EC=B6=9C=20(Cards=20=ED=83=AD=20?= =?UTF-8?q?=EC=9A=B0=EC=B8=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/insta/InstaCards.jsx | 313 +-------------------- src/pages/insta/components/PagesStrip.jsx | 89 ++++++ src/pages/insta/components/SlateDetail.jsx | 129 +++++++++ src/pages/insta/components/SlatesPanel.jsx | 102 +++++++ 4 files changed, 322 insertions(+), 311 deletions(-) create mode 100644 src/pages/insta/components/PagesStrip.jsx create mode 100644 src/pages/insta/components/SlateDetail.jsx create mode 100644 src/pages/insta/components/SlatesPanel.jsx diff --git a/src/pages/insta/InstaCards.jsx b/src/pages/insta/InstaCards.jsx index 3859da6..1739471 100644 --- a/src/pages/insta/InstaCards.jsx +++ b/src/pages/insta/InstaCards.jsx @@ -1,14 +1,8 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import PullToRefresh from '../../components/PullToRefresh'; import { getInstaStatus, createInstaSlate, - getInstaSlates, - getInstaSlate, - renderInstaSlate, - deleteInstaSlate, - getInstaAssetUrl, - instaPackageUrl, getInstaTask, getInstaPrompt, putInstaPrompt, @@ -19,10 +13,10 @@ import { } from '../../api'; import './InstaCards.css'; import { fmtDate } from './instaUtils'; -import StatusBadge from './components/StatusBadge'; import TaskStatusBox from './components/TaskStatusBox'; import TriggerPanel from './components/TriggerPanel'; import KeywordsPanel from './components/KeywordsPanel'; +import SlatesPanel from './components/SlatesPanel'; /* ══════════════════════ Trends 탭 패널 1: AccountFocusPanel ══════════════ */ function AccountFocusPanel() { @@ -394,309 +388,6 @@ export default function InstaCards() { ); } -/* ══════════════════════ 슬레이트 목록 ══════════════════════════════════ */ -function SlatesPanel({ selectedId, onSelect }) { - const [slates, setSlates] = useState([]); - const [detail, setDetail] = useState(null); - - const loadSlates = useCallback(() => { - getInstaSlates(50).then((r) => setSlates(r.items || [])).catch(() => {}); - }, []); - - useEffect(() => { loadSlates(); }, [loadSlates]); - - useEffect(() => { - if (!selectedId) { setDetail(null); return; } - getInstaSlate(selectedId).then(setDetail).catch(() => setDetail(null)); - }, [selectedId]); - - function handleSelect(id) { - onSelect(id === selectedId ? null : id); - } - - async function handleDelete(id) { - if (!confirm('슬레이트를 삭제하시겠습니까?')) return; - try { - await deleteInstaSlate(id); - if (selectedId === id) onSelect(null); - loadSlates(); - } catch (e) { - alert('삭제 실패: ' + e.message); - } - } - - async function handleRender(id) { - try { - const res = await renderInstaSlate(id); - // Re-render is fire-and-forget from the panel; user can refresh detail - alert('재렌더 요청 완료 (task: ' + res.task_id + ')'); - setTimeout(loadSlates, 3000); - } catch (e) { - alert('재렌더 실패: ' + e.message); - } - } - - return ( -
-
-
-

슬레이트 목록

- -
- - {slates.length === 0 ? ( -
슬레이트가 없습니다. 카드를 생성해 보세요.
- ) : ( -
- {slates.map((s) => ( -
handleSelect(s.id)} - > - {s.status === 'rendered' || s.status === 'sent' ? ( - {s.keyword} - ) : ( -
🎴
- )} -
-
{s.keyword}
-
- {fmtDate(s.created_at)} - -
-
-
- ))} -
- )} -
- - {/* 슬레이트 상세 */} - {detail && ( - handleDelete(detail.id)} - onRender={() => handleRender(detail.id)} - /> - )} -
- ); -} - -/* ══════════════════════ 페이지 스트립 (chevron + indicator) ═══════════ */ -function PagesStrip({ slateId, pageCount }) { - const stripRef = useRef(null); - const [activePage, setActivePage] = useState(1); - - const scrollToPage = useCallback((pageNo) => { - const strip = stripRef.current; - if (!strip) return; - const next = Math.max(1, Math.min(pageCount, pageNo)); - const child = strip.children[next - 1]; - if (child) { - child.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); - setActivePage(next); - } - }, [pageCount]); - - // 스크롤/드래그 시 가운데 카드 감지 - const onScroll = useCallback(() => { - const strip = stripRef.current; - if (!strip) return; - const rect = strip.getBoundingClientRect(); - const centerX = rect.left + rect.width / 2; - let best = 1, bestDist = Infinity; - Array.from(strip.children).forEach((child, i) => { - const cRect = child.getBoundingClientRect(); - const cCenter = cRect.left + cRect.width / 2; - const dist = Math.abs(cCenter - centerX); - if (dist < bestDist) { bestDist = dist; best = i + 1; } - }); - if (best !== activePage) setActivePage(best); - }, [activePage]); - - // 키보드 ←/→ - useEffect(() => { - const onKey = (e) => { - if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; - if (e.key === 'ArrowLeft') { scrollToPage(activePage - 1); e.preventDefault(); } - else if (e.key === 'ArrowRight') { scrollToPage(activePage + 1); e.preventDefault(); } - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [activePage, scrollToPage]); - - return ( -
- - -
- {Array.from({ length: pageCount }, (_, i) => i + 1).map((page) => ( - {`Page scrollToPage(page)} - /> - ))} -
- - - -
- - {activePage} - / - {pageCount} - -
-
- ); -} - -/* ══════════════════════ 슬레이트 상세 ══════════════════════════════════ */ -function SlateDetail({ slate, onDelete, onRender }) { - const pages = slate.assets || []; - const pageCount = pages.length > 0 ? pages.length : 10; - - function copyCaption() { - const text = [slate.suggested_caption, slate.hashtags?.join(' ')].filter(Boolean).join('\n\n'); - navigator.clipboard.writeText(text).then(() => alert('클립보드에 복사되었습니다!')); - } - - return ( -
-
-
- {slate.keyword} - -
- -
- - {/* 페이지 이미지 스트립 (캐러셀: chevron + indicator + ←/→ 키보드) */} - {(slate.status === 'rendered' || slate.status === 'sent') ? ( - - ) : ( -
- {slate.status === 'failed' ? '렌더 실패 — 재렌더를 시도하세요.' : '렌더링 전입니다.'} -
- )} - - {/* 캡션 */} - {slate.suggested_caption && ( -
-
- 캡션 - -
-
{slate.suggested_caption}
- {slate.hashtags?.length > 0 && ( -
- {slate.hashtags.join(' ')} -
- )} -
- )} - - {/* 커버 카피 (1/10) */} - {slate.cover_copy && typeof slate.cover_copy === 'object' && ( -
-
🎯 커버 (1/10)
-
- {slate.cover_copy.headline} - {slate.cover_copy.body && ( -
- {slate.cover_copy.body} -
- )} - {slate.cover_copy.accent_color && ( -
- accent: {slate.cover_copy.accent_color} -
- )} -
-
- )} - - {/* 본문 카피 8장 (2~9/10) */} - {Array.isArray(slate.body_copies) && slate.body_copies.length > 0 && ( -
-
📝 본문 8장 (2~9/10)
- {slate.body_copies.map((b, i) => ( -
0 ? '1px solid rgba(255,255,255,0.06)' : 'none', - padding: '10px 0', - }} - > - {i + 2}. {b?.headline || ''} - {b?.body && ( -
- {b.body} -
- )} -
- ))} -
- )} - - {/* CTA 카피 (10/10) */} - {slate.cta_copy && typeof slate.cta_copy === 'object' && ( -
-
📣 마무리 (10/10)
-
- {slate.cta_copy.headline} - {slate.cta_copy.body && ( -
- {slate.cta_copy.body} -
- )} - {slate.cta_copy.cta && ( -
- CTA: {slate.cta_copy.cta} -
- )} -
-
- )} -
- ); -} - /* ══════════════════════ 프롬프트 템플릿 에디터 ══════════════════════════ */ const PROMPT_NAMES = ['slate_writer', 'category_seeds']; diff --git a/src/pages/insta/components/PagesStrip.jsx b/src/pages/insta/components/PagesStrip.jsx new file mode 100644 index 0000000..5441442 --- /dev/null +++ b/src/pages/insta/components/PagesStrip.jsx @@ -0,0 +1,89 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { getInstaAssetUrl } from '../../../api'; + +/* ══════════════════════ 페이지 스트립 (chevron + indicator) ═══════════ */ +function PagesStrip({ slateId, pageCount }) { + const stripRef = useRef(null); + const [activePage, setActivePage] = useState(1); + + const scrollToPage = useCallback((pageNo) => { + const strip = stripRef.current; + if (!strip) return; + const next = Math.max(1, Math.min(pageCount, pageNo)); + const child = strip.children[next - 1]; + if (child) { + child.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); + setActivePage(next); + } + }, [pageCount]); + + // 스크롤/드래그 시 가운데 카드 감지 + const onScroll = useCallback(() => { + const strip = stripRef.current; + if (!strip) return; + const rect = strip.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + let best = 1, bestDist = Infinity; + Array.from(strip.children).forEach((child, i) => { + const cRect = child.getBoundingClientRect(); + const cCenter = cRect.left + cRect.width / 2; + const dist = Math.abs(cCenter - centerX); + if (dist < bestDist) { bestDist = dist; best = i + 1; } + }); + if (best !== activePage) setActivePage(best); + }, [activePage]); + + // 키보드 ←/→ + useEffect(() => { + const onKey = (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + if (e.key === 'ArrowLeft') { scrollToPage(activePage - 1); e.preventDefault(); } + else if (e.key === 'ArrowRight') { scrollToPage(activePage + 1); e.preventDefault(); } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [activePage, scrollToPage]); + + return ( +
+ + +
+ {Array.from({ length: pageCount }, (_, i) => i + 1).map((page) => ( + {`Page scrollToPage(page)} + /> + ))} +
+ + + +
+ + {activePage} + / + {pageCount} + +
+
+ ); +} + +export default PagesStrip; diff --git a/src/pages/insta/components/SlateDetail.jsx b/src/pages/insta/components/SlateDetail.jsx new file mode 100644 index 0000000..db6d339 --- /dev/null +++ b/src/pages/insta/components/SlateDetail.jsx @@ -0,0 +1,129 @@ +import React from 'react'; +import StatusBadge from './StatusBadge'; +import PagesStrip from './PagesStrip'; +import { instaPackageUrl } from '../../../api'; + +/* ══════════════════════ 슬레이트 상세 ══════════════════════════════════ */ +function SlateDetail({ slate, onDelete, onRender }) { + const pages = slate.assets || []; + const pageCount = pages.length > 0 ? pages.length : 10; + + function copyCaption() { + const text = [slate.suggested_caption, slate.hashtags?.join(' ')].filter(Boolean).join('\n\n'); + navigator.clipboard.writeText(text).then(() => alert('클립보드에 복사되었습니다!')); + } + + return ( +
+
+
+ {slate.keyword} + +
+ +
+ + {/* 페이지 이미지 스트립 (캐러셀: chevron + indicator + ←/→ 키보드) */} + {(slate.status === 'rendered' || slate.status === 'sent') ? ( + + ) : ( +
+ {slate.status === 'failed' ? '렌더 실패 — 재렌더를 시도하세요.' : '렌더링 전입니다.'} +
+ )} + + {/* 캡션 */} + {slate.suggested_caption && ( +
+
+ 캡션 + +
+
{slate.suggested_caption}
+ {slate.hashtags?.length > 0 && ( +
+ {slate.hashtags.join(' ')} +
+ )} +
+ )} + + {/* 커버 카피 (1/10) */} + {slate.cover_copy && typeof slate.cover_copy === 'object' && ( +
+
🎯 커버 (1/10)
+
+ {slate.cover_copy.headline} + {slate.cover_copy.body && ( +
+ {slate.cover_copy.body} +
+ )} + {slate.cover_copy.accent_color && ( +
+ accent: {slate.cover_copy.accent_color} +
+ )} +
+
+ )} + + {/* 본문 카피 8장 (2~9/10) */} + {Array.isArray(slate.body_copies) && slate.body_copies.length > 0 && ( +
+
📝 본문 8장 (2~9/10)
+ {slate.body_copies.map((b, i) => ( +
0 ? '1px solid rgba(255,255,255,0.06)' : 'none', + padding: '10px 0', + }} + > + {i + 2}. {b?.headline || ''} + {b?.body && ( +
+ {b.body} +
+ )} +
+ ))} +
+ )} + + {/* CTA 카피 (10/10) */} + {slate.cta_copy && typeof slate.cta_copy === 'object' && ( +
+
📣 마무리 (10/10)
+
+ {slate.cta_copy.headline} + {slate.cta_copy.body && ( +
+ {slate.cta_copy.body} +
+ )} + {slate.cta_copy.cta && ( +
+ CTA: {slate.cta_copy.cta} +
+ )} +
+
+ )} +
+ ); +} + +export default SlateDetail; diff --git a/src/pages/insta/components/SlatesPanel.jsx b/src/pages/insta/components/SlatesPanel.jsx new file mode 100644 index 0000000..9350a71 --- /dev/null +++ b/src/pages/insta/components/SlatesPanel.jsx @@ -0,0 +1,102 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import StatusBadge from './StatusBadge'; +import SlateDetail from './SlateDetail'; +import { fmtDate } from '../instaUtils'; +import { getInstaSlates, getInstaSlate, getInstaAssetUrl, renderInstaSlate, deleteInstaSlate } from '../../../api'; + +/* ══════════════════════ 슬레이트 목록 ══════════════════════════════════ */ +function SlatesPanel({ selectedId, onSelect }) { + const [slates, setSlates] = useState([]); + const [detail, setDetail] = useState(null); + + const loadSlates = useCallback(() => { + getInstaSlates(50).then((r) => setSlates(r.items || [])).catch(() => {}); + }, []); + + useEffect(() => { loadSlates(); }, [loadSlates]); + + useEffect(() => { + if (!selectedId) { setDetail(null); return; } + getInstaSlate(selectedId).then(setDetail).catch(() => setDetail(null)); + }, [selectedId]); + + function handleSelect(id) { + onSelect(id === selectedId ? null : id); + } + + async function handleDelete(id) { + if (!confirm('슬레이트를 삭제하시겠습니까?')) return; + try { + await deleteInstaSlate(id); + if (selectedId === id) onSelect(null); + loadSlates(); + } catch (e) { + alert('삭제 실패: ' + e.message); + } + } + + async function handleRender(id) { + try { + const res = await renderInstaSlate(id); + // Re-render is fire-and-forget from the panel; user can refresh detail + alert('재렌더 요청 완료 (task: ' + res.task_id + ')'); + setTimeout(loadSlates, 3000); + } catch (e) { + alert('재렌더 실패: ' + e.message); + } + } + + return ( +
+
+
+

슬레이트 목록

+ +
+ + {slates.length === 0 ? ( +
슬레이트가 없습니다. 카드를 생성해 보세요.
+ ) : ( +
+ {slates.map((s) => ( +
handleSelect(s.id)} + > + {s.status === 'rendered' || s.status === 'sent' ? ( + {s.keyword} + ) : ( +
🎴
+ )} +
+
{s.keyword}
+
+ {fmtDate(s.created_at)} + +
+
+
+ ))} +
+ )} +
+ + {/* 슬레이트 상세 */} + {detail && ( + handleDelete(detail.id)} + onRender={() => handleRender(detail.id)} + /> + )} +
+ ); +} + +export default SlatesPanel; From ad81f150a516dbdf855caf8e8c6717c7b0fd7f6b Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 10 Jul 2026 13:51:41 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor(insta):=20Trends=203=ED=8C=A8?= =?UTF-8?q?=EB=84=90+PromptTemplatesEditor=20=EC=B6=94=EC=B6=9C=20?= =?UTF-8?q?=E2=86=92=20InstaCards=20shell=20=ED=99=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/insta/InstaCards.jsx | 287 +----------------- .../insta/components/AccountFocusPanel.jsx | 73 +++++ .../insta/components/ExternalTrendsPanel.jsx | 104 +++++++ .../components/PreferenceImpactPanel.jsx | 37 +++ .../components/PromptTemplatesEditor.jsx | 80 +++++ 5 files changed, 298 insertions(+), 283 deletions(-) create mode 100644 src/pages/insta/components/AccountFocusPanel.jsx create mode 100644 src/pages/insta/components/ExternalTrendsPanel.jsx create mode 100644 src/pages/insta/components/PreferenceImpactPanel.jsx create mode 100644 src/pages/insta/components/PromptTemplatesEditor.jsx diff --git a/src/pages/insta/InstaCards.jsx b/src/pages/insta/InstaCards.jsx index 1739471..3cae41e 100644 --- a/src/pages/insta/InstaCards.jsx +++ b/src/pages/insta/InstaCards.jsx @@ -4,219 +4,15 @@ import { getInstaStatus, createInstaSlate, getInstaTask, - getInstaPrompt, - putInstaPrompt, - getInstaTrends, - instaCollectTrends, - getInstaPreferences, - putInstaPreferences, } from '../../api'; import './InstaCards.css'; -import { fmtDate } from './instaUtils'; -import TaskStatusBox from './components/TaskStatusBox'; import TriggerPanel from './components/TriggerPanel'; import KeywordsPanel from './components/KeywordsPanel'; import SlatesPanel from './components/SlatesPanel'; - -/* ══════════════════════ Trends 탭 패널 1: AccountFocusPanel ══════════════ */ -function AccountFocusPanel() { - const [prefs, setPrefs] = useState([]); - const [draft, setDraft] = useState({}); - const [saving, setSaving] = useState(false); - const [newCat, setNewCat] = useState(''); - - const load = useCallback(async () => { - const data = await getInstaPreferences(); - setPrefs(data.categories || []); - const m = {}; - (data.categories || []).forEach(p => { m[p.category] = Math.round(p.weight * 100); }); - setDraft(m); - }, []); - - useEffect(() => { load(); }, [load]); - - const save = async () => { - setSaving(true); - try { - const payload = {}; - Object.entries(draft).forEach(([k, v]) => { payload[k] = (Number(v) || 0) / 100; }); - await putInstaPreferences(payload); - await load(); - } finally { setSaving(false); } - }; - - const addCat = () => { - const name = newCat.trim().toLowerCase(); - if (!name || draft[name] !== undefined) return; - setDraft({ ...draft, [name]: 0 }); - setNewCat(''); - }; - - return ( -
-

🎯 이 계정의 주제 (카테고리 가중치)

-

슬라이더는 각 카테고리에 자동 추출 키워드 비율을 결정합니다. 합계는 자동 정규화됩니다.

-
- {Object.entries(draft).map(([cat, val]) => ( -
- - setDraft({ ...draft, [cat]: Number(e.target.value) })} - className="ic-focus__slider" - /> - {val}% -
- ))} -
-
- setNewCat(e.target.value)} - /> - -
- -
- 💡 신규 카테고리를 추가했다면 Cards 탭의 Prompt Templates Editor에서 - category_seeds에 시드 키워드도 함께 정의해야 자동 추출에 반영됩니다. -
-
- ); -} - -/* ══════════════════════ Trends 탭 패널 2: ExternalTrendsPanel ══════════ */ -const CATEGORY_COLORS = { - economy: '#0F62FE', psychology: '#A66CFF', - celebrity: '#FF5C8A', uncategorized: '#6B7280', -}; - -function ExternalTrendsPanel({ onCreateSlate }) { - const [naver, setNaver] = useState([]); - const [google, setGoogle] = useState([]); - const [lastFetched, setLastFetched] = useState(null); - const [collecting, setCollecting] = useState(false); - const [task, setTask] = useState(null); - - const load = useCallback(async () => { - const [n, g] = await Promise.all([ - getInstaTrends({ source: 'naver_popular', days: 2 }), - getInstaTrends({ source: 'youtube_trending', days: 2 }), - ]); - setNaver(n.items || []); - setGoogle(g.items || []); - const all = [...(n.items || []), ...(g.items || [])]; - if (all.length) { - const latest = all.map(t => t.suggested_at).sort().reverse()[0]; - setLastFetched(latest); - } - }, []); - - useEffect(() => { load(); }, [load]); - - const trigger = async () => { - setCollecting(true); - try { - const { task_id } = await instaCollectTrends(); - let st = null; - for (let i = 0; i < 60; i++) { - st = await getInstaTask(task_id); - setTask(st); - if (st.status === 'succeeded' || st.status === 'failed') break; - await new Promise(r => setTimeout(r, 3000)); - } - await load(); - } finally { setCollecting(false); } - }; - - const groupByCat = (items) => { - const g = {}; - items.forEach(it => { (g[it.category] = g[it.category] || []).push(it); }); - return g; - }; - - const renderRow = (t) => ( -
- - {t.keyword} - {(t.score || 0).toFixed(2)} - -
- ); - - const naverGrouped = groupByCat(naver); - return ( -
-
-

📈 외부 트렌드

-
- - {lastFetched ? `마지막 수집: ${fmtDate(lastFetched)}` : '아직 수집 없음'} - - -
-
- {task && } -
-
-

🔥 NAVER 인기

- {Object.keys(naverGrouped).length === 0 &&

없음

} - {Object.entries(naverGrouped).map(([cat, items]) => ( -
-
{cat}
- {items.map(renderRow)} -
- ))} -
-
-

📺 YouTube 인기

- {google.length === 0 &&

없음

} - {google.map(renderRow)} -
-
-
- ); -} - -/* ══════════════════════ Trends 탭 패널 3: PreferenceImpactPanel ══════ */ -function PreferenceImpactPanel() { - const [prefs, setPrefs] = useState([]); - const TOTAL = 15; - - useEffect(() => { - (async () => { - const data = await getInstaPreferences(); - setPrefs(data.categories || []); - })(); - }, []); - - const totalWeight = prefs.reduce((s, p) => s + (p.weight || 0), 0) || 1; - const breakdown = prefs.map(p => ({ - category: p.category, - count: Math.round(TOTAL * (p.weight || 0) / totalWeight), - })); - - return ( -
-

📊 다음 자동 추출 미리보기

-
- {breakdown.map(b => ( -
- {b.category} - {b.count}개 -
- ))} -
-
- ); -} +import PromptTemplatesEditor from './components/PromptTemplatesEditor'; +import AccountFocusPanel from './components/AccountFocusPanel'; +import ExternalTrendsPanel from './components/ExternalTrendsPanel'; +import PreferenceImpactPanel from './components/PreferenceImpactPanel'; /* ══════════════════════════════════════════════════════════════════════════ */ export default function InstaCards() { @@ -387,78 +183,3 @@ export default function InstaCards() {
); } - -/* ══════════════════════ 프롬프트 템플릿 에디터 ══════════════════════════ */ -const PROMPT_NAMES = ['slate_writer', 'category_seeds']; - -function PromptTemplatesEditor() { - const [prompts, setPrompts] = useState({}); - const [drafts, setDrafts] = useState({}); - const [saving, setSaving] = useState({}); - - useEffect(() => { - PROMPT_NAMES.forEach((name) => { - getInstaPrompt(name) - .then((p) => { - setPrompts((prev) => ({ ...prev, [name]: p })); - setDrafts((prev) => ({ ...prev, [name]: p.template })); - }) - .catch(() => { - setPrompts((prev) => ({ ...prev, [name]: null })); - setDrafts((prev) => ({ ...prev, [name]: '' })); - }); - }); - }, []); - - async function handleSave(name) { - setSaving((prev) => ({ ...prev, [name]: true })); - try { - const updated = await putInstaPrompt(name, drafts[name] || '', prompts[name]?.description || ''); - setPrompts((prev) => ({ ...prev, [name]: updated })); - alert(`${name} 저장 완료`); - } catch (e) { - alert('저장 실패: ' + e.message); - } finally { - setSaving((prev) => ({ ...prev, [name]: false })); - } - } - - return ( -
-

프롬프트 템플릿

- {PROMPT_NAMES.map((name) => ( -
-
- {name} - {prompts[name]?.updated_at && ( - - 최종 수정: {fmtDate(prompts[name].updated_at)} - - )} -
- {prompts[name]?.description && ( -
- {prompts[name].description} -
- )} -