// 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 (
);
}
if (!usage || usage.ok === false) {
return (
);
}
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 (
);
}
function MorePage({ onNavigate }) {
const items = [
...NAV_ITEMS.filter(i => !['home','cycles','portfolio','orders'].includes(i.id)),
...NAV_ITEMS_2,
];
return (
{items.map(it => (
))}
);
}
function Placeholder({ page }) {
return (
);
}
// 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();