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')
let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true'
// 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',
justifyContent: 'space-between',
padding: '0 16px',
fontSize: 20,
fontWeight: 'bold',
borderBottom: `1px solid ${theme('colors-border')}`,
})
const HamburgerButton = define('HamburgerButton', {
base: 'button',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 4,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: 4,
selectors: {
'&:hover span': { background: theme('colors-text') },
},
})
const HamburgerLine = define('HamburgerLine', {
width: 18,
height: 2,
background: theme('colors-textMuted'),
borderRadius: 1,
transition: 'background 0.15s',
})
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: '10px 24px',
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,
display: 'inline',
})
let selectedTab: 'overview' | 'todo' = 'overview'
const TabContent = define('TabContent', {
display: 'none',
variants: {
active: {
display: 'block'
}
}
})
const NavButton = define('NavButton', {
render({ props }) {
return (
)
}
})
function setSelectedTab(tab: 'overview' | 'todo') {
selectedTab = tab
render()
}
const Nav = () => {
return <>
setSelectedTab('overview')}>Overview
setSelectedTab('todo')}>TODO
>
}
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 toggleSidebar = () => {
sidebarCollapsed = !sidebarCollapsed
localStorage.setItem('sidebarCollapsed', String(sidebarCollapsed))
render()
}
const AppDetail = ({ app }: { app: App }) => (
<>
{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') && (
)}
hardy har har
>
)
const Dashboard = () => {
const selected = apps.find(a => a.name === selectedApp)
return (
{!sidebarCollapsed && 🐾 Toes}
{!sidebarCollapsed && Apps}
{apps.map(app => (
selectApp(app.name)}
selected={app.name === selectedApp ? true : undefined}
style={sidebarCollapsed ? { justifyContent: 'center', padding: '10px 12px' } : undefined}
title={sidebarCollapsed ? app.name : undefined}
>
{app.state === 'running' && app.icon ? (
{app.icon}
) : (
)}
{!sidebarCollapsed && app.name}
))}
{!sidebarCollapsed && (
+ 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()
}