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