// Column 3 — Board. Drop zone for selected keywords + brainstorm (omni LLM) + final section.
const boardStyles = {
col: { background:'linear-gradient(180deg, var(--paper) 0%, var(--paper-2) 100%)', display:'flex', flexDirection:'column', minHeight:0 },
head: { padding:'16px 24px 14px 24px', borderBottom:'1px solid var(--line)' },
eyebrow: { fontFamily:'var(--mono)', fontSize:10, letterSpacing:'.16em', color:'var(--ink-3)', textTransform:'uppercase' },
title: { fontFamily:'var(--serif)', fontSize:22, lineHeight:1.1, marginTop:4, color:'var(--ink)' },
sub: { fontSize:11.5, color:'var(--ink-3)', marginTop:4 },
scroll: { overflowY:'auto', flex:1, padding:'18px 22px 32px 22px' },
section: { marginBottom:22 },
sectionHead: { display:'flex', alignItems:'baseline', justifyContent:'space-between', marginBottom:10 },
sectionTitle: { fontFamily:'var(--mono)', fontSize:10, letterSpacing:'.14em', color:'var(--ink-3)', textTransform:'uppercase' },
sectionCount: { fontFamily:'var(--mono)', fontSize:10, color:'var(--ink-4)' },
dropzone: (hot) => ({
minHeight: 60, border: hot ? '1.5px dashed var(--accent)' : '1.5px dashed var(--line-2)',
borderRadius:10, padding:10, display:'flex', flexWrap:'wrap', gap:8, alignContent:'flex-start',
background: hot ? 'rgba(244,81,28,.06)' : 'transparent', transition:'all .15s',
}),
pinned: (colorVar) => ({
display:'inline-flex', alignItems:'stretch', borderRadius:8, overflow:'hidden',
border:'1px solid var(--line-2)',
background:'var(--paper)',
boxShadow:'0 1px 0 rgba(0,0,0,.04)',
}),
pinnedAccent: (c) => ({
width:4, background: c==='accent-2' ? 'var(--accent-2)' : c==='accent-3' ? 'var(--accent-3)' : c==='ink' ? 'var(--ink)' : 'var(--accent)',
}),
pinnedBody: { padding:'8px 10px 8px 10px', minWidth:120, maxWidth:260 },
pinnedRow: { display:'flex', alignItems:'baseline', gap:8, flexWrap:'wrap', rowGap:2 },
pinnedEn: { fontSize:13, color:'var(--ink)', fontWeight:500, whiteSpace:'nowrap' },
pinnedKo: { fontFamily:'var(--display)', fontSize:13.5, color:'var(--ink-2)', fontStyle:'italic', whiteSpace:'nowrap' },
pinnedNote: { fontSize:10.5, color:'var(--ink-3)', marginTop:4, lineHeight:1.4 },
pinnedSrc: { fontFamily:'var(--mono)', fontSize:9, color:'var(--ink-4)', marginTop:3, letterSpacing:'.05em' },
pinnedClose: { padding:'0 8px', borderLeft:'1px solid var(--line)', display:'flex', alignItems:'center', color:'var(--ink-4)', cursor:'pointer' },
emptyDrop: { fontSize:11.5, color:'var(--ink-4)', fontStyle:'italic', padding:'8px 4px' },
brainBox: {
border:'1px solid var(--line)', borderRadius:10, background:'var(--paper)',
padding:0, overflow:'hidden', marginBottom:18,
},
brainHead: { padding:'10px 14px', borderBottom:'1px solid var(--line)', display:'flex', justifyContent:'space-between', alignItems:'center', background:'var(--paper-2)' },
brainTitle: { fontFamily:'var(--serif)', fontSize:14, color:'var(--ink)' },
modelPick: { display:'flex', gap:4, fontFamily:'var(--mono)', fontSize:9.5, letterSpacing:'.08em' },
modelChip: (a) => ({
padding:'3px 7px', borderRadius:4,
border:'1px solid var(--line-2)',
background: a ? 'var(--ink)' : 'var(--paper)',
color: a ? 'var(--paper)' : 'var(--ink-3)', cursor:'pointer',
textTransform:'uppercase',
}),
messages: { padding:'12px 14px', maxHeight:260, overflowY:'auto', display:'flex', flexDirection:'column', gap:10 },
msg: (me) => ({
maxWidth:'86%', alignSelf: me ? 'flex-end' : 'flex-start',
padding:'8px 11px', fontSize:12.5, lineHeight:1.5,
borderRadius:10,
background: me ? 'var(--ink)' : 'var(--paper-2)',
color: me ? 'var(--paper)' : 'var(--ink)',
border: me ? 'none' : '1px solid var(--line)',
}),
msgKw: { display:'flex', flexWrap:'wrap', gap:6, marginTop:8 },
ghostKw: {
display:'inline-flex', alignItems:'center', gap:6, padding:'3px 8px', borderRadius:999,
background:'var(--paper)', border:'1px dashed var(--line-2)', fontSize:11.5,
cursor:'grab',
},
msgMeta: { fontFamily:'var(--mono)', fontSize:9, color:'var(--ink-4)', marginTop:4, letterSpacing:'.06em' },
composer: { display:'flex', gap:8, padding:'10px 12px', borderTop:'1px solid var(--line)', background:'var(--paper-2)', alignItems:'center' },
composerInput: { flex:1, border:'1px solid var(--line-2)', borderRadius:8, padding:'8px 10px', fontSize:12.5, background:'var(--paper)', outline:'none', fontFamily:'var(--sans)' },
sendBtn: { height:32, padding:'0 12px', border:'1px solid var(--ink)', borderRadius:8, background:'var(--ink)', color:'var(--paper)', fontSize:12, cursor:'pointer', display:'inline-flex', alignItems:'center', gap:5 },
finalBox: {
background:'var(--ink)', color:'var(--paper)', borderRadius:12, padding:'16px 18px 18px 18px',
marginTop:6, boxShadow:'0 10px 30px rgba(0,0,0,.12)',
backgroundImage:'radial-gradient(circle at 100% 0%, rgba(244,81,28,.35), transparent 55%)',
},
finalHead: { display:'flex', justifyContent:'space-between', alignItems:'baseline', marginBottom:10 },
finalTitle: { fontFamily:'var(--serif)', fontSize:22, color:'var(--paper)', letterSpacing:'-.01em' },
finalTag: { fontFamily:'var(--mono)', fontSize:9.5, letterSpacing:'.14em', color:'rgba(255,255,255,.55)', textTransform:'uppercase' },
finalList: { display:'flex', flexWrap:'wrap', gap:8, marginTop:12, marginBottom:14 },
finalKw: { padding:'6px 10px', borderRadius:999, border:'1px solid rgba(255,255,255,.25)', fontSize:12.5, display:'inline-flex', alignItems:'center', gap:8 },
finalKo: { fontFamily:'var(--display)', fontStyle:'italic', color:'rgba(255,255,255,.7)' },
finalActions: { display:'flex', gap:8, marginTop:4 },
ghostBtn: { height:30, padding:'0 12px', borderRadius:999, border:'1px solid rgba(255,255,255,.3)', background:'transparent', color:'var(--paper)', fontSize:11.5, cursor:'pointer' },
primaryBtn: { height:30, padding:'0 14px', borderRadius:999, border:'1px solid var(--accent)', background:'var(--accent)', color:'var(--paper)', fontSize:11.5, cursor:'pointer', fontWeight:500 },
};
function PinnedChip({ k, onRemove, fileNameById }) {
const fromLabel = k.from && fileNameById && fileNameById[k.from] ? fileNameById[k.from] : k.from;
return (
{k.w}
{k.ko}
{(k.note || k.why) &&
{k.note || k.why}
}
{fromLabel &&
from · {fromLabel}
}
onRemove(k)}>
);
}
function WatchingChip({ k, onPromote, onRemove }) {
return (
{k.w}
{k.ko}
);
}
const BRAINSTORM_INTRO = {
me:false, model:'Palette · omni',
text:"왼쪽에서 키워드를 모아두면 여기서 함께 이야기해요. 상투어는 부숴 드리고, 숨은 긴장감도 찾아낼게요.",
kws:[],
};
const refStyles = {
wrap: { border:'1px solid var(--line)', borderRadius:10, background:'var(--paper)', overflow:'hidden' },
bar: { display:'flex', gap:8, padding:'10px 12px', borderBottom:'1px solid var(--line)', alignItems:'center', background:'var(--paper-2)', flexWrap:'wrap' },
input: { flex:1, minWidth:140, border:'1px solid var(--line-2)', borderRadius:999, padding:'6px 12px', fontSize:12, fontFamily:'var(--sans)', background:'var(--paper)', outline:'none' },
siteChip: (a) => ({
padding:'4px 9px', borderRadius:999, border:'1px solid var(--line-2)',
background: a ? 'var(--ink)' : 'var(--paper)', color: a ? 'var(--paper)' : 'var(--ink-3)',
fontSize:10.5, fontFamily:'var(--mono)', letterSpacing:'.08em', textTransform:'uppercase', cursor:'pointer',
}),
searchBtn: { height:28, padding:'0 14px', borderRadius:999, border:'1px solid var(--accent)', background:'var(--accent)', color:'var(--paper)', fontSize:12, cursor:'pointer', fontFamily:'var(--sans)' },
grid: { display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(150px, 1fr))', gridAutoRows:'1fr', gap:10, padding:'12px', alignItems:'stretch' },
card: { border:'1px solid var(--line)', borderRadius:8, overflow:'hidden', background:'var(--paper)', display:'flex', flexDirection:'column', cursor:'pointer', transition:'transform .12s, box-shadow .12s', height:'100%' },
cardBody: { display:'flex', flexDirection:'column', flex:1, minHeight:0 },
thumb: { width:'100%', aspectRatio:'16/9', objectFit:'cover', background:'var(--paper-3)', display:'block' },
meta: { padding:'6px 8px 4px 8px', fontSize:11, color:'var(--ink)', lineHeight:1.35, flex:1, display:'flex', flexDirection:'column', minHeight:0 },
titleL: {
display:'-webkit-box', WebkitLineClamp:2, WebkitBoxOrient:'vertical',
overflow:'hidden', wordBreak:'break-word',
minHeight:'calc(1.35em * 2)', // always reserve 2 lines so cards align
},
siteTag: { fontFamily:'var(--mono)', fontSize:9, letterSpacing:'.1em', color:'var(--ink-4)', textTransform:'uppercase', marginTop:3, whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' },
actions: { display:'flex', justifyContent:'space-between', padding:'0 8px 8px 8px', alignItems:'center', marginTop:'auto' },
iconBtn: { width:22, height:22, border:'1px solid var(--line-2)', borderRadius:'50%', display:'inline-flex', alignItems:'center', justifyContent:'center', cursor:'pointer', background:'var(--paper)', color:'var(--ink-3)' },
empty: { padding:'28px 16px', textAlign:'center', color:'var(--ink-4)', fontFamily:'var(--serif)', fontStyle:'italic', fontSize:14 },
savedStrip: { display:'flex', gap:8, overflowX:'auto', padding:'10px 12px', borderTop:'1px solid var(--line)' },
savedCard: { width:150, minWidth:150, maxWidth:150, border:'1px solid var(--line-2)', borderRadius:6, overflow:'hidden', position:'relative', cursor:'pointer', flexShrink:0, display:'flex', flexDirection:'column' },
savedThumb: { width:150, aspectRatio:'16/9', objectFit:'cover', display:'block', background:'var(--paper-3)' },
savedTitle: { padding:'4px 6px 6px 6px', fontSize:10.5, lineHeight:1.3, color:'var(--ink)', display:'-webkit-box', WebkitLineClamp:2, WebkitBoxOrient:'vertical', overflow:'hidden', wordBreak:'break-word', minHeight:'calc(1.3em * 2 + 10px)' },
savedRemove: { position:'absolute', top:4, right:4, width:18, height:18, borderRadius:'50%', border:'none', background:'rgba(0,0,0,.55)', color:'white', fontSize:10, cursor:'pointer', display:'inline-flex', alignItems:'center', justifyContent:'center' },
};
const SITE_GROUPS = [
{ id: 'youtube_kr', label: 'YouTube KR', kind: 'video' },
{ id: 'youtube_jp', label: 'YouTube JP', kind: 'video' },
{ id: 'tvcf', label: 'TVCF', kind: 'video' },
{ id: 'ads_of_the_world', label: 'Ads of the World · 글로벌 광고', kind: 'video' },
{ id: 'naver_images', label: 'Naver · 네이버', kind: 'image' },
{ id: 'google_images_kr', label: 'Google KR', kind: 'image' },
{ id: 'google_images_jp', label: 'Google JP', kind: 'image' },
{ id: 'pinterest', label: 'Pinterest', kind: 'image' },
];
const SITE_LABEL = SITE_GROUPS.reduce((a, s) => (a[s.id] = s.label, a), {});
function ReferencesPanel({ project, boardPinned, savedRefs, onSearched, onSaveRef, onRemoveRef, onDownloadRef }) {
const folders = (project && project.folders) || [];
// Prefer a folder that has "reference" or "레퍼런스" in its name.
const defaultFolderId = React.useMemo(() => {
const hit = folders.find(f => /reference|레퍼런스/i.test(f.name || ''));
return (hit || folders[0] || {}).id;
}, [folders]);
const [targetFolderId, setTargetFolderId] = React.useState(defaultFolderId);
const [downloading, setDownloading] = React.useState({});
// Keep targetFolderId valid for the current project: if it isn't one of
// this project's folders (because we switched projects), snap to default.
React.useEffect(() => {
const valid = folders.some(f => f.id === targetFolderId);
if (!valid && defaultFolderId) setTargetFolderId(defaultFolderId);
}, [defaultFolderId, folders, targetFolderId]);
const triggerDownload = async (cand, rid) => {
if (!onDownloadRef) { console.warn('onDownloadRef not wired'); return; }
const key = rid || cand.url;
setDownloading(d => ({...d, [key]: true}));
try {
await onDownloadRef({ ref_id: rid, candidate: cand, folder_id: targetFolderId });
} catch(e) {
console.error('download failed', e);
} finally {
setDownloading(d => { const x={...d}; delete x[key]; return x; });
}
};
const [query, setQuery] = React.useState('');
const [sites, setSites] = React.useState(() =>
SITE_GROUPS.reduce((a, s) => (a[s.id] = true, a), {})
);
const [busy, setBusy] = React.useState(false);
const [results, setResults] = React.useState(null);
const [error, setError] = React.useState(null);
const [tab, setTab] = React.useState('all'); // 'all' | 'videos' | 'images'
// Clear search state whenever we switch to a different project so the
// previous project's results don't linger on screen.
React.useEffect(() => {
setResults(null);
setError(null);
setBusy(false);
setTab('all');
setQuery('');
}, [project && project.id]);
const kwList = React.useMemo(() => boardPinned.map(k => k.ko || k.w).filter(Boolean), [boardPinned]);
const toggleSite = (id) => setSites(s => ({...s, [id]: !s[id]}));
const runSearch = async () => {
setError(null); setBusy(true);
try {
const chosen = Object.keys(sites).filter(s => sites[s]);
const res = await API.searchReferences(project.id, {
natural_language_query: query.trim() || null,
keywords: kwList,
sites: chosen.length ? chosen : SITE_GROUPS.map(s => s.id),
target_count: 24,
per_site_max: 10,
});
setResults(res.candidates || []);
onSearched && onSearched(res.candidates || []);
} catch(e) {
setError('레퍼런스 찾기 실패 — ' + e.message);
} finally { setBusy(false); }
};
const filtered = React.useMemo(() => {
if (!results) return null;
if (tab === 'videos') return results.filter(c => c.kind !== 'image');
if (tab === 'images') return results.filter(c => c.kind === 'image');
return results;
}, [results, tab]);
const tabStyle = (a) => ({
padding:'4px 10px', borderRadius:999, border:'1px solid ' + (a ? 'var(--ink)' : 'var(--line-2)'),
background: a ? 'var(--ink)' : 'var(--paper)', color: a ? 'var(--paper)' : 'var(--ink-2)',
fontSize:11, fontFamily:'var(--sans)', cursor:'pointer',
});
const openCard = (c) => window.open(c.url, '_blank', 'noopener,noreferrer');
return (
setQuery(e.target.value)}
onKeyDown={e => { if (e.key==='Enter') runSearch(); }}/>
{SITE_GROUPS.map(s => (
toggleSite(s.id)}
title={s.kind === 'image' ? 'image source' : 'video source'}>
{s.kind === 'image' ? '🖼 ' : '▶ '}{s.label}
))}
자료실 폴더 ·
· 다운로드 버튼 누르면 이 폴더로 저장
{results && results.length > 0 && (
)}
{error &&
{error}
}
{results === null && !busy && (
🎬 🖼
{kwList.length
? `모아둔 ${kwList.length}개 키워드로 YouTube · TVCF · Naver · Google · Pinterest 한 번에 검색`
: '키워드를 모아둔 뒤 ▶ 레퍼런스 찾기 버튼을 누르면 영상과 이미지가 여기 나와요.'}
Korean + Japanese bias · click to open · + to save
)}
{results !== null && results.length === 0 && !busy && (
결과 없음 — 다른 키워드로 시도해볼까요?
)}
{filtered && filtered.length > 0 && (
{filtered.map((c, i) => {
const isImage = c.kind === 'image';
const thumbStyle = isImage
? { ...refStyles.thumb, aspectRatio:'1 / 1' }
: refStyles.thumb;
return (
{ e.currentTarget.style.transform='translateY(-1px)'; e.currentTarget.style.boxShadow='0 8px 18px rgba(0,0,0,.08)'; }}
onMouseLeave={e => { e.currentTarget.style.transform='none'; e.currentTarget.style.boxShadow='none'; }}>
openCard(c)} title={c.title}
style={{position:'relative', display:'flex', flexDirection:'column', flex:1, minHeight:0}}>
{c.thumbnail
?

{ e.currentTarget.style.display='none'; }}/>
:
no thumb
}
{isImage ? '🖼 ' : '▶ '}{SITE_LABEL[c.site] || c.site}
{c.title || '(제목 없음)'}
{SITE_LABEL[c.site] || c.site}{c.duration ? ` · ${Math.round(c.duration/60)}분` : ''}
{c.matched_keywords && c.matched_keywords.length ? `↳ ${c.matched_keywords.slice(0,2).join(', ')}` : ''}
{e.stopPropagation(); if (!downloading[c.url]) triggerDownload(c, null);}}>
{downloading[c.url] ? '…' : '↓'}
{e.stopPropagation(); onSaveRef(c);}}>
);
})}
)}
{savedRefs && savedRefs.length > 0 && (
{savedRefs.map(s => (
window.open(s.url, '_blank', 'noopener,noreferrer')} title={s.title}>
{s.thumbnail &&

}
{s.title}
))}
)}
);
}
function BoardCol({ project, board, setBoard, messages, setMessages, distilled, onDistill, onExport, onExportDeck, fileNameById, onAddKw, savedRefs, onSaveRef, onRemoveRef, onDownloadRef, onSearchedRefs, step }) {
const [hot, setHot] = React.useState(null); // 'pinned' | 'watching' | null
const [model, setModel] = React.useState('omni');
const [input, setInput] = React.useState('');
const msgs = (messages && messages.length) ? messages : [BRAINSTORM_INTRO];
const onDrop = (zone) => (e) => {
e.preventDefault();
setHot(null);
const raw = e.dataTransfer.getData('application/kw');
if (!raw) return;
const k = JSON.parse(raw);
if (zone === 'pinned') {
setBoard({...board, pinned:[...board.pinned, {...k, color: ['accent','accent-2','accent-3','ink'][board.pinned.length%4]}]});
} else {
setBoard({...board, watching:[...board.watching, k]});
}
};
const allowDrop = (zone) => (e) => { e.preventDefault(); setHot(zone); };
const leave = () => setHot(null);
const removePinned = (k) => setBoard({...board, pinned: board.pinned.filter(x => x.w !== k.w)});
const removeWatch = (k) => setBoard({...board, watching: board.watching.filter(x => x.w !== k.w)});
const promote = (k) => setBoard({
...board,
pinned: [...board.pinned, {...k, color:'accent'}],
watching: board.watching.filter(x => x.w !== k.w)
});
const [sending, setSending] = React.useState(false);
const send = async () => {
if (!input.trim() || sending) return;
const base = (messages && messages.length) ? messages : [BRAINSTORM_INTRO];
const withUser = [...base, { me:true, text: input }];
setMessages(withUser);
const prompt = input;
setInput('');
setSending(true);
try {
const res = await API.brainstorm(project.id, model, withUser);
const reply = { me:false, model: res.model, text: res.reply, kws: res.kws || [] };
setMessages([...withUser, reply]);
} catch(e) {
setMessages([...withUser, { me:false, model:'(error)', text:'Brainstorm failed: ' + e.message, kws:[] }]);
} finally {
setSending(false);
}
};
const resetBrainstorm = () => {
if (!confirm('Clear the brainstorm history for this project?')) return;
setMessages([]);
};
// Project-scoped sub-tabs: Board (default), References (fullscreen), Final.
const [tab, setTab] = React.useState('board');
React.useEffect(() => { setTab('board'); }, [project && project.id]);
// Sync with the top-bar step indicator: step 4 = references, step 5 = final.
// (steps 0–2 are the 3 columns, step 3 = Board column root, so map 4/5 here.)
React.useEffect(() => {
if (step === 4) setTab('references');
else if (step === 5) setTab('final');
else if (step === 3) setTab('board');
}, [step]);
const TABS = [
{ id:'board', label:'🎨 보드 · Board' },
{ id:'references', label:'🎬 레퍼런스 · References' },
{ id:'final', label:'📝 최종 · Final deck' },
];
const tabBtn = (active) => ({
padding:'6px 14px', borderRadius:999,
border:'1px solid ' + (active ? 'var(--ink)' : 'var(--line-2)'),
background: active ? 'var(--ink)' : 'var(--paper)',
color: active ? 'var(--paper)' : 'var(--ink-2)',
fontSize:11.5, fontFamily:'var(--sans)', cursor:'pointer',
});
// Final-deck subtree, reused by the stacked + dedicated views.
const finalDeck = (
04 · 최종 키워드 · Final
프로젝트에 저장됨 · v0.4
{project.name}
{board.pinned.length ? `확정 · ${board.pinned.length}개` : '작성 중'}
{project.brief && (
“{project.brief}”
)}
{board.pinned.length === 0 && (
위에서 키워드를 모아두면 여기 최종본으로 굳어요.
)}
{board.pinned.map((k,i) => (
{k.w} · {k.ko}
{k.note &&
— {k.note}}
))}
{onExport && onExportDeck && (
)}
);
const referencesPanelNode = (
);
return (
03 · 조합 · Board
{tab === 'references' ? '레퍼런스 영상·이미지 · References'
: tab === 'final' ? '최종 키워드 · Final deck'
: '키워드 정리 · Keyword board'}
{tab === 'references' ? '검색 · 다운로드 · 자료실로 바로 저장'
: tab === 'final' ? '모아둔 키워드로 덱을 출력하고 공유하세요.'
: '키워드를 여기 끌어 모으고, 레퍼런스를 붙이고, 최종본을 내보내세요.'}
{TABS.map(t => (
))}
{tab === 'references' ? (
{referencesPanelNode}
) : tab === 'final' ? (
{finalDeck}
) : (
● 모아둔 키워드 · Pinned
{board.pinned.length} / 6 권장
{board.pinned.length === 0 &&
여기로 키워드를 끌어오거나, + 를 눌러 모아두세요.
}
{board.pinned.map((k,i) =>
)}
○ 관심 · Watching
{board.watching.length}
{board.watching.length === 0 &&
당장은 아니지만 기억해두고 싶은 단어들.
}
{board.watching.map((k,i) =>
)}
✦ 핵심 키워드 · Top words
{distilled && distilled.length ? '새로고침' : '추출'}
{(!distilled || !distilled.length) && (
파일 분석이 끝나면 자동으로 여기에 쌓여요 · 수동 추출 도 가능.
)}
{distilled && distilled.map((k,i) => (
{
e.dataTransfer.setData('application/kw', JSON.stringify(k));
e.dataTransfer.effectAllowed = 'copy';
}}
onClick={() => onAddKw && onAddKw(k)}
style={{...boardStyles.ghostKw, cursor:'grab'}}
title={(k.why||'') + (k.occurrences ? ` · seen ×${k.occurrences}` : '')}>
{k.w}
{k.ko}
{k.occurrences > 1 && ×{k.occurrences}}
))}
▶ 레퍼런스 영상 · Video references
{savedRefs ? `${savedRefs.length} 저장됨` : 'YouTube · TVCF · AoTW'}
대화로 아이디어 · Brainstorm
{messages && messages.length > 0 && (
초기화
)}
함께 이야기 나눠요
{[['omni','Omni'],['claude','Claude'],['gpt','GPT'],['gemini','Gemini']].map(([id,l]) => (
setModel(id)}>{l}
))}
{msgs.map((m,i) => (
{m.text}
{m.kws && m.kws.length > 0 && (
{m.kws.map((k,j) => (
{
e.dataTransfer.setData('application/kw', JSON.stringify(k));
e.dataTransfer.effectAllowed = 'copy';
}}
style={boardStyles.ghostKw}
title={k.why}>
{k.w}
{k.ko}
))}
)}
{!m.me && m.model &&
{m.model}
}
))}
setInput(e.target.value)}
onKeyDown={e => { if (e.key==='Enter') send(); }}
disabled={sending}
placeholder={sending ? '생각하는 중…' : "대화로 묻기 — '조용함과 유능함 사이를 잇는 단어는?'"}/>
04 · 최종 키워드 · Final
프로젝트에 저장됨 · v0.4
{project.name}
{board.pinned.length ? `확정 · ${board.pinned.length}개` : '작성 중'}
{project.brief && (
“{project.brief}”
)}
{board.pinned.length === 0 && (
위에서 키워드를 모아두면 여기 최종본으로 굳어요.
)}
{board.pinned.map((k,i) => (
{k.w} {k.ko}
))}
)}
);
}
window.BoardCol = BoardCol;