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 {
|
||||
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 (
|
||||
<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'];
|
||||
|
||||
|
||||
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