// v2 App root — orchestrates Home + 5-stage shell, hydrates from /api/state. // // Renders only when the URL has ?v=2 (hash route opt-in). The legacy v1 app // stays fully functional otherwise. const V2App = () => { const [authChecked, setAuthChecked] = React.useState(false); const [user, setUser] = React.useState(null); const [session, setSession] = React.useState(null); const [state, setState] = React.useState({projects: []}); const [loaded, setLoaded] = React.useState(false); const _readURL = () => { const sp = new URLSearchParams(location.search); return { pid: sp.get('p') || null, stage: sp.get('s') || 'data' }; }; const _initial = _readURL(); const [activeProjectId, setActiveProjectId] = React.useState(_initial.pid); const [stage, _setStage] = React.useState(_initial.stage); const setStage = (s) => _setStage(s); const [toast, setToast] = React.useState(null); const [localPinned, setLocalPinned] = React.useState([]); const [runningJob, setRunningJob] = React.useState(null); const flash = (t) => { setToast(t); setTimeout(() => setToast(null), 2400); }; // Sync URL ↔ activeProjectId / stage so each project has a unique deep-link. React.useEffect(() => { const onPop = () => { const u = _readURL(); setActiveProjectId(u.pid); _setStage(u.stage); }; window.addEventListener('popstate', onPop); return () => window.removeEventListener('popstate', onPop); }, []); React.useEffect(() => { const sp = new URLSearchParams(location.search); const cur = { p: sp.get('p') || null, s: sp.get('s') || 'data' }; const next = { p: activeProjectId || null, s: stage || 'data' }; if (cur.p === next.p && cur.s === next.s) return; if (next.p) sp.set('p', next.p); else sp.delete('p'); if (next.s && next.s !== 'data') sp.set('s', next.s); else sp.delete('s'); const qs = sp.toString(); history.pushState(null, '', qs ? '?' + qs : location.pathname); }, [activeProjectId, stage]); // Auth bootstrap (mirrors v1 flow) React.useEffect(() => { let unsub = null; (async () => { if (!window.palette_auth) { setAuthChecked(true); return; } const s = await window.palette_auth.waitForSession(); setSession(s); setAuthChecked(true); unsub = window.palette_auth.onChange(next => { setSession(next); if (!next) { setUser(null); setLoaded(false); } }); })(); const onUnauth = () => { setSession(null); setUser(null); }; window.addEventListener('palette:unauth', onUnauth); return () => { if (unsub) unsub(); window.removeEventListener('palette:unauth', onUnauth); }; }, []); React.useEffect(() => { if (!session) return; (async () => { try { const me = await window.API.me(); setUser(me); await hydrate(); } catch(e) { console.error(e); flash('세션을 불러올 수 없어요'); } })(); }, [session]); const hydrate = React.useCallback(async () => { const s = await window.API.state(); setState(s); setLoaded(true); return s; }, []); // Auto-refresh state every 8s while on Data stage (catches analyze progress) React.useEffect(() => { if (!loaded || stage !== 'data') return; const t = setInterval(() => { window.API.state().then(setState).catch(()=>{}); }, 8000); return () => clearInterval(t); }, [loaded, stage]); // Pinned-board persistence callback. MUST be declared before any conditional // returns or React's hook order will diverge between renders. const persistPinned = React.useCallback(async (newSet, _palette, _pid, _board) => { if (!_pid) return; const map = {}; (_palette?.blocks || []).forEach(b => (b.keywords || []).forEach(k => map[k.id] = k)); const items = newSet.map(id => map[id]).filter(Boolean).map(k => ({ w: k.en, ko: k.ko, why: k.gloss, tier: (k.type||'literal').toLowerCase(), })); try { await window.API.saveBoard(_pid, {pinned: items, watching: _board?.watching || []}); } catch(e) { console.error('saveBoard failed', e); } }, []); const _bootScreen = (msg) => (
PALETTE · MOSAIC {msg}
); if (!authChecked) return _bootScreen('인증 확인 중…'); if (!session) { const path = window.location.pathname || ''; const m = path.match(/\/invite\/([\w\-]+)/); const inviteToken = (m && m[1]) || new URLSearchParams(window.location.search).get('invite') || null; return {}}/>; } if (!loaded) return _bootScreen('워크스페이스 불러오는 중…'); // Project list view (Home) const project = (state.projects || []).find(p => p.id === activeProjectId); if (!project) { return ( { setActiveProjectId(p.id); setStage('data'); }} onNewProject={async ({client, name, brief}) => { const created = await window.API.createProject({client, name, brief}); await hydrate(); setActiveProjectId(created.id); setStage('data'); }} /> ); } // Build palette-data shape from live state const palette = window.v2BuildPaletteData(state, project.id) || {project, files: [], blocks: [], pinned: [], watching: []}; // Pinned IDs are derived from the saved board — but board uses a different id // shape (we built v2 ids as `${block.id}_k${i}`). Use heat-based fallback so // the v2 UI never shows an empty board: pin the first 6 metaphor-tier kws if // the user hasn't explicitly pinned anything in v2 yet. const board = (state.boards || {})[project.id] || {pinned: [], watching: []}; const v2PinnedFromBackend = (board.pinned || []) .map(p => `${p.w}|${p.ko}`) .reduce((acc, key) => { acc[key] = true; return acc; }, {}); const pinnedIds = []; (palette.blocks || []).forEach(b => (b.keywords || []).forEach(k => { const key = `${k.en}|${k.ko}`; if (v2PinnedFromBackend[key]) pinnedIds.push(k.id); })); const allPinned = [...new Set([...pinnedIds, ...localPinned])]; const onPin = async (kw) => { const isPinned = allPinned.includes(kw.id); const next = isPinned ? allPinned.filter(id => id !== kw.id) : [...allPinned, kw.id]; setLocalPinned(next); await persistPinned(next, palette, project.id, board); await hydrate(); }; const onUnpin = async (id) => { const next = allPinned.filter(x => x !== id); setLocalPinned(next); await persistPinned(next, palette, project.id, board); await hydrate(); }; const onUpload = async (folderId, file, onProgress) => { await window.API.uploadFile(project.id, folderId, file, onProgress); await hydrate(); flash(`업로드 완료 · ${file.name}`); }; const onAnalyzeFile = async (fileId) => { flash('분석 시작…'); try { const r = await window.API.analyzeFile(fileId); await hydrate(); const a = r?.analysis || {}; if (a.error) { flash('분석 실패: ' + String(a.error).slice(0, 140)); } else if (a.low_signal) { flash('본문 신호가 약함 — 텍스트가 거의 없는 파일'); } else { flash(`분석 완료 · ${a.block_count || 0} blocks · ${(a.keywords||[]).length} keywords`); } } catch(e) { flash('분석 실패: ' + e.message); } }; const onAnalyzeAll = async () => { setRunningJob('대기 중…'); flash('Batch analyze 시작…'); try { const r = await window.API.analyzeProject(project.id); if (r.job_id) { pollJob(r.job_id); } else { setRunningJob(null); flash('할 일 없음'); } } catch(e) { setRunningJob(null); flash('Batch failed: ' + e.message); } }; const pollJob = (jobId) => { const tick = async () => { try { const j = await window.API.getJob(jobId); const label = `분석 ${j.completed}/${j.total}`; setRunningJob(label); flash(label + '…'); if (j.status === 'running') setTimeout(tick, 4000); else { setRunningJob(null); await hydrate(); flash(`완료 ${j.completed}/${j.total}`); } } catch(e) { console.error(e); setRunningJob(null); } }; tick(); }; const onSaveSummary = async (block, summary) => { try { await window.API.patchBlock(block.file ? (block.file) : '', block.id, {summary}); await hydrate(); flash('요약 저장됨'); } catch(e) { flash('저장 실패: ' + e.message); } }; const onDownloadRef = async ({candidate, folder_id}) => { flash('자료실로 다운로드 중…'); try { const res = await window.API.downloadReference(project.id, {candidate, folder_id}); await hydrate(); flash(res.note ? `⚠ ${res.note}` : '저장 완료'); } catch(e) { flash('다운로드 실패: ' + e.message); } }; const onUpdateProject = async (patch) => { try { await window.API.patchProject(project.id, patch); await hydrate(); flash('프로젝트 정보 저장됨'); } catch(e) { flash('저장 실패: ' + e.message); } }; return (
setActiveProjectId(null)} onSignOut={async ()=>{ try { await window.palette_auth.signOut(); } catch(e){} setSession(null); setUser(null); }} onAnalyzeAll={onAnalyzeAll} runningJob={runningJob}/>
{stage === 'data' && ( )} {stage === 'analysis' && ( setStage('ideate')}/> )} {stage === 'ideate' && ( )} {stage === 'references' && ( )} {stage === 'final' && ( window.open(`/api/projects/${project.id}/deck.html`,'_blank')} onExportMarkdown={async () => { try { const r = await window.API.exportProject(project.id); const blob = new Blob([r.markdown], {type:'text/markdown'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = (project.name||'project').replace(/[^\w]+/g,'_')+'.md'; a.click(); URL.revokeObjectURL(url); } catch(e) { flash('Markdown 실패: '+e.message); } }}/> )}
{toast && (
{toast}
)}
); }; window.V2App = V2App;