This commit is contained in:
Chris Wanstrath 2026-01-29 21:24:07 -08:00
parent 9d22008f32
commit dd56dc0df6
4 changed files with 148 additions and 4 deletions

View File

@ -1,8 +1,9 @@
import { render as renderApp } from 'hono/jsx/dom' import { render as renderApp } from 'hono/jsx/dom'
import { define, Styles } from '@because/forge' import { define, Styles } from '@because/forge'
import type { App, AppState } from '../shared/types' import type { App, AppState } from '../shared/types'
import { generateTemplates } from '../shared/templates'
import { theme } from './themes' import { theme } from './themes'
import { Modal, initModal } from './tags/modal' import { closeModal, initModal, Modal, openModal, rerenderModal } from './tags/modal'
import { initUpdate } from './update' import { initUpdate } from './update'
import { openEmojiPicker } from './tags/emoji-picker' import { openEmojiPicker } from './tags/emoji-picker'
@ -346,6 +347,143 @@ const stateLabels: Record<AppState, string> = {
stopping: 'Stopping', 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
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', () => (
<Form onSubmit={(e: Event) => {
e.preventDefault()
const input = (e.target as HTMLFormElement).querySelector('input') as HTMLInputElement
createNewApp(input)
}}>
<FormField>
<FormLabel for="app-name">App Name</FormLabel>
<FormInput
id="app-name"
type="text"
placeholder="my-app"
autofocus
/>
{newAppError && <FormError>{newAppError}</FormError>}
</FormField>
<FormActions>
<Button type="button" onClick={closeModal} disabled={newAppCreating}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={newAppCreating}>
{newAppCreating ? 'Creating...' : 'Create App'}
</Button>
</FormActions>
</Form>
))
}
// Actions - call API then let SSE update the state // Actions - call API then let SSE update the state
const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' }) const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })
const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' }) const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' })
@ -522,7 +660,7 @@ const Dashboard = () => {
</AppList> </AppList>
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<SidebarFooter> <SidebarFooter>
<NewAppButton>+ New App</NewAppButton> <NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>
</SidebarFooter> </SidebarFooter>
)} )}
</Sidebar> </Sidebar>

View File

@ -1,13 +1,12 @@
import type { App as SharedApp, AppState, LogLine } from '@types' import type { App as SharedApp, AppState, LogLine } from '@types'
import type { Subprocess } from 'bun' import type { Subprocess } from 'bun'
import { DEFAULT_EMOJI } from '@types'
import { existsSync, readdirSync, readFileSync, statSync, watch, writeFileSync } from 'fs' import { existsSync, readdirSync, readFileSync, statSync, watch, writeFileSync } from 'fs'
import { join } from 'path' import { join } from 'path'
export type { AppState } from '@types' export type { AppState } from '@types'
export const APPS_DIR = join(process.env.DATA_DIR ?? '.', 'apps') export const APPS_DIR = join(process.env.DATA_DIR ?? '.', 'apps')
const DEFAULT_EMOJI = '🖥️'
const MAX_LOGS = 100 const MAX_LOGS = 100
const _apps = new Map<string, App>() const _apps = new Map<string, App>()
const _listeners = new Set<() => void>() const _listeners = new Set<() => void>()

View File

@ -1,3 +1,5 @@
import { DEFAULT_EMOJI } from './types'
export interface AppTemplates { export interface AppTemplates {
'index.tsx': string 'index.tsx': string
'package.json': string 'package.json': string
@ -41,6 +43,9 @@ export function generateTemplates(appName: string): AppTemplates {
start: 'bun toes', start: 'bun toes',
dev: 'bun run --hot index.tsx', dev: 'bun run --hot index.tsx',
}, },
toes: {
icon: DEFAULT_EMOJI,
},
devDependencies: { devDependencies: {
'@types/bun': 'latest', '@types/bun': 'latest',
}, },

View File

@ -1,3 +1,5 @@
export const DEFAULT_EMOJI = '🖥️'
export type AppState = 'invalid' | 'stopped' | 'starting' | 'running' | 'stopping' export type AppState = 'invalid' | 'stopped' | 'starting' | 'running' | 'stopping'
export type LogLine = { export type LogLine = {