From f1cf2b9291bd0afe0a6a15d7a16f5b8ae68fb3b6 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Fri, 30 Jan 2026 08:23:29 -0800 Subject: [PATCH] reorg --- src/cli/commands/index.ts | 13 + src/cli/commands/logs.ts | 59 ++ src/cli/commands/manage.ts | 188 ++++ src/cli/commands/sync.ts | 309 ++++++ src/cli/http.ts | 89 ++ src/cli/index.ts | 793 +--------------- src/cli/name.ts | 21 + src/cli/prompts.ts | 29 + src/cli/setup.ts | 132 +++ src/client/api.ts | 5 + src/client/components/AppDetail.tsx | 151 +++ src/client/components/Dashboard.tsx | 23 + src/client/components/Nav.tsx | 16 + src/client/components/Sidebar.tsx | 66 ++ .../{tags => components}/emoji-picker.tsx | 0 src/client/components/index.ts | 4 + src/client/{tags => components}/modal.tsx | 0 src/client/index.tsx | 878 +----------------- src/client/modals/DeleteApp.tsx | 83 ++ src/client/modals/NewApp.tsx | 87 ++ src/client/modals/RenameApp.tsx | 102 ++ src/client/modals/index.ts | 3 + src/client/state.ts | 34 + src/client/styles/buttons.ts | 46 + src/client/styles/forms.ts | 53 ++ src/client/styles/index.ts | 34 + src/client/styles/layout.ts | 137 +++ src/client/styles/logs.tsx | 32 + src/client/styles/misc.ts | 121 +++ 29 files changed, 1845 insertions(+), 1663 deletions(-) create mode 100644 src/cli/commands/index.ts create mode 100644 src/cli/commands/logs.ts create mode 100644 src/cli/commands/manage.ts create mode 100644 src/cli/commands/sync.ts create mode 100644 src/cli/http.ts create mode 100644 src/cli/name.ts create mode 100644 src/cli/prompts.ts create mode 100644 src/cli/setup.ts create mode 100644 src/client/api.ts create mode 100644 src/client/components/AppDetail.tsx create mode 100644 src/client/components/Dashboard.tsx create mode 100644 src/client/components/Nav.tsx create mode 100644 src/client/components/Sidebar.tsx rename src/client/{tags => components}/emoji-picker.tsx (100%) create mode 100644 src/client/components/index.ts rename src/client/{tags => components}/modal.tsx (100%) create mode 100644 src/client/modals/DeleteApp.tsx create mode 100644 src/client/modals/NewApp.tsx create mode 100644 src/client/modals/RenameApp.tsx create mode 100644 src/client/modals/index.ts create mode 100644 src/client/state.ts create mode 100644 src/client/styles/buttons.ts create mode 100644 src/client/styles/forms.ts create mode 100644 src/client/styles/index.ts create mode 100644 src/client/styles/layout.ts create mode 100644 src/client/styles/logs.tsx create mode 100644 src/client/styles/misc.ts diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts new file mode 100644 index 0000000..b5cb9d2 --- /dev/null +++ b/src/cli/commands/index.ts @@ -0,0 +1,13 @@ +export { logApp } from './logs' +export { + infoApp, + listApps, + newApp, + openApp, + renameApp, + restartApp, + rmApp, + startApp, + stopApp, +} from './manage' +export { getApp, pullApp, pushApp, syncApp } from './sync' diff --git a/src/cli/commands/logs.ts b/src/cli/commands/logs.ts new file mode 100644 index 0000000..a341abb --- /dev/null +++ b/src/cli/commands/logs.ts @@ -0,0 +1,59 @@ +import type { LogLine } from '@types' +import { get, makeUrl } from '../http' +import { resolveAppName } from '../name' + +export const printLog = (line: LogLine) => + console.log(`${new Date(line.time).toLocaleTimeString()} ${line.text}`) + +export async function logApp(arg: string | undefined, options: { follow?: boolean }) { + const name = resolveAppName(arg) + if (!name) return + + if (options.follow) { + await tailLogs(name) + return + } + + const logs: LogLine[] | undefined = await get(`/api/apps/${name}/logs`) + if (!logs) { + console.error(`App not found: ${name}`) + return + } + if (logs.length === 0) { + console.log('No logs yet') + return + } + for (const line of logs) { + printLog(line) + } +} + +export async function tailLogs(name: string) { + const url = makeUrl(`/api/apps/${name}/logs/stream`) + const res = await fetch(url) + if (!res.ok) { + console.error(`App not found: ${name}`) + return + } + if (!res.body) return + + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n\n') + buffer = lines.pop() ?? '' + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = JSON.parse(line.slice(6)) as LogLine + printLog(data) + } + } + } +} diff --git a/src/cli/commands/manage.ts b/src/cli/commands/manage.ts new file mode 100644 index 0000000..a536bbb --- /dev/null +++ b/src/cli/commands/manage.ts @@ -0,0 +1,188 @@ +import type { App } from '@types' +import { generateTemplates } from '@templates' +import color from 'kleur' +import { existsSync, mkdirSync, writeFileSync } from 'fs' +import { basename, join } from 'path' +import { del, get, getManifest, post } from '../http' +import { confirm, prompt } from '../prompts' +import { resolveAppName } from '../name' +import { pushApp } from './sync' + +export const STATE_ICONS: Record = { + running: color.green('●'), + starting: color.yellow('◎'), + stopped: color.gray('◯'), + invalid: color.red('◌'), +} + +export async function infoApp(arg?: string) { + const name = resolveAppName(arg) + if (!name) return + + const app: App | undefined = await get(`/api/apps/${name}`) + if (!app) { + console.error(`App not found: ${name}`) + return + } + + const icon = STATE_ICONS[app.state] ?? '◯' + console.log(`${icon} ${color.bold(app.name)}`) + console.log(` State: ${app.state}`) + if (app.port) { + console.log(` Port: ${app.port}`) + console.log(` URL: http://localhost:${app.port}`) + } + if (app.started) { + const uptime = Date.now() - app.started + const seconds = Math.floor(uptime / 1000) % 60 + const minutes = Math.floor(uptime / 60000) % 60 + const hours = Math.floor(uptime / 3600000) + const parts = [] + if (hours) parts.push(`${hours}h`) + if (minutes) parts.push(`${minutes}m`) + parts.push(`${seconds}s`) + console.log(` Uptime: ${parts.join(' ')}`) + } + if (app.error) console.log(` Error: ${color.red(app.error)}`) +} + +export async function listApps() { + const apps: App[] | undefined = await get('/api/apps') + if (!apps) return + + for (const app of apps) { + console.log(`${STATE_ICONS[app.state] ?? '◯'} ${app.name}`) + } +} + +export async function newApp(name?: string) { + const appPath = name ? join(process.cwd(), name) : process.cwd() + const appName = name ?? basename(process.cwd()) + + if (name && existsSync(appPath)) { + console.error(`Directory already exists: ${name}`) + return + } + + const filesToCheck = ['index.tsx', 'package.json', 'tsconfig.json'] + const existing = filesToCheck.filter((f) => existsSync(join(appPath, f))) + if (existing.length > 0) { + console.error(`Files already exist: ${existing.join(', ')}`) + return + } + + const ok = await confirm(`Create ${color.bold(appName)} in ${appPath}?`) + if (!ok) return + + mkdirSync(join(appPath, 'src', 'pages'), { recursive: true }) + + const templates = generateTemplates(appName) + for (const [filename, content] of Object.entries(templates)) { + writeFileSync(join(appPath, filename), content) + } + + process.chdir(appPath) + await pushApp() + + console.log(color.green(`✓ Created ${appName}`)) + console.log() + console.log('Next steps:') + if (name) { + console.log(` cd ${name}`) + } + console.log(' bun install') + console.log(' bun dev') +} + +export async function openApp(arg?: string) { + const name = resolveAppName(arg) + if (!name) return + + const app: App | undefined = await get(`/api/apps/${name}`) + if (!app) { + console.error(`App not found: ${name}`) + return + } + if (app.state !== 'running') { + console.error(`App is not running: ${name}`) + return + } + const url = `http://localhost:${app.port}` + console.log(`Opening ${url}`) + Bun.spawn(['open', url]) +} + +export async function renameApp(arg: string | undefined, newName: string) { + const name = resolveAppName(arg) + if (!name) return + + const result = await getManifest(name) + if (result === null) return + if (!result.exists) { + console.error(`App not found on server: ${name}`) + return + } + + const expected = `sudo rename ${name} ${newName}` + console.log(`This will rename ${color.bold(name)} to ${color.bold(newName)}.`) + const answer = await prompt(`Type "${expected}" to confirm: `) + + if (answer !== expected) { + console.log('Aborted.') + return + } + + const response = await post<{ ok: boolean, error?: string, name?: string }>(`/api/apps/${name}/rename`, { name: newName }) + if (!response) return + + if (!response.ok) { + console.error(color.red(`Error: ${response.error}`)) + return + } + + console.log(color.green(`✓ Renamed ${name} to ${response.name}`)) +} + +export async function restartApp(arg?: string) { + const name = resolveAppName(arg) + if (!name) return + await post(`/api/apps/${name}/restart`) +} + +export async function rmApp(arg?: string) { + const name = resolveAppName(arg) + if (!name) return + + const result = await getManifest(name) + if (result === null) return + if (!result.exists) { + console.error(`App not found on server: ${name}`) + return + } + + const expected = `sudo rm ${name}` + console.log(`This will ${color.red('permanently delete')} ${color.bold(name)} from the server.`) + const answer = await prompt(`Type "${expected}" to confirm: `) + + if (answer !== expected) { + console.log('Aborted.') + return + } + + const success = await del(`/api/sync/apps/${name}`) + if (success) { + console.log(color.green(`✓ Removed ${name}`)) + } +} + +export async function startApp(arg?: string) { + const name = resolveAppName(arg) + if (!name) return + await post(`/api/apps/${name}/start`) +} + +export async function stopApp(arg?: string) { + const name = resolveAppName(arg) + if (!name) return + await post(`/api/apps/${name}/stop`) +} diff --git a/src/cli/commands/sync.ts b/src/cli/commands/sync.ts new file mode 100644 index 0000000..219332c --- /dev/null +++ b/src/cli/commands/sync.ts @@ -0,0 +1,309 @@ +import type { Manifest } from '@types' +import { loadGitignore } from '@gitignore' +import { computeHash, generateManifest } from '%sync' +import color from 'kleur' +import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, watch, writeFileSync } from 'fs' +import { dirname, join } from 'path' +import { del, download, get, getManifest, handleError, makeUrl, put } from '../http' +import { confirm } from '../prompts' +import { getAppName, isApp } from '../name' + +export async function getApp(name: string) { + console.log(`Fetching ${color.bold(name)} from server...`) + + const manifest: Manifest | undefined = await get(`/api/sync/apps/${name}/manifest`) + if (!manifest) { + console.error(`App not found: ${name}`) + return + } + + const appPath = join(process.cwd(), name) + if (existsSync(appPath)) { + console.error(`Directory already exists: ${name}`) + return + } + + mkdirSync(appPath, { recursive: true }) + + const files = Object.keys(manifest.files) + console.log(`Downloading ${files.length} files...`) + + for (const file of files) { + const content = await download(`/api/sync/apps/${name}/files/${file}`) + if (!content) { + console.error(`Failed to download: ${file}`) + continue + } + + const fullPath = join(appPath, file) + const dir = dirname(fullPath) + + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + + writeFileSync(fullPath, content) + } + + console.log(color.green(`✓ Downloaded ${name}`)) +} + +export async function pushApp() { + if (!isApp()) { + console.error('Not a toes app. Use `toes get ` to grab one.') + return + } + + const appName = getAppName() + + const localManifest = generateManifest(process.cwd(), appName) + const result = await getManifest(appName) + + if (result === null) { + // Connection error - already printed + return + } + + if (!result.exists) { + const ok = await confirm(`App ${color.bold(appName)} doesn't exist on server. Create it?`) + if (!ok) return + } + + const localFiles = new Set(Object.keys(localManifest.files)) + const remoteFiles = new Set(Object.keys(result.manifest?.files ?? {})) + + // Files to upload (new or changed) + const toUpload: string[] = [] + for (const file of localFiles) { + const local = localManifest.files[file]! + const remote = result.manifest?.files[file] + if (!remote || local.hash !== remote.hash) { + toUpload.push(file) + } + } + + // Files to delete (in remote but not local) + const toDelete: string[] = [] + for (const file of remoteFiles) { + if (!localFiles.has(file)) { + toDelete.push(file) + } + } + + if (toUpload.length === 0 && toDelete.length === 0) { + console.log('Already up to date') + return + } + + console.log(`Pushing ${color.bold(appName)} to server...`) + + if (toUpload.length > 0) { + console.log(`Uploading ${toUpload.length} files...`) + for (const file of toUpload) { + const content = readFileSync(join(process.cwd(), file)) + const success = await put(`/api/sync/apps/${appName}/files/${file}`, content) + if (success) { + console.log(` ${color.green('↑')} ${file}`) + } else { + console.log(` ${color.red('✗')} ${file}`) + } + } + } + + if (toDelete.length > 0) { + console.log(`Deleting ${toDelete.length} files on server...`) + for (const file of toDelete) { + const success = await del(`/api/sync/apps/${appName}/files/${file}`) + if (success) { + console.log(` ${color.red('✗')} ${file}`) + } else { + console.log(` ${color.red('Failed to delete')} ${file}`) + } + } + } + + console.log(color.green('✓ Push complete')) +} + +export async function pullApp() { + if (!isApp()) { + console.error('Not a toes app. Use `toes get ` to grab one.') + return + } + + const appName = getAppName() + + const remoteManifest: Manifest | undefined = await get(`/api/sync/apps/${appName}/manifest`) + if (!remoteManifest) { + console.error('App not found on server') + return + } + + const localManifest = generateManifest(process.cwd(), appName) + + const localFiles = new Set(Object.keys(localManifest.files)) + const remoteFiles = new Set(Object.keys(remoteManifest.files)) + + // Files to download (new or changed) + const toDownload: string[] = [] + for (const file of remoteFiles) { + const remote = remoteManifest.files[file]! + const local = localManifest.files[file] + if (!local || remote.hash !== local.hash) { + toDownload.push(file) + } + } + + // Files to delete (in local but not remote) + const toDelete: string[] = [] + for (const file of localFiles) { + if (!remoteFiles.has(file)) { + toDelete.push(file) + } + } + + if (toDownload.length === 0 && toDelete.length === 0) { + console.log('Already up to date') + return + } + + console.log(`Pulling ${color.bold(appName)} from server...`) + + if (toDownload.length > 0) { + console.log(`Downloading ${toDownload.length} files...`) + for (const file of toDownload) { + const content = await download(`/api/sync/apps/${appName}/files/${file}`) + if (!content) { + console.log(` ${color.red('✗')} ${file}`) + continue + } + + const fullPath = join(process.cwd(), file) + const dir = dirname(fullPath) + + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + + writeFileSync(fullPath, content) + console.log(` ${color.green('↓')} ${file}`) + } + } + + if (toDelete.length > 0) { + console.log(`Deleting ${toDelete.length} local files...`) + for (const file of toDelete) { + const fullPath = join(process.cwd(), file) + unlinkSync(fullPath) + console.log(` ${color.red('✗')} ${file}`) + } + } + + console.log(color.green('✓ Pull complete')) +} + +export async function syncApp() { + if (!isApp()) { + console.error('Not a toes app. Use `toes get ` to grab one.') + return + } + + const appName = getAppName() + const gitignore = loadGitignore(process.cwd()) + const localHashes = new Map() + + // Initialize local hashes + const manifest = generateManifest(process.cwd(), appName) + for (const [path, info] of Object.entries(manifest.files)) { + localHashes.set(path, info.hash) + } + + console.log(`Syncing ${color.bold(appName)}...`) + + // Watch local files + const watcher = watch(process.cwd(), { recursive: true }, async (_event, filename) => { + if (!filename || gitignore.shouldExclude(filename)) return + + const fullPath = join(process.cwd(), filename) + + if (existsSync(fullPath) && statSync(fullPath).isFile()) { + const content = readFileSync(fullPath) + const hash = computeHash(content) + if (localHashes.get(filename) !== hash) { + localHashes.set(filename, hash) + await put(`/api/sync/apps/${appName}/files/${filename}`, content) + console.log(` ${color.green('↑')} ${filename}`) + } + } else if (!existsSync(fullPath)) { + localHashes.delete(filename) + await del(`/api/sync/apps/${appName}/files/${filename}`) + console.log(` ${color.red('✗')} ${filename}`) + } + }) + + // Connect to SSE for remote changes + const url = makeUrl(`/api/sync/apps/${appName}/watch`) + let res: Response + try { + res = await fetch(url) + if (!res.ok) { + console.error(`Failed to connect to server: ${res.status} ${res.statusText}`) + watcher.close() + return + } + } catch (error) { + handleError(error) + watcher.close() + return + } + + if (!res.body) { + console.error('No response body from server') + watcher.close() + return + } + + console.log(` Connected to server, watching for changes...`) + + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n\n') + buffer = lines.pop() ?? '' + + for (const line of lines) { + if (!line.startsWith('data: ')) continue + const event = JSON.parse(line.slice(6)) as { type: 'change' | 'delete', path: string, hash?: string } + + if (event.type === 'change') { + // Skip if we already have this version (handles echo from our own changes) + if (localHashes.get(event.path) === event.hash) continue + const content = await download(`/api/sync/apps/${appName}/files/${event.path}`) + if (content) { + const fullPath = join(process.cwd(), event.path) + mkdirSync(dirname(fullPath), { recursive: true }) + writeFileSync(fullPath, content) + localHashes.set(event.path, event.hash!) + console.log(` ${color.green('↓')} ${event.path}`) + } + } else if (event.type === 'delete') { + const fullPath = join(process.cwd(), event.path) + if (existsSync(fullPath)) { + unlinkSync(fullPath) + localHashes.delete(event.path) + console.log(` ${color.red('✗')} ${event.path} (remote)`) + } + } + } + } + } finally { + watcher.close() + } +} diff --git a/src/cli/http.ts b/src/cli/http.ts new file mode 100644 index 0000000..6f5be60 --- /dev/null +++ b/src/cli/http.ts @@ -0,0 +1,89 @@ +import type { Manifest } from '@types' + +export const HOST = `http://localhost:${process.env.PORT ?? 3000}` + +export function makeUrl(path: string): string { + return `${HOST}${path}` +} + +export function handleError(error: unknown): void { + if (error instanceof Error && 'code' in error && error.code === 'ConnectionRefused') { + console.error(`🐾 Can't connect to toes server at ${HOST}`) + return + } + console.error(error) +} + +export async function get(url: string): Promise { + try { + const res = await fetch(makeUrl(url)) + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) + return await res.json() + } catch (error) { + handleError(error) + } +} + +export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest } | null> { + try { + const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`)) + if (res.status === 404) return { exists: false } + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) + return { exists: true, manifest: await res.json() } + } catch (error) { + handleError(error) + return null + } +} + +export async function post(url: string, body?: B): Promise { + try { + const res = await fetch(makeUrl(url), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) + return await res.json() + } catch (error) { + handleError(error) + } +} + +export async function put(url: string, body: Buffer | Uint8Array): Promise { + try { + const res = await fetch(makeUrl(url), { + method: 'PUT', + body: body as BodyInit, + }) + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) + return true + } catch (error) { + handleError(error) + return false + } +} + +export async function download(url: string): Promise { + try { + const fullUrl = makeUrl(url) + const res = await fetch(fullUrl) + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) + return Buffer.from(await res.arrayBuffer()) + } catch (error) { + handleError(error) + } +} + +export async function del(url: string): Promise { + try { + const res = await fetch(makeUrl(url), { + method: 'DELETE', + }) + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) + return true + } catch (error) { + handleError(error) + return false + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 19e7455..b60b2f7 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,793 +1,4 @@ #!/usr/bin/env bun -import type { App, LogLine, Manifest } from '@types' -import { loadGitignore } from '@gitignore' -import { computeHash, generateManifest } from '%sync' -import { generateTemplates } from '@templates' -import { program } from 'commander' -import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, watch, writeFileSync } from 'fs' -import color from 'kleur' -import { basename, dirname, join } from 'path' -import * as readline from 'readline' +import { program } from './setup' -const HOST = `http://localhost:${process.env.PORT ?? 3000}` - -const STATE_ICONS: Record = { - running: color.green('●'), - starting: color.yellow('◎'), - stopped: color.gray('◯'), - invalid: color.red('◌'), -} - -function makeUrl(path: string): string { - return `${HOST}${path}` -} - -function handleError(error: unknown): void { - if (error instanceof Error && 'code' in error && error.code === 'ConnectionRefused') { - console.error(`🐾 Can't connect to toes server at ${HOST}`) - return - } - console.error(error) -} - -async function get(url: string): Promise { - try { - const res = await fetch(makeUrl(url)) - if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) - return await res.json() - } catch (error) { - handleError(error) - } -} - -async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest } | null> { - try { - const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`)) - if (res.status === 404) return { exists: false } - if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) - return { exists: true, manifest: await res.json() } - } catch (error) { - handleError(error) - return null - } -} - -async function post(url: string, body?: B): Promise { - try { - const res = await fetch(makeUrl(url), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) - return await res.json() - } catch (error) { - handleError(error) - } -} - -async function put(url: string, body: Buffer | Uint8Array): Promise { - try { - const res = await fetch(makeUrl(url), { - method: 'PUT', - body: body as BodyInit, - }) - if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) - return true - } catch (error) { - handleError(error) - return false - } -} - -async function download(url: string): Promise { - try { - const fullUrl = makeUrl(url) - const res = await fetch(fullUrl) - if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) - return Buffer.from(await res.arrayBuffer()) - } catch (error) { - handleError(error) - } -} - -async function del(url: string): Promise { - try { - const res = await fetch(makeUrl(url), { - method: 'DELETE', - }) - if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) - return true - } catch (error) { - handleError(error) - return false - } -} - -async function infoApp(arg?: string) { - const name = resolveAppName(arg) - if (!name) return - - const app: App | undefined = await get(`/api/apps/${name}`) - if (!app) { - console.error(`App not found: ${name}`) - return - } - - const icon = STATE_ICONS[app.state] ?? '◯' - console.log(`${icon} ${color.bold(app.name)}`) - console.log(` State: ${app.state}`) - if (app.port) { - console.log(` Port: ${app.port}`) - console.log(` URL: http://localhost:${app.port}`) - } - if (app.started) { - const uptime = Date.now() - app.started - const seconds = Math.floor(uptime / 1000) % 60 - const minutes = Math.floor(uptime / 60000) % 60 - const hours = Math.floor(uptime / 3600000) - const parts = [] - if (hours) parts.push(`${hours}h`) - if (minutes) parts.push(`${minutes}m`) - parts.push(`${seconds}s`) - console.log(` Uptime: ${parts.join(' ')}`) - } - if (app.error) console.log(` Error: ${color.red(app.error)}`) -} - -async function listApps() { - const apps: App[] | undefined = await get('/api/apps') - if (!apps) return - - for (const app of apps) { - console.log(`${STATE_ICONS[app.state] ?? '◯'} ${app.name}`) - } -} - -async function startApp(arg?: string) { - const name = resolveAppName(arg) - if (!name) return - await post(`/api/apps/${name}/start`) -} - -async function stopApp(arg?: string) { - const name = resolveAppName(arg) - if (!name) return - await post(`/api/apps/${name}/stop`) -} - -async function restartApp(arg?: string) { - const name = resolveAppName(arg) - if (!name) return - await post(`/api/apps/${name}/restart`) -} - -const printLog = (line: LogLine) => - console.log(`${new Date(line.time).toLocaleTimeString()} ${line.text}`) - -async function logApp(arg: string | undefined, options: { follow?: boolean }) { - const name = resolveAppName(arg) - if (!name) return - - if (options.follow) { - await tailLogs(name) - return - } - - const logs: LogLine[] | undefined = await get(`/api/apps/${name}/logs`) - if (!logs) { - console.error(`App not found: ${name}`) - return - } - if (logs.length === 0) { - console.log('No logs yet') - return - } - for (const line of logs) { - printLog(line) - } -} - -async function tailLogs(name: string) { - const url = makeUrl(`/api/apps/${name}/logs/stream`) - const res = await fetch(url) - if (!res.ok) { - console.error(`App not found: ${name}`) - return - } - if (!res.body) return - - const reader = res.body.getReader() - const decoder = new TextDecoder() - let buffer = '' - - while (true) { - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n\n') - buffer = lines.pop() ?? '' - - for (const line of lines) { - if (line.startsWith('data: ')) { - const data = JSON.parse(line.slice(6)) as LogLine - printLog(data) - } - } - } -} - -async function openApp(arg?: string) { - const name = resolveAppName(arg) - if (!name) return - - const app: App | undefined = await get(`/api/apps/${name}`) - if (!app) { - console.error(`App not found: ${name}`) - return - } - if (app.state !== 'running') { - console.error(`App is not running: ${name}`) - return - } - const url = `http://localhost:${app.port}` - console.log(`Opening ${url}`) - Bun.spawn(['open', url]) -} - -async function getApp(name: string) { - console.log(`Fetching ${color.bold(name)} from server...`) - - const manifest: Manifest | undefined = await get(`/api/sync/apps/${name}/manifest`) - if (!manifest) { - console.error(`App not found: ${name}`) - return - } - - const appPath = join(process.cwd(), name) - if (existsSync(appPath)) { - console.error(`Directory already exists: ${name}`) - return - } - - mkdirSync(appPath, { recursive: true }) - - const files = Object.keys(manifest.files) - console.log(`Downloading ${files.length} files...`) - - for (const file of files) { - const content = await download(`/api/sync/apps/${name}/files/${file}`) - if (!content) { - console.error(`Failed to download: ${file}`) - continue - } - - const fullPath = join(appPath, file) - const dir = dirname(fullPath) - - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - - writeFileSync(fullPath, content) - } - - console.log(color.green(`✓ Downloaded ${name}`)) -} - -function getAppPackage(): { name?: string, scripts?: { toes?: string } } | null { - try { - return JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8')) - } catch { - return null - } -} - -const getAppName = () => getAppPackage()?.name || basename(process.cwd()) -const isApp = () => !!getAppPackage()?.scripts?.toes - -function resolveAppName(name?: string): string | undefined { - if (name) return name - if (isApp()) return getAppName() - console.error('No app specified and current directory is not a toes app') - return undefined -} - -async function pushApp() { - if (!isApp()) { - console.error('Not a toes app. Use `toes get ` to grab one.') - return - } - - const appName = getAppName() - - const localManifest = generateManifest(process.cwd(), appName) - const result = await getManifest(appName) - - if (result === null) { - // Connection error - already printed - return - } - - if (!result.exists) { - const ok = await confirm(`App ${color.bold(appName)} doesn't exist on server. Create it?`) - if (!ok) return - } - - const localFiles = new Set(Object.keys(localManifest.files)) - const remoteFiles = new Set(Object.keys(result.manifest?.files ?? {})) - - // Files to upload (new or changed) - const toUpload: string[] = [] - for (const file of localFiles) { - const local = localManifest.files[file]! - const remote = result.manifest?.files[file] - if (!remote || local.hash !== remote.hash) { - toUpload.push(file) - } - } - - // Files to delete (in remote but not local) - const toDelete: string[] = [] - for (const file of remoteFiles) { - if (!localFiles.has(file)) { - toDelete.push(file) - } - } - - if (toUpload.length === 0 && toDelete.length === 0) { - console.log('Already up to date') - return - } - - console.log(`Pushing ${color.bold(appName)} to server...`) - - if (toUpload.length > 0) { - console.log(`Uploading ${toUpload.length} files...`) - for (const file of toUpload) { - const content = readFileSync(join(process.cwd(), file)) - const success = await put(`/api/sync/apps/${appName}/files/${file}`, content) - if (success) { - console.log(` ${color.green('↑')} ${file}`) - } else { - console.log(` ${color.red('✗')} ${file}`) - } - } - } - - if (toDelete.length > 0) { - console.log(`Deleting ${toDelete.length} files on server...`) - for (const file of toDelete) { - const success = await del(`/api/sync/apps/${appName}/files/${file}`) - if (success) { - console.log(` ${color.red('✗')} ${file}`) - } else { - console.log(` ${color.red('Failed to delete')} ${file}`) - } - } - } - - console.log(color.green('✓ Push complete')) -} - -async function confirm(message: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) - - return new Promise((resolve) => { - rl.question(`${message} [y/N] `, (answer) => { - rl.close() - resolve(answer.toLowerCase() === 'y') - }) - }) -} - -async function newApp(name?: string) { - const appPath = name ? join(process.cwd(), name) : process.cwd() - const appName = name ?? basename(process.cwd()) - - if (name && existsSync(appPath)) { - console.error(`Directory already exists: ${name}`) - return - } - - const filesToCheck = ['index.tsx', 'package.json', 'tsconfig.json'] - const existing = filesToCheck.filter((f) => existsSync(join(appPath, f))) - if (existing.length > 0) { - console.error(`Files already exist: ${existing.join(', ')}`) - return - } - - const ok = await confirm(`Create ${color.bold(appName)} in ${appPath}?`) - if (!ok) return - - mkdirSync(join(appPath, 'src', 'pages'), { recursive: true }) - - const templates = generateTemplates(appName) - for (const [filename, content] of Object.entries(templates)) { - writeFileSync(join(appPath, filename), content) - } - - process.chdir(appPath) - await pushApp() - - console.log(color.green(`✓ Created ${appName}`)) - console.log() - console.log('Next steps:') - if (name) { - console.log(` cd ${name}`) - } - console.log(' bun install') - console.log(' bun dev') -} - -async function pullApp() { - if (!isApp()) { - console.error('Not a toes app. Use `toes get ` to grab one.') - return - } - - const appName = getAppName() - - const remoteManifest: Manifest | undefined = await get(`/api/sync/apps/${appName}/manifest`) - if (!remoteManifest) { - console.error('App not found on server') - return - } - - const localManifest = generateManifest(process.cwd(), appName) - - const localFiles = new Set(Object.keys(localManifest.files)) - const remoteFiles = new Set(Object.keys(remoteManifest.files)) - - // Files to download (new or changed) - const toDownload: string[] = [] - for (const file of remoteFiles) { - const remote = remoteManifest.files[file]! - const local = localManifest.files[file] - if (!local || remote.hash !== local.hash) { - toDownload.push(file) - } - } - - // Files to delete (in local but not remote) - const toDelete: string[] = [] - for (const file of localFiles) { - if (!remoteFiles.has(file)) { - toDelete.push(file) - } - } - - if (toDownload.length === 0 && toDelete.length === 0) { - console.log('Already up to date') - return - } - - console.log(`Pulling ${color.bold(appName)} from server...`) - - if (toDownload.length > 0) { - console.log(`Downloading ${toDownload.length} files...`) - for (const file of toDownload) { - const content = await download(`/api/sync/apps/${appName}/files/${file}`) - if (!content) { - console.log(` ${color.red('✗')} ${file}`) - continue - } - - const fullPath = join(process.cwd(), file) - const dir = dirname(fullPath) - - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - - writeFileSync(fullPath, content) - console.log(` ${color.green('↓')} ${file}`) - } - } - - if (toDelete.length > 0) { - console.log(`Deleting ${toDelete.length} local files...`) - for (const file of toDelete) { - const fullPath = join(process.cwd(), file) - unlinkSync(fullPath) - console.log(` ${color.red('✗')} ${file}`) - } - } - - console.log(color.green('✓ Pull complete')) -} - -async function syncApp() { - if (!isApp()) { - console.error('Not a toes app. Use `toes get ` to grab one.') - return - } - - const appName = getAppName() - const gitignore = loadGitignore(process.cwd()) - const localHashes = new Map() - - // Initialize local hashes - const manifest = generateManifest(process.cwd(), appName) - for (const [path, info] of Object.entries(manifest.files)) { - localHashes.set(path, info.hash) - } - - console.log(`Syncing ${color.bold(appName)}...`) - - // Watch local files - const watcher = watch(process.cwd(), { recursive: true }, async (_event, filename) => { - if (!filename || gitignore.shouldExclude(filename)) return - - const fullPath = join(process.cwd(), filename) - - if (existsSync(fullPath) && statSync(fullPath).isFile()) { - const content = readFileSync(fullPath) - const hash = computeHash(content) - if (localHashes.get(filename) !== hash) { - localHashes.set(filename, hash) - await put(`/api/sync/apps/${appName}/files/${filename}`, content) - console.log(` ${color.green('↑')} ${filename}`) - } - } else if (!existsSync(fullPath)) { - localHashes.delete(filename) - await del(`/api/sync/apps/${appName}/files/${filename}`) - console.log(` ${color.red('✗')} ${filename}`) - } - }) - - // Connect to SSE for remote changes - const url = makeUrl(`/api/sync/apps/${appName}/watch`) - let res: Response - try { - res = await fetch(url) - if (!res.ok) { - console.error(`Failed to connect to server: ${res.status} ${res.statusText}`) - watcher.close() - return - } - } catch (error) { - handleError(error) - watcher.close() - return - } - - if (!res.body) { - console.error('No response body from server') - watcher.close() - return - } - - console.log(` Connected to server, watching for changes...`) - - const reader = res.body.getReader() - const decoder = new TextDecoder() - let buffer = '' - - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n\n') - buffer = lines.pop() ?? '' - - for (const line of lines) { - if (!line.startsWith('data: ')) continue - const event = JSON.parse(line.slice(6)) as { type: 'change' | 'delete', path: string, hash?: string } - - if (event.type === 'change') { - // Skip if we already have this version (handles echo from our own changes) - if (localHashes.get(event.path) === event.hash) continue - const content = await download(`/api/sync/apps/${appName}/files/${event.path}`) - if (content) { - const fullPath = join(process.cwd(), event.path) - mkdirSync(dirname(fullPath), { recursive: true }) - writeFileSync(fullPath, content) - localHashes.set(event.path, event.hash!) - console.log(` ${color.green('↓')} ${event.path}`) - } - } else if (event.type === 'delete') { - const fullPath = join(process.cwd(), event.path) - if (existsSync(fullPath)) { - unlinkSync(fullPath) - localHashes.delete(event.path) - console.log(` ${color.red('✗')} ${event.path} (remote)`) - } - } - } - } - } finally { - watcher.close() - } -} - -async function prompt(message: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) - - return new Promise(resolve => { - rl.question(message, (answer) => { - rl.close() - resolve(answer) - }) - }) -} - -async function renameApp(arg: string | undefined, newName: string) { - const name = resolveAppName(arg) - if (!name) return - - const result = await getManifest(name) - if (result === null) return - if (!result.exists) { - console.error(`App not found on server: ${name}`) - return - } - - const expected = `sudo rename ${name} ${newName}` - console.log(`This will rename ${color.bold(name)} to ${color.bold(newName)}.`) - const answer = await prompt(`Type "${expected}" to confirm: `) - - if (answer !== expected) { - console.log('Aborted.') - return - } - - const response = await post<{ ok: boolean, error?: string, name?: string }>(`/api/apps/${name}/rename`, { name: newName }) - if (!response) return - - if (!response.ok) { - console.error(color.red(`Error: ${response.error}`)) - return - } - - console.log(color.green(`✓ Renamed ${name} to ${response.name}`)) -} - -async function rmApp(arg?: string) { - const name = resolveAppName(arg) - if (!name) return - - const result = await getManifest(name) - if (result === null) return - if (!result.exists) { - console.error(`App not found on server: ${name}`) - return - } - - const expected = `sudo rm ${name}` - console.log(`This will ${color.red('permanently delete')} ${color.bold(name)} from the server.`) - const answer = await prompt(`Type "${expected}" to confirm: `) - - if (answer !== expected) { - console.log('Aborted.') - return - } - - const success = await del(`/api/sync/apps/${name}`) - if (success) { - console.log(color.green(`✓ Removed ${name}`)) - } -} - -program - .name('toes') - .version('0.0.1', '-v, --version') - .addHelpText('beforeAll', (ctx) => { - if (ctx.command === program) { - return color.bold().cyan('\n🐾 Toes') + color.gray(' - personal web appliance\n') - } - return '' - }) - .configureOutput({ - writeOut: (str) => { - const colored = str - .replace(/^(Usage:)/gm, color.yellow('$1')) - .replace(/^(Commands:)/gm, color.yellow('$1')) - .replace(/^(Options:)/gm, color.yellow('$1')) - .replace(/^(Arguments:)/gm, color.yellow('$1')) - process.stdout.write(colored) - }, - }) - -program - .command('version', { hidden: true }) - .action(() => console.log(program.version())) - -program - .command('info') - .description('Show info for an app') - .argument('[name]', 'app name (uses current directory if omitted)') - .action(infoApp) - -program - .command('list') - .description('List all apps') - .action(listApps) - -program - .command('start') - .description('Start an app') - .argument('[name]', 'app name (uses current directory if omitted)') - .action(startApp) - -program - .command('stop') - .description('Stop an app') - .argument('[name]', 'app name (uses current directory if omitted)') - .action(stopApp) - -program - .command('restart') - .description('Restart an app') - .argument('[name]', 'app name (uses current directory if omitted)') - .action(restartApp) - -program - .command('logs') - .description('Show logs for an app') - .argument('[name]', 'app name (uses current directory if omitted)') - .option('-f, --follow', 'follow log output') - .action(logApp) - -program - .command('log', { hidden: true }) - .argument('[name]', 'app name (uses current directory if omitted)') - .option('-f, --follow', 'follow log output') - .action(logApp) - -program - .command('open') - .description('Open an app in browser') - .argument('[name]', 'app name (uses current directory if omitted)') - .action(openApp) - -program - .command('get') - .description('Download an app from server') - .argument('', 'app name') - .action(getApp) - -program - .command('new') - .description('Create a new toes app') - .argument('[name]', 'app name (uses current directory if omitted)') - .action(newApp) - -program - .command('push') - .description('Push local changes to server') - .action(pushApp) - -program - .command('pull') - .description('Pull changes from server') - .action(pullApp) - -program - .command('sync') - .description('Watch and sync changes bidirectionally') - .action(syncApp) - -program - .command('rm') - .description('Remove an app from the server') - .argument('[name]', 'app name (uses current directory if omitted)') - .action(rmApp) - -program - .command('rename') - .description('Rename an app') - .argument('[name]', 'app name (uses current directory if omitted)') - .argument('', 'new app name') - .action(renameApp) - -program.parse() \ No newline at end of file +program.parse() diff --git a/src/cli/name.ts b/src/cli/name.ts new file mode 100644 index 0000000..9f399b4 --- /dev/null +++ b/src/cli/name.ts @@ -0,0 +1,21 @@ +import { readFileSync } from 'fs' +import { basename, join } from 'path' + +export function getAppPackage(): { name?: string, scripts?: { toes?: string } } | null { + try { + return JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8')) + } catch { + return null + } +} + +export const getAppName = () => getAppPackage()?.name || basename(process.cwd()) + +export const isApp = () => !!getAppPackage()?.scripts?.toes + +export function resolveAppName(name?: string): string | undefined { + if (name) return name + if (isApp()) return getAppName() + console.error('No app specified and current directory is not a toes app') + return undefined +} diff --git a/src/cli/prompts.ts b/src/cli/prompts.ts new file mode 100644 index 0000000..aa26ba7 --- /dev/null +++ b/src/cli/prompts.ts @@ -0,0 +1,29 @@ +import * as readline from 'readline' + +export async function confirm(message: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + return new Promise((resolve) => { + rl.question(`${message} [y/N] `, (answer) => { + rl.close() + resolve(answer.toLowerCase() === 'y') + }) + }) +} + +export async function prompt(message: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + return new Promise(resolve => { + rl.question(message, (answer) => { + rl.close() + resolve(answer) + }) + }) +} diff --git a/src/cli/setup.ts b/src/cli/setup.ts new file mode 100644 index 0000000..3317eb4 --- /dev/null +++ b/src/cli/setup.ts @@ -0,0 +1,132 @@ +import { program } from 'commander' +import color from 'kleur' +import { + getApp, + infoApp, + listApps, + logApp, + newApp, + openApp, + pullApp, + pushApp, + renameApp, + restartApp, + rmApp, + startApp, + stopApp, + syncApp, +} from './commands' + +program + .name('toes') + .version('0.0.1', '-v, --version') + .addHelpText('beforeAll', (ctx) => { + if (ctx.command === program) { + return color.bold().cyan('\n🐾 Toes') + color.gray(' - personal web appliance\n') + } + return '' + }) + .configureOutput({ + writeOut: (str) => { + const colored = str + .replace(/^(Usage:)/gm, color.yellow('$1')) + .replace(/^(Commands:)/gm, color.yellow('$1')) + .replace(/^(Options:)/gm, color.yellow('$1')) + .replace(/^(Arguments:)/gm, color.yellow('$1')) + process.stdout.write(colored) + }, + }) + +program + .command('version', { hidden: true }) + .action(() => console.log(program.version())) + +program + .command('info') + .description('Show info for an app') + .argument('[name]', 'app name (uses current directory if omitted)') + .action(infoApp) + +program + .command('list') + .description('List all apps') + .action(listApps) + +program + .command('start') + .description('Start an app') + .argument('[name]', 'app name (uses current directory if omitted)') + .action(startApp) + +program + .command('stop') + .description('Stop an app') + .argument('[name]', 'app name (uses current directory if omitted)') + .action(stopApp) + +program + .command('restart') + .description('Restart an app') + .argument('[name]', 'app name (uses current directory if omitted)') + .action(restartApp) + +program + .command('logs') + .description('Show logs for an app') + .argument('[name]', 'app name (uses current directory if omitted)') + .option('-f, --follow', 'follow log output') + .action(logApp) + +program + .command('log', { hidden: true }) + .argument('[name]', 'app name (uses current directory if omitted)') + .option('-f, --follow', 'follow log output') + .action(logApp) + +program + .command('open') + .description('Open an app in browser') + .argument('[name]', 'app name (uses current directory if omitted)') + .action(openApp) + +program + .command('get') + .description('Download an app from server') + .argument('', 'app name') + .action(getApp) + +program + .command('new') + .description('Create a new toes app') + .argument('[name]', 'app name (uses current directory if omitted)') + .action(newApp) + +program + .command('push') + .description('Push local changes to server') + .action(pushApp) + +program + .command('pull') + .description('Pull changes from server') + .action(pullApp) + +program + .command('sync') + .description('Watch and sync changes bidirectionally') + .action(syncApp) + +program + .command('rm') + .description('Remove an app from the server') + .argument('[name]', 'app name (uses current directory if omitted)') + .action(rmApp) + +program + .command('rename') + .description('Rename an app') + .argument('[name]', 'app name (uses current directory if omitted)') + .argument('', 'new app name') + .action(renameApp) + +export { program } diff --git a/src/client/api.ts b/src/client/api.ts new file mode 100644 index 0000000..0f7b794 --- /dev/null +++ b/src/client/api.ts @@ -0,0 +1,5 @@ +export const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' }) + +export const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' }) + +export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' }) diff --git a/src/client/components/AppDetail.tsx b/src/client/components/AppDetail.tsx new file mode 100644 index 0000000..5c51142 --- /dev/null +++ b/src/client/components/AppDetail.tsx @@ -0,0 +1,151 @@ +import { define } from '@because/forge' +import type { App } from '../../shared/types' +import { restartApp, startApp, stopApp } from '../api' +import { openDeleteAppModal, openRenameAppModal } from '../modals' +import { selectedTab } from '../state' +import { + ActionBar, + Button, + ClickableAppName, + HeaderActions, + InfoLabel, + InfoRow, + InfoValue, + Link, + LogLine, + LogsContainer, + LogTime, + Main, + MainContent, + MainHeader, + MainTitle, + Section, + SectionTitle, + stateLabels, + StatusDot, + TabContent, +} from '../styles' +import { openEmojiPicker } from './emoji-picker' +import { theme } from '../themes' +import { Nav } from './Nav' + +const OpenEmojiPicker = define('OpenEmojiPicker', { + cursor: 'pointer', + + render({ props: { app, children, render: renderFn }, parts: { Root } }) { + return openEmojiPicker((emoji) => { + if (!app) return + + fetch(`/api/apps/${app.name}/icon?icon=${emoji}`, { method: 'POST' }) + app.icon = emoji + renderFn() + })}>{children} + } +}) + +export function AppDetail({ app, render }: { app: App, render: () => void }) { + return ( +
+ + + {app.icon} +   + openRenameAppModal(app)}>{app.name} + + + + + + +
+ ) +} diff --git a/src/client/components/Dashboard.tsx b/src/client/components/Dashboard.tsx new file mode 100644 index 0000000..356ff74 --- /dev/null +++ b/src/client/components/Dashboard.tsx @@ -0,0 +1,23 @@ +import { Styles } from '@because/forge' +import { Modal } from './modal' +import { apps, selectedApp } from '../state' +import { EmptyState, Layout } from '../styles' +import { AppDetail } from './AppDetail' +import { Sidebar } from './Sidebar' + +export function Dashboard({ render }: { render: () => void }) { + const selected = apps.find(a => a.name === selectedApp) + + return ( + + + + {selected ? ( + + ) : ( + Select an app to view details + )} + + + ) +} diff --git a/src/client/components/Nav.tsx b/src/client/components/Nav.tsx new file mode 100644 index 0000000..1305164 --- /dev/null +++ b/src/client/components/Nav.tsx @@ -0,0 +1,16 @@ +import { selectedTab, setSelectedTab } from '../state' +import { Tab, TabBar } from '../styles' + +export function Nav({ render }: { render: () => void }) { + const handleTabClick = (tab: 'overview' | 'todo') => { + setSelectedTab(tab) + render() + } + + return ( + + handleTabClick('overview')}>Overview + handleTabClick('todo')}>TODO + + ) +} diff --git a/src/client/components/Sidebar.tsx b/src/client/components/Sidebar.tsx new file mode 100644 index 0000000..d2bf315 --- /dev/null +++ b/src/client/components/Sidebar.tsx @@ -0,0 +1,66 @@ +import { openNewAppModal } from '../modals' +import { apps, selectedApp, setSelectedApp, setSidebarCollapsed, sidebarCollapsed } from '../state' +import { + AppItem, + AppList, + HamburgerButton, + HamburgerLine, + Logo, + NewAppButton, + SectionLabel, + Sidebar as SidebarContainer, + SidebarFooter, + StatusDot, +} from '../styles' + +export function Sidebar({ render }: { render: () => void }) { + const selectApp = (name: string) => { + setSelectedApp(name) + render() + } + + const toggleSidebar = () => { + setSidebarCollapsed(!sidebarCollapsed) + render() + } + + 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 + + )} + + ) +} diff --git a/src/client/tags/emoji-picker.tsx b/src/client/components/emoji-picker.tsx similarity index 100% rename from src/client/tags/emoji-picker.tsx rename to src/client/components/emoji-picker.tsx diff --git a/src/client/components/index.ts b/src/client/components/index.ts new file mode 100644 index 0000000..318742c --- /dev/null +++ b/src/client/components/index.ts @@ -0,0 +1,4 @@ +export { AppDetail } from './AppDetail' +export { Dashboard } from './Dashboard' +export { Nav } from './Nav' +export { Sidebar } from './Sidebar' diff --git a/src/client/tags/modal.tsx b/src/client/components/modal.tsx similarity index 100% rename from src/client/tags/modal.tsx rename to src/client/components/modal.tsx diff --git a/src/client/index.tsx b/src/client/index.tsx index b67b0a8..bc26d7c 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -1,877 +1,11 @@ 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 { Dashboard } from './components' +import { apps, selectedApp, setApps, setSelectedApp } from './state' +import { initModal } from './components/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} - - - {/* */} - - - - -