feat(insta): replace Blog Lab page with Insta cards UI

/blog-lab → /insta route. New InstaCards page consumes insta-lab API
(news/keywords/slates + 10-page card preview + prompt template editor).
25개 blog-marketing API helper 제거, 13개 insta helper 추가.
This commit is contained in:
2026-05-16 02:47:19 +09:00
parent f261a80d52
commit a8e411ec22
8 changed files with 689 additions and 955 deletions

View File

@@ -0,0 +1,102 @@
/* ── InstaCards ──────────────────────────────────────────────────────────── */
.ic { max-width: 1100px; margin: 0 auto; padding: 24px 16px 80px; }
/* 헤더 */
.ic-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.ic-header h1 { font-size: 1.5rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin: 0; }
.ic-status-badges { display: flex; gap: 8px; margin-left: auto; }
.ic-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 99px; background: rgba(236,72,153,.15); color: #ec4899; }
.ic-badge--on { background: rgba(16,185,129,.15); color: #10b981; }
.ic-badge--off { background: rgba(239,68,68,.12); color: #ef4444; }
/* 버튼 공통 */
.ic-btn { padding: 8px 18px; border-radius: 8px; border: none; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all .15s; display: inline-flex; align-items: center; gap: 6px; }
.ic-btn--primary { background: #ec4899; color: #fff; }
.ic-btn--primary:hover { background: #db2777; }
.ic-btn--primary:disabled { opacity: .5; cursor: not-allowed; }
.ic-btn--secondary { background: rgba(255,255,255,.08); color: rgba(255,255,255,.7); }
.ic-btn--secondary:hover { background: rgba(255,255,255,.12); }
.ic-btn--secondary:disabled { opacity: .5; cursor: not-allowed; }
.ic-btn--danger { background: rgba(239,68,68,.15); color: #ef4444; }
.ic-btn--danger:hover { background: rgba(239,68,68,.25); }
.ic-btn--sm { padding: 4px 10px; font-size: 0.75rem; }
.ic-spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: ic-spin .6s linear infinite; display: inline-block; }
@keyframes ic-spin { to { transform: rotate(360deg); } }
/* 레이아웃: 모바일 1컬럼, 데스크탑 2컬럼 */
.ic-layout { display: grid; grid-template-columns: 1fr; gap: 20px; }
@media (min-width: 768px) {
.ic-layout { grid-template-columns: 320px 1fr; }
}
/* 섹션 카드 */
.ic-section { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; }
.ic-section__title { font-size: 0.85rem; font-weight: 700; color: rgba(255,255,255,.6); text-transform: uppercase; letter-spacing: .05em; margin: 0 0 14px; }
/* 트리거 패널 */
.ic-trigger-buttons { display: flex; flex-direction: column; gap: 10px; }
.ic-task-status { margin-top: 10px; padding: 10px 12px; background: rgba(255,255,255,.03); border-radius: 8px; font-size: 0.8rem; }
.ic-task-status__label { color: rgba(255,255,255,.4); margin-bottom: 4px; }
.ic-task-status__msg { color: var(--text-primary, #e4e4e7); }
.ic-task-status__progress { margin-top: 6px; height: 3px; background: rgba(255,255,255,.08); border-radius: 2px; }
.ic-task-status__fill { height: 100%; background: #ec4899; border-radius: 2px; transition: width .3s; }
/* 카테고리 필터 */
.ic-filter { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.ic-filter-btn { padding: 4px 12px; border-radius: 99px; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.04); color: rgba(255,255,255,.5); font-size: 0.75rem; cursor: pointer; transition: all .15s; }
.ic-filter-btn--active { background: rgba(236,72,153,.18); border-color: #ec4899; color: #ec4899; }
/* 키워드 목록 */
.ic-keywords { display: flex; flex-direction: column; gap: 8px; }
.ic-keyword-row { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: rgba(255,255,255,.03); border-radius: 8px; }
.ic-keyword-row__kw { font-size: 0.9rem; font-weight: 600; color: var(--text-primary, #e4e4e7); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ic-keyword-row__meta { font-size: 0.72rem; color: rgba(255,255,255,.35); white-space: nowrap; }
.ic-keyword-row__score { font-size: 0.75rem; font-weight: 700; color: #ec4899; min-width: 36px; text-align: right; }
/* 슬레이트 그리드 */
.ic-slates-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
.ic-slate-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 10px; overflow: hidden; cursor: pointer; transition: border-color .15s; }
.ic-slate-card:hover { border-color: rgba(236,72,153,.4); }
.ic-slate-card--active { border-color: #ec4899; }
.ic-slate-thumb { width: 100%; aspect-ratio: 4/5; object-fit: cover; background: rgba(255,255,255,.06); display: block; }
.ic-slate-thumb--placeholder { width: 100%; aspect-ratio: 4/5; background: rgba(255,255,255,.04); display: flex; align-items: center; justify-content: center; font-size: 1.8rem; }
.ic-slate-card__info { padding: 8px; }
.ic-slate-card__kw { font-size: 0.78rem; font-weight: 600; color: var(--text-primary, #e4e4e7); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ic-slate-card__meta { display: flex; align-items: center; justify-content: space-between; margin-top: 4px; }
.ic-slate-card__date { font-size: 0.65rem; color: rgba(255,255,255,.3); }
/* 상태 뱃지 */
.ic-status-badge { font-size: 0.65rem; padding: 1px 6px; border-radius: 99px; font-weight: 600; }
.ic-status-badge--draft { background: rgba(161,161,170,.15); color: #a1a1aa; }
.ic-status-badge--rendered { background: rgba(96,165,250,.15); color: #60a5fa; }
.ic-status-badge--sent { background: rgba(16,185,129,.15); color: #10b981; }
.ic-status-badge--failed { background: rgba(239,68,68,.12); color: #ef4444; }
/* 슬레이트 상세 패널 */
.ic-detail { margin-top: 20px; padding: 16px; background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; }
.ic-detail__header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
.ic-detail__title { font-size: 1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); flex: 1; }
.ic-detail__actions { display: flex; gap: 8px; }
.ic-pages-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 8px; margin-bottom: 14px; scroll-snap-type: x mandatory; }
.ic-page-img { width: 120px; flex-shrink: 0; aspect-ratio: 4/5; border-radius: 6px; object-fit: cover; scroll-snap-align: start; border: 1px solid rgba(255,255,255,.08); background: rgba(255,255,255,.04); }
.ic-caption-box { background: rgba(255,255,255,.03); border-radius: 8px; padding: 12px; margin-bottom: 10px; }
.ic-caption-box__label { font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,.4); text-transform: uppercase; margin-bottom: 6px; }
.ic-caption-text { font-size: 0.85rem; color: var(--text-primary, #e4e4e7); line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
.ic-hashtags { font-size: 0.8rem; color: #60a5fa; line-height: 1.8; word-break: break-all; }
/* 프롬프트 에디터 */
.ic-prompt-editor { margin-top: 20px; }
.ic-prompt-editor__title { font-size: 0.85rem; font-weight: 700; color: rgba(255,255,255,.5); margin-bottom: 12px; text-transform: uppercase; }
.ic-prompt-block { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 10px; padding: 14px; margin-bottom: 12px; }
.ic-prompt-block__head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.ic-prompt-block__name { font-size: 0.8rem; font-weight: 700; color: rgba(255,255,255,.7); flex: 1; }
.ic-prompt-block__date { font-size: 0.68rem; color: rgba(255,255,255,.3); }
.ic-prompt-textarea { width: 100%; min-height: 140px; background: rgba(0,0,0,.3); border: 1px solid rgba(255,255,255,.1); border-radius: 6px; color: var(--text-primary, #e4e4e7); font-size: 0.8rem; font-family: monospace; line-height: 1.5; padding: 10px; resize: vertical; box-sizing: border-box; outline: none; }
.ic-prompt-textarea:focus { border-color: #ec4899; }
.ic-prompt-save-row { display: flex; justify-content: flex-end; margin-top: 8px; }
/* 빈 상태 */
.ic-empty { text-align: center; padding: 40px 20px; color: rgba(255,255,255,.3); font-size: 0.85rem; }

View File

@@ -0,0 +1,525 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import PullToRefresh from '../../components/PullToRefresh';
import {
getInstaStatus,
instaCollectNews,
instaExtractKeywords,
getInstaKeywords,
createInstaSlate,
getInstaSlates,
getInstaSlate,
renderInstaSlate,
deleteInstaSlate,
getInstaAssetUrl,
getInstaTask,
getInstaPrompt,
putInstaPrompt,
} 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 (
<span className={`ic-status-badge ic-status-badge--${status || 'draft'}`}>
{status || 'draft'}
</span>
);
}
/* ────────────────────── 폴링 훅 ────────────────────── */
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 (
<div className="ic-task-status">
<div className="ic-task-status__label">
{task.status === 'succeeded' ? '완료' : task.status === 'failed' ? '실패' : '진행 중'}
</div>
<div className="ic-task-status__msg">{task.message || task.error || ''}</div>
<div className="ic-task-status__progress">
<div className="ic-task-status__fill" style={{ width: `${pct}%` }} />
</div>
</div>
);
}
/* ══════════════════════════════════════════════════════════════════════════ */
export default function InstaCards() {
const [status, setStatus] = useState(null);
const [selectedSlateId, setSelectedSlateId] = useState(null);
const loadStatus = useCallback(() => {
return getInstaStatus().then(setStatus).catch(() => {});
}, []);
useEffect(() => {
loadStatus();
}, [loadStatus]);
return (
<PullToRefresh onRefresh={loadStatus}>
<div className="ic">
{/* 헤더 + 상태 배너 */}
<header className="ic-header">
<h1>Insta Cards</h1>
{status && (
<div className="ic-status-badges">
<span className={`ic-badge ${status.naver_api ? 'ic-badge--on' : 'ic-badge--off'}`}>
Naver {status.naver_api ? 'ON' : 'OFF'}
</span>
<span className={`ic-badge ${status.anthropic_api ? 'ic-badge--on' : 'ic-badge--off'}`}>
AI {status.anthropic_api ? 'ON' : 'OFF'}
</span>
</div>
)}
</header>
<div className="ic-layout">
{/* 왼쪽: 트리거 + 키워드 */}
<div>
<TriggerPanel />
<div style={{ height: 16 }} />
<KeywordsPanel onCreateSlate={() => setSelectedSlateId(null)} />
</div>
{/* 오른쪽: 슬레이트 목록 + 상세 */}
<div>
<SlatesPanel
selectedId={selectedSlateId}
onSelect={setSelectedSlateId}
/>
</div>
</div>
<PromptTemplatesEditor />
</div>
</PullToRefresh>
);
}
/* ══════════════════════ 트리거 패널 ══════════════════════════════════════ */
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'];
function KeywordsPanel({ onCreateSlate }) {
const [category, setCategory] = useState('전체');
const [keywords, setKeywords] = useState([]);
const [creating, setCreating] = useState(null); // keyword_id being created
const slatePoll = usePollTask((t) => {
if (t.status === 'succeeded') onCreateSlate?.();
setCreating(null);
});
const load = useCallback(() => {
const cat = category === '전체' ? undefined : category;
getInstaKeywords({ category: cat }).then((r) => setKeywords(r.items || [])).catch(() => {});
}, [category]);
useEffect(() => { load(); }, [load]);
async function handleCreate(kw) {
if (creating) return;
setCreating(kw.id);
try {
const res = await createInstaSlate({
keyword: kw.keyword,
category: kw.category,
keyword_id: kw.id,
});
slatePoll.start(res.task_id);
} catch (e) {
alert('카드 생성 실패: ' + e.message);
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>
{slatePoll.task && <TaskStatusBox task={slatePoll.task} />}
{keywords.length === 0 ? (
<div className="ic-empty">키워드가 없습니다. 키워드 추출을 실행하세요.</div>
) : (
<div className="ic-keywords">
{keywords.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>
)}
</div>
);
}
/* ══════════════════════ 슬레이트 목록 ══════════════════════════════════ */
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>
);
}
/* ══════════════════════ 슬레이트 상세 ══════════════════════════════════ */
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>
<button className="ic-btn ic-btn--danger ic-btn--sm" onClick={onDelete}>삭제</button>
</div>
</div>
{/* 페이지 이미지 스트립 */}
{(slate.status === 'rendered' || slate.status === 'sent') ? (
<div className="ic-pages-strip">
{Array.from({ length: pageCount }, (_, i) => i + 1).map((page) => (
<img
key={page}
className="ic-page-img"
src={getInstaAssetUrl(slate.id, page)}
alt={`Page ${page}`}
loading="lazy"
/>
))}
</div>
) : (
<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>
)}
{/* 커버 카피 / 바디 카피 */}
{slate.cover_copy && (
<div className="ic-caption-box">
<div className="ic-caption-box__label">커버 카피</div>
<div className="ic-caption-text">{slate.cover_copy}</div>
</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>
);
}