From dd56dc0df61115577c4e6bd04b49fbae8f629d1b Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Thu, 29 Jan 2026 21:24:07 -0800 Subject: [PATCH] new app --- src/client/index.tsx | 142 +++++++++++++++++++++++++++++++++++++++- src/server/apps.ts | 3 +- src/shared/templates.ts | 5 ++ src/shared/types.ts | 2 + 4 files changed, 148 insertions(+), 4 deletions(-) diff --git a/src/client/index.tsx b/src/client/index.tsx index c0dc7cf..f58513c 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -1,8 +1,9 @@ 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 { Modal, initModal } from './tags/modal' +import { closeModal, initModal, Modal, openModal, rerenderModal } from './tags/modal' import { initUpdate } from './update' import { openEmojiPicker } from './tags/emoji-picker' @@ -346,6 +347,143 @@ const stateLabels: Record = { 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', () => ( +
{ + e.preventDefault() + const input = (e.target as HTMLFormElement).querySelector('input') as HTMLInputElement + createNewApp(input) + }}> + + App Name + + {newAppError && {newAppError}} + + + + + +
+ )) +} + // 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' }) @@ -522,7 +660,7 @@ const Dashboard = () => { {!sidebarCollapsed && ( - + New App + + New App )} diff --git a/src/server/apps.ts b/src/server/apps.ts index ebc2800..5539834 100644 --- a/src/server/apps.ts +++ b/src/server/apps.ts @@ -1,13 +1,12 @@ import type { App as SharedApp, AppState, LogLine } from '@types' import type { Subprocess } from 'bun' +import { DEFAULT_EMOJI } from '@types' import { existsSync, readdirSync, readFileSync, statSync, watch, writeFileSync } from 'fs' import { join } from 'path' export type { AppState } from '@types' export const APPS_DIR = join(process.env.DATA_DIR ?? '.', 'apps') - -const DEFAULT_EMOJI = '🖥️' const MAX_LOGS = 100 const _apps = new Map() const _listeners = new Set<() => void>() diff --git a/src/shared/templates.ts b/src/shared/templates.ts index f0e70a5..1e2a143 100644 --- a/src/shared/templates.ts +++ b/src/shared/templates.ts @@ -1,3 +1,5 @@ +import { DEFAULT_EMOJI } from './types' + export interface AppTemplates { 'index.tsx': string 'package.json': string @@ -41,6 +43,9 @@ export function generateTemplates(appName: string): AppTemplates { start: 'bun toes', dev: 'bun run --hot index.tsx', }, + toes: { + icon: DEFAULT_EMOJI, + }, devDependencies: { '@types/bun': 'latest', }, diff --git a/src/shared/types.ts b/src/shared/types.ts index ce03c6b..8e47fa5 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,3 +1,5 @@ +export const DEFAULT_EMOJI = '🖥️' + export type AppState = 'invalid' | 'stopped' | 'starting' | 'running' | 'stopping' export type LogLine = {