= {
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', () => (
))
}
// 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', () => (
))
}
// 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', () => (
))
}
// 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}
{/* */}
Status
State
{stateLabels[app.state]}
{app.state === 'running' && app.port && (
URL
http://localhost:{app.port}
)}
{app.state === 'running' && app.port && (
Port
{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}
>
{sidebarCollapsed ? (
{app.icon}
) : (
<>
{app.icon}
{app.name}
>
)}
))}
{!sidebarCollapsed && (
+ New App
)}
{selected ? (
) : (
Select an app to view details
)}
)
}
const render = () => {
renderApp(, document.getElementById('app')!)
}
// Initialize render functions
initModal(render)
initUpdate(render)
// 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()
}