refactor(insta): Trends 3패널+PromptTemplatesEditor 추출 → InstaCards shell 확정
This commit is contained in:
@@ -4,219 +4,15 @@ import {
|
|||||||
getInstaStatus,
|
getInstaStatus,
|
||||||
createInstaSlate,
|
createInstaSlate,
|
||||||
getInstaTask,
|
getInstaTask,
|
||||||
getInstaPrompt,
|
|
||||||
putInstaPrompt,
|
|
||||||
getInstaTrends,
|
|
||||||
instaCollectTrends,
|
|
||||||
getInstaPreferences,
|
|
||||||
putInstaPreferences,
|
|
||||||
} from '../../api';
|
} from '../../api';
|
||||||
import './InstaCards.css';
|
import './InstaCards.css';
|
||||||
import { fmtDate } from './instaUtils';
|
|
||||||
import TaskStatusBox from './components/TaskStatusBox';
|
|
||||||
import TriggerPanel from './components/TriggerPanel';
|
import TriggerPanel from './components/TriggerPanel';
|
||||||
import KeywordsPanel from './components/KeywordsPanel';
|
import KeywordsPanel from './components/KeywordsPanel';
|
||||||
import SlatesPanel from './components/SlatesPanel';
|
import SlatesPanel from './components/SlatesPanel';
|
||||||
|
import PromptTemplatesEditor from './components/PromptTemplatesEditor';
|
||||||
/* ══════════════════════ Trends 탭 패널 1: AccountFocusPanel ══════════════ */
|
import AccountFocusPanel from './components/AccountFocusPanel';
|
||||||
function AccountFocusPanel() {
|
import ExternalTrendsPanel from './components/ExternalTrendsPanel';
|
||||||
const [prefs, setPrefs] = useState([]);
|
import PreferenceImpactPanel from './components/PreferenceImpactPanel';
|
||||||
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() {
|
||||||
@@ -387,78 +183,3 @@ export default function InstaCards() {
|
|||||||
</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;
|
||||||
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;
|
||||||
Reference in New Issue
Block a user