refactor(insta): SlatesPanel/SlateDetail/PagesStrip 추출 (Cards 탭 우측)
This commit is contained in:
@@ -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 PullToRefresh from '../../components/PullToRefresh';
|
||||||
import {
|
import {
|
||||||
getInstaStatus,
|
getInstaStatus,
|
||||||
createInstaSlate,
|
createInstaSlate,
|
||||||
getInstaSlates,
|
|
||||||
getInstaSlate,
|
|
||||||
renderInstaSlate,
|
|
||||||
deleteInstaSlate,
|
|
||||||
getInstaAssetUrl,
|
|
||||||
instaPackageUrl,
|
|
||||||
getInstaTask,
|
getInstaTask,
|
||||||
getInstaPrompt,
|
getInstaPrompt,
|
||||||
putInstaPrompt,
|
putInstaPrompt,
|
||||||
@@ -19,10 +13,10 @@ import {
|
|||||||
} from '../../api';
|
} from '../../api';
|
||||||
import './InstaCards.css';
|
import './InstaCards.css';
|
||||||
import { fmtDate } from './instaUtils';
|
import { fmtDate } from './instaUtils';
|
||||||
import StatusBadge from './components/StatusBadge';
|
|
||||||
import TaskStatusBox from './components/TaskStatusBox';
|
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';
|
||||||
|
|
||||||
/* ══════════════════════ Trends 탭 패널 1: AccountFocusPanel ══════════════ */
|
/* ══════════════════════ Trends 탭 패널 1: AccountFocusPanel ══════════════ */
|
||||||
function 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 (
|
|
||||||
<div>
|
|
||||||
<div className="ic-section">
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 14 }}>
|
|
||||||
<p className="ic-section__title" style={{ margin: 0, flex: 1 }}>슬레이트 목록</p>
|
|
||||||
<button className="ic-btn ic-btn--secondary ic-btn--sm" onClick={loadSlates}>↻ 새로고침</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{slates.length === 0 ? (
|
|
||||||
<div className="ic-empty">슬레이트가 없습니다. 카드를 생성해 보세요.</div>
|
|
||||||
) : (
|
|
||||||
<div className="ic-slates-grid">
|
|
||||||
{slates.map((s) => (
|
|
||||||
<div
|
|
||||||
key={s.id}
|
|
||||||
className={`ic-slate-card ${selectedId === s.id ? 'ic-slate-card--active' : ''}`}
|
|
||||||
onClick={() => handleSelect(s.id)}
|
|
||||||
>
|
|
||||||
{s.status === 'rendered' || s.status === 'sent' ? (
|
|
||||||
<img
|
|
||||||
className="ic-slate-thumb"
|
|
||||||
src={getInstaAssetUrl(s.id, 1)}
|
|
||||||
alt={s.keyword}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="ic-slate-thumb--placeholder">🎴</div>
|
|
||||||
)}
|
|
||||||
<div className="ic-slate-card__info">
|
|
||||||
<div className="ic-slate-card__kw">{s.keyword}</div>
|
|
||||||
<div className="ic-slate-card__meta">
|
|
||||||
<span className="ic-slate-card__date">{fmtDate(s.created_at)}</span>
|
|
||||||
<StatusBadge status={s.status} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 슬레이트 상세 */}
|
|
||||||
{detail && (
|
|
||||||
<SlateDetail
|
|
||||||
slate={detail}
|
|
||||||
onDelete={() => handleDelete(detail.id)}
|
|
||||||
onRender={() => handleRender(detail.id)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══════════════════════ 페이지 스트립 (chevron + indicator) ═══════════ */
|
|
||||||
function PagesStrip({ slateId, pageCount }) {
|
|
||||||
const stripRef = useRef(null);
|
|
||||||
const [activePage, setActivePage] = useState(1);
|
|
||||||
|
|
||||||
const scrollToPage = useCallback((pageNo) => {
|
|
||||||
const strip = stripRef.current;
|
|
||||||
if (!strip) return;
|
|
||||||
const next = Math.max(1, Math.min(pageCount, pageNo));
|
|
||||||
const child = strip.children[next - 1];
|
|
||||||
if (child) {
|
|
||||||
child.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
|
||||||
setActivePage(next);
|
|
||||||
}
|
|
||||||
}, [pageCount]);
|
|
||||||
|
|
||||||
// 스크롤/드래그 시 가운데 카드 감지
|
|
||||||
const onScroll = useCallback(() => {
|
|
||||||
const strip = stripRef.current;
|
|
||||||
if (!strip) return;
|
|
||||||
const rect = strip.getBoundingClientRect();
|
|
||||||
const centerX = rect.left + rect.width / 2;
|
|
||||||
let best = 1, bestDist = Infinity;
|
|
||||||
Array.from(strip.children).forEach((child, i) => {
|
|
||||||
const cRect = child.getBoundingClientRect();
|
|
||||||
const cCenter = cRect.left + cRect.width / 2;
|
|
||||||
const dist = Math.abs(cCenter - centerX);
|
|
||||||
if (dist < bestDist) { bestDist = dist; best = i + 1; }
|
|
||||||
});
|
|
||||||
if (best !== activePage) setActivePage(best);
|
|
||||||
}, [activePage]);
|
|
||||||
|
|
||||||
// 키보드 ←/→
|
|
||||||
useEffect(() => {
|
|
||||||
const onKey = (e) => {
|
|
||||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
||||||
if (e.key === 'ArrowLeft') { scrollToPage(activePage - 1); e.preventDefault(); }
|
|
||||||
else if (e.key === 'ArrowRight') { scrollToPage(activePage + 1); e.preventDefault(); }
|
|
||||||
};
|
|
||||||
window.addEventListener('keydown', onKey);
|
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
|
||||||
}, [activePage, scrollToPage]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="ic-pages-wrap">
|
|
||||||
<button
|
|
||||||
className="ic-pages-nav ic-pages-nav--prev"
|
|
||||||
onClick={() => scrollToPage(activePage - 1)}
|
|
||||||
disabled={activePage <= 1}
|
|
||||||
aria-label="이전 페이지"
|
|
||||||
type="button"
|
|
||||||
>‹</button>
|
|
||||||
|
|
||||||
<div className="ic-pages-strip" ref={stripRef} onScroll={onScroll}>
|
|
||||||
{Array.from({ length: pageCount }, (_, i) => i + 1).map((page) => (
|
|
||||||
<img
|
|
||||||
key={page}
|
|
||||||
className={`ic-page-img ${activePage === page ? 'is-active' : ''}`}
|
|
||||||
src={getInstaAssetUrl(slateId, page)}
|
|
||||||
alt={`Page ${page}`}
|
|
||||||
loading="lazy"
|
|
||||||
onClick={() => scrollToPage(page)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="ic-pages-nav ic-pages-nav--next"
|
|
||||||
onClick={() => scrollToPage(activePage + 1)}
|
|
||||||
disabled={activePage >= pageCount}
|
|
||||||
aria-label="다음 페이지"
|
|
||||||
type="button"
|
|
||||||
>›</button>
|
|
||||||
|
|
||||||
<div className="ic-pages-indicator-row">
|
|
||||||
<span className="ic-pages-indicator">
|
|
||||||
<span className="ic-pages-indicator__current">{activePage}</span>
|
|
||||||
<span className="ic-pages-indicator__sep">/</span>
|
|
||||||
<span className="ic-pages-indicator__total">{pageCount}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══════════════════════ 슬레이트 상세 ══════════════════════════════════ */
|
|
||||||
function SlateDetail({ slate, onDelete, onRender }) {
|
|
||||||
const pages = slate.assets || [];
|
|
||||||
const pageCount = pages.length > 0 ? pages.length : 10;
|
|
||||||
|
|
||||||
function copyCaption() {
|
|
||||||
const text = [slate.suggested_caption, slate.hashtags?.join(' ')].filter(Boolean).join('\n\n');
|
|
||||||
navigator.clipboard.writeText(text).then(() => alert('클립보드에 복사되었습니다!'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="ic-detail">
|
|
||||||
<div className="ic-detail__header">
|
|
||||||
<div className="ic-detail__title">
|
|
||||||
{slate.keyword}
|
|
||||||
<span style={{ marginLeft: 8 }}><StatusBadge status={slate.status} /></span>
|
|
||||||
</div>
|
|
||||||
<div className="ic-detail__actions">
|
|
||||||
<button className="ic-btn ic-btn--secondary ic-btn--sm" onClick={onRender}>재렌더</button>
|
|
||||||
<a className="ic-btn ic-btn--secondary ic-btn--sm" href={instaPackageUrl(slate.id)} download>
|
|
||||||
📦 패키지 다운로드 (10장 + 캡션)
|
|
||||||
</a>
|
|
||||||
<button className="ic-btn ic-btn--danger ic-btn--sm" onClick={onDelete}>삭제</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 페이지 이미지 스트립 (캐러셀: chevron + indicator + ←/→ 키보드) */}
|
|
||||||
{(slate.status === 'rendered' || slate.status === 'sent') ? (
|
|
||||||
<PagesStrip slateId={slate.id} pageCount={pageCount} />
|
|
||||||
) : (
|
|
||||||
<div className="ic-empty" style={{ padding: '20px 0' }}>
|
|
||||||
{slate.status === 'failed' ? '렌더 실패 — 재렌더를 시도하세요.' : '렌더링 전입니다.'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 캡션 */}
|
|
||||||
{slate.suggested_caption && (
|
|
||||||
<div className="ic-caption-box">
|
|
||||||
<div className="ic-caption-box__label">
|
|
||||||
캡션
|
|
||||||
<button
|
|
||||||
className="ic-btn ic-btn--secondary ic-btn--sm"
|
|
||||||
style={{ marginLeft: 8 }}
|
|
||||||
onClick={copyCaption}
|
|
||||||
>
|
|
||||||
복사
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="ic-caption-text">{slate.suggested_caption}</div>
|
|
||||||
{slate.hashtags?.length > 0 && (
|
|
||||||
<div className="ic-hashtags" style={{ marginTop: 8 }}>
|
|
||||||
{slate.hashtags.join(' ')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 커버 카피 (1/10) */}
|
|
||||||
{slate.cover_copy && typeof slate.cover_copy === 'object' && (
|
|
||||||
<div className="ic-caption-box">
|
|
||||||
<div className="ic-caption-box__label">🎯 커버 (1/10)</div>
|
|
||||||
<div className="ic-caption-text">
|
|
||||||
<strong>{slate.cover_copy.headline}</strong>
|
|
||||||
{slate.cover_copy.body && (
|
|
||||||
<div style={{ marginTop: 6, opacity: 0.85, whiteSpace: 'pre-wrap' }}>
|
|
||||||
{slate.cover_copy.body}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{slate.cover_copy.accent_color && (
|
|
||||||
<div style={{ marginTop: 6, fontSize: '0.72rem', opacity: 0.5 }}>
|
|
||||||
accent: <code>{slate.cover_copy.accent_color}</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 본문 카피 8장 (2~9/10) */}
|
|
||||||
{Array.isArray(slate.body_copies) && slate.body_copies.length > 0 && (
|
|
||||||
<div className="ic-caption-box">
|
|
||||||
<div className="ic-caption-box__label">📝 본문 8장 (2~9/10)</div>
|
|
||||||
{slate.body_copies.map((b, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
borderTop: i > 0 ? '1px solid rgba(255,255,255,0.06)' : 'none',
|
|
||||||
padding: '10px 0',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>{i + 2}. {b?.headline || ''}</strong>
|
|
||||||
{b?.body && (
|
|
||||||
<div style={{ marginTop: 4, opacity: 0.85, whiteSpace: 'pre-wrap' }}>
|
|
||||||
{b.body}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* CTA 카피 (10/10) */}
|
|
||||||
{slate.cta_copy && typeof slate.cta_copy === 'object' && (
|
|
||||||
<div className="ic-caption-box">
|
|
||||||
<div className="ic-caption-box__label">📣 마무리 (10/10)</div>
|
|
||||||
<div className="ic-caption-text">
|
|
||||||
<strong>{slate.cta_copy.headline}</strong>
|
|
||||||
{slate.cta_copy.body && (
|
|
||||||
<div style={{ marginTop: 6, opacity: 0.85, whiteSpace: 'pre-wrap' }}>
|
|
||||||
{slate.cta_copy.body}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{slate.cta_copy.cta && (
|
|
||||||
<div style={{ marginTop: 8, color: '#ec4899', fontWeight: 700 }}>
|
|
||||||
CTA: {slate.cta_copy.cta}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══════════════════════ 프롬프트 템플릿 에디터 ══════════════════════════ */
|
/* ══════════════════════ 프롬프트 템플릿 에디터 ══════════════════════════ */
|
||||||
const PROMPT_NAMES = ['slate_writer', 'category_seeds'];
|
const PROMPT_NAMES = ['slate_writer', 'category_seeds'];
|
||||||
|
|
||||||
|
|||||||
89
src/pages/insta/components/PagesStrip.jsx
Normal file
89
src/pages/insta/components/PagesStrip.jsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { getInstaAssetUrl } from '../../../api';
|
||||||
|
|
||||||
|
/* ══════════════════════ 페이지 스트립 (chevron + indicator) ═══════════ */
|
||||||
|
function PagesStrip({ slateId, pageCount }) {
|
||||||
|
const stripRef = useRef(null);
|
||||||
|
const [activePage, setActivePage] = useState(1);
|
||||||
|
|
||||||
|
const scrollToPage = useCallback((pageNo) => {
|
||||||
|
const strip = stripRef.current;
|
||||||
|
if (!strip) return;
|
||||||
|
const next = Math.max(1, Math.min(pageCount, pageNo));
|
||||||
|
const child = strip.children[next - 1];
|
||||||
|
if (child) {
|
||||||
|
child.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||||||
|
setActivePage(next);
|
||||||
|
}
|
||||||
|
}, [pageCount]);
|
||||||
|
|
||||||
|
// 스크롤/드래그 시 가운데 카드 감지
|
||||||
|
const onScroll = useCallback(() => {
|
||||||
|
const strip = stripRef.current;
|
||||||
|
if (!strip) return;
|
||||||
|
const rect = strip.getBoundingClientRect();
|
||||||
|
const centerX = rect.left + rect.width / 2;
|
||||||
|
let best = 1, bestDist = Infinity;
|
||||||
|
Array.from(strip.children).forEach((child, i) => {
|
||||||
|
const cRect = child.getBoundingClientRect();
|
||||||
|
const cCenter = cRect.left + cRect.width / 2;
|
||||||
|
const dist = Math.abs(cCenter - centerX);
|
||||||
|
if (dist < bestDist) { bestDist = dist; best = i + 1; }
|
||||||
|
});
|
||||||
|
if (best !== activePage) setActivePage(best);
|
||||||
|
}, [activePage]);
|
||||||
|
|
||||||
|
// 키보드 ←/→
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e) => {
|
||||||
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||||
|
if (e.key === 'ArrowLeft') { scrollToPage(activePage - 1); e.preventDefault(); }
|
||||||
|
else if (e.key === 'ArrowRight') { scrollToPage(activePage + 1); e.preventDefault(); }
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [activePage, scrollToPage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ic-pages-wrap">
|
||||||
|
<button
|
||||||
|
className="ic-pages-nav ic-pages-nav--prev"
|
||||||
|
onClick={() => scrollToPage(activePage - 1)}
|
||||||
|
disabled={activePage <= 1}
|
||||||
|
aria-label="이전 페이지"
|
||||||
|
type="button"
|
||||||
|
>‹</button>
|
||||||
|
|
||||||
|
<div className="ic-pages-strip" ref={stripRef} onScroll={onScroll}>
|
||||||
|
{Array.from({ length: pageCount }, (_, i) => i + 1).map((page) => (
|
||||||
|
<img
|
||||||
|
key={page}
|
||||||
|
className={`ic-page-img ${activePage === page ? 'is-active' : ''}`}
|
||||||
|
src={getInstaAssetUrl(slateId, page)}
|
||||||
|
alt={`Page ${page}`}
|
||||||
|
loading="lazy"
|
||||||
|
onClick={() => scrollToPage(page)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="ic-pages-nav ic-pages-nav--next"
|
||||||
|
onClick={() => scrollToPage(activePage + 1)}
|
||||||
|
disabled={activePage >= pageCount}
|
||||||
|
aria-label="다음 페이지"
|
||||||
|
type="button"
|
||||||
|
>›</button>
|
||||||
|
|
||||||
|
<div className="ic-pages-indicator-row">
|
||||||
|
<span className="ic-pages-indicator">
|
||||||
|
<span className="ic-pages-indicator__current">{activePage}</span>
|
||||||
|
<span className="ic-pages-indicator__sep">/</span>
|
||||||
|
<span className="ic-pages-indicator__total">{pageCount}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PagesStrip;
|
||||||
129
src/pages/insta/components/SlateDetail.jsx
Normal file
129
src/pages/insta/components/SlateDetail.jsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import StatusBadge from './StatusBadge';
|
||||||
|
import PagesStrip from './PagesStrip';
|
||||||
|
import { instaPackageUrl } from '../../../api';
|
||||||
|
|
||||||
|
/* ══════════════════════ 슬레이트 상세 ══════════════════════════════════ */
|
||||||
|
function SlateDetail({ slate, onDelete, onRender }) {
|
||||||
|
const pages = slate.assets || [];
|
||||||
|
const pageCount = pages.length > 0 ? pages.length : 10;
|
||||||
|
|
||||||
|
function copyCaption() {
|
||||||
|
const text = [slate.suggested_caption, slate.hashtags?.join(' ')].filter(Boolean).join('\n\n');
|
||||||
|
navigator.clipboard.writeText(text).then(() => alert('클립보드에 복사되었습니다!'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ic-detail">
|
||||||
|
<div className="ic-detail__header">
|
||||||
|
<div className="ic-detail__title">
|
||||||
|
{slate.keyword}
|
||||||
|
<span style={{ marginLeft: 8 }}><StatusBadge status={slate.status} /></span>
|
||||||
|
</div>
|
||||||
|
<div className="ic-detail__actions">
|
||||||
|
<button className="ic-btn ic-btn--secondary ic-btn--sm" onClick={onRender}>재렌더</button>
|
||||||
|
<a className="ic-btn ic-btn--secondary ic-btn--sm" href={instaPackageUrl(slate.id)} download>
|
||||||
|
📦 패키지 다운로드 (10장 + 캡션)
|
||||||
|
</a>
|
||||||
|
<button className="ic-btn ic-btn--danger ic-btn--sm" onClick={onDelete}>삭제</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지 이미지 스트립 (캐러셀: chevron + indicator + ←/→ 키보드) */}
|
||||||
|
{(slate.status === 'rendered' || slate.status === 'sent') ? (
|
||||||
|
<PagesStrip slateId={slate.id} pageCount={pageCount} />
|
||||||
|
) : (
|
||||||
|
<div className="ic-empty" style={{ padding: '20px 0' }}>
|
||||||
|
{slate.status === 'failed' ? '렌더 실패 — 재렌더를 시도하세요.' : '렌더링 전입니다.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 캡션 */}
|
||||||
|
{slate.suggested_caption && (
|
||||||
|
<div className="ic-caption-box">
|
||||||
|
<div className="ic-caption-box__label">
|
||||||
|
캡션
|
||||||
|
<button
|
||||||
|
className="ic-btn ic-btn--secondary ic-btn--sm"
|
||||||
|
style={{ marginLeft: 8 }}
|
||||||
|
onClick={copyCaption}
|
||||||
|
>
|
||||||
|
복사
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="ic-caption-text">{slate.suggested_caption}</div>
|
||||||
|
{slate.hashtags?.length > 0 && (
|
||||||
|
<div className="ic-hashtags" style={{ marginTop: 8 }}>
|
||||||
|
{slate.hashtags.join(' ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 커버 카피 (1/10) */}
|
||||||
|
{slate.cover_copy && typeof slate.cover_copy === 'object' && (
|
||||||
|
<div className="ic-caption-box">
|
||||||
|
<div className="ic-caption-box__label">🎯 커버 (1/10)</div>
|
||||||
|
<div className="ic-caption-text">
|
||||||
|
<strong>{slate.cover_copy.headline}</strong>
|
||||||
|
{slate.cover_copy.body && (
|
||||||
|
<div style={{ marginTop: 6, opacity: 0.85, whiteSpace: 'pre-wrap' }}>
|
||||||
|
{slate.cover_copy.body}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{slate.cover_copy.accent_color && (
|
||||||
|
<div style={{ marginTop: 6, fontSize: '0.72rem', opacity: 0.5 }}>
|
||||||
|
accent: <code>{slate.cover_copy.accent_color}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 본문 카피 8장 (2~9/10) */}
|
||||||
|
{Array.isArray(slate.body_copies) && slate.body_copies.length > 0 && (
|
||||||
|
<div className="ic-caption-box">
|
||||||
|
<div className="ic-caption-box__label">📝 본문 8장 (2~9/10)</div>
|
||||||
|
{slate.body_copies.map((b, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
borderTop: i > 0 ? '1px solid rgba(255,255,255,0.06)' : 'none',
|
||||||
|
padding: '10px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>{i + 2}. {b?.headline || ''}</strong>
|
||||||
|
{b?.body && (
|
||||||
|
<div style={{ marginTop: 4, opacity: 0.85, whiteSpace: 'pre-wrap' }}>
|
||||||
|
{b.body}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CTA 카피 (10/10) */}
|
||||||
|
{slate.cta_copy && typeof slate.cta_copy === 'object' && (
|
||||||
|
<div className="ic-caption-box">
|
||||||
|
<div className="ic-caption-box__label">📣 마무리 (10/10)</div>
|
||||||
|
<div className="ic-caption-text">
|
||||||
|
<strong>{slate.cta_copy.headline}</strong>
|
||||||
|
{slate.cta_copy.body && (
|
||||||
|
<div style={{ marginTop: 6, opacity: 0.85, whiteSpace: 'pre-wrap' }}>
|
||||||
|
{slate.cta_copy.body}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{slate.cta_copy.cta && (
|
||||||
|
<div style={{ marginTop: 8, color: '#ec4899', fontWeight: 700 }}>
|
||||||
|
CTA: {slate.cta_copy.cta}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SlateDetail;
|
||||||
102
src/pages/insta/components/SlatesPanel.jsx
Normal file
102
src/pages/insta/components/SlatesPanel.jsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import StatusBadge from './StatusBadge';
|
||||||
|
import SlateDetail from './SlateDetail';
|
||||||
|
import { fmtDate } from '../instaUtils';
|
||||||
|
import { getInstaSlates, getInstaSlate, getInstaAssetUrl, renderInstaSlate, deleteInstaSlate } from '../../../api';
|
||||||
|
|
||||||
|
/* ══════════════════════ 슬레이트 목록 ══════════════════════════════════ */
|
||||||
|
function SlatesPanel({ selectedId, onSelect }) {
|
||||||
|
const [slates, setSlates] = useState([]);
|
||||||
|
const [detail, setDetail] = useState(null);
|
||||||
|
|
||||||
|
const loadSlates = useCallback(() => {
|
||||||
|
getInstaSlates(50).then((r) => setSlates(r.items || [])).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadSlates(); }, [loadSlates]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedId) { setDetail(null); return; }
|
||||||
|
getInstaSlate(selectedId).then(setDetail).catch(() => setDetail(null));
|
||||||
|
}, [selectedId]);
|
||||||
|
|
||||||
|
function handleSelect(id) {
|
||||||
|
onSelect(id === selectedId ? null : id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id) {
|
||||||
|
if (!confirm('슬레이트를 삭제하시겠습니까?')) return;
|
||||||
|
try {
|
||||||
|
await deleteInstaSlate(id);
|
||||||
|
if (selectedId === id) onSelect(null);
|
||||||
|
loadSlates();
|
||||||
|
} catch (e) {
|
||||||
|
alert('삭제 실패: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRender(id) {
|
||||||
|
try {
|
||||||
|
const res = await renderInstaSlate(id);
|
||||||
|
// Re-render is fire-and-forget from the panel; user can refresh detail
|
||||||
|
alert('재렌더 요청 완료 (task: ' + res.task_id + ')');
|
||||||
|
setTimeout(loadSlates, 3000);
|
||||||
|
} catch (e) {
|
||||||
|
alert('재렌더 실패: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="ic-section">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 14 }}>
|
||||||
|
<p className="ic-section__title" style={{ margin: 0, flex: 1 }}>슬레이트 목록</p>
|
||||||
|
<button className="ic-btn ic-btn--secondary ic-btn--sm" onClick={loadSlates}>↻ 새로고침</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{slates.length === 0 ? (
|
||||||
|
<div className="ic-empty">슬레이트가 없습니다. 카드를 생성해 보세요.</div>
|
||||||
|
) : (
|
||||||
|
<div className="ic-slates-grid">
|
||||||
|
{slates.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className={`ic-slate-card ${selectedId === s.id ? 'ic-slate-card--active' : ''}`}
|
||||||
|
onClick={() => handleSelect(s.id)}
|
||||||
|
>
|
||||||
|
{s.status === 'rendered' || s.status === 'sent' ? (
|
||||||
|
<img
|
||||||
|
className="ic-slate-thumb"
|
||||||
|
src={getInstaAssetUrl(s.id, 1)}
|
||||||
|
alt={s.keyword}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="ic-slate-thumb--placeholder">🎴</div>
|
||||||
|
)}
|
||||||
|
<div className="ic-slate-card__info">
|
||||||
|
<div className="ic-slate-card__kw">{s.keyword}</div>
|
||||||
|
<div className="ic-slate-card__meta">
|
||||||
|
<span className="ic-slate-card__date">{fmtDate(s.created_at)}</span>
|
||||||
|
<StatusBadge status={s.status} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 슬레이트 상세 */}
|
||||||
|
{detail && (
|
||||||
|
<SlateDetail
|
||||||
|
slate={detail}
|
||||||
|
onDelete={() => handleDelete(detail.id)}
|
||||||
|
onRender={() => handleRender(detail.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SlatesPanel;
|
||||||
Reference in New Issue
Block a user