// 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;