/* ============================================================ 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 : (
설명
); }; // 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 && }
{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) => ( ))}
)} {loading && [0, 1].map((i) => (
{[0, 1, 2].map((j) =>
)}
))} {error && } {!loading && !error && visibleThemes.length === 0 && ( } title="표시할 콘텐츠가 없습니다" desc="이 달에 운영가이드 표기(ON)된 에피소드가 아직 없습니다." /> )} {!loading && !error && visibleThemes.map((t, ti) => (
Theme {String(ti + 1).padStart(2, '0')}
{t.name}
{t.subtitle}
{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) => ( ))}
{curMonthId && (() => { const curMonth = data.months.find((m) => m.id === curMonthId); return curMonth ? ( <> ) : null; })()} } onClick={() => setEditMonth({ month: null })}>월 추가
)} {/* CNX toolbar: add-theme + import (only when a month is selected) */} {!loading && !error && data && curMonthId && (
{visibleThemes.length}개 테마
} onClick={() => setEditTheme({ theme: null, defaultMonthId: curMonthId })}>테마 추가 window.api.exportEpisodeTemplate().catch((ex) => toast('오류: ' + ex.message))}>양식 다운로드 } onClick={() => importRef && importRef.click()}>엑셀 업로드 setImportRef(el)} onChange={handleImport} />
)} {loading && [0, 1].map((i) => (
{[0, 1, 2].map((j) =>
)}
))} {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')}
{t.name}
{t.subtitle}
} onClick={() => setPickTheme({ 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)}>
{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 && ( )} {onDelete && ( )}
)}
); } /* ============================================================ 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)} />)}
)} {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 (
{filtered ? `${cards.length} / ${allCards.length}개 카드` : `${allCards.length}개 카드`} · 글로벌 라이브러리 · 등록됨 {usedN}개
} onClick={() => setEditEp({ episode: null })}>카드 추가 window.api.exportEpisodeTemplate().catch((ex) => toast('오류: ' + ex.message))}>양식 다운로드 } onClick={() => importRef && importRef.click()}>엑셀 업로드 setImportRef(el)} onChange={handleImport} />
{/* search · product · usage · sort */} {!loading && !error && allCards.length > 0 && ( )} {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 전용.
{view === 'contents' && (detailId ? (
) : 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) => ( ))}
); } /* 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 && ( )} {paused && } {paused &&
일시중지
}
{ep.title}
{(ep.products || []).length > 0 &&
{(ep.products || []).map((p, i) => {p})}
}
{personaLabel ? {personaLabel} : (selected ? 페르소나 미지정 : 선택 안 됨)} {isStaff ? e.stopPropagation()}>}>
: 상세 }
); } /* ---------------- 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.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 (
{hasDetail && ( )}
{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 && (
navigate({ name: 'refs', chip, kind: 'pdp', label: d.tag.label })}>더보기 — 전체 {products.length}개 →
)}
근거 기사 · LG 뉴스룸{articles.length ? ` (${articles.length})` : ''} · 최신순
이 기능을 다룬 LG 뉴스룸 기사입니다. 최신순이며, 누르면 기사로 이동합니다.
{articles.length === 0 &&
없음
} {articles.slice(0, 10).map((a) => (
{a.title} {a.postedDate && {a.postedDate}}
))} {articles.length > 10 && navigate && (
navigate({ name: 'refs', chip, kind: 'newsroom', label: d.tag.label })}>더보기 — 전체 {articles.length}개 →
)}
)}
); } /* 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 && (
{items.map((a) => (
{a.title} {a.postedDate && {a.postedDate}}
))}
)}
); } 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인 가구 · 직장인" />