import { render as renderApp } from 'hono/jsx/dom' import { define, Styles } from 'forge' import type { App, AppState } from '../shared/types' import { theme } from './themes' // UI state (survives re-renders) let selectedApp: string | null = localStorage.getItem('selectedApp') // Server state (from SSE) let apps: App[] = [] // Layout const Layout = define('Layout', { display: 'flex', height: '100vh', fontFamily: theme('fonts-sans'), background: theme('colors-bg'), color: theme('colors-text'), }) const Sidebar = define('Sidebar', { width: 220, borderRight: `1px solid ${theme('colors-border')}`, display: 'flex', flexDirection: 'column', flexShrink: 0, }) const Logo = define('Logo', { height: 64, display: 'flex', alignItems: 'center', padding: '0 16px', fontSize: 20, fontWeight: 'bold', borderBottom: `1px solid ${theme('colors-border')}`, }) const SectionLabel = define('SectionLabel', { padding: '16px 16px 8px', fontSize: 12, fontWeight: 600, color: theme('colors-textFaint'), textTransform: 'uppercase', letterSpacing: '0.05em', }) const AppList = define('AppList', { flex: 1, overflow: 'auto', }) const AppItem = define('AppItem', { display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 8, padding: '8px 16px', color: theme('colors-textMuted'), textDecoration: 'none', fontSize: 14, cursor: 'pointer', selectors: { '&:hover': { background: theme('colors-bgHover'), color: theme('colors-text') }, }, variants: { selected: { background: theme('colors-bgSelected'), color: theme('colors-text'), fontWeight: 500 }, }, }) const StatusDot = define('StatusDot', { width: 8, height: 8, borderRadius: '50%', flexShrink: 0, variants: { state: { invalid: { background: theme('colors-statusInvalid') }, stopped: { background: theme('colors-statusStopped') }, starting: { background: theme('colors-statusStarting') }, running: { background: theme('colors-statusRunning') }, stopping: { background: theme('colors-statusStarting') }, }, inline: { display: 'inline' } }, }) const SidebarFooter = define('SidebarFooter', { padding: 16, borderTop: `1px solid ${theme('colors-border')}`, }) const NewAppButton = define('NewAppButton', { display: 'block', padding: '8px 12px', background: theme('colors-bgElement'), border: `1px solid ${theme('colors-border')}`, borderRadius: theme('radius-md'), color: theme('colors-textMuted'), textDecoration: 'none', fontSize: 14, textAlign: 'center', cursor: 'pointer', selectors: { '&:hover': { background: theme('colors-bgHover'), color: theme('colors-text') }, }, }) // Main pane const Main = define('Main', { flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', }) const MainHeader = define('MainHeader', { height: 64, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 24px', borderBottom: `1px solid ${theme('colors-border')}`, }) const MainTitle = define('MainTitle', { display: 'flex', alignItems: 'center', gap: 8, fontSize: 20, fontWeight: 600, margin: 0, }) const HeaderActions = define('HeaderActions', { display: 'flex', gap: 8, }) const MainContent = define('MainContent', { flex: 1, padding: 24, overflow: 'auto', }) const Section = define('Section', { marginBottom: 32, }) const SectionTitle = define('SectionTitle', { fontSize: 12, fontWeight: 600, color: theme('colors-textFaint'), textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 12, paddingBottom: 8, borderBottom: `1px solid ${theme('colors-border')}`, }) const InfoRow = define('InfoRow', { display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12, fontSize: 14, }) const InfoLabel = define('InfoLabel', { color: theme('colors-textFaint'), width: 80, flexShrink: 0, }) const InfoValue = define('InfoValue', { color: theme('colors-text'), display: 'flex', alignItems: 'center', gap: 8, }) const Link = define('Link', { base: 'a', color: theme('colors-link'), textDecoration: 'none', selectors: { '&:hover': { textDecoration: 'underline' }, }, }) const Button = define('Button', { base: 'button', padding: '6px 12px', background: theme('colors-bgElement'), border: `1px solid ${theme('colors-border')}`, borderRadius: theme('radius-md'), color: theme('colors-text'), fontSize: 13, cursor: 'pointer', selectors: { '&:hover': { background: theme('colors-bgHover') }, }, variants: { variant: { danger: { borderColor: theme('colors-dangerBorder'), color: theme('colors-dangerText') }, primary: { background: theme('colors-primary'), borderColor: theme('colors-primary'), color: theme('colors-primaryText') }, }, }, }) const ActionBar = define('ActionBar', { display: 'flex', gap: 8, marginTop: 24, paddingTop: 24, borderTop: `1px solid ${theme('colors-border')}`, }) const EmptyState = define('EmptyState', { display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: theme('colors-textFaint'), fontSize: 14, }) const LogsContainer = define('LogsContainer', { background: theme('colors-bgSubtle'), borderRadius: theme('radius-md'), padding: 12, fontFamily: theme('fonts-mono'), fontSize: 12, color: theme('colors-textMuted'), maxHeight: 200, overflow: 'auto', }) const LogLine = define('LogLine', { marginBottom: 4, selectors: { '&:last-child': { marginBottom: 0 }, }, }) const LogTime = define('LogTime', { color: theme('colors-textFaintest'), marginRight: 12, }) const stateLabels: Record = { invalid: 'Invalid', stopped: 'Stopped', starting: 'Starting', running: 'Running', stopping: 'Stopping', } // Actions - call API then let SSE update the state const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' }) const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' }) const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' }) const selectApp = (name: string) => { selectedApp = name localStorage.setItem('selectedApp', name) render() } const AppDetail = ({ app }: { app: App }) => ( <> {app.state === 'running' && app.icon ? <>{app.icon} : ( )}   {app.name}
Status State {stateLabels[app.state]} {app.port ? ` on :${app.port}` : ''} {app.state === 'running' && app.port && ( URL http://localhost:{app.port} )} {app.started && ( Started {new Date(app.started).toLocaleString()} )} {app.error && ( Error {app.error} )}
Logs {app.logs?.length ? ( app.logs.map((line, i) => ( {new Date(line.time).toLocaleTimeString()} {line.text} )) ) : ( --:--:-- No logs yet )}
{app.state === 'stopped' && ( )} {app.state === 'running' && ( <> )} {(app.state === 'starting' || app.state === 'stopping') && ( )}
) const Dashboard = () => { const selected = apps.find(a => a.name === selectedApp) return ( 🐾 Toes Apps {apps.map(app => ( selectApp(app.name)} selected={app.name === selectedApp ? true : undefined} > {app.state === 'running' && app.icon ? ( {app.icon} ) : ( )} {app.name} ))} + New App
{selected ? ( ) : ( Select an app to view details )}
) } const render = () => { renderApp(, document.getElementById('app')!) } // Set theme based on system preference const setTheme = () => { const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light') } // Listen for system theme changes window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { setTheme() render() }) // Set initial theme setTheme() // SSE connection const events = new EventSource('/api/apps/stream') events.onmessage = e => { apps = JSON.parse(e.data) const valid = selectedApp && apps.some(a => a.name === selectedApp) if (!valid && apps.length) selectedApp = apps[0]!.name render() }