import { render as renderApp } from 'hono/jsx/dom' import { define, Styles } from '@because/forge' import type { App, AppState } from '../shared/types' import { generateTemplates } from '../shared/templates' import { theme } from './themes' import { closeModal, initModal, Modal, openModal, rerenderModal } from './tags/modal' import { initUpdate } from './update' import { openEmojiPicker } from './tags/emoji-picker' // 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 ClickableAppName = define('ClickableAppName', { cursor: 'pointer', borderRadius: theme('radius-md'), padding: '2px 6px', margin: '-2px -6px', selectors: { '&:hover': { background: theme('colors-bgHover'), }, }, }) 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', render({ props: { children }, parts: { Root } }) { return { if (el) requestAnimationFrame(() => el.scrollTop = el.scrollHeight) }}>{children} } }) 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 TabBar = define('TabBar', { display: 'flex', gap: 24, marginBottom: 20, }) const Tab = define('Tab', { base: 'button', padding: '6px 0', background: 'none', border: 'none', borderBottom: '2px solid transparent', cursor: 'pointer', fontSize: 14, color: theme('colors-textMuted'), selectors: { '&:hover': { color: theme('colors-text') }, }, variants: { active: { color: theme('colors-text'), borderBottomColor: theme('colors-primary'), fontWeight: 500, }, }, }) 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', } // Form styles for modal const Form = define('Form', { base: 'form', display: 'flex', flexDirection: 'column', gap: 16, }) const FormField = define('FormField', { display: 'flex', flexDirection: 'column', gap: 6, }) const FormLabel = define('FormLabel', { base: 'label', fontSize: 13, fontWeight: 500, color: theme('colors-text'), }) const FormInput = define('FormInput', { base: 'input', padding: '8px 12px', background: theme('colors-bgSubtle'), border: `1px solid ${theme('colors-border')}`, borderRadius: theme('radius-md'), color: theme('colors-text'), fontSize: 14, selectors: { '&:focus': { outline: 'none', borderColor: theme('colors-primary'), }, '&::placeholder': { color: theme('colors-textFaint'), }, }, }) const FormError = define('FormError', { fontSize: 13, color: theme('colors-error'), }) const FormActions = define('FormActions', { display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 8, }) // New App creation let newAppError = '' let newAppCreating = false // Delete App confirmation let deleteAppError = '' let deleteAppDeleting = false let deleteAppTarget: App | null = null // Rename App let renameAppError = '' let renameAppRenaming = false let renameAppTarget: App | null = null async function createNewApp(input: HTMLInputElement) { const name = input.value.trim().toLowerCase().replace(/\s+/g, '-') if (!name) { newAppError = 'App name is required' rerenderModal() return } if (!/^[a-z][a-z0-9-]*$/.test(name)) { newAppError = 'Name must start with a letter and contain only lowercase letters, numbers, and hyphens' rerenderModal() return } if (apps.some(a => a.name === name)) { newAppError = 'An app with this name already exists' rerenderModal() return } newAppCreating = true newAppError = '' rerenderModal() try { const templates = generateTemplates(name) for (const [filename, content] of Object.entries(templates)) { const res = await fetch(`/api/sync/apps/${name}/files/${filename}`, { method: 'PUT', body: content, }) if (!res.ok) { throw new Error(`Failed to create ${filename}`) } } // Success - close modal and select the new app selectedApp = name localStorage.setItem('selectedApp', name) closeModal() } catch (err) { newAppError = err instanceof Error ? err.message : 'Failed to create app' newAppCreating = false rerenderModal() } } function openNewAppModal() { newAppError = '' newAppCreating = false openModal('New App', () => (
{ e.preventDefault() const input = (e.target as HTMLFormElement).querySelector('input') as HTMLInputElement createNewApp(input) }}> App Name {newAppError && {newAppError}}
)) } // Delete App confirmation modal async function deleteApp(input: HTMLInputElement) { if (!deleteAppTarget) return const expected = `sudo rm ${deleteAppTarget.name}` const value = input.value.trim() if (value !== expected) { deleteAppError = `Type "${expected}" to confirm` rerenderModal() return } deleteAppDeleting = true deleteAppError = '' rerenderModal() try { const res = await fetch(`/api/sync/apps/${deleteAppTarget.name}`, { method: 'DELETE', }) if (!res.ok) { throw new Error(`Failed to delete app: ${res.statusText}`) } // Success - close modal and clear selection if (selectedApp === deleteAppTarget.name) { selectedApp = null localStorage.removeItem('selectedApp') } closeModal() } catch (err) { deleteAppError = err instanceof Error ? err.message : 'Failed to delete app' deleteAppDeleting = false rerenderModal() } } function openDeleteAppModal(app: App) { deleteAppError = '' deleteAppDeleting = false deleteAppTarget = app const expected = `sudo rm ${app.name}` openModal('Delete App', () => (
{ e.preventDefault() const input = (e.target as HTMLFormElement).querySelector('input') as HTMLInputElement deleteApp(input) }}>

This will permanently delete {app.name} from the server.

Type "{expected}" to confirm {deleteAppError && {deleteAppError}}
)) } // Rename App modal async function doRenameApp(input: HTMLInputElement) { if (!renameAppTarget) return const newName = input.value.trim().toLowerCase().replace(/\s+/g, '-') if (!newName) { renameAppError = 'App name is required' rerenderModal() return } if (!/^[a-z][a-z0-9-]*$/.test(newName)) { renameAppError = 'Name must start with a letter and contain only lowercase letters, numbers, and hyphens' rerenderModal() return } if (newName === renameAppTarget.name) { closeModal() return } if (apps.some(a => a.name === newName)) { renameAppError = 'An app with this name already exists' rerenderModal() return } renameAppRenaming = true renameAppError = '' rerenderModal() try { const res = await fetch(`/api/apps/${renameAppTarget.name}/rename`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName }), }) const text = await res.text() let data: { ok?: boolean, error?: string, name?: string } try { data = JSON.parse(text) } catch { throw new Error(`Server error: ${text.slice(0, 100)}`) } if (!res.ok || !data.ok) { throw new Error(data.error || 'Failed to rename app') } // Success - update selection and close modal selectedApp = data.name || newName localStorage.setItem('selectedApp', data.name || newName) closeModal() } catch (err) { renameAppError = err instanceof Error ? err.message : 'Failed to rename app' renameAppRenaming = false rerenderModal() } } function openRenameAppModal(app: App) { renameAppError = '' renameAppRenaming = false renameAppTarget = app openModal('Rename App', () => (
{ e.preventDefault() const input = (e.target as HTMLFormElement).querySelector('input') as HTMLInputElement doRenameApp(input) }}> App Name {renameAppError && {renameAppError}}
)) } // 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 OpenEmojiPicker = define('OpenEmojiPicker', { cursor: 'pointer', render({ props: { app, children }, parts: { Root } }) { return openEmojiPicker((emoji) => { if (!app) return fetch(`/api/apps/${app.name}/icon?icon=${emoji}`, { method: 'POST' }) app.icon = emoji render() })}>{children} } }) const AppDetail = ({ app }: { app: App }) => ( <> {app.icon}   openRenameAppModal(app)}>{app.name} {/* */}