/* ============================================================
Episode browse · detail (persona selector) · submit · history
============================================================ */
const { useState: useSE, useMemo: useME, useEffect: useEE } = React;
/* selection helpers — selection = [{episodeId, personaLabel}] */
const selHas = (sel, id) => sel.some((s) => s.episodeId === id);
const selPersonaLabel = (sel, id) => (sel.find((s) => s.episodeId === id) || {}).personaLabel || null;
/* an episode's persona objects: prefer personas[] (4 fields), fall back to legacy persona1/2/3 */
const epPersonas = (ep) => {
const arr = Array.isArray(ep && ep.personas) ? ep.personas : [];
const fromNew = arr.filter((p) => p && String(p.title || '').trim());
if (fromNew.length) return fromNew.map((p) => ({ title: String(p.title).trim(), description: p.description || '', buildup: p.buildup || '', solution: p.solution || '' }));
return [ep && ep.persona1, ep && ep.persona2, ep && ep.persona3]
.filter((x) => x && String(x).trim())
.map((t) => ({ title: String(t).trim(), description: '', buildup: '', solution: '' }));
};
/* just the labels (titles) — used where only the name is needed */
const epPersonaOptions = (ep) => epPersonas(ep).map((p) => p.title);
/* ---- card list controls (shared by ② 카드 관리 + 라이브러리 피커) ---- */
// Split a free-text 설명 into bullet lines for card display. Honors explicit
// line breaks and bullet chars; otherwise breaks on sentence ends (다././?/!),
// so a single paragraph reads as scannable bullets. 문장 끝+공백을 개행으로 치환 후
// 분리 — 정규식 lookbehind는 구형 Safari/WebView에서 모듈 로드를 깨뜨려 쓰지 않는다.
const descBullets = (text) =>
String(text || '')
.split(/\n+/)
.flatMap((line) => line.replace(/([.!?。])\s+/g, '$1\n').split('\n'))
.map((s) => s.replace(/^[\s•·\-–—]+/, '').trim())
.filter(Boolean);
// 설명을 "설명" 라벨 + 불릿 리스트로 — 카드 본문(EpisodeCard·라이브러리 피커 공용).
// 불릿이 하나도 없으면(빈/공백뿐 설명) 라벨까지 통째로 미렌더.
const EpDescBlock = ({ text }) => {
const items = descBullets(text);
return items.length === 0 ? null : (
설명
{items.map((b, i) => {b} )}
);
};
// how many themes a card is placed in (사용 유무 = placeCount > 0)
const placeCount = (c) => (c.placements || []).length;
// union of every product across the given cards, ko-collated.
// String() guards against non-string product values (products is a JSON column).
const cardProductOptions = (cards) => {
const s = new Set();
(cards || []).forEach((c) => (c.products || []).forEach((p) => p && s.add(p)));
return Array.from(s).sort((a, b) => String(a).localeCompare(String(b), 'ko'));
};
// does a card pass the {q, product, usage} filter? (search over title·설명·텐션·제품·페르소나)
const matchCard = (c, { q, product, usage }) => {
if (product && !(c.products || []).includes(product)) return false;
if (usage === 'used' && placeCount(c) === 0) return false;
if (usage === 'unused' && placeCount(c) > 0) return false;
const needle = (q || '').trim().toLowerCase();
if (!needle) return true;
const hay = [c.title, c.description, c.tension, ...(c.products || []), ...epPersonaOptions(c)]
.filter(Boolean).join(' ').toLowerCase();
return hay.includes(needle);
};
// search box + 제품 select + 전체/사용중/미사용 segment. `children` = extra trailing
// control (e.g. the manager's sort select); picker passes none.
function CardFilterBar({ q, setQ, product, setProduct, productOptions, usage, setUsage, mb = 12, children }) {
return (
setQ(e.target.value)} placeholder="제목·설명·텐션·제품·페르소나 검색" style={{ paddingLeft: 30, paddingRight: q ? 28 : 12, width: '100%' }} />
{q && setQ('')} title="검색 지우기" style={{ position: 'absolute', right: 4, top: '50%', transform: 'translateY(-50%)', width: 22, height: 22 }}> }
setProduct(e.target.value)} style={{ flex: '0 0 auto' }}>
전체 제품
{productOptions.map((p) => {p} )}
setUsage('all')}>전체
setUsage('used')}>등록됨
setUsage('unused')}>미등록
{children}
);
}
// "조건에 맞는 카드가 없습니다" + 필터 초기화 — shown when a filter hides everything.
function CardFilterEmpty({ onReset }) {
return } title="조건에 맞는 카드가 없습니다" desc="검색어나 필터를 바꿔보세요."
action={필터 초기화 } />;
}
/* ============================================================
EPISODE BROWSE (view / select / submit — no authoring)
============================================================ */
function EpisodeBrowse({ project, month, user, navigate, toast }) {
const { loading, error, data, reload } = useAsync(() => window.api.getContent(project.id), [project.id]);
// month tab state — keyed by month.id; guard so tab switching works after reload
const [curMonthId, setCurMonthId] = useSE(null);
useEE(() => {
if (data && (!curMonthId || !data.months.some((m) => m.id === curMonthId))) {
setCurMonthId(data.months[0]?.id ?? null);
}
}, [data]);
const [sel, setSel] = useSE([]);
// session-only persona choices for not-yet-selected episodes: shown (blue) on
// the card without selecting/submitting the episode. Carried into the
// selection if/when the episode is later checked.
const [personaDraft, setPersonaDraft] = useSE({});
const [detail, setDetail] = useSE(null); // episode being viewed
const [submitOpen, setSubmitOpen] = useSE(false);
const [submitting, setSubmitting] = useSE(false);
useEE(() => { if (data) setSel(data.currentSelection || []); }, [data]);
// per-month quota
const cm = data ? data.months.find((m) => m.id === curMonthId) : null;
const monthCap = cm ? (cm.maxSelections || 0) : 0;
// build episode→monthId map from themes
const epMonth = useME(() => {
const map = {};
if (data) data.themes.forEach((t) => t.episodes.forEach((ep) => { map[ep.id] = t.monthId; }));
return map;
}, [data]);
const monthSelCount = sel.filter((s) => epMonth[s.episodeId] === curMonthId).length;
const count = sel.length;
const allPersona = sel.every((s) => s.personaLabel);
const persist = (next) => { setSel(next); window.api.setSelection(project.id, null, next); };
const toggleSelect = (ep) => {
if (ep.status === 'paused') return;
if (selHas(sel, ep.id)) {
persist(sel.filter((s) => s.episodeId !== ep.id));
} else {
// enforce the per-month cap (0 = unlimited); mirrors backend MONTH_QUOTA
if (monthCap > 0 && monthSelCount >= monthCap) {
toast(`${cm?.label ?? '이 달'}은 최대 ${monthCap}개까지 선택할 수 있습니다`);
return;
}
persist([...sel, { episodeId: ep.id, personaLabel: personaDraft[ep.id] ?? null }]);
}
};
const assignPersona = (epId, personaLabel) => {
// Picking a persona never selects the episode — selection goes through
// toggleSelect, which enforces the month cap (mirrors backend MONTH_QUOTA).
// Always remember the choice as a session draft (so it shows on the card);
// if the episode is already selected, also persist it onto the snapshot.
setPersonaDraft((d) => ({ ...d, [epId]: personaLabel }));
if (selHas(sel, epId)) persist(sel.map((s) => (s.episodeId === epId ? { ...s, personaLabel } : s)));
};
const doSubmit = async (note) => {
setSubmitting(true);
await window.api.submit(project.id, null, { items: sel, note });
setSubmitting(false); setSubmitOpen(false);
toast(`에피소드 ${sel.length}개가 제출되었습니다`);
navigate({ name: 'project', projectId: project.id, tab: user.role === 'LGE' ? 'episodes' : 'history' });
};
// flat episode map (for drawer/submit-modal episode lookups)
const epById = useME(() => flatEpisodes(data ? data.themes : []), [data]);
// themes for the selected month tab, showing ONLY 운영가이드 표기-ON episodes
// (status !== 'paused'). Themes left with no visible episode are dropped so the
// LGE guide never shows paused/hidden content; if nothing is left the month
// renders the empty-state notice below.
const visibleThemes = data
? data.themes
.filter((t) => t.monthId === curMonthId)
.map((t) => ({ ...t, episodes: t.episodes.filter((ep) => ep.status !== 'paused') }))
.filter((t) => t.episodes.length > 0)
: [];
return (
{/* sticky submit bar — hidden when the selected month has no 표기-ON content */}
{visibleThemes.length > 0 && (
{monthCap > 0
?
:
}
에피소드 선택
{monthCap > 0
?
= 1 ? 'ok' : 'under'}`}>
에피소드를 {monthCap}개 선택해주세요. (현재 {monthSelCount}개)
:
= 1 ? 'ok' : 'under'}`}>
{count}개 선택됨{count >= 1 ? ' · 제출 가능' : ' · 1개 이상 선택'}
}
{!allPersona && count >= 1 && 페르소나 미지정 {sel.filter((s) => !s.personaLabel).length}개 }
{user.role !== 'LGE' && } className="hide-mobile" onClick={() => navigate({ name: 'project', projectId: project.id, tab: 'history' })}>제출 히스토리}
} disabled={count < 1} onClick={() => setSubmitOpen(true)}>
제출{count > 0 ? ` (${count})` : ''}
)}
{/* Month tabs */}
{!loading && !error && data && data.months.length > 0 && (
{data.months.map((m) => (
setCurMonthId(m.id)}>{m.label}
))}
)}
{loading && [0, 1].map((i) => (
))}
{error &&
}
{!loading && !error && visibleThemes.length === 0 && (
} title="표시할 콘텐츠가 없습니다"
desc="이 달에 운영가이드 표기(ON)된 에피소드가 아직 없습니다." />
)}
{!loading && !error && visibleThemes.map((t, ti) => (
Theme {String(ti + 1).padStart(2, '0')}
{t.episodes.map((ep, ei) => {
const ec = ep;
return (
toggleSelect(ec)} onOpen={() => setDetail({ ...ec, themeId: t.id, themeName: t.name })} />
);
})}
))}
{/* DETAIL DRAWER — no onEdit affordance */}
{detail && (
setDetail(null)} onToggleSelect={() => toggleSelect(detail)}
onAssignPersona={(label) => assignPersona(detail.id, label)}
onEdit={null} navigate={navigate} />
)}
{/* SUBMIT MODAL */}
{submitOpen && (
setSubmitOpen(false)} onConfirm={doSubmit} />
)}
);
}
/* ============================================================
CONTENT DETAIL (month tabs / themes / pool / edit modals —
extracted from EpisodeAdmin so EpisodeAdmin stays a thin shell)
============================================================ */
function ContentDetail({ projectId, user, toast }) {
const { loading, error, data, reload } = useAsync(
() => projectId ? window.api.getContent(projectId) : Promise.resolve(null),
[projectId]
);
// month tab state — keyed by month.id; guard so tab switching works after reload
const [curMonthId, setCurMonthId] = useSE(null);
useEE(() => {
if (data && (!curMonthId || !data.months.some((m) => m.id === curMonthId))) {
setCurMonthId(data.months[0]?.id ?? null);
}
}, [data]);
const [editEp, setEditEp] = useSE(null); // {themeId, episode} — edit an already-placed card
const [pickTheme, setPickTheme] = useSE(null); // {theme} — library picker target
const [editTheme, setEditTheme] = useSE(null); // {theme|null, defaultMonthId?}
const [editMonth, setEditMonth] = useSE(null); // {month|null} for month create/edit
const [confirm, setConfirm] = useSE(null);
const [importRef, setImportRef] = useSE(null); // file input ref
/* CNX mutations */
// Editing a placed card edits the shared library card (reflected everywhere it's linked).
const saveEpisode = async (themeId, body) => {
await window.api.updateEpisode(body.id, {
title: body.title, description: body.description,
tension: body.tension, products: body.products, voc: body.voc, rtb: body.rtb, output: body.output,
personas: body.personas,
});
setEditEp(null); toast('에피소드가 수정되었습니다'); reload();
};
const epStatus = async (ep, status) => { await window.api.updateEpisode(ep.id, { status }); toast(status === 'paused' ? '에피소드 일시중지됨' : '에피소드 활성화됨'); reload(); };
// Removing a card from a theme UNLINKS it (the card stays in the library).
const unlinkEpisode = async () => { const { themeId, episode } = confirm.ep; setConfirm(null); await window.api.removePlacement(episode.id, themeId); toast('테마에서 제거됨'); reload(); };
const linkEpisodes = async (themeId, episodeIds) => {
for (const eid of episodeIds) await window.api.addPlacement(eid, themeId);
setPickTheme(null); toast(`${episodeIds.length}개 카드가 추가되었습니다`); reload();
};
const saveTheme = async (body) => {
if (body.id) await window.api.updateTheme(body.id, body);
else await window.api.createTheme(projectId, body);
setEditTheme(null); toast(body.id ? '테마 수정됨' : '테마 추가됨'); reload();
};
const delTheme = async () => { const t = confirm.theme; setConfirm(null); await window.api.deleteTheme(t.id); toast('테마 삭제됨'); reload(); };
const saveMonth = async (body) => {
try {
if (body.id) {
await window.api.updateMonth(body.id, { label: body.label, maxSelections: body.maxSelections, sortOrder: body.sortOrder });
toast('월 수정됨');
} else {
const created = await window.api.createMonth(projectId, { label: body.label, maxSelections: body.maxSelections, sortOrder: body.sortOrder ?? 0 });
if (created && created.id) setCurMonthId(created.id);
toast('월 추가됨');
}
setEditMonth(null); reload();
} catch (ex) { toast('오류: ' + ex.message); }
};
const delMonth = async () => {
const m = confirm.month; setConfirm(null);
try { await window.api.deleteMonth(m.id); toast('월 삭제됨'); reload(); }
catch (ex) { toast('오류: ' + ex.message); }
};
const handleImport = async (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
e.target.value = '';
try {
const r = await window.api.importEpisodes(file);
toast(`${r.imported}개 생성` + (r.skipped && r.skipped.length ? `, ${r.skipped.length}개 스킵` : ''));
reload();
} catch (ex) { toast('오류: ' + ex.message); }
};
const visibleThemes = data ? data.themes.filter((t) => t.monthId === curMonthId) : [];
return (
{/* Month tabs + 월 추가/편집 */}
{!loading && !error && data && (
{data.months.length === 0 && 월 없음 — 아래에서 추가하세요 }
{data.months.map((m) => (
setCurMonthId(m.id)}>{m.label}{m.maxSelections > 0 ? ` (최대 ${m.maxSelections})` : ''}
))}
{curMonthId && (() => {
const curMonth = data.months.find((m) => m.id === curMonthId);
return curMonth ? (
<>
setEditMonth({ month: curMonth })}>
setConfirm({ month: curMonth })}>
>
) : null;
})()}
} onClick={() => setEditMonth({ month: null })}>월 추가
)}
{/* CNX toolbar: add-theme + import (only when a month is selected) */}
{!loading && !error && data && curMonthId && (
)}
{loading && [0, 1].map((i) => (
))}
{error &&
}
{/* No month state */}
{!loading && !error && data && data.months.length === 0 && (
} title="월이 없습니다"
desc="먼저 월을 추가하고, 테마와 에피소드를 등록하세요."
action={
} onClick={() => setEditMonth({ month: null })}>월 추가} />
)}
{!loading && !error && data && curMonthId && visibleThemes.length === 0 && data.months.length > 0 && (
} title="등록된 테마가 없습니다"
desc="테마를 추가하고 에피소드를 등록하세요."
action={
} onClick={() => setEditTheme({ theme: null, defaultMonthId: curMonthId })}>테마 추가} />
)}
{!loading && !error && visibleThemes.map((t, ti) => (
Theme {String(ti + 1).padStart(2, '0')}
} onClick={() => setPickTheme({ theme: t })}>에피소드 추가
}>
setEditTheme({ theme: t })}> 테마 수정
setConfirm({ theme: t })}> 테마 삭제
{t.episodes.map((ep, ei) => {
const ec = ep;
return (
{}} onOpen={() => {}}
onEdit={() => setEditEp({ themeId: t.id, episode: ep })}
onPause={() => epStatus(ec, ec.status === 'paused' ? 'active' : 'paused')}
onDelete={() => setConfirm({ ep: { themeId: t.id, episode: ec } })} />
);
})}
{t.episodes.length === 0 && 에피소드가 없습니다.
}
))}
{/* EDIT MODALS */}
{editEp &&
setEditEp(null)} onSave={(body) => saveEpisode(editEp.themeId, body)} />}
{pickTheme && (
e.id)}
onClose={() => setPickTheme(null)}
onConfirm={(ids) => linkEpisodes(pickTheme.theme.id, ids)} />
)}
{editTheme && (
setEditTheme(null)}
onSave={saveTheme} />
)}
{editMonth && setEditMonth(null)} onSave={saveMonth} />}
{confirm && (
setConfirm(null)}
footer={<> setConfirm(null)}>취소 } onClick={confirm.month ? delMonth : confirm.theme ? delTheme : unlinkEpisode}>{confirm.ep ? '제거' : '삭제'}>}>
{confirm.month ? '이 월과 포함된 모든 테마가 삭제됩니다.' : confirm.theme ? '이 테마와 포함된 모든 배치가 해제됩니다.' : '이 카드를 테마에서 제거합니다. 카드는 라이브러리에 남습니다.'}
)}
);
}
/* ============================================================
LIBRARY PICKER (① 테마에 라이브러리 카드를 링크로 추가)
============================================================ */
function LibraryPickerModal({ theme, linkedIds, onClose, onConfirm }) {
const { loading, error, data, reload } = useAsync(() => window.api.getEpisodes(), []);
const [picked, setPicked] = useSE([]); // episodeId[]
const [busy, setBusy] = useSE(false);
// list controls — mirror ② 카드 관리: search · product · usage
const [q, setQ] = useSE('');
const [product, setProduct] = useSE('');
const [usage, setUsage] = useSE('all'); // 'all' | 'used' | 'unused'
const available = useME(() => { const linked = new Set(linkedIds || []); return (data || []).filter((e) => !linked.has(e.id)); }, [data, linkedIds]);
const productOptions = useME(() => cardProductOptions(available), [available]);
const shown = useME(() => available.filter((c) => matchCard(c, { q, product, usage })), [available, q, product, usage]);
const filtered = !!(q.trim() || product || usage !== 'all');
const resetFilters = () => { setQ(''); setProduct(''); setUsage('all'); };
const toggle = (id) => setPicked((p) => (p.includes(id) ? p.filter((x) => x !== id) : [...p, id]));
return (
취소 } onClick={async () => { setBusy(true); try { await onConfirm(picked); } finally { setBusy(false); } }}>{picked.length > 0 ? `${picked.length}개 추가` : '추가'}>}>
{loading && {[0,1,2].map((i) => )}
}
{error && }
{!loading && !error && available.length === 0 && (
} title="추가할 카드가 없습니다" desc="라이브러리의 모든 카드가 이미 이 테마에 배치되어 있거나, 라이브러리가 비어 있습니다. ② 에피소드 카드 관리에서 카드를 추가하세요." />
)}
{!loading && !error && available.length > 0 && (
<>
{filtered ? `${shown.length} / ${available.length}개 표시` : `${available.length}개 추가 가능`} · 선택 {picked.length}개
{shown.length === 0
?
: (
{shown.map((ec) => {
const on = picked.includes(ec.id);
return (
toggle(ec.id)}>
{ e.stopPropagation(); toggle(ec.id); }} aria-label="선택">
{ec.title}
{(ec.products || []).length > 0 &&
{(ec.products || []).map((p, j) => {p} )}
}
);
})}
)}
>
)}
);
}
/* ============================================================
CONTENT CARD (single card; lazy-loads its own counts)
============================================================ */
function ContentCard({ project, onOpen, onEdit, onDelete }) {
const { data } = useAsync(() => window.api.getContent(project.id), [project.id]);
const monthsN = data ? data.months.length : null;
const themesN = data ? data.themes.length : null;
const epN = data ? data.themes.reduce((a, t) => a + ((t.episodes && t.episodes.length) || 0), 0) : null;
// Use periodStart — badge is start-based (due date is optional)
const hasPeriod = !!project.periodStart;
const fmtDate = (d) => d ? String(d).slice(0, 10) : '';
const periodTxt = hasPeriod
? `${fmtDate(project.periodStart)}${project.periodEnd ? ` – ${fmtDate(project.periodEnd)}` : ' ~ 미정'}`
: '기간 미설정';
const isLive = project.status === 'active';
const Badge = ({ on, children }) => (
{children}
);
return (
{project.name}
{isLive && 운영가이드 노출중 }
{periodTxt}
{hasPeriod ? '기간 설정됨' : '기간 미설정'}
0}>월 {monthsN == null ? '·' : monthsN}
0}>테마 {themesN == null ? '·' : themesN}
0}>에피소드 {epN == null ? '·' : epN}
{(onEdit || onDelete) && (
{onEdit && (
{ e.stopPropagation(); onEdit(project); }}
style={{ width: 28, height: 28, borderRadius: 6, border: '1px solid var(--line, #e2e4e8)', background: 'var(--surface, #fff)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--ink-3, #8a9098)' }}>
)}
{onDelete && (
{ e.stopPropagation(); onDelete(project); }}
style={{ width: 28, height: 28, borderRadius: 6, border: '1px solid var(--line, #e2e4e8)', background: 'var(--surface, #fff)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--ink-3, #8a9098)' }}>
)}
)}
);
}
/* ============================================================
CONTENT GRID (project card grid + 콘텐츠 추가)
============================================================ */
function ContentGrid({ onOpen, toast }) {
const projects = useAsync(() => window.api.getProjects(), []);
const [createProject, setCreateProject] = useSE(false);
const [editProject, setEditProject] = useSE(null);
const [delProject, setDelProject] = useSE(null);
const [delBusy, setDelBusy] = useSE(false);
return (
{projects.loading &&
{[0,1,2].map((i) => )}
}
{!projects.loading && (
{(projects.data || []).map((p) => onOpen(p.id)} onEdit={(p) => setEditProject(p)} onDelete={(p) => setDelProject(p)} />)}
setCreateProject(true)} style={{ border: '1px dashed var(--line, #e2e4e8)', borderRadius: 12, background: 'transparent', color: 'var(--accent, #16181d)', fontWeight: 600, cursor: 'pointer', minHeight: 120 }}>+ 콘텐츠 추가
)}
{createProject &&
setCreateProject(false)} onSave={async (body) => {
try { const p = await window.api.createProject(body); await projects.reload(); setCreateProject(false); toast('콘텐츠가 추가되었습니다'); if (p && p.id) onOpen(p.id); }
catch (ex) { toast('오류: ' + ex.message); }
}} />}
{editProject && setEditProject(null)} onSave={async (body) => {
try {
const visChanged = (editProject.status === 'active') !== body.visible;
await window.api.updateProject(editProject.id, { name: body.name, description: body.description, periodStart: body.periodStart, periodEnd: body.periodEnd });
await window.api.setProjectVisible(editProject.id, body.visible);
await projects.reload(); setEditProject(null); toast('콘텐츠가 수정되었습니다');
// 운영중(표기) 변경은 운영가이드 뷰어의 프로젝트 선택(app.jsx projQ, [user] 캐시)에
// 영향을 주므로 하드 리프레시로 바로 반영시킨다 (토스트가 잠깐 보인 뒤 리로드).
if (visChanged) setTimeout(() => window.location.reload(), 500);
} catch (ex) { toast('오류: ' + ex.message); }
}} />}
{delProject && (
setDelProject(null)}
footer={<> setDelProject(null)}>취소 } onClick={async () => {
setDelBusy(true);
try { await window.api.deleteProject(delProject.id); await projects.reload(); setDelProject(null); toast('콘텐츠가 삭제되었습니다'); }
catch (ex) { toast('오류: ' + ex.message); }
finally { setDelBusy(false); }
}}>삭제>}>
이 콘텐츠와 포함된 월·테마·배치가 삭제됩니다. (에피소드 카드 자체는 라이브러리에 남습니다.)
)}
);
}
/* Where a library card is used (placements) — shown on each manager card.
placement = {themeId, themeName, projectId, projectName, monthId, monthLabel} */
function PlacementInfo({ placements }) {
const list = placements || [];
// faint hairline above; compact "등록됨" label; 콘텐츠/테마 on separate label-rows
const hdr = { fontSize: 10, fontWeight: 800, letterSpacing: '.04em', textTransform: 'uppercase' };
return (
{list.length === 0
?
미등록
: (
<>
등록됨{list.length > 1 ? ` · ${list.length}곳` : ''}
{list.map((p, i) => (
콘텐츠 {p.projectName}
테마 {p.monthLabel ? `${p.monthLabel} ` : ''}{p.themeName}
))}
>
)}
);
}
/* ============================================================
EPISODE CARD MANAGER (② 에피소드 카드 관리 — global card pool)
============================================================ */
function EpisodeCardManager({ user, toast }) {
const { loading, error, data, reload } = useAsync(() => window.api.getEpisodes(), []);
const [editEp, setEditEp] = useSE(null); // { episode: ep|null }
const [confirm, setConfirm] = useSE(null); // { episode }
const [importRef, setImportRef] = useSE(null);
// list controls: text search · product filter · usage filter · sort
const [q, setQ] = useSE('');
const [product, setProduct] = useSE(''); // '' = 전체 제품
const [usage, setUsage] = useSE('all'); // 'all' | 'used' | 'unused'
const [sort, setSort] = useSE('default'); // 'default' | 'title' | 'usage'
const allCards = data || [];
const productOptions = useME(() => cardProductOptions(allCards), [allCards]);
const usedN = useME(() => allCards.filter((c) => placeCount(c) > 0).length, [allCards]);
const cards = useME(() => {
let out = allCards.filter((c) => matchCard(c, { q, product, usage }));
if (sort === 'title') out = [...out].sort((a, b) => String(a.title).localeCompare(String(b.title), 'ko'));
else if (sort === 'usage') out = [...out].sort((a, b) => placeCount(b) - placeCount(a));
return out;
}, [allCards, q, product, usage, sort]);
const filtered = !!(q.trim() || product || usage !== 'all');
const resetFilters = () => { setQ(''); setProduct(''); setUsage('all'); };
const saveEpisode = async (body) => {
await window.api.updateEpisode(body.id, { title: body.title, description: body.description, tension: body.tension, products: body.products, voc: body.voc, rtb: body.rtb, output: body.output, personas: body.personas });
setEditEp(null); toast('에피소드가 수정되었습니다'); reload();
};
const createCard = async (body) => { await window.api.createEpisode(body); setEditEp(null); toast('카드가 추가되었습니다'); reload(); };
const delEpisode = async () => { const e = confirm.episode; setConfirm(null); await window.api.deleteEpisode(e.id); toast('에피소드 삭제됨'); reload(); };
const handleImport = async (e) => {
const file = e.target.files && e.target.files[0]; if (!file) return; e.target.value = '';
try { const r = await window.api.importEpisodes(file); toast(`${r.imported}개 생성` + (r.skipped && r.skipped.length ? `, ${r.skipped.length}개 스킵` : '')); reload(); }
catch (ex) { toast('오류: ' + ex.message); }
};
return (
{/* search · product · usage · sort */}
{!loading && !error && allCards.length > 0 && (
setSort(e.target.value)} style={{ flex: '0 0 auto' }}>
기본 정렬
제목순
사용 많은순
)}
{loading &&
{[0,1,2].map((i) => )}
}
{error &&
}
{!loading && !error && allCards.length === 0 && (
} title="카드가 없습니다" desc="카드를 추가하거나 엑셀로 업로드하세요." action={
} onClick={() => setEditEp({ episode: null })}>카드 추가} />
)}
{!loading && !error && allCards.length > 0 && cards.length === 0 && (
)}
{!loading && !error && cards.length > 0 && (
{cards.map((ec) => (
{ec.title}
{ec.description &&
{ec.description}
}
{(ec.products || []).map((p, j) => {p} )}
{ec.tension &&
핵심 텐션: {ec.tension}
}
{(ec.voc || ec.rtb || ec.output) && (
{ec.voc &&
VOC {ec.voc}
}
{ec.rtb &&
RTB {ec.rtb}
}
{ec.output &&
아웃풋 {ec.output}
}
)}
} onClick={() => setEditEp({ episode: ec })}>편집
} onClick={() => setConfirm({ episode: ec })}>삭제
))}
)}
{editEp &&
setEditEp(null)} onSave={(body) => (body.id ? saveEpisode(body) : createCard(body))} />}
{confirm && (
setConfirm(null)}
footer={<> setConfirm(null)}>취소 } onClick={delEpisode}>삭제>}>
이 에피소드가 삭제됩니다.
)}
);
}
/* ============================================================
EPISODE ADMIN (CNX / ADMIN only — authoring: create, edit,
delete themes & episodes, Excel import, pool placement)
============================================================ */
function EpisodeAdmin({ user, toast, navigate }) {
const [view, setView] = useSE('contents'); // 'contents' | 'cards'
const [detailId, setDetailId] = useSE(null); // null = grid, id = detail
return (
에피소드 관리
콘텐츠의 월·테마 관리와 라이브러리 카드 배치, 글로벌 에피소드 카드 생성·수정·삭제·엑셀 업로드 — CNX/ADMIN 전용.
{ setView('contents'); setDetailId(null); }}>① 콘텐츠 관리
setView('cards')}>② 에피소드 카드 관리
{view === 'contents' && (detailId
? (
setDetailId(null)} style={{ background: 'none', border: 'none', color: 'var(--accent, #16181d)', cursor: 'pointer', fontSize: 13, fontWeight: 600, marginBottom: 12, padding: 0 }}>← 콘텐츠 목록
)
:
setDetailId(id)} toast={toast} />
)}
{view === 'cards' && }
);
}
// Circled number derived from an episode's position within its theme (①②③…).
// Replaces the old hand-entered `code` so numbering always tracks order.
function epNum(i) { return i < 20 ? String.fromCharCode(0x2460 + i) : `${i + 1}.`; }
function flatEpisodes(themes) { const m = {}; (themes || []).forEach((t) => t.episodes.forEach((e, i) => { m[e.id] = { ...e, code: epNum(i), themeName: t.name }; })); return m; }
/* ---------------- Month tab bar (sub-nav inside a tab) ---------------- */
function MonthTabBar({ project, month, navigate, tab, onSelect }) {
return (
{project.months.map((m) => (
onSelect ? onSelect(m.month) : navigate({ name: 'project', projectId: project.id, tab, month: m.month })}>
{m.label}
{m.isSubmitted && }
))}
);
}
/* personas → compact chip row (only non-empty); shared across card displays */
function PersonaChips({ ep }) {
const list = epPersonaOptions(ep);
if (list.length === 0) return null;
return (
{list.map((p, i) => (
{p}
))}
);
}
/* ---------------- Episode card ---------------- */
function EpisodeCard({ ep, selected, personaLabel, isStaff, onToggle, onOpen, onEdit, onPause, onDelete }) {
const paused = ep.status === 'paused';
return (
{!paused && (
{ e.stopPropagation(); onToggle(); }} aria-label="선택">
)}
{paused &&
}
{paused &&
일시중지
}
{ep.title}
{(ep.products || []).length > 0 &&
{(ep.products || []).map((p, i) => {p} )}
}
{personaLabel
?
{personaLabel}
: (selected
?
페르소나 미지정
:
선택 안 됨 )}
{isStaff
?
e.stopPropagation()}> }>
수정
{paused ? <> 활성화> : <> 일시중지>}
삭제
:
상세 }
);
}
/* ---------------- Episode detail drawer (PERSONA SELECTOR) ---------------- */
function EpisodeDetailDrawer({ episode, project, user, selectedPersonaLabel, isSelected, draftPersonaLabel, onClose, onToggleSelect, onAssignPersona, onEdit, navigate }) {
const isStaff = user.role === 'ADMIN' || user.role === 'CNX';
const personaList = epPersonas(episode); // [{title,description,buildup,solution}]
const [expanded, setExpanded] = useSE(null); // index of expanded persona (펼침 ≠ 선택)
const [refChip, setRefChip] = useSE(null); // product/feature chip whose references are open (레퍼런스 모달)
// Picking a persona never selects the episode — selection goes through the
// card / "이 에피소드 선택" button (which enforces the month cap). For a
// not-yet-selected episode the choice is kept as a session draft and shown
// (blue) on the card; the highlight reads from the selection or that draft.
const activePersona = isSelected ? selectedPersonaLabel : draftPersonaLabel;
return (
{episode.themeName}
{episode.title}
{episode.products.map((p, i) => (
setRefChip(p)} style={{ cursor: 'pointer' }} title="레퍼런스 보기">{p} ›
))}
}
footer={
{isSelected ? (selectedPersonaLabel ? '선택됨 · 페르소나 지정 완료' : '선택됨 · 페르소나 미지정') : (draftPersonaLabel ? '선택 안 됨 · 페르소나 표시만' : '선택되지 않음')}
{episode.status === 'paused'
?
일시중지된 에피소드
: (
{isSelected
? } onClick={onToggleSelect}>선택 해제
: } onClick={onToggleSelect}>이 에피소드 선택}
} onClick={onClose}>확인
)}
}>
{episode.description && (
)}
핵심 텐션
”{episode.tension}”
콘텐츠 상세
VOC {episode.voc}
RTB {episode.rtb}
아웃풋 {episode.output}
{/* PERSONA SELECTOR — choose ONE; click chevron to EXPAND 4 fields (expand ≠ select) */}
페르소나 {isSelected && !selectedPersonaLabel && · 지정 필요 }
{personaList.map((p, i) => {
const open = expanded === i;
const hasDetail = p.description || p.buildup || p.solution;
return (
onAssignPersona(p.title)} aria-label="이 페르소나 선택">
{p.title}
{hasDetail && (
setExpanded(open ? null : i)} aria-label="상세 펼치기">
)}
{open && (
{p.description &&
설명 {p.description}
}
{p.buildup &&
빌드업 {p.buildup}
}
{p.solution &&
LG AI 해결책 {p.solution}
}
)}
);
})}
{personaList.length === 0 &&
이 에피소드에 등록된 페르소나가 없습니다.
}
{!isStaff && personaList.length > 0 &&
에피소드를 선택한 뒤 타깃 페르소나를 1개 지정하세요. 행을 펼치면 설명·빌드업·LG AI 해결책을 볼 수 있습니다.
}
{isStaff && onEdit && (
} onClick={onEdit}>에피소드 내용 편집
)}
{refChip && setRefChip(null)} navigate={navigate} />}
);
}
/* Feature reference popup — click a product/feature chip to see the curated note,
related PDP products and supporting LG Newsroom articles for that feature. */
function FeatureRefModal({ chip, onClose, navigate }) {
const [st, setSt] = useSE({ loading: true, error: null, data: null });
useEE(() => {
let alive = true;
window.api.featureReferences(chip)
.then((data) => { if (alive) setSt({ loading: false, error: null, data }); })
.catch((e) => { if (alive) setSt({ loading: false, error: e.message || '불러오기 실패', data: null }); });
return () => { alive = false; };
}, [chip]);
const d = st.data;
const note = d && d.note;
const hasNote = note && (note.techText || note.problemText || note.solutionText);
const products = (d && d.products) || [];
const articles = (d && d.articles) || [];
const N = 12;
const thS = { textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid var(--line)', fontSize: 11, color: 'var(--ink-3)', fontWeight: 650 };
const tdS = { padding: '6px 8px', borderBottom: '1px solid var(--line)', verticalAlign: 'top' };
return (
닫기}>
이 콘텐츠 기능과 연관된 LG 제품·근거 기사입니다. 콘텐츠 제작 시 출처로 활용하세요.
{st.loading &&
}
{st.error && {st.error}
}
{d && !d.matched && 이 항목과 연결된 레퍼런스가 아직 없습니다.
}
{d && d.matched && (
<>
{hasNote && (
큐레이션
{note.techText} 를 통해 {note.problemText} 문제를 {note.solutionText}(으)로 해결합니다.
)}
연관 제품{products.length ? ` (${products.length})` : ''}
이 기능이 적용된 실제 LG 제품입니다. 행을 누르면 제품 페이지로 이동합니다.
{products.length === 0 &&
없음
}
{products.length > 0 && (
모델명 이름
{products.slice(0, N).map((p) => (
p.url && window.open(p.url, '_blank', 'noopener')}
style={{ cursor: 'pointer' }} title={p.url || ''}>
{p.model}
{(p.title || '').replace(/\s*\|\s*LG\b.*$/i, '')}
))}
)}
{products.length > N && navigate && (
)}
근거 기사 · LG 뉴스룸{articles.length ? ` (${articles.length})` : ''} · 최신순
이 기능을 다룬 LG 뉴스룸 기사입니다. 최신순이며, 누르면 기사로 이동합니다.
{articles.length === 0 &&
없음
}
{articles.slice(0, 10).map((a) => (
))}
{articles.length > 10 && navigate && (
)}
>
)}
);
}
/* Full-list page for a feature's references — reached from the modal's "더보기".
kind='pdp' → all related products, kind='newsroom' → all source articles. */
function RefListScreen({ chip, kind, label, navigate }) {
const [st, setSt] = useSE({ loading: true, error: null, data: null });
useEE(() => {
let alive = true;
window.api.featureReferences(chip)
.then((data) => { if (alive) setSt({ loading: false, error: null, data }); })
.catch((e) => { if (alive) setSt({ loading: false, error: e.message || '불러오기 실패', data: null }); });
return () => { alive = false; };
}, [chip]);
const d = st.data;
const isPdp = kind === 'pdp';
const items = d && d.matched ? (isPdp ? d.products : d.articles) : [];
const thS = { textAlign: 'left', padding: '7px 8px', borderBottom: '1px solid var(--line)', fontSize: 11.5, color: 'var(--ink-3)', fontWeight: 650 };
const tdS = { padding: '7px 8px', borderBottom: '1px solid var(--line)', verticalAlign: 'top' };
return (
} onClick={() => navigate({ name: 'project', tab: 'episodes' })}>돌아가기
{isPdp ? '연관 제품' : '근거 기사 · LG 뉴스룸'} 전체{items.length ? ` (${items.length})` : ''}
매칭 키워드: {label || chip}{isPdp ? ' · 행을 누르면 제품 페이지로 이동합니다.' : ' · 최신순이며, 누르면 기사로 이동합니다.'}
{st.loading &&
}
{st.error &&
{st.error}
}
{d && !d.matched &&
연결된 레퍼런스가 없습니다.
}
{d && d.matched && isPdp && (
모델명 이름
{items.map((p) => (
p.url && window.open(p.url, '_blank', 'noopener')} style={{ cursor: 'pointer' }} title={p.url || ''}>
{p.model}
{(p.title || '').replace(/\s*\|\s*LG\b.*$/i, '')}
))}
)}
{d && d.matched && !isPdp && (
)}
);
}
function PersonaQuickAdd({ onClose, onSave }) {
const [name, setName] = useSE(''); const [segment, setSegment] = useSE(''); const [description, setDescription] = useSE('');
const [busy, setBusy] = useSE(false);
return (
취소 { setBusy(true); await onSave({ name: name.trim(), segment, description }); }}>추가 >}>
이름 setName(e.target.value)} placeholder="예: 효율러 민지" />
세그먼트 setSegment(e.target.value)} placeholder="예: 1인 가구 · 직장인" />
설명
);
}
/* ---------------- Submit modal ---------------- */
function SubmitModal({ sel, episodes, toast, submitting, reSubmit, onAssign, onClose, onConfirm }) {
const [note, setNote] = useSE('');
const missing = sel.filter((s) => !s.personaLabel).length;
// Episodes that COULD have a persona (options exist) but none is chosen.
// Episodes with no registered personas can't be assigned, so they don't block.
const needsPersona = sel.some((s) => !s.personaLabel && epPersonaOptions(episodes[s.episodeId] || {}).length > 0);
const tryConfirm = () => {
if (needsPersona) { toast('페르소나를 지정해주세요'); return; }
onConfirm(note);
};
return (
취소
: } disabled={submitting} onClick={tryConfirm}>
{submitting ? '제출 중…' : reSubmit ? '재제출' : '제출 확정'}
>
}>
{missing > 0 && (
페르소나가 지정되지 않은 에피소드 {missing}개 가 있습니다. 아래에서 지정할 수 있습니다.
)}
{sel.map((s, i) => {
const ep = episodes[s.episodeId];
if (!ep) return null;
const opts = epPersonaOptions(ep);
return (
{i + 1}
{ep.title}
{ep.themeName}
{opts.length === 0
? 등록된 페르소나 없음
: opts.map((label, j) => {
const on = s.personaLabel === label;
return (
onAssign(s.episodeId, on ? null : label)}>
{label}
);
})}
);
})}
메모 (선택)
);
}
/* Selectable products — the Product column of the RTB sheet
(reference/LG Product_RTB_260522_공유.xlsx, 3rd sheet). Edit here to change the list. */
const PRODUCTS = [
'Washing Machine', 'Dryer', 'Styler', 'Robot Vacuum', 'Refrigerator',
'Dishwasher', 'Oven', 'Air Conditioner', 'Air purifier', 'AI TV (webOS)', 'ThinQ AI',
];
/* ---------------- CNX: Episode edit ---------------- */
function EpisodeEditModal({ data, onClose, onSave }) {
const ep = data.episode;
// lazy initializer: 폼 상태(페르소나 3슬롯 패딩 포함)는 마운트 시 1회만 계산
const [f, setF] = useSE(() => {
const base = ep ? epPersonas(ep) : [];
return {
id: ep?.id, title: ep?.title || '', code: ep?.code || '', description: ep?.description || '',
products: ep?.products || [], tension: ep?.tension || '', voc: ep?.voc || '', rtb: ep?.rtb || '', output: ep?.output || 'IG 캐러셀 × 6 / LinkedIn / LG.com 뉴스룸',
personas: [0, 1, 2].map((i) => base[i] || { title: '', description: '', buildup: '', solution: '' }),
};
});
const set = (k) => (e) => setF((p) => ({ ...p, [k]: e.target.value }));
const setPersona = (i, k) => (e) => setF((p) => ({ ...p, personas: p.personas.map((pp, idx) => idx === i ? { ...pp, [k]: e.target.value } : pp) }));
const [busy, setBusy] = useSE(false);
const [custom, setCustom] = useSE('');
const addCustom = () => {
const v = custom.trim();
if (!v) return;
setF((s) => (s.products.includes(v) ? s : { ...s, products: [...s.products, v] }));
setCustom('');
};
return (
취소 } onClick={async () => { setBusy(true); await onSave({ ...f }); }}>{ep ? '저장' : '추가'}>}>
제목
설명
핵심 텐션
VOC
RTB
아웃풋
{f.personas.map((pp, i) => (
페르소나 {i + 1}
제목
설명
빌드업
LG AI 해결책
))}
);
}
/* ---------------- CNX: Theme edit (monthId-based) ---------------- */
function ThemeEditModal({ theme, defaultMonthId, months, onClose, onSave }) {
const [name, setName] = useSE(theme?.name || '');
const [subtitle, setSubtitle] = useSE(theme?.subtitle || '');
const [monthId, setMonthId] = useSE(theme?.monthId || defaultMonthId || '');
const [busy, setBusy] = useSE(false);
return (
취소 } onClick={async () => { setBusy(true); await onSave({ id: theme?.id, name: name.trim(), subtitle, monthId: monthId || null }); }}>{theme ? '저장' : '추가'}>}>
테마명 setName(e.target.value)} placeholder="예: 에코라이프" />
부제 setSubtitle(e.target.value)} placeholder="테마 설명" />
{months && months.length > 0 && (
월
setMonthId(e.target.value)}>
월 선택…
{months.map((m) => {m.label} )}
)}
);
}
/* ---------------- CNX: Month create/edit (1~12 click picker) ---------------- */
function MonthEditModal({ month, months, onClose, onSave }) {
// Derive the currently-edited month number from its label/sortOrder (e.g. "6월" → 6).
const numFromMonth = (m) => {
if (!m) return null;
const mm = /(\d+)/.exec(m.label || '');
return mm ? Number(mm[1]) : (m.sortOrder || null);
};
const [picked, setPicked] = useSE(numFromMonth(month));
const [maxSel, setMaxSel] = useSE(month?.maxSelections != null ? String(month.maxSelections) : '0');
const [busy, setBusy] = useSE(false);
// months already created (exclude the one being edited) → disabled in picker.
const usedNums = new Set((months || [])
.filter((m) => !month || m.id !== month.id)
.map((m) => numFromMonth(m))
.filter((n) => n != null));
return (
취소 } onClick={async () => { setBusy(true); await onSave({ id: month?.id, label: `${picked}월`, maxSelections: Number(maxSel) || 0, sortOrder: picked }); }}>{month ? '저장' : '추가'}>}>
월 선택
{Array.from({ length: 12 }, (_, i) => i + 1).map((n) => {
const used = usedNums.has(n);
const on = picked === n;
return (
setPicked(n)}
title={used ? '이미 추가된 월입니다' : ''}
style={{
padding: '8px 0', borderRadius: 8, fontWeight: 700, fontSize: 13,
cursor: used ? 'not-allowed' : 'pointer',
border: on ? '2px solid var(--accent, #16181d)' : '1px solid var(--line, #e2e4e8)',
background: used ? 'var(--line, #f1f2f4)' : on ? 'var(--accent-50, #f1f2f4)' : 'var(--surface, #fff)',
color: used ? 'var(--ink-3, #b0b6bd)' : on ? 'var(--accent, #16181d)' : 'var(--ink-1, #2a2f36)',
opacity: used ? 0.6 : 1,
}}>
{n}월
);
})}
이미 추가된 월은 선택할 수 없습니다.
);
}
/* ---------------- CNX: 콘텐츠(Project) 수정 ---------------- */
function ContentEditModal({ project, onClose, onSave }) {
const [name, setName] = useSE(project.name || '');
const [description, setDescription] = useSE(project.description || '');
const [periodStart, setPeriodStart] = useSE((project.periodStart || '').slice(0, 10));
const [periodEnd, setPeriodEnd] = useSE((project.periodEnd || '').slice(0, 10));
const [visible, setVisible] = useSE(project.status === 'active');
const [busy, setBusy] = useSE(false);
return (
취소 }
onClick={async () => { setBusy(true); try { await onSave({ name: name.trim(), description, periodStart, periodEnd, visible }); } finally { setBusy(false); } }}>저장>}>
콘텐츠명 setName(e.target.value)} />
설명 (선택) setDescription(e.target.value)} />
시작일 setPeriodStart(e.target.value)} />
종료일 (선택) setPeriodEnd(e.target.value)} />
운영가이드 표기
setVisible((v) => !v)} style={{ cursor: 'pointer', padding: '5px 12px', borderRadius: 20, border: '1px solid var(--line)', fontWeight: 700, fontSize: 12.5, background: visible ? 'var(--lg-red-50, #fdecea)' : 'var(--surface, #fff)', color: visible ? 'var(--lg-red, #c0392b)' : 'var(--ink-3, #8a9098)' }}>{visible ? 'ON · 노출중' : 'OFF · 숨김'}
ON이면 LGE 운영가이드에 노출됩니다
);
}
/* ---------------- CNX: 콘텐츠(Project) 추가 ---------------- */
function ProjectCreateModal({ onClose, onSave }) {
const [name, setName] = useSE('');
const [description, setDescription] = useSE('');
const [periodStart, setPeriodStart] = useSE('');
const [periodEnd, setPeriodEnd] = useSE('');
const [busy, setBusy] = useSE(false);
return (
취소 } onClick={async () => { setBusy(true); await onSave({ name: name.trim(), description, periodStart: periodStart || undefined, periodEnd: periodEnd || undefined }); }}>추가>}>
프로젝트명 setName(e.target.value)} placeholder="예: LG AI 365 · 2026 하반기" />
설명 (선택) setDescription(e.target.value)} placeholder="간단한 설명" />
기간 시작 setPeriodStart(e.target.value)} />
기간 종료 (선택) setPeriodEnd(e.target.value)} />
);
}
/* ============================================================
SUBMIT HISTORY (tab)
============================================================ */
function SubmitHistory({ project, user, navigate }) {
// Items are now self-describing (enriched by backend): no client-side episode resolution.
const { loading, error, data, reload } = useAsync(
() => window.api.getSubmissions(project.id).then((subs) => ({ subs })),
[project.id]
);
const [detail, setDetail] = useSE(null); // clicked submission
return (
} onClick={() => navigate({ name: 'project', projectId: project.id, tab: 'episodes' })}>에피소드 선택으로
제출 히스토리
누가 · 언제 · 어떤 테마의 어떤 에피소드를 제출했는지 시간순으로 보여줍니다. 재제출 시 이전 이력은 보존됩니다.
{loading &&
}
{error &&
}
{!loading && !error && data.subs.length === 0 && (
} title="제출 이력이 없습니다" desc="에피소드 탭에서 선택을 완료하고 제출하면 여기에 기록됩니다."
action={
navigate({ name: 'project', projectId: project.id, tab: 'episodes' })}>에피소드 선택하기 } />
)}
{!loading && !error && data.subs.length > 0 && (
{data.subs.map((s, i) => {
const first = s.items[0] || {};
const firstTitle = first.episodeTitle || (first.episodeId ? '(삭제된 에피소드)' : '');
return (
setDetail(s)}>
{s.actor}
{i === 0 && 최신 }
{fmtDateTime(s.at)}
{firstTitle &&
{firstTitle}{s.items.length > 1 ? ` 외 ${s.items.length - 1}건` : ''}
}
에피소드 {s.items.length}개
);
})}
)}
{detail && (() => {
const s = detail;
const personaN = s.items.filter((it) => it.personaName).length;
return (
setDetail(null)}
head={
제출 상세
setDetail(null)}>
{fmtDateTime(s.at)}
}
footer={
총 에피소드 {s.items.length}개 · 페르소나 지정 {personaN}개
}>
{s.note && (
)}
{project.name}
{groupByMonthTheme(s.items).map(([key, rows]) => (
{key}
{rows.map((it, ri) =>
)}
))}
);
})()}
);
}
// Group enriched submission items by `${monthLabel} · ${themeName}` preserving order.
function groupByMonthTheme(items) {
const groups = []; const idx = {};
(items || []).forEach((it) => {
const key = `${it.monthLabel || '월 미지정'} · ${it.themeName || '기타'}`;
if (idx[key] === undefined) { idx[key] = groups.length; groups.push([key, []]); }
groups[idx[key]][1].push(it);
});
return groups;
}
// One episode rendered as a clear block: bold title + blue persona chip + compact detail.
// Shared visual contract with the EpisodeLog drawer in screens-admin.jsx.
function EpisodeDetailBlock({ it }) {
const title = it.episodeTitle || '(삭제된 에피소드)';
const products = Array.isArray(it.products) ? it.products : (it.products ? [it.products] : []);
const hasDetail = it.tension || products.length || it.voc || it.rtb || it.output;
return (
{title}
{it.personaName
? {it.personaName}
: 페르소나 미지정 }
{hasDetail && (
{it.tension &&
핵심텐션 {it.tension}
}
{products.length > 0 && (
제품
{products.map((p, j) => {p} )}
)}
{it.voc &&
VOC {it.voc}
}
{it.rtb &&
RTB {it.rtb}
}
{it.output &&
아웃풋 {it.output}
}
)}
);
}
Object.assign(window, { EpisodeBrowse, EpisodeAdmin, SubmitHistory, MonthTabBar, EpisodeDetailBlock, groupByMonthTheme, RefListScreen });