// app.jsx — App shell, navigation, theme + Tweaks const { useState: useAppState, useEffect: useAppEffect } = React; const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "theme": "dark", "density": "regular", "accent": "#5cc3e6", "showPaperBanner": true, "chartStyle": "area", "navStyle": "auto" }/*EDITMODE-END*/; const NAV_ITEMS = [ { id: 'home', label: '홈', shortcut: 'G H', icon: ( ) }, { id: 'cycles', label: '사이클', icon: ( ) }, { id: 'portfolio', label: '포트폴리오', icon: ( ) }, { id: 'orders', label: '주문', icon: ( ) }, { id: 'problems', label: '문제 로그', icon: ( ), dot: true }, ]; const NAV_ITEMS_2 = [ { id: 'schedule', label: '스케줄', icon: ( ) }, { id: 'news', label: '뉴스', icon: ( ) }, { id: 'memory', label: '메모리', icon: ( ) }, { id: 'backtest', label: '백테스트', icon: ( ) }, { id: 'system', label: '시스템', icon: ( ) }, ]; // Mobile bottom nav: 5 tabs const MOBILE_NAV = [ NAV_ITEMS[0], NAV_ITEMS[1], NAV_ITEMS[2], NAV_ITEMS[3], { id: 'more', label: '더보기', icon: ( ) }, ]; function CodexUsageMini({ usage, loading }) { if (loading && !usage) { return (
Codex 사용량
); } if (!usage || usage.ok === false) { return (
Codex 사용량
기록 없음
); } const rows = [ { key: '5h', label: '5h', win: usage.primary }, { key: 'wk', label: '주', win: usage.secondary }, ]; return (
Codex 사용량 {usage.plan_type || '—'}
{rows.map(r => { const used = r.win && typeof r.win.used_percent === 'number' ? r.win.used_percent : null; const tone = used == null ? '' : used >= 90 ? 'danger' : used >= 70 ? 'warn' : ''; const width = used == null ? 0 : Math.max(0, Math.min(100, used)); return (
{r.label} {used == null ? '—' : used.toFixed(1) + '%'}
); })}
); } function Sidenav({ active, onNavigate }) { const sys = useApi(() => API.system.state(), [], { refreshMs: 30000 }); const codex = useApi(() => API.system.codexUsage(), [], { refreshMs: 60000 }); const sd = (sys.data) || {}; const dbSize = sd.db && typeof sd.db.size_bytes === 'number' ? fmtBytes(sd.db.size_bytes) : '—'; const ver = sd.version || '—'; const paper = sd.paper_trading; return ( ); } function BottomNav({ active, onNavigate }) { return ( ); } function ModeBadge({ paperTrading }) { // paperTrading === undefined → still loading; render nothing to avoid // flashing a false "LIVE" before the health probe completes. if (paperTrading === undefined || paperTrading === null) return null; if (paperTrading === true) return ; // LIVE — distinct critical-toned chip so it's never mistaken for the orange paper one. return ( LIVE ); } function Topbar({ theme, onThemeChange, lastSync, page, onLogout }) { // /api/health is public + tiny; poll lightly so a server-side mode flip // surfaces in the chrome within ~minute. The Sidenav also polls /api/system/state // (authenticated) for the heavier resource block. const healthQ = useApi(() => API.health(), [], { refreshMs: 60000 }); const paperTrading = healthQ.data ? healthQ.data.paper_trading : undefined; const pageLabel = { home: '홈', cycles: '사이클', portfolio: '포트폴리오', orders: '주문', problems: '문제 로그', schedule: '스케줄', news: '뉴스', memory: '메모리', backtest: '백테스트', system: '시스템', more: '더보기', }[page] || ''; const pageSub = { home: '오늘의 봇 운영 상태', cycles: 'LLM 사이클 이력', portfolio: '보유 자산 및 시계열', orders: '예약 / 체결 / 자동 실행', problems: '심각도별 시스템 이슈', }[page] || ''; return (
L
{pageLabel}
{pageSub &&
{pageSub}
}
last sync {fmtTimeShort(lastSync)}
{onLogout && ( )}
); } function MorePage({ onNavigate }) { const items = [ ...NAV_ITEMS.filter(i => !['home','cycles','portfolio','orders'].includes(i.id)), ...NAV_ITEMS_2, ]; return (

더보기

/more · 확장 페이지
{items.map(it => ( ))}
); } function Placeholder({ page }) { return (
📋
「{page}」 페이지
); } // Each page fetches its own data via the useApi hook — no shared mock state. const PAGES = { home: (nav) => , cycles: (nav) => , portfolio: (nav) => , orders: (nav) => , problems: (nav) => , schedule: (nav) => , news: (nav) => , memory: (nav) => , backtest: (nav) => , system: (nav) => , }; const VALID_PAGES = new Set([ 'home', 'cycles', 'portfolio', 'orders', 'problems', 'schedule', 'news', 'memory', 'backtest', 'system', 'more', ]); // Hash routing: the page lives in location.hash (#/news) so a refresh restores // the current view and each page gets a shareable URL. Falls back to 'home'. function pageFromHash() { const raw = (window.location.hash || '').replace(/^#\/?/, '').trim(); return VALID_PAGES.has(raw) ? raw : 'home'; } function App() { const [tweaks, setTweak] = useTweaks(TWEAK_DEFAULTS); const [page, setPage] = useAppState(pageFromHash); // Keep page state in sync with the URL hash (covers browser back/forward and // manual edits), and normalize an empty hash to the resolved page on boot. useAppEffect(() => { if (!window.location.hash) { window.history.replaceState(null, '', '#/' + page); } function onHash() { setPage(pageFromHash()); } window.addEventListener('hashchange', onHash); return () => window.removeEventListener('hashchange', onHash); }, []); function navigate(id) { const next = VALID_PAGES.has(id) ? id : 'home'; window.location.hash = '#/' + next; // fires hashchange when it actually changes setPage(next); // immediate update; idempotent with the listener } // lastSync is a wall-clock heartbeat for the Topbar — real freshness is per-card // (each useApi hook re-fetches on its own schedule). Tick every 30s. const [lastSync, setLastSync] = useAppState(() => new Date()); useAppEffect(() => { const id = setInterval(() => setLastSync(new Date()), 30000); return () => clearInterval(id); }, []); // null = session check pending; true / false = resolved. // Initial guess from sessionStorage avoids the login flash for returning // visitors with a still-valid cookie; the /api/auth/session call is the // authoritative source. const [authed, setAuthed] = useAppState(() => { try { return sessionStorage.getItem('lstl_authed') === '1' ? null : false; } catch (_) { return false; } }); // Check existing cookie session on boot useAppEffect(() => { let cancelled = false; fetch('/api/auth/session', { credentials: 'same-origin' }) .then((r) => r.json()) .then((d) => { if (cancelled) return; if (d && d.authenticated) { try { sessionStorage.setItem('lstl_authed', '1'); } catch (_) {} setAuthed(true); } else { try { sessionStorage.removeItem('lstl_authed'); } catch (_) {} setAuthed(false); } }) .catch(() => { if (!cancelled) setAuthed(false); }); return () => { cancelled = true; }; }, []); // Listen for 401 broadcasts from the API client (cookie expired mid-session) useAppEffect(() => { function onUnauth() { try { sessionStorage.removeItem('lstl_authed'); } catch (_) {} setAuthed(false); } window.addEventListener('lstl:unauthenticated', onUnauth); return () => window.removeEventListener('lstl:unauthenticated', onUnauth); }, []); // apply theme to document useAppEffect(() => { document.documentElement.setAttribute('data-theme', tweaks.theme); }, [tweaks.theme]); // apply accent useAppEffect(() => { if (tweaks.accent) { document.documentElement.style.setProperty('--accent', tweaks.accent); } }, [tweaks.accent]); if (authed === null) { // session check still pending — keep screen blank to avoid login flash return
; } if (!authed) { return ( { try { sessionStorage.setItem('lstl_authed', '1'); } catch (_) {} setAuthed(true); }} /> ); } return (
setTweak('theme', v)} lastSync={lastSync} page={page} onLogout={async () => { try { await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' }); } catch (_) {} try { sessionStorage.removeItem('lstl_authed'); } catch (_) {} setAuthed(false); }} />
{PAGES[page] ? PAGES[page](navigate) : }
setTweak('theme', v)} /> setTweak('accent', v)} /> setTweak('density', v)} /> setTweak('showPaperBanner', v)} />
); } ReactDOM.createRoot(document.getElementById('root')).render();