Compare commits
No commits in common. "abdfaf8402c198b160e24982e682a6d831bf3c66" and "002f0a64ef83423ec482edfcdeb1ba5e33282098" have entirely different histories.
abdfaf8402
...
002f0a64ef
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user