refactor(insta): TriggerPanel/KeywordsPanel 추출 (Cards 탭 좌측)
This commit is contained in:
@@ -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 PullToRefresh from '../../components/PullToRefresh';
|
||||||
import {
|
import {
|
||||||
getInstaStatus,
|
getInstaStatus,
|
||||||
instaCollectNews,
|
|
||||||
instaExtractKeywords,
|
|
||||||
getInstaKeywords,
|
|
||||||
createInstaSlate,
|
createInstaSlate,
|
||||||
getInstaSlates,
|
getInstaSlates,
|
||||||
getInstaSlate,
|
getInstaSlate,
|
||||||
@@ -22,9 +19,10 @@ import {
|
|||||||
} from '../../api';
|
} from '../../api';
|
||||||
import './InstaCards.css';
|
import './InstaCards.css';
|
||||||
import { fmtDate } from './instaUtils';
|
import { fmtDate } from './instaUtils';
|
||||||
import usePollTask from './hooks/usePollTask';
|
|
||||||
import StatusBadge from './components/StatusBadge';
|
import StatusBadge from './components/StatusBadge';
|
||||||
import TaskStatusBox from './components/TaskStatusBox';
|
import TaskStatusBox from './components/TaskStatusBox';
|
||||||
|
import TriggerPanel from './components/TriggerPanel';
|
||||||
|
import KeywordsPanel from './components/KeywordsPanel';
|
||||||
|
|
||||||
/* ══════════════════════ Trends 탭 패널 1: AccountFocusPanel ══════════════ */
|
/* ══════════════════════ Trends 탭 패널 1: AccountFocusPanel ══════════════ */
|
||||||
function 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 (
|
|
||||||
<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 }) {
|
function SlatesPanel({ selectedId, onSelect }) {
|
||||||
const [slates, setSlates] = useState([]);
|
const [slates, setSlates] = useState([]);
|
||||||
|
|||||||
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;
|
||||||
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;
|
||||||
Reference in New Issue
Block a user