// App — composes the three columns. Hydrates from /api/state and routes // every flow (upload, analyze, distill, brainstorm, export) through the API. class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { error: null }; } static getDerivedStateFromError(error) { return { error }; } componentDidCatch(error, info) { console.error('ErrorBoundary caught:', error, info); } render() { if (!this.state.error) return this.props.children; return (
잠시 문제가 생겼어요
화면을 그리는 중 오류가 났어요. 새로고침으로 보통 해결됩니다.
            {String(this.state.error?.stack || this.state.error)}
          
); } } const appStyles = { shell: (dataCol, analysisCol) => ({ display:'grid', gridTemplateColumns: `${dataCol} ${analysisCol} 1fr`, height:'calc(100vh - 64px)', minHeight:600, transition:'grid-template-columns .24s ease', }), collapsed: { width:'100%', background:'var(--paper-2)', borderRight:'1px solid var(--line)', display:'flex', flexDirection:'column', alignItems:'center', padding:'14px 0', gap:14, cursor:'pointer', }, collapsedLabel: { writingMode:'vertical-rl', transform:'rotate(180deg)', letterSpacing:'.18em', fontFamily:'var(--mono)', fontSize:10, textTransform:'uppercase', color:'var(--ink-3)', marginTop:6, }, expandBtn: { width:30, height:30, borderRadius:'50%', border:'1px solid var(--line-2)', background:'var(--paper)', color:'var(--ink-2)', fontSize:13, cursor:'pointer', display:'flex', alignItems:'center', justifyContent:'center', }, collapseBtn: { position:'absolute', top:10, right:10, width:24, height:24, borderRadius:'50%', border:'1px solid var(--line-2)', background:'var(--paper)', color:'var(--ink-3)', fontSize:12, cursor:'pointer', display:'flex', alignItems:'center', justifyContent:'center', zIndex:5, }, bootSplash: { position:'fixed', inset:0, display:'flex', alignItems:'center', justifyContent:'center', background:'var(--paper)', zIndex:100, fontFamily:'var(--serif)', fontSize:20, color:'var(--ink-3)', }, toast: { position:'fixed', bottom:18, left:'50%', transform:'translateX(-50%)', padding:'10px 16px', background:'var(--ink)', color:'var(--paper)', borderRadius:999, fontSize:12, fontFamily:'var(--sans)', zIndex:60, boxShadow:'0 10px 30px rgba(0,0,0,.12)', }, jobBar: { position:'fixed', top:64, left:0, right:0, height:3, zIndex:30, background:'var(--paper-2)', }, jobFill: (pct) => ({ height:'100%', width: (pct*100).toFixed(1)+'%', background:'linear-gradient(90deg, var(--accent), var(--accent-3))', transition:'width .4s', }), }; function App() { const [authChecked, setAuthChecked] = React.useState(false); const [user, setUser] = React.useState(null); const [session, setSession] = React.useState(null); const [loaded, setLoaded] = React.useState(false); const [projects, setProjects] = React.useState([]); const [analyses, setAnalyses] = React.useState({}); const [boards, setBoards] = React.useState({}); const [brainstorms, setBrainstorms] = React.useState({}); const [distilled, setDistilled] = React.useState({}); const [activeProjectId, setActiveProjectId] = React.useState(null); const [selectedFileId, setSelectedFileId] = React.useState(null); const [step, setStep] = React.useState(1); const [toast, setToast] = React.useState(null); const [newProjectOpen, setNewProjectOpen] = React.useState(false); const [analyzing, setAnalyzing] = React.useState({}); const [job, setJob] = React.useState(null); // active batch job const [references, setReferences] = React.useState({}); // {pid: {saved, last_results, last_query}} const [dataCollapsed, setDataCollapsed] = React.useState(false); const [analysisCollapsed, setAnalysisCollapsed] = React.useState(false); const pollRef = React.useRef(null); const flash = (msg) => { setToast(msg); setTimeout(() => setToast(null), 2400); }; const hydrate = React.useCallback(async () => { const s = await API.state(); setProjects(s.projects || []); setAnalyses(s.analyses || {}); setBoards(s.boards || {}); setBrainstorms(s.brainstorms || {}); setDistilled(s.distilled || {}); setReferences(s.references || {}); if (!activeProjectId && s.projects?.[0]) setActiveProjectId(s.projects[0].id); setLoaded(true); return s; }, [activeProjectId]); React.useEffect(() => { // Wait for Supabase session, then hydrate. onChange handles token refresh. 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 API.me(); setUser(me); await hydrate(); } catch(e) { console.error(e); flash('세션을 불러올 수 없어요 — 다시 로그인해주세요'); } })(); }, [session, hydrate]); const onSignOut = async () => { try { await window.palette_auth.signOut(); } catch(e) {} setSession(null); setUser(null); setProjects([]); setAnalyses({}); setBoards({}); }; // File-id → file-name map for chip provenance. const fileNameById = React.useMemo(() => { const m = {}; for (const p of projects) for (const f of p.folders||[]) for (const fl of f.files||[]) m[fl.id] = fl.name; return m; }, [projects]); const project = projects.find(p => p.id === activeProjectId); const board = boards[activeProjectId] || { pinned:[], watching:[] }; const messages = brainstorms[activeProjectId] || []; const distilledKws = distilled[activeProjectId] || []; const onSwitchProject = (pid) => { if (pid === activeProjectId) return; setActiveProjectId(pid); setSelectedFileId(null); }; const setBoard = async (b) => { setBoards(prev => ({...prev, [activeProjectId]: b})); try { await API.saveBoard(activeProjectId, b); } catch(e) { flash('Save failed'); } }; const setMessages = async (next) => { setBrainstorms(prev => ({...prev, [activeProjectId]: next})); try { await API.saveBrainstorm(activeProjectId, next); } catch(e) { /* silent */ } }; const addKw = (k) => { const exists = board.pinned.some(x => x.w === k.w && x.ko === k.ko); if (exists) { flash('Already pinned'); return; } const next = { ...board, pinned: [...board.pinned, {...k, color: ['accent','accent-2','accent-3','ink'][board.pinned.length%4]}], }; setBoard(next); }; const savedRefs = (references[activeProjectId] || {}).saved || []; const onSaveRef = async (candidate) => { try { const res = await API.archiveReference(activeProjectId, candidate); if (res.already) { flash('이미 저장된 레퍼런스'); return; } setReferences(r => { const cur = r[activeProjectId] || {}; return {...r, [activeProjectId]: {...cur, saved: [...(cur.saved || []), res.ref]}}; }); flash('레퍼런스 저장됨'); } catch(e) { flash('저장 실패'); } }; const onDownloadRef = async ({ ref_id, candidate, folder_id }) => { if (!activeProjectId) return; flash('자료실로 다운로드 중…'); try { const res = await API.downloadReference(activeProjectId, { ref_id, candidate, folder_id }); // Refresh projects so the new file shows up in the dataroom. const s = await API.state(); setProjects(s.projects || []); if (res.note) { flash(`⚠ ${res.note} · 썸네일 저장됨`); } else { flash(`자료실 저장 완료 · ${res.file?.name || 'file'}`); } return res; } catch(e) { console.error(e); flash('다운로드 실패 — ' + (e.message || '')); } }; const onRemoveRef = async (rid) => { try { await API.removeReference(activeProjectId, rid); setReferences(r => { const cur = r[activeProjectId] || {}; return {...r, [activeProjectId]: {...cur, saved: (cur.saved || []).filter(s => s.id !== rid)}}; }); } catch(e) { flash('삭제 실패'); } }; const onSearchedRefs = (_candidates) => {}; const onPatchBlock = async (file_id, block_id, patch) => { try { const res = await API.patchBlock(file_id, block_id, patch); setAnalyses(prev => { const ana = prev[file_id]; if (!ana) return prev; const blocks = (ana.blocks || []).map(b => b.id === block_id ? res.block : b); return {...prev, [file_id]: {...ana, blocks, keywords: res.keywords || ana.keywords}}; }); } catch(e) { console.error(e); flash('Edit failed'); } }; const onCreateProject = async ({client, name, brief}) => { const p = await API.createProject({client, name, brief}); setProjects(ps => [...ps, p]); setBoards(bs => ({...bs, [p.id]:{pinned:[], watching:[]}})); setBrainstorms(bs => ({...bs, [p.id]:[]})); onSwitchProject(p.id); setNewProjectOpen(false); flash('Project created'); }; const onUpload = async (folderId, file) => { if (!project) return; flash(`Uploading ${file.name}…`); const entry = await API.uploadFile(project.id, folderId, file); setProjects(ps => ps.map(p => p.id !== project.id ? p : ({ ...p, folders: p.folders.map(f => f.id !== folderId ? f : ({...f, files:[...f.files, entry]})) }))); setSelectedFileId(entry.id); flash(`Uploaded ${file.name}`); }; const applyAnalysis = (file_id, ana) => { setAnalyses(a => ({...a, [file_id]: ana})); setProjects(ps => ps.map(p => ({ ...p, folders: p.folders.map(f => ({ ...f, files: f.files.map(fl => fl.id !== file_id ? fl : ({ ...fl, status: ana?.error ? 'idle' : 'analyzed', })), })), }))); }; const onAnalyzeFile = (file_id) => { // Stream analyze: block placeholders appear immediately, each card fills // in as that block's ideation finishes. Much lower perceived latency. setAnalyzing(a => ({...a, [file_id]: {phase:'parse', completed:0, total:0}})); setSelectedFileId(file_id); flash('분석 시작 · reading file…'); let fellBack = false; const close = API.streamAnalyze(file_id, { onParsed: (p) => { // Seed analysis with empty block skeletons so cards appear at once. const skeletons = (p.blocks || []).map(b => ({ id: b.id, title: b.title, page: b.page, summary: '', keywords: [], scenes: [], _pending: true, })); setAnalyses(a => ({...a, [file_id]: {blocks: skeletons, keywords: [], block_count: p.block_count, _streaming: true}})); setAnalyzing(a => ({...a, [file_id]: {phase:'ideate', completed:0, total: p.block_count || 0}})); }, onBlock: (p) => { setAnalyses(a => { const prev = a[file_id] || {blocks: [], keywords: []}; const blocks = (prev.blocks || []).map(b => b.id === p.block.id ? {...p.block, _pending:false} : b ); return {...a, [file_id]: {...prev, blocks}}; }); setAnalyzing(a => ({...a, [file_id]: {phase:'ideate', completed: p.completed, total: p.total}})); }, onDone: (full) => { applyAnalysis(file_id, full); setAnalyzing(a => { const x={...a}; delete x[file_id]; return x; }); // Refresh distilled panel since backend auto-updates it on stream done. API.state().then(s => setDistilled(s.distilled || {})).catch(()=>{}); flash(full?.low_signal ? '분석 완료 · low-signal doc' : '분석 완료'); }, onError: async (err) => { if (fellBack) return; fellBack = true; console.warn('stream failed, falling back to POST', err); // Graceful fallback for environments that block SSE. try { const res = await API.analyzeFile(file_id); applyAnalysis(file_id, res.analysis); flash(res.analysis?.low_signal ? '분석 완료 · low-signal doc' : '분석 완료'); } catch(e) { flash('분석 실패 · ' + (err?.error || e.message || '')); } finally { setAnalyzing(a => { const x={...a}; delete x[file_id]; return x; }); } }, }); // Close SSE if user navigates away. return close; }; const stopPolling = () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } }; const onAnalyzeAll = async () => { if (!project) return; if (job?.status === 'running') { flash('Already analyzing'); return; } const todo = project.folders.flatMap(f => f.files.filter(fl => fl.status !== 'analyzed')).map(fl => fl.id); if (!todo.length) { flash('Nothing to analyze'); return; } flash(`Analyzing ${todo.length} files…`); setAnalyzing(a => todo.reduce((m,id) => ({...m, [id]: true}), a)); let res; try { res = await API.analyzeProject(project.id); } catch(e) { flash('Batch analyze failed'); setAnalyzing({}); return; } if (!res.job_id) { flash(res.message || 'Nothing to analyze'); setAnalyzing({}); return; } setJob({id: res.job_id, total: res.todo, completed: 0, status:'running'}); stopPolling(); let lastCompleted = 0; pollRef.current = setInterval(async () => { try { const j = await API.getJob(res.job_id); setJob({id: j.id, total: j.total, completed: j.completed, status: j.status}); if (j.completed > lastCompleted) { // Re-hydrate just the analyses we now have. const fresh = await API.state(); setAnalyses(fresh.analyses || {}); setProjects(fresh.projects || []); lastCompleted = j.completed; } if (j.status !== 'running') { stopPolling(); setAnalyzing({}); flash(`Done — ${j.completed}/${j.total} analyzed`); setTimeout(() => setJob(null), 1500); } } catch(e) { /* keep polling */ } }, 1500); }; const onDeleteFile = async (file_id) => { await API.deleteFile(file_id); setProjects(ps => ps.map(p => ({ ...p, folders: p.folders.map(f => ({...f, files: f.files.filter(fl => fl.id !== file_id)})) }))); setAnalyses(a => { const x={...a}; delete x[file_id]; return x; }); if (selectedFileId === file_id) setSelectedFileId(null); }; const onDeleteProject = async (pid) => { if (!confirm('Delete this project and all its files?')) return; await API.deleteProject(pid); setProjects(ps => ps.filter(p => p.id !== pid)); setBoards(bs => { const x={...bs}; delete x[pid]; return x; }); setBrainstorms(bs => { const x={...bs}; delete x[pid]; return x; }); if (activeProjectId === pid) { const next = projects.find(p => p.id !== pid); setActiveProjectId(next?.id || null); setSelectedFileId(null); } flash('Project deleted'); }; const onDistill = async () => { if (!project) return; flash('Distilling cross-file keywords…'); try { const res = await API.distill(project.id); setDistilled(d => ({...d, [project.id]: res.keywords})); flash(`Distilled ${res.count} keywords`); } catch(e) { flash('Distill failed'); } }; const onExport = async () => { if (!project) return; try { const res = await API.exportProject(project.id); const blob = new Blob([res.markdown], {type:'text/markdown;charset=utf-8'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = (project.name.replace(/[^\w\-]+/g,'_') || 'project') + '.md'; a.click(); URL.revokeObjectURL(url); flash('저장됨 · .data/exports/ 에 복사본'); } catch(e) { flash('내보내기 실패'); } }; const onExportDeck = async () => { if (!project) return; const url = `/api/projects/${encodeURIComponent(project.id)}/deck.html`; window.open(url, '_blank', 'noopener,noreferrer'); }; if (!authChecked) { return
Palette · Keyword Agent — loading…
; } if (!session) { // Invite token may be in the URL as /invite/ or ?invite= 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 { /* onAuthStateChange handles */ }}/>; } if (!loaded) { return
Palette · Keyword Agent — loading…
; } if (!project) { return (
setNewProjectOpen(true)} onAnalyzeAll={()=>{}}/>
{user?.name || user?.email || '팀원'}님, 환영해요!
아직 프로젝트가 없어요. 첫 RFP 를 업로드할 프로젝트를 만들어볼까요?
{newProjectOpen && setNewProjectOpen(false)} onCreate={onCreateProject}/>}
); } return (
setNewProjectOpen(true)} onAnalyzeAll={onAnalyzeAll} onExport={onExport}/> {job && job.status === 'running' && (
)}
{dataCollapsed ? (
setDataCollapsed(false)} title="자료실 열기">
01 · Dataroom · 자료실
) : (
setNewProjectOpen(true)} onUpload={onUpload} onAnalyzeAll={onAnalyzeAll} onAnalyzeFile={onAnalyzeFile} onDeleteFile={onDeleteFile} onDeleteProject={onDeleteProject} analyzing={analyzing} job={job} />
)} {analysisCollapsed ? (
setAnalysisCollapsed(false)} title="분석 열기">
02 · Analysis · 분석
) : (
)}
{newProjectOpen && setNewProjectOpen(false)} onCreate={onCreateProject}/>} {toast &&
{toast}
}
); } function NewProjectModal({ onClose, onCreate }) { const [client, setClient] = React.useState(''); const [name, setName] = React.useState(''); const [brief, setBrief] = React.useState(''); const [busy, setBusy] = React.useState(false); const submit = async () => { if (!name.trim()) return; setBusy(true); try { await onCreate({client, name, brief}); } finally { setBusy(false); } }; const overlay = { position:'fixed', inset:0, background:'rgba(0,0,0,.4)', zIndex:50, display:'flex', alignItems:'center', justifyContent:'center' }; const modal = { width:460, background:'var(--paper)', borderRadius:12, padding:24, boxShadow:'0 30px 60px rgba(0,0,0,.2)', fontFamily:'var(--sans)' }; const label = { fontFamily:'var(--mono)', fontSize:10, letterSpacing:'.14em', color:'var(--ink-3)', textTransform:'uppercase', marginBottom:6 }; const input = { width:'100%', border:'1px solid var(--line-2)', borderRadius:6, padding:'8px 10px', fontSize:13, background:'var(--paper-2)', outline:'none', marginBottom:16, fontFamily:'var(--sans)', color:'var(--ink)' }; const textarea = {...input, minHeight:90, resize:'vertical', fontFamily:'var(--serif)', fontSize:14, lineHeight:1.5}; return (
e.stopPropagation()}>
New project
Creates 01·Brief / 02·References / 03·Ideation / 04·Deck folders.
Client
setClient(e.target.value)}/>
Project name
setName(e.target.value)}/>
Brief (short)