Compare commits

..

No commits in common. "abdfaf8402c198b160e24982e682a6d831bf3c66" and "002f0a64ef83423ec482edfcdeb1ba5e33282098" have entirely different histories.

8 changed files with 75 additions and 48 deletions

View File

@ -14,6 +14,7 @@ import {
import { AppDetail } from './AppDetail' import { AppDetail } from './AppDetail'
import { AppSelector } from './AppSelector' import { AppSelector } from './AppSelector'
import { DashboardLanding } from './DashboardLanding' import { DashboardLanding } from './DashboardLanding'
import { Modal } from './modal'
import { SettingsPage } from './SettingsPage' import { SettingsPage } from './SettingsPage'
import { Sidebar } from './Sidebar' import { Sidebar } from './Sidebar'
@ -54,6 +55,7 @@ export function Dashboard({ render }: { render: () => void }) {
<Styles /> <Styles />
{!isNarrow && <Sidebar render={render} />} {!isNarrow && <Sidebar render={render} />}
<MainContent render={render} /> <MainContent render={render} />
<Modal />
</Layout> </Layout>
) )
} }

View File

@ -1,21 +1,19 @@
import type { Child } from 'hono/jsx' import type { Child } from 'hono/jsx'
import { render } from 'hono/jsx/dom'
import { define } from '@because/forge' import { define } from '@because/forge'
import { theme } from '../themes' import { theme } from '../themes'
let modalTitle: string | null = null let modalTitle: string | null = null
let modalContent: (() => Child) | null = null let modalContent: (() => Child) | null = null
let renderFn: (() => void) | null = null
const root = document.getElementById('modal')! export const initModal = (render: () => void) => {
renderFn = render
const renderModal = () => {
render(<Modal />, root)
} }
export const openModal = (title: string, content: () => Child) => { export const openModal = (title: string, content: () => Child) => {
modalTitle = title modalTitle = title
modalContent = content modalContent = content
renderModal() renderFn?.()
requestAnimationFrame(() => { requestAnimationFrame(() => {
document.querySelector<HTMLInputElement>('[data-modal-body] input')?.focus() document.querySelector<HTMLInputElement>('[data-modal-body] input')?.focus()
}) })
@ -24,10 +22,12 @@ export const openModal = (title: string, content: () => Child) => {
export const closeModal = () => { export const closeModal = () => {
modalTitle = null modalTitle = null
modalContent = null modalContent = null
renderModal() renderFn?.()
} }
export { renderModal } export const rerenderModal = () => {
renderFn?.()
}
// ESC key handler // ESC key handler
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {

View File

@ -1,5 +1,6 @@
import { render as renderApp } from 'hono/jsx/dom' import { render as renderApp } from 'hono/jsx/dom'
import { Dashboard } from './components' import { Dashboard } from './components'
import { initModal } from './components/modal'
import { initRouter, navigate } from './router' import { initRouter, navigate } from './router'
import { apps, dashboardTab, getSelectedTab, selectedApp, setApps, setIsNarrow } from './state' import { apps, dashboardTab, getSelectedTab, selectedApp, setApps, setIsNarrow } from './state'
import { initToolIframes, updateToolIframes } from './tool-iframes' import { initToolIframes, updateToolIframes } from './tool-iframes'
@ -20,6 +21,7 @@ const render = () => {
} }
// Initialize render functions // Initialize render functions
initModal(render)
initUpdate(render) initUpdate(render)
initToolIframes() initToolIframes()

View File

@ -1,5 +1,5 @@
import type { App } from '../../shared/types' import type { App } from '../../shared/types'
import { closeModal, openModal, renderModal } from '../components/modal' import { closeModal, openModal, rerenderModal } from '../components/modal'
import { navigate } from '../router' import { navigate } from '../router'
import { selectedApp } from '../state' import { selectedApp } from '../state'
import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles' import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles'
@ -17,13 +17,13 @@ async function deleteApp(input: HTMLInputElement) {
if (value !== expected) { if (value !== expected) {
deleteAppError = `Type "${expected}" to confirm` deleteAppError = `Type "${expected}" to confirm`
renderModal() rerenderModal()
return return
} }
deleteAppDeleting = true deleteAppDeleting = true
deleteAppError = '' deleteAppError = ''
renderModal() rerenderModal()
try { try {
const res = await fetch(`/api/sync/apps/${deleteAppTarget.name}`, { const res = await fetch(`/api/sync/apps/${deleteAppTarget.name}`, {
@ -41,7 +41,7 @@ async function deleteApp(input: HTMLInputElement) {
} catch (err) { } catch (err) {
deleteAppError = err instanceof Error ? err.message : 'Failed to delete app' deleteAppError = err instanceof Error ? err.message : 'Failed to delete app'
deleteAppDeleting = false deleteAppDeleting = false
renderModal() rerenderModal()
} }
} }

View File

@ -1,4 +1,4 @@
import { closeModal, openModal, renderModal } from '../components/modal' import { closeModal, openModal, rerenderModal } from '../components/modal'
import { navigate } from '../router' import { navigate } from '../router'
import { apps } from '../state' import { apps } from '../state'
import { Button, Form, FormActions, FormCheckbox, FormCheckboxField, FormCheckboxLabel, FormError, FormField, FormInput, FormLabel, FormSelect } from '../styles' import { Button, Form, FormActions, FormCheckbox, FormCheckboxField, FormCheckboxLabel, FormError, FormField, FormInput, FormLabel, FormSelect } from '../styles'
@ -16,25 +16,25 @@ async function createNewApp() {
if (!name) { if (!name) {
newAppError = 'App name is required' newAppError = 'App name is required'
renderModal() rerenderModal()
return return
} }
if (!/^[a-z][a-z0-9-]*$/.test(name)) { if (!/^[a-z][a-z0-9-]*$/.test(name)) {
newAppError = 'Name must start with a letter and contain only lowercase letters, numbers, and hyphens' newAppError = 'Name must start with a letter and contain only lowercase letters, numbers, and hyphens'
renderModal() rerenderModal()
return return
} }
if (apps.some(a => a.name === name)) { if (apps.some(a => a.name === name)) {
newAppError = 'An app with this name already exists' newAppError = 'An app with this name already exists'
renderModal() rerenderModal()
return return
} }
newAppCreating = true newAppCreating = true
newAppError = '' newAppError = ''
renderModal() rerenderModal()
try { try {
const res = await fetch('/api/apps', { const res = await fetch('/api/apps', {
@ -55,7 +55,7 @@ async function createNewApp() {
} catch (err) { } catch (err) {
newAppError = err instanceof Error ? err.message : 'Failed to create app' newAppError = err instanceof Error ? err.message : 'Failed to create app'
newAppCreating = false newAppCreating = false
renderModal() rerenderModal()
} }
} }
@ -105,7 +105,7 @@ export function openNewAppModal() {
checked={newAppTool} checked={newAppTool}
onChange={(e: Event) => { onChange={(e: Event) => {
newAppTool = (e.target as HTMLInputElement).checked newAppTool = (e.target as HTMLInputElement).checked
renderModal() rerenderModal()
}} }}
/> />
<FormCheckboxLabel for="app-tool">Tool</FormCheckboxLabel> <FormCheckboxLabel for="app-tool">Tool</FormCheckboxLabel>

View File

@ -1,5 +1,5 @@
import type { App } from '../../shared/types' import type { App } from '../../shared/types'
import { closeModal, openModal, renderModal } from '../components/modal' import { closeModal, openModal, rerenderModal } from '../components/modal'
import { navigate } from '../router' import { navigate } from '../router'
import { apps } from '../state' import { apps } from '../state'
import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles' import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles'
@ -15,13 +15,13 @@ async function doRenameApp(input: HTMLInputElement) {
if (!newName) { if (!newName) {
renameAppError = 'App name is required' renameAppError = 'App name is required'
renderModal() rerenderModal()
return return
} }
if (!/^[a-z][a-z0-9-]*$/.test(newName)) { if (!/^[a-z][a-z0-9-]*$/.test(newName)) {
renameAppError = 'Name must start with a letter and contain only lowercase letters, numbers, and hyphens' renameAppError = 'Name must start with a letter and contain only lowercase letters, numbers, and hyphens'
renderModal() rerenderModal()
return return
} }
@ -32,13 +32,13 @@ async function doRenameApp(input: HTMLInputElement) {
if (apps.some(a => a.name === newName)) { if (apps.some(a => a.name === newName)) {
renameAppError = 'An app with this name already exists' renameAppError = 'An app with this name already exists'
renderModal() rerenderModal()
return return
} }
renameAppRenaming = true renameAppRenaming = true
renameAppError = '' renameAppError = ''
renderModal() rerenderModal()
try { try {
const res = await fetch(`/api/apps/${renameAppTarget.name}/rename`, { const res = await fetch(`/api/apps/${renameAppTarget.name}/rename`, {
@ -65,7 +65,7 @@ async function doRenameApp(input: HTMLInputElement) {
} catch (err) { } catch (err) {
renameAppError = err instanceof Error ? err.message : 'Failed to rename app' renameAppError = err instanceof Error ? err.message : 'Failed to rename app'
renameAppRenaming = false renameAppRenaming = false
renderModal() rerenderModal()
} }
} }

View File

@ -9,7 +9,6 @@ export const Shell = () => (
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<div id="modal"></div>
<div id="tool-iframes"></div> <div id="tool-iframes"></div>
<script type="module" src="/client/index.js"></script> <script type="module" src="/client/index.js"></script>
</body> </body>

View File

@ -11,37 +11,61 @@ interface Listener {
const _listeners = new Set<Listener>() const _listeners = new Set<Listener>()
let _source: EventSource | undefined let _abort: AbortController | undefined
let _connected = false
function ensureConnection() { function ensureConnection() {
if (_source) return if (_connected) return
_connected = true
const url = `${process.env.TOES_URL}/api/events/stream` const url = `${process.env.TOES_URL}/api/events/stream`
_source = new EventSource(url) _abort = new AbortController()
_source.onerror = () => { fetch(url, { signal: _abort.signal })
if (_source?.readyState === EventSource.CLOSED) { .then(async (res) => {
console.warn('[toes] Event stream closed unexpectedly') const reader = res.body!.getReader()
_source = undefined const decoder = new TextDecoder()
} let buf = ''
}
_source.onmessage = (e) => { while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop()!
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const payload = line.slice(6)
if (!payload) continue
try { try {
const event: ToesEvent = JSON.parse(e.data) const event: ToesEvent = JSON.parse(payload)
_listeners.forEach(l => { _listeners.forEach(l => {
if (l.types.includes(event.type)) l.callback(event) if (l.types.includes(event.type)) l.callback(event)
}) })
} catch { } catch (e) {
// Ignore malformed events console.warn('[toes] Failed to parse event:', e)
} }
} }
} }
})
.catch((e) => {
if (e.name === 'AbortError') return
console.warn('[toes] Event stream error, reconnecting...', e.message)
})
.finally(() => {
_connected = false
if (_listeners.size > 0) {
setTimeout(ensureConnection, 1000)
}
})
}
function closeConnection() { function closeConnection() {
if (_source) { if (_abort) {
_source.close() _abort.abort()
_source = undefined _abort = undefined
} }
_connected = false
} }
export function on(type: ToesEventType | ToesEventType[], callback: EventCallback): () => void { export function on(type: ToesEventType | ToesEventType[], callback: EventCallback): () => void {