// v2 REFERENCES stage — Board (search-first), Results (masonry), Final tab
const V2_REF_SOURCES_BUILTIN = [
{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:'ad'},
{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'},
];
// Persisted across stage mounts in the same session.
const V2_RECENT_SEARCHES_KEY = 'v2_ref_recent';
const V2RefStage = ({palette, projectId, pinnedIds, onDownload}) => {
const map = {};
(palette.blocks || []).forEach(b => (b.keywords || []).forEach(k => map[k.id] = {...k, blockId: b.id, fileId: b.file}));
const pinned = pinnedIds.map(id => map[id]).filter(Boolean);
const folderOptions = ((palette.files || []).reduce((acc,f) => {
if (!acc.includes(f.folder)) acc.push(f.folder); return acc;
}, []));
const [tab, setTab] = React.useState('board');
const [query, setQuery] = React.useState('');
const [sources, setSources] = React.useState(V2_REF_SOURCES_BUILTIN);
const [activeSources, setActiveSources] = React.useState(new Set(V2_REF_SOURCES_BUILTIN.map(s => s.id)));
const [selectedKw, setSelectedKw] = React.useState(new Set(pinnedIds.slice(0, 6)));
const [savedFolder, setSavedFolder] = React.useState(folderOptions[0] || '02 · References');
const [results, setResults] = React.useState(null);
const [searching, setSearching] = React.useState(false);
const [searchPhase, setSearchPhase] = React.useState('');
const [savedItems, setSavedItems] = React.useState(new Set());
const [downloading, setDownloading] = React.useState(false);
const [error, setError] = React.useState(null);
const [recentSearches, setRecentSearches] = React.useState(() => {
try { return JSON.parse(sessionStorage.getItem(V2_RECENT_SEARCHES_KEY) || '[]'); } catch(e) { return []; }
});
const [showAddSource, setShowAddSource] = React.useState(false);
const [newSrcLabel, setNewSrcLabel] = React.useState('');
const [newSrcKind, setNewSrcKind] = React.useState('web');
const addCustomSource = () => {
const label = newSrcLabel.trim();
if (!label) return;
const id = 'custom_' + label.toLowerCase().replace(/[^a-z0-9]+/g,'_');
if (sources.some(s => s.id === id)) {
setShowAddSource(false); setNewSrcLabel(''); return;
}
setSources([...sources, {id, label: label.toUpperCase(), kind: newSrcKind}]);
setActiveSources(new Set([...activeSources, id]));
setNewSrcLabel('');
setShowAddSource(false);
};
const recordSearch = (q, kws, srcs, hits) => {
const entry = {
q: q || (kws.slice(0,3).join(' + ') || '(pinned)'),
sources: srcs.map(s => sources.find(x => x.id === s)?.label || s).slice(0, 3).join(' · '),
hits, when: new Date().toISOString(),
query: q, kws, srcs,
};
const next = [entry, ...recentSearches.filter(r => r.q !== entry.q)].slice(0, 6);
setRecentSearches(next);
try { sessionStorage.setItem(V2_RECENT_SEARCHES_KEY, JSON.stringify(next)); } catch(e) {}
};
const replayRecent = (r) => {
setQuery(r.query || '');
if (Array.isArray(r.srcs)) setActiveSources(new Set(r.srcs));
setTimeout(runSearch, 50);
};
const formatRecentWhen = (iso) => {
const t = Date.now() - new Date(iso).getTime();
if (t < 60000) return '방금';
if (t < 3600000) return `${Math.floor(t/60000)}분 전`;
if (t < 86400000) return `${Math.floor(t/3600000)}시간 전`;
return `${Math.floor(t/86400000)}일 전`;
};
const toggleSource = (id) => setActiveSources(prev => { const n = new Set(prev); n.has(id)?n.delete(id):n.add(id); return n; });
const toggleKw = (id) => setSelectedKw(prev => { const n = new Set(prev); n.has(id)?n.delete(id):n.add(id); return n; });
const kwTexts = Array.from(selectedKw).map(id => map[id]).filter(Boolean).map(k => k.ko || k.en);
const hasBrief = !!(palette.project?.brief || '').trim();
// Backend auto-falls-back through pinned → distilled → analyzed keywords,
// so the button enables as soon as there's *any* keyword surface available.
const hasAnyAnalyzed = (palette.blocks || []).some(b => (b.keywords || []).length > 0);
const hasDistilled = (palette.distilled || []).length > 0;
const canSearch = !!(query.trim() || kwTexts.length || hasBrief || hasAnyAnalyzed || hasDistilled);
const runSearch = async () => {
setError(null); setSearching(true); setSearchPhase('쿼리 다듬는 중…');
// Cycle through phase strings so the user knows it's still working.
const phases = ['쿼리 다듬는 중…', '8개 소스 동시 검색 중…', '결과 정리 중…'];
let phaseIdx = 0;
const phaseTimer = setInterval(() => {
phaseIdx = Math.min(phaseIdx + 1, phases.length - 1);
setSearchPhase(phases[phaseIdx]);
}, 4000);
try {
const res = await window.API.searchReferences(projectId, {
natural_language_query: query.trim() || null,
keywords: kwTexts,
sites: Array.from(activeSources),
target_count: 60, per_site_max: 8,
});
setResults(res.candidates || []);
setTab('results');
recordSearch(query.trim(), kwTexts, Array.from(activeSources), (res.candidates||[]).length);
} catch(e) {
const msg = String(e.message || '');
const detail = (msg.match(/\{"detail":"([^"]+)"/) || [])[1];
setError('레퍼런스 찾기 실패 — ' + (detail || msg));
}
finally {
clearInterval(phaseTimer);
setSearching(false); setSearchPhase('');
}
};
const toggleSave = (url) => setSavedItems(prev => { const n = new Set(prev); n.has(url)?n.delete(url):n.add(url); return n; });
const downloadAll = async () => {
if (!results || !onDownload) return;
setDownloading(true);
try {
for (const cand of results) {
if (!savedItems.has(cand.url)) continue;
try { await onDownload({candidate: cand, folder_id: null}); }
catch(e) { console.error('download failed', e); }
}
setSavedItems(new Set());
} finally { setDownloading(false); }
};
return (
레퍼런스 영상·이미지 · References
검색 · 다운로드 · 자료실로 바로 저장
{[{id:'board',label:'보드 · Board',icon:'pin'},{id:'results',label:'레퍼런스 · References',icon:'search'},{id:'final',label:'최종 · Final',icon:'doc'}].map(t => (
))}
{searching && (
)}
{tab === 'board' && (
setQuery(e.target.value)}
onKeyDown={e=>{ if (e.key==='Enter') runSearch(); }}
placeholder="자유 검색어 (비우면 모아둔 키워드로 검색)"
style={{flex:1,padding:'16px 20px',border:'none',outline:'none',
fontSize:14,fontFamily:'inherit',background:'transparent'}}/>
{!canSearch && !error && (
💡 시작하려면 — 위에 자유 검색어를 입력하거나, 02 · ANALYSIS에서 키워드를 몇 개 핀하거나, Data 단계에서 프로젝트 브리프를 작성하세요.
)}
{error &&
⚠ {error}
}
{sources.map(s => {
const on = activeSources.has(s.id);
return (
);
})}
{!showAddSource ? (
) : (
setNewSrcLabel(e.target.value)}
onKeyDown={e=>{ if(e.key==='Enter') addCustomSource(); if(e.key==='Escape') setShowAddSource(false); }}
placeholder="소스 이름 (예: Vimeo, Behance)"
style={{border:'none',outline:'none',background:'transparent',
fontFamily:'var(--font-mono)',fontSize:11,width:180,color:'var(--ink)'}}/>
)}
자료실 폴더 ·
· 다운로드 버튼이 이 폴더로 저장
검색에 사용할 키워드
· {selectedKw.size} of {pinned.length} selected · 클릭으로 토글
{pinned.map(kw => {
const on = selectedKw.has(kw.id);
return (
);
})}
{pinned.length === 0 && 핀된 키워드가 없습니다 — Analysis 단계에서 핀해주세요.}
{recentSearches.length > 0 && (
RECENT SEARCHES
{recentSearches.map((r, i) => (
replayRecent(r)} style={{
display:'flex',alignItems:'center',gap:14,padding:'12px 16px',
background:'var(--paper)',border:'1px solid var(--rule)',borderRadius:8,
marginBottom:6,cursor:'pointer'}}>
{r.q}
{r.sources} · {r.hits} hits
{formatRecentWhen(r.when)}
))}
)}
)}
{tab === 'results' && results && (
<>
activeSources.has(s.id))}
savedItems={savedItems} toggleSave={toggleSave} folder={savedFolder}
onBack={()=>setTab('board')} onDownloadAll={downloadAll}
downloading={downloading}/>
{savedItems.size > 0 && (
{savedItems.size}개 저장 대기 중
저장 폴더 · {savedFolder}
)}
>
)}
{tab === 'final' && (
📑
최종 덱은 05 · FINAL 단계에서 빌드됩니다.
)}
);
};
const V2RefResults = ({results, sources, savedItems, toggleSave, folder, onBack, onDownloadAll, downloading}) => {
const bySource = {};
results.forEach(r => { (bySource[r.site] ||= []).push(r); });
const flat = [];
const queues = sources.map(s => (bySource[s.id] || []).map(it => ({...it, _source: s})));
let pulled = true;
while (pulled) {
pulled = false;
for (const q of queues) { if (q.length) { flat.push(q.shift()); pulled = true; } }
}
const others = results.filter(r => !sources.find(s => s.id === r.site));
flat.push(...others.map(it => ({...it, _source: {label:(it.site||'').toUpperCase(), kind: it.kind||'web'}})));
// Fallback height when a thumbnail fails to load or there's none — keeps
// the placeholder reasonably-sized for the medium type instead of zero-height.
const placeholderHeight = (kind) => ({video: 230, ad: 250, image: 260, web: 170}[kind] || 200);
return (
QUERY
{results.length} hits across {sources.length} sources
{flat.map((it, idx) => (
))}
);
};
// Single result card — uses the real image's natural aspect ratio for masonry.
// Falls back to a kind-typed placeholder while loading or on error.
const V2RefCard = ({it, saved, toggleSave, placeholderHeight}) => {
const isPlayable = (it.kind === 'video') || (it._source.kind === 'video') || (it._source.kind === 'ad');
const [loaded, setLoaded] = React.useState(false);
const [errored, setErrored] = React.useState(false);
const ph = placeholderHeight(it._source?.kind || it.kind);
return (
window.open(it.url,'_blank','noopener')} style={{
breakInside:'avoid',marginBottom:14,position:'relative',
background:'var(--paper)',borderRadius:10,overflow:'hidden',
boxShadow: saved ? '0 0 0 2px var(--accent), var(--shadow-1)' : 'var(--shadow-1)',
cursor:'pointer',display:'block'}}>
{it.thumbnail && !errored && (

setLoaded(true)}
onError={()=>setErrored(true)}
style={{display:'block',width:'100%',height:'auto',
opacity: loaded ? 1 : 0, transition:'opacity .25s ease-out'}}/>
)}
{!loaded && !errored && it.thumbnail && (
)}
{(errored || !it.thumbnail) && (
{(it._source?.label || it.site || 'WEB').toUpperCase()}
)}
{it._source.label}
{isPlayable && (
)}
{it.title || '(no title)'}
{(it.author) && (
{it.author}
)}
);
};
window.V2RefStage = V2RefStage;