themes + emoji
This commit is contained in:
parent
15775bc022
commit
06bcfc5f35
2
bun.lock
2
bun.lock
|
|
@ -27,7 +27,7 @@
|
||||||
|
|
||||||
"hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
"hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
||||||
|
|
||||||
"hype": ["hype@git+https://git.nose.space/defunkt/hype#b9b4e205c9b04cb3897054db2940ff67ce7cfd5b", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "b9b4e205c9b04cb3897054db2940ff67ce7cfd5b"],
|
"hype": ["hype@git+https://git.nose.space/defunkt/hype#a8d3a8203e145df7a222ea409588c2ea3a1ee4e6", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "a8d3a8203e145df7a222ea409588c2ea3a1ee4e6"],
|
||||||
|
|
||||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { render as renderApp } from 'hono/jsx/dom'
|
import { render as renderApp } from 'hono/jsx/dom'
|
||||||
import { define, Styles } from 'forge'
|
import { define, Styles } from 'forge'
|
||||||
import type { App, AppState } from '../shared/types'
|
import type { App, AppState } from '../shared/types'
|
||||||
|
import { theme } from './themes'
|
||||||
|
|
||||||
// UI state (survives re-renders)
|
// UI state (survives re-renders)
|
||||||
let selectedApp: string | null = null
|
let selectedApp: string | null = localStorage.getItem('selectedApp')
|
||||||
|
|
||||||
// Server state (from SSE)
|
// Server state (from SSE)
|
||||||
let apps: App[] = []
|
let apps: App[] = []
|
||||||
|
|
@ -12,31 +13,34 @@ let apps: App[] = []
|
||||||
const Layout = define('Layout', {
|
const Layout = define('Layout', {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
height: '100vh',
|
height: '100vh',
|
||||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
fontFamily: theme('fonts-sans'),
|
||||||
background: '#0a0a0a',
|
background: theme('colors-bg'),
|
||||||
color: '#e5e5e5',
|
color: theme('colors-text'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const Sidebar = define('Sidebar', {
|
const Sidebar = define('Sidebar', {
|
||||||
width: 220,
|
width: 220,
|
||||||
borderRight: '1px solid #333',
|
borderRight: `1px solid ${theme('colors-border')}`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const Logo = define('Logo', {
|
const Logo = define('Logo', {
|
||||||
padding: '20px 16px',
|
height: 64,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0 16px',
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
borderBottom: '1px solid #333',
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
const SectionLabel = define('SectionLabel', {
|
const SectionLabel = define('SectionLabel', {
|
||||||
padding: '16px 16px 8px',
|
padding: '16px 16px 8px',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: '#666',
|
color: theme('colors-textFaint'),
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: '0.05em',
|
letterSpacing: '0.05em',
|
||||||
})
|
})
|
||||||
|
|
@ -48,18 +52,19 @@ const AppList = define('AppList', {
|
||||||
|
|
||||||
const AppItem = define('AppItem', {
|
const AppItem = define('AppItem', {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
color: '#999',
|
color: theme('colors-textMuted'),
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
selectors: {
|
selectors: {
|
||||||
'&:hover': { background: '#1a1a1a', color: '#e5e5e5' },
|
'&:hover': { background: theme('colors-bgHover'), color: theme('colors-text') },
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
selected: { background: '#1f1f1f', color: '#fff', fontWeight: 500 },
|
selected: { background: theme('colors-bgSelected'), color: theme('colors-text'), fontWeight: 500 },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -70,33 +75,36 @@ const StatusDot = define('StatusDot', {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
variants: {
|
variants: {
|
||||||
state: {
|
state: {
|
||||||
invalid: { background: '#ef4444' },
|
invalid: { background: theme('colors-statusInvalid') },
|
||||||
stopped: { background: '#666' },
|
stopped: { background: theme('colors-statusStopped') },
|
||||||
starting: { background: '#eab308' },
|
starting: { background: theme('colors-statusStarting') },
|
||||||
running: { background: '#22c55e' },
|
running: { background: theme('colors-statusRunning') },
|
||||||
stopping: { background: '#eab308' },
|
stopping: { background: theme('colors-statusStarting') },
|
||||||
},
|
},
|
||||||
|
inline: {
|
||||||
|
display: 'inline'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const SidebarFooter = define('SidebarFooter', {
|
const SidebarFooter = define('SidebarFooter', {
|
||||||
padding: 16,
|
padding: 16,
|
||||||
borderTop: '1px solid #333',
|
borderTop: `1px solid ${theme('colors-border')}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
const NewAppButton = define('NewAppButton', {
|
const NewAppButton = define('NewAppButton', {
|
||||||
display: 'block',
|
display: 'block',
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
background: '#1f1f1f',
|
background: theme('colors-bgElement'),
|
||||||
border: '1px solid #333',
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
borderRadius: 6,
|
borderRadius: theme('radius-md'),
|
||||||
color: '#999',
|
color: theme('colors-textMuted'),
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
selectors: {
|
selectors: {
|
||||||
'&:hover': { background: '#2a2a2a', color: '#e5e5e5' },
|
'&:hover': { background: theme('colors-bgHover'), color: theme('colors-text') },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -109,14 +117,18 @@ const Main = define('Main', {
|
||||||
})
|
})
|
||||||
|
|
||||||
const MainHeader = define('MainHeader', {
|
const MainHeader = define('MainHeader', {
|
||||||
|
height: 64,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
padding: '16px 24px',
|
padding: '0 24px',
|
||||||
borderBottom: '1px solid #333',
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
const MainTitle = define('MainTitle', {
|
const MainTitle = define('MainTitle', {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
margin: 0,
|
margin: 0,
|
||||||
|
|
@ -140,12 +152,12 @@ const Section = define('Section', {
|
||||||
const SectionTitle = define('SectionTitle', {
|
const SectionTitle = define('SectionTitle', {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: '#666',
|
color: theme('colors-textFaint'),
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: '0.05em',
|
letterSpacing: '0.05em',
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
paddingBottom: 8,
|
paddingBottom: 8,
|
||||||
borderBottom: '1px solid #333',
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
const InfoRow = define('InfoRow', {
|
const InfoRow = define('InfoRow', {
|
||||||
|
|
@ -157,13 +169,13 @@ const InfoRow = define('InfoRow', {
|
||||||
})
|
})
|
||||||
|
|
||||||
const InfoLabel = define('InfoLabel', {
|
const InfoLabel = define('InfoLabel', {
|
||||||
color: '#666',
|
color: theme('colors-textFaint'),
|
||||||
width: 80,
|
width: 80,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const InfoValue = define('InfoValue', {
|
const InfoValue = define('InfoValue', {
|
||||||
color: '#e5e5e5',
|
color: theme('colors-text'),
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
|
|
@ -171,7 +183,7 @@ const InfoValue = define('InfoValue', {
|
||||||
|
|
||||||
const Link = define('Link', {
|
const Link = define('Link', {
|
||||||
base: 'a',
|
base: 'a',
|
||||||
color: '#22d3ee',
|
color: theme('colors-link'),
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
selectors: {
|
selectors: {
|
||||||
'&:hover': { textDecoration: 'underline' },
|
'&:hover': { textDecoration: 'underline' },
|
||||||
|
|
@ -181,19 +193,19 @@ const Link = define('Link', {
|
||||||
const Button = define('Button', {
|
const Button = define('Button', {
|
||||||
base: 'button',
|
base: 'button',
|
||||||
padding: '6px 12px',
|
padding: '6px 12px',
|
||||||
background: '#1f1f1f',
|
background: theme('colors-bgElement'),
|
||||||
border: '1px solid #333',
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
borderRadius: 6,
|
borderRadius: theme('radius-md'),
|
||||||
color: '#e5e5e5',
|
color: theme('colors-text'),
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
selectors: {
|
selectors: {
|
||||||
'&:hover': { background: '#2a2a2a' },
|
'&:hover': { background: theme('colors-bgHover') },
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
danger: { borderColor: '#7f1d1d', color: '#fca5a5' },
|
danger: { borderColor: theme('colors-dangerBorder'), color: theme('colors-dangerText') },
|
||||||
primary: { background: '#1d4ed8', borderColor: '#1d4ed8' },
|
primary: { background: theme('colors-primary'), borderColor: theme('colors-primary'), color: theme('colors-primaryText') },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -203,7 +215,7 @@ const ActionBar = define('ActionBar', {
|
||||||
gap: 8,
|
gap: 8,
|
||||||
marginTop: 24,
|
marginTop: 24,
|
||||||
paddingTop: 24,
|
paddingTop: 24,
|
||||||
borderTop: '1px solid #333',
|
borderTop: `1px solid ${theme('colors-border')}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
const EmptyState = define('EmptyState', {
|
const EmptyState = define('EmptyState', {
|
||||||
|
|
@ -211,17 +223,17 @@ const EmptyState = define('EmptyState', {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
color: '#666',
|
color: theme('colors-textFaint'),
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
})
|
})
|
||||||
|
|
||||||
const LogsContainer = define('LogsContainer', {
|
const LogsContainer = define('LogsContainer', {
|
||||||
background: '#111',
|
background: theme('colors-bgSubtle'),
|
||||||
borderRadius: 6,
|
borderRadius: theme('radius-md'),
|
||||||
padding: 12,
|
padding: 12,
|
||||||
fontFamily: 'ui-monospace, monospace',
|
fontFamily: theme('fonts-mono'),
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: '#888',
|
color: theme('colors-textMuted'),
|
||||||
maxHeight: 200,
|
maxHeight: 200,
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
})
|
})
|
||||||
|
|
@ -234,7 +246,7 @@ const LogLine = define('LogLine', {
|
||||||
})
|
})
|
||||||
|
|
||||||
const LogTime = define('LogTime', {
|
const LogTime = define('LogTime', {
|
||||||
color: '#555',
|
color: theme('colors-textFaintest'),
|
||||||
marginRight: 12,
|
marginRight: 12,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -253,13 +265,20 @@ const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method
|
||||||
|
|
||||||
const selectApp = (name: string) => {
|
const selectApp = (name: string) => {
|
||||||
selectedApp = name
|
selectedApp = name
|
||||||
|
localStorage.setItem('selectedApp', name)
|
||||||
render()
|
render()
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppDetail = ({ app }: { app: App }) => (
|
const AppDetail = ({ app }: { app: App }) => (
|
||||||
<>
|
<>
|
||||||
<MainHeader>
|
<MainHeader>
|
||||||
<MainTitle>{app.name}</MainTitle>
|
<MainTitle>
|
||||||
|
{app.state === 'running' && app.icon ? <>{app.icon}</> : (
|
||||||
|
<StatusDot state={app.state} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{app.name}
|
||||||
|
</MainTitle>
|
||||||
<HeaderActions>
|
<HeaderActions>
|
||||||
<Button>Settings</Button>
|
<Button>Settings</Button>
|
||||||
<Button variant="danger">Delete</Button>
|
<Button variant="danger">Delete</Button>
|
||||||
|
|
@ -292,11 +311,11 @@ const AppDetail = ({ app }: { app: App }) => (
|
||||||
<InfoValue>{new Date(app.started).toLocaleString()}</InfoValue>
|
<InfoValue>{new Date(app.started).toLocaleString()}</InfoValue>
|
||||||
</InfoRow>
|
</InfoRow>
|
||||||
)}
|
)}
|
||||||
{app.state === 'invalid' && (
|
{app.error && (
|
||||||
<InfoRow>
|
<InfoRow>
|
||||||
<InfoLabel>Error</InfoLabel>
|
<InfoLabel>Error</InfoLabel>
|
||||||
<InfoValue style={{ color: '#f87171' }}>
|
<InfoValue style={{ color: theme('colors-error') }}>
|
||||||
Missing or invalid package.json
|
{app.error}
|
||||||
</InfoValue>
|
</InfoValue>
|
||||||
</InfoRow>
|
</InfoRow>
|
||||||
)}
|
)}
|
||||||
|
|
@ -315,7 +334,7 @@ const AppDetail = ({ app }: { app: App }) => (
|
||||||
) : (
|
) : (
|
||||||
<LogLine>
|
<LogLine>
|
||||||
<LogTime>--:--:--</LogTime>
|
<LogTime>--:--:--</LogTime>
|
||||||
<span style={{ color: '#666' }}>No logs yet</span>
|
<span style={{ color: theme('colors-textFaint') }}>No logs yet</span>
|
||||||
</LogLine>
|
</LogLine>
|
||||||
)}
|
)}
|
||||||
</LogsContainer>
|
</LogsContainer>
|
||||||
|
|
@ -359,7 +378,11 @@ const Dashboard = () => {
|
||||||
onClick={() => selectApp(app.name)}
|
onClick={() => selectApp(app.name)}
|
||||||
selected={app.name === selectedApp ? true : undefined}
|
selected={app.name === selectedApp ? true : undefined}
|
||||||
>
|
>
|
||||||
<StatusDot state={app.state} />
|
{app.state === 'running' && app.icon ? (
|
||||||
|
<span style={{ fontSize: 14 }}>{app.icon}</span>
|
||||||
|
) : (
|
||||||
|
<StatusDot state={app.state} />
|
||||||
|
)}
|
||||||
{app.name}
|
{app.name}
|
||||||
</AppItem>
|
</AppItem>
|
||||||
))}
|
))}
|
||||||
|
|
@ -383,10 +406,26 @@ const render = () => {
|
||||||
renderApp(<Dashboard />, document.getElementById('app')!)
|
renderApp(<Dashboard />, document.getElementById('app')!)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// SSE connection
|
||||||
const events = new EventSource('/api/apps/stream')
|
const events = new EventSource('/api/apps/stream')
|
||||||
events.onmessage = e => {
|
events.onmessage = e => {
|
||||||
apps = JSON.parse(e.data)
|
apps = JSON.parse(e.data)
|
||||||
if (!selectedApp && apps.length) selectedApp = apps[0]!.name
|
const valid = selectedApp && apps.some(a => a.name === selectedApp)
|
||||||
|
if (!valid && apps.length) selectedApp = apps[0]!.name
|
||||||
render()
|
render()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
src/client/themes/dark.ts
Normal file
38
src/client/themes/dark.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
export default {
|
||||||
|
'colors-bg': '#0a0a0a',
|
||||||
|
'colors-bgSubtle': '#111',
|
||||||
|
'colors-bgElement': '#1f1f1f',
|
||||||
|
'colors-bgHover': '#2a2a2a',
|
||||||
|
'colors-bgSelected': '#1f1f1f',
|
||||||
|
|
||||||
|
'colors-text': '#e5e5e5',
|
||||||
|
'colors-textMuted': '#999',
|
||||||
|
'colors-textFaint': '#666',
|
||||||
|
'colors-textFaintest': '#555',
|
||||||
|
|
||||||
|
'colors-border': '#333',
|
||||||
|
'colors-link': '#22d3ee',
|
||||||
|
|
||||||
|
'colors-primary': '#1d4ed8',
|
||||||
|
'colors-primaryText': '#e5e5e5',
|
||||||
|
|
||||||
|
'colors-dangerBorder': '#7f1d1d',
|
||||||
|
'colors-dangerText': '#fca5a5',
|
||||||
|
'colors-error': '#f87171',
|
||||||
|
|
||||||
|
'colors-statusRunning': '#22c55e',
|
||||||
|
'colors-statusStopped': '#666',
|
||||||
|
'colors-statusStarting': '#eab308',
|
||||||
|
'colors-statusInvalid': '#ef4444',
|
||||||
|
|
||||||
|
'fonts-sans': 'system-ui, -apple-system, sans-serif',
|
||||||
|
'fonts-mono': 'ui-monospace, monospace',
|
||||||
|
|
||||||
|
'spacing-xs': '4px',
|
||||||
|
'spacing-sm': '8px',
|
||||||
|
'spacing-md': '12px',
|
||||||
|
'spacing-lg': '16px',
|
||||||
|
'spacing-xl': '24px',
|
||||||
|
|
||||||
|
'radius-md': '6px',
|
||||||
|
} as const
|
||||||
8
src/client/themes/index.ts
Normal file
8
src/client/themes/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { createThemes } from 'forge'
|
||||||
|
import dark from './dark'
|
||||||
|
import light from './light'
|
||||||
|
|
||||||
|
export const theme = createThemes({
|
||||||
|
dark,
|
||||||
|
light,
|
||||||
|
})
|
||||||
38
src/client/themes/light.ts
Normal file
38
src/client/themes/light.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
export default {
|
||||||
|
'colors-bg': '#f9fafb',
|
||||||
|
'colors-bgSubtle': '#f3f4f6',
|
||||||
|
'colors-bgElement': '#e5e7eb',
|
||||||
|
'colors-bgHover': '#d1d5db',
|
||||||
|
'colors-bgSelected': '#dbeafe',
|
||||||
|
|
||||||
|
'colors-text': '#111827',
|
||||||
|
'colors-textMuted': '#6b7280',
|
||||||
|
'colors-textFaint': '#9ca3af',
|
||||||
|
'colors-textFaintest': '#9ca3af',
|
||||||
|
|
||||||
|
'colors-border': '#d1d5db',
|
||||||
|
'colors-link': '#0891b2',
|
||||||
|
|
||||||
|
'colors-primary': '#2563eb',
|
||||||
|
'colors-primaryText': '#fff',
|
||||||
|
|
||||||
|
'colors-dangerBorder': '#fecaca',
|
||||||
|
'colors-dangerText': '#dc2626',
|
||||||
|
'colors-error': '#dc2626',
|
||||||
|
|
||||||
|
'colors-statusRunning': '#16a34a',
|
||||||
|
'colors-statusStopped': '#9ca3af',
|
||||||
|
'colors-statusStarting': '#ca8a04',
|
||||||
|
'colors-statusInvalid': '#dc2626',
|
||||||
|
|
||||||
|
'fonts-sans': 'system-ui, -apple-system, sans-serif',
|
||||||
|
'fonts-mono': 'ui-monospace, monospace',
|
||||||
|
|
||||||
|
'spacing-xs': '4px',
|
||||||
|
'spacing-sm': '8px',
|
||||||
|
'spacing-md': '12px',
|
||||||
|
'spacing-lg': '16px',
|
||||||
|
'spacing-xl': '24px',
|
||||||
|
|
||||||
|
'radius-md': '6px',
|
||||||
|
} as const
|
||||||
|
|
@ -48,8 +48,10 @@ const getPort = () => NEXT_PORT++
|
||||||
/** Discover all apps and set initial states */
|
/** Discover all apps and set initial states */
|
||||||
const discoverApps = () => {
|
const discoverApps = () => {
|
||||||
for (const dir of allAppDirs()) {
|
for (const dir of allAppDirs()) {
|
||||||
const state: AppState = isApp(dir) ? 'stopped' : 'invalid'
|
const { pkg, error } = loadApp(dir)
|
||||||
_apps.set(dir, { name: dir, state })
|
const state: AppState = error ? 'invalid' : 'stopped'
|
||||||
|
const icon = pkg.toes?.icon
|
||||||
|
_apps.set(dir, { name: dir, state, icon, error })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,10 +63,9 @@ export const runApps = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isApp = (dir: string): boolean =>
|
type LoadResult = { pkg: any; error?: string }
|
||||||
Object.values(loadApp(dir)).length > 0
|
|
||||||
|
|
||||||
const loadApp = (dir: string) => {
|
const loadApp = (dir: string): LoadResult => {
|
||||||
try {
|
try {
|
||||||
const file = readFileSync(join(APPS_DIR, dir, 'package.json'), 'utf-8')
|
const file = readFileSync(join(APPS_DIR, dir, 'package.json'), 'utf-8')
|
||||||
|
|
||||||
|
|
@ -72,24 +73,30 @@ const loadApp = (dir: string) => {
|
||||||
const json = JSON.parse(file)
|
const json = JSON.parse(file)
|
||||||
|
|
||||||
if (json.scripts?.toes) {
|
if (json.scripts?.toes) {
|
||||||
return json
|
return { pkg: json }
|
||||||
} else {
|
} else {
|
||||||
err(dir, 'No `bun toes` script in package.json')
|
const error = 'Missing scripts.toes in package.json'
|
||||||
return {}
|
err(dir, error)
|
||||||
|
return { pkg: json, error }
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
err(dir, 'Invalid JSON in package.json:', e instanceof Error ? e.message : String(e))
|
const error = `Invalid JSON in package.json: ${e instanceof Error ? e.message : String(e)}`
|
||||||
return {}
|
err(dir, error)
|
||||||
|
return { pkg: {}, error }
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
err(dir, 'No package.json')
|
const error = 'Missing package.json'
|
||||||
return {}
|
err(dir, error)
|
||||||
|
return { pkg: {}, error }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isApp = (dir: string): boolean =>
|
||||||
|
!loadApp(dir).error
|
||||||
|
|
||||||
const runApp = async (dir: string, port: number) => {
|
const runApp = async (dir: string, port: number) => {
|
||||||
const pkg = loadApp(dir)
|
const { pkg, error } = loadApp(dir)
|
||||||
if (!pkg.scripts?.toes) return
|
if (error) return
|
||||||
|
|
||||||
const app = _apps.get(dir)
|
const app = _apps.get(dir)
|
||||||
if (!app) return
|
if (!app) return
|
||||||
|
|
@ -129,13 +136,14 @@ const runApp = async (dir: string, port: number) => {
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read()
|
const { done, value } = await reader.read()
|
||||||
if (done) break
|
if (done) break
|
||||||
const text = decoder.decode(value).trimEnd()
|
const chunk = decoder.decode(value)
|
||||||
if (text) {
|
const lines = chunk.split('\n').map(l => l.trimEnd()).filter(Boolean)
|
||||||
|
for (const text of lines) {
|
||||||
log(dir, text)
|
log(dir, text)
|
||||||
const line: LogLine = { time: Date.now(), text }
|
const line: LogLine = { time: Date.now(), text }
|
||||||
app.logs = [...(app.logs ?? []).slice(-(MAX_LOGS - 1)), line]
|
app.logs = [...(app.logs ?? []).slice(-(MAX_LOGS - 1)), line]
|
||||||
update()
|
|
||||||
}
|
}
|
||||||
|
if (lines.length) update()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,10 +202,12 @@ const watchAppsDir = () => {
|
||||||
|
|
||||||
// Handle new directory appearing
|
// Handle new directory appearing
|
||||||
if (!_apps.has(dir)) {
|
if (!_apps.has(dir)) {
|
||||||
const state: AppState = isApp(dir) ? 'stopped' : 'invalid'
|
const { pkg, error } = loadApp(dir)
|
||||||
_apps.set(dir, { name: dir, state })
|
const state: AppState = error ? 'invalid' : 'stopped'
|
||||||
|
const icon = pkg.toes?.icon
|
||||||
|
_apps.set(dir, { name: dir, state, icon, error })
|
||||||
update()
|
update()
|
||||||
if (state === 'stopped') {
|
if (!error) {
|
||||||
runApp(dir, getPort())
|
runApp(dir, getPort())
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|
@ -208,26 +218,30 @@ const watchAppsDir = () => {
|
||||||
// Only care about package.json changes for existing apps
|
// Only care about package.json changes for existing apps
|
||||||
if (!filename.endsWith('package.json')) return
|
if (!filename.endsWith('package.json')) return
|
||||||
|
|
||||||
const valid = isApp(dir)
|
const { pkg, error } = loadApp(dir)
|
||||||
|
|
||||||
|
// Update icon and error from package.json
|
||||||
|
app.icon = pkg.toes?.icon
|
||||||
|
app.error = error
|
||||||
|
|
||||||
// App became valid - start it if stopped
|
// App became valid - start it if stopped
|
||||||
if (valid && app.state === 'invalid') {
|
if (!error && app.state === 'invalid') {
|
||||||
app.state = 'stopped'
|
app.state = 'stopped'
|
||||||
runApp(dir, getPort())
|
runApp(dir, getPort())
|
||||||
}
|
}
|
||||||
|
|
||||||
// App became invalid - stop it if running
|
// App became invalid - stop it if running
|
||||||
if (!valid && app.state === 'running') {
|
if (error && app.state === 'running') {
|
||||||
app.state = 'invalid'
|
app.state = 'invalid'
|
||||||
app.proc?.kill()
|
app.proc?.kill()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update state if already stopped/invalid
|
// Update state if already stopped/invalid
|
||||||
if (!valid && app.state === 'stopped') {
|
if (error && app.state === 'stopped') {
|
||||||
app.state = 'invalid'
|
app.state = 'invalid'
|
||||||
update()
|
update()
|
||||||
}
|
}
|
||||||
if (valid && app.state === 'invalid') {
|
if (!error && app.state === 'invalid') {
|
||||||
app.state = 'stopped'
|
app.state = 'stopped'
|
||||||
update()
|
update()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,11 @@ app.get('/api/apps/stream', c => {
|
||||||
start(controller) {
|
start(controller) {
|
||||||
const send = () => {
|
const send = () => {
|
||||||
// Strip proc field from apps before sending
|
// Strip proc field from apps before sending
|
||||||
const apps: SharedApp[] = allApps().map(({ name, state, icon, port, started, logs }) => ({
|
const apps: SharedApp[] = allApps().map(({ name, state, icon, error, port, started, logs }) => ({
|
||||||
name,
|
name,
|
||||||
state,
|
state,
|
||||||
icon,
|
icon,
|
||||||
|
error,
|
||||||
port,
|
port,
|
||||||
started,
|
started,
|
||||||
logs,
|
logs,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export type App = {
|
||||||
name: string
|
name: string
|
||||||
state: AppState
|
state: AppState
|
||||||
icon?: string
|
icon?: string
|
||||||
|
error?: string
|
||||||
port?: number
|
port?: number
|
||||||
started?: number
|
started?: number
|
||||||
logs?: LogLine[]
|
logs?: LogLine[]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user