// ════════════════════════════════════════════════════════════════ // Symbiosis.DataHub — app entry // ════════════════════════════════════════════════════════════════ const { useState, useEffect } = React; const DEFAULT_TWEAKS = /*EDITMODE-BEGIN*/{ "density": "cozy", "accent": "bronze", "sparkW": 42, "focus": false, "actAsUserId": 1 }/*EDITMODE-END*/; function App() { const [route, setRoute] = useState(() => { const h = window.location.hash.replace('#', ''); return h || 'corpus'; }); const [theme, setTheme] = useState('light'); const [tweaks, setTweaks] = useState(DEFAULT_TWEAKS); const [tweaksOpen, setTweaksOpen] = useState(false); const [megaOpen, setMegaOpen] = useState(false); const [activeCluster, setActiveCluster] = useState(null); const [activeStartup, setActiveStartup] = useState(null); const [activeSynthesis, setActiveSynthesis] = useState(null); const [authState, setAuthState] = useState('in'); // 'in' | 'out' | 'invite' const [inviteToken, setInviteToken] = useState(null); // API hydration state (только если SDH_USE_API=true) const [hydrateState, setHydrateState] = useState(window.SDH_HYDRATE ? window.SDH_HYDRATE.state : 'ready'); const [hydrateTick, setHydrateTick] = useState(0); // force re-render при notify // ── API hydration startup (если SDH_USE_API=true) ─────────── useEffect(() => { if (!window.SDH_USE_API || !window.SDH_HYDRATE) return; // Subscribe на state changes hydrator'а const unsubscribe = window.SDH_HYDRATE.subscribe((newState) => { setHydrateState(newState); setHydrateTick(t => t + 1); if (newState === 'unauthenticated') setAuthState('out'); else if (newState === 'ready' && authState === 'out') setAuthState('in'); }); // Запустить hydration window.SDH_HYDRATE.runAll(); return unsubscribe; }, []); // eslint-disable-line // Current user from tweaks (acts as) ИЛИ из API const currentUser = React.useMemo(() => { if (window.SDH_USE_API && window.SDH_HYDRATE && window.SDH_HYDRATE.currentUser) { // В API mode currentUser приходит из /api/auth/me, role switcher отключён const apiUser = window.SDH_HYDRATE.currentUser; // Найти в users массиве чтобы добавить недостающие поля для UI const fullUser = (window.SDH_ADMIN.users || []).find(u => u.id === apiUser.id); return fullUser || { id: apiUser.id, email: apiUser.email, name: apiUser.name, role: apiUser.role, status: apiUser.status || 'active', token_label: apiUser.token_expires_at ? 'TTL' : 'бессрочный', }; } // Mock mode: role switcher из Tweaks const id = tweaks.actAsUserId || 1; return window.SDH_ADMIN.users.find(u => u.id === id) || window.SDH_ADMIN.users[0]; }, [tweaks.actAsUserId, hydrateTick]); // close mega on route change useEffect(() => { setMegaOpen(false); setActiveCluster(null); setActiveSynthesis(null); }, [route]); // sync theme to useEffect(() => { document.documentElement.setAttribute('data-theme', theme); }, [theme]); // sync route to hash useEffect(() => { window.location.hash = route; }, [route]); // Tweaks protocol useEffect(() => { function onMsg(e) { if (!e.data || typeof e.data !== 'object') return; if (e.data.type === '__activate_edit_mode') setTweaksOpen(true); if (e.data.type === '__deactivate_edit_mode') setTweaksOpen(false); } window.addEventListener('message', onMsg); window.parent.postMessage({ type: '__edit_mode_available' }, '*'); return () => window.removeEventListener('message', onMsg); }, []); function setTweak(key, value) { setTweaks(prev => { const next = { ...prev, [key]: value }; try { window.parent.postMessage({ type: '__edit_mode_set_keys', edits: { [key]: value } }, '*'); } catch (e) {} return next; }); } function closeTweaks() { setTweaksOpen(false); try { window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*'); } catch (e) {} } // apply accent → CSS custom property useEffect(() => { const map = { bronze: { base: '#B8855D', hover: '#A6764F', deep: '#8E6440', subtle: '#EDD9C2' }, emerald: { base: '#6B8E5A', hover: '#5C7A4C', deep: '#4D673F', subtle: '#E3ECDD' }, rose: { base: '#B85A4A', hover: '#A04F40', deep: '#83422F', subtle: '#F4DBD5' }, }; const c = map[tweaks.accent] || map.bronze; const r = document.documentElement.style; r.setProperty('--brand-bronze', c.base); r.setProperty('--brand-bronze-hover', c.hover); r.setProperty('--brand-bronze-deep', c.deep); r.setProperty('--brand-bronze-subtle', c.subtle); }, [tweaks.accent]); useEffect(() => { document.documentElement.dataset.density = tweaks.density; }, [tweaks.density]); // ── Logout handler (API-aware) ────────────────────────────── async function handleLogout() { if (window.SDH_USE_API && window.SDH_HYDRATE) { try { await window.SDH_HYDRATE.logout(); } catch (_) {} } setAuthState('out'); } // ── API hydration loading state (только в API mode) ──────── if (window.SDH_USE_API && hydrateState === 'loading') { return (
загружаю данные из API…
); } if (window.SDH_USE_API && hydrateState === 'error') { return (
Ошибка подключения к API
{window.SDH_HYDRATE && window.SDH_HYDRATE.error}
); } // ── Auth-only pages (logged out / invite redeem) ─────────── if (authState === 'out') { return ( { setTweak('actAsUserId', userId); setAuthState('in'); setRoute('corpus'); }} onGoToCorpus={() => { setAuthState('in'); setRoute('pricing'); }} /> ); } if (authState === 'invite') { return ( { setAuthState('in'); setRoute('corpus'); }} onLogin={() => setAuthState('out')} /> ); } // ── RBAC route guard ─────────────────────────────────────── function checkAccess(r) { const can = (p) => window.SDH_ADMIN.can(currentUser.role, p); if (r === 'admin' || r === 'admin.users' || r === 'admin.invites' || r === 'admin.audit' || r === 'admin.pipeline') return can('admin.view'); if (r === 'admin.cost') return can('admin.cost.view_total') || can('admin.cost.view_full'); if (r === 'admin.settings') return can('admin.settings.edit'); if (r === 'admin.billing') return can('admin.billing.view_total') || can('admin.billing.view_full'); return true; } // ── Render route ─────────────────────────────────────────── function renderRoute() { if (route.startsWith('admin') && !checkAccess(route)) { return setRoute('corpus')} />; } switch (route) { // public + member case 'corpus': return ; case 'ideas': return { setActiveSynthesis(i); window.scrollTo({ top: 0 }); }} />; case 'trends': return { setActiveCluster(c); window.scrollTo({ top: 0 }); }} />; case 'markets': return ; case 'methods': return ; case 'costs': return ; // pricing & billing case 'pricing': return ; case 'account': return ; case 'account.billing': return ; // admin case 'admin': return ; case 'admin.users': return ; case 'admin.invites': return ; case 'admin.audit': return ; case 'admin.pipeline': return ; case 'admin.cost': return ; case 'admin.settings': return ; case 'admin.billing': return ; default: return ; } } // Drill-down views take over the page area when set const mainContent = activeSynthesis ? { setActiveSynthesis(null); window.scrollTo({ top: 0 }); }} /> : activeCluster ? { setActiveCluster(null); window.scrollTo({ top: 0 }); }} onOpenStartup={setActiveStartup} /> : renderRoute(); return (
setTheme(t => t === 'light' ? 'dark' : 'light')} megaOpen={megaOpen} onToggleMega={() => setMegaOpen(o => !o)} currentUser={currentUser} onLogout={handleLogout} /> setMegaOpen(false)} onRoute={setRoute} route={route} currentUser={currentUser} />
{mainContent}
Symbiosis.DataHub · Symbiosis Lab · 2026
{tweaksOpen && } {activeStartup && setActiveStartup(null)} />}
); } ReactDOM.createRoot(document.getElementById('app')).render();