Compare commits

...

3 Commits

14 changed files with 115 additions and 63 deletions

View File

@ -2,7 +2,6 @@ import type { CSSProperties } from 'hono/jsx'
import { import {
apps, apps,
selectedApp, selectedApp,
setSelectedApp,
setSidebarSection, setSidebarSection,
sidebarSection, sidebarSection,
} from '../state' } from '../state'
@ -17,19 +16,12 @@ import {
interface AppSelectorProps { interface AppSelectorProps {
render: () => void render: () => void
onSelect?: () => void onSelect?: () => void
onDashboard?: () => void
collapsed?: boolean collapsed?: boolean
switcherStyle?: CSSProperties switcherStyle?: CSSProperties
listStyle?: CSSProperties listStyle?: CSSProperties
} }
export function AppSelector({ render, onSelect, onDashboard, collapsed, switcherStyle, listStyle }: AppSelectorProps) { export function AppSelector({ render, onSelect, collapsed, switcherStyle, listStyle }: AppSelectorProps) {
const selectApp = (name: string) => {
setSelectedApp(name)
onSelect?.()
render()
}
const switchSection = (section: 'apps' | 'tools') => { const switchSection = (section: 'apps' | 'tools') => {
setSidebarSection(section) setSidebarSection(section)
render() render()
@ -52,9 +44,9 @@ export function AppSelector({ render, onSelect, onDashboard, collapsed, switcher
</SectionSwitcher> </SectionSwitcher>
)} )}
<AppList style={listStyle}> <AppList style={listStyle}>
{collapsed && onDashboard && ( {collapsed && (
<AppItem <AppItem
onClick={onDashboard} href="/"
selected={!selectedApp ? true : undefined} selected={!selectedApp ? true : undefined}
style={{ justifyContent: 'center', padding: '10px 12px' }} style={{ justifyContent: 'center', padding: '10px 12px' }}
title="Toes" title="Toes"
@ -65,7 +57,8 @@ export function AppSelector({ render, onSelect, onDashboard, collapsed, switcher
{activeApps.map(app => ( {activeApps.map(app => (
<AppItem <AppItem
key={app.name} key={app.name}
onClick={() => selectApp(app.name)} href={`/app/${app.name}`}
onClick={onSelect}
selected={app.name === selectedApp ? true : undefined} selected={app.name === selectedApp ? true : undefined}
style={collapsed ? { justifyContent: 'center', padding: '10px 12px' } : undefined} style={collapsed ? { justifyContent: 'center', padding: '10px 12px' } : undefined}
title={collapsed ? app.name : undefined} title={collapsed ? app.name : undefined}

View File

@ -1,6 +1,7 @@
import { useEffect } from 'hono/jsx' import { useEffect } from 'hono/jsx'
import { openAppSelectorModal } from '../modals' import { openAppSelectorModal } from '../modals'
import { dashboardTab, isNarrow, setCurrentView, setDashboardTab, setSelectedApp } from '../state' import { navigate } from '../router'
import { dashboardTab, isNarrow } from '../state'
import { import {
AppSelectorChevron, AppSelectorChevron,
DashboardContainer, DashboardContainer,
@ -25,14 +26,11 @@ export function DashboardLanding({ render }: { render: () => void }) {
const narrow = isNarrow || undefined const narrow = isNarrow || undefined
const openSettings = () => { const openSettings = () => {
setSelectedApp(null) navigate('/settings')
setCurrentView('settings')
render()
} }
const switchTab = (tab: typeof dashboardTab) => { const switchTab = (tab: typeof dashboardTab) => {
setDashboardTab(tab) navigate(tab === 'urls' ? '/' : `/${tab}`)
render()
if (tab === 'logs') scrollLogsToBottom() if (tab === 'logs') scrollLogsToBottom()
} }

View File

@ -1,5 +1,6 @@
import type { App } from '../../shared/types' import type { App } from '../../shared/types'
import { apps, getSelectedTab, setSelectedTab } from '../state' import { navigate } from '../router'
import { apps, getSelectedTab } from '../state'
import { Tab, TabBar } from '../styles' import { Tab, TabBar } from '../styles'
import { resetToolIframe } from '../tool-iframes' import { resetToolIframe } from '../tool-iframes'
@ -12,8 +13,7 @@ export function Nav({ app, render }: { app: App; render: () => void }) {
resetToolIframe(tab, app.name) resetToolIframe(tab, app.name)
return return
} }
setSelectedTab(app.name, tab) navigate(tab === 'overview' ? `/app/${app.name}` : `/app/${app.name}/${tab}`)
render()
} }
// Find all tools // Find all tools

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'hono/jsx' import { useEffect, useState } from 'hono/jsx'
import { getWifiConfig, saveWifiConfig } from '../api' import { getWifiConfig, saveWifiConfig } from '../api'
import { setCurrentView } from '../state' import { navigate } from '../router'
import { import {
Button, Button,
DashboardInstallCmd, DashboardInstallCmd,
@ -31,8 +31,7 @@ export function SettingsPage({ render }: { render: () => void }) {
}, []) }, [])
const goBack = () => { const goBack = () => {
setCurrentView('dashboard') navigate('/')
render()
} }
const handleSave = async (e: Event) => { const handleSave = async (e: Event) => {

View File

@ -1,7 +1,5 @@
import { openNewAppModal } from '../modals' import { openNewAppModal } from '../modals'
import { import {
setCurrentView,
setSelectedApp,
setSidebarCollapsed, setSidebarCollapsed,
sidebarCollapsed, sidebarCollapsed,
} from '../state' } from '../state'
@ -17,12 +15,6 @@ import {
import { AppSelector } from './AppSelector' import { AppSelector } from './AppSelector'
export function Sidebar({ render }: { render: () => void }) { export function Sidebar({ render }: { render: () => void }) {
const goToDashboard = () => {
setSelectedApp(null)
setCurrentView('dashboard')
render()
}
const toggleSidebar = () => { const toggleSidebar = () => {
setSidebarCollapsed(!sidebarCollapsed) setSidebarCollapsed(!sidebarCollapsed)
render() render()
@ -40,7 +32,7 @@ export function Sidebar({ render }: { render: () => void }) {
</div> </div>
) : ( ) : (
<Logo> <Logo>
<LogoLink onClick={goToDashboard} title="Go to dashboard"> <LogoLink href="/" title="Go to dashboard">
🐾 Toes 🐾 Toes
</LogoLink> </LogoLink>
<HamburgerButton onClick={toggleSidebar} title="Hide sidebar"> <HamburgerButton onClick={toggleSidebar} title="Hide sidebar">
@ -50,7 +42,7 @@ export function Sidebar({ render }: { render: () => void }) {
</HamburgerButton> </HamburgerButton>
</Logo> </Logo>
)} )}
<AppSelector render={render} collapsed={sidebarCollapsed} onDashboard={goToDashboard} /> <AppSelector render={render} collapsed={sidebarCollapsed} />
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<SidebarFooter> <SidebarFooter>
<NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton> <NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>

View File

@ -1,5 +1,6 @@
import { buildAppUrl } from '../../shared/urls' import { buildAppUrl } from '../../shared/urls'
import { apps, setSelectedApp } from '../state' import { navigate } from '../router'
import { apps } from '../state'
import { import {
EmptyState, EmptyState,
Tile, Tile,
@ -22,20 +23,19 @@ export function Urls({ render }: { render: () => void }) {
{nonTools.map(app => { {nonTools.map(app => {
const url = buildAppUrl(app.name, location.origin) const url = buildAppUrl(app.name, location.origin)
const running = app.state === 'running' const running = app.state === 'running'
const appPage = `/app/${app.name}`
const openAppPage = (e: MouseEvent) => { const openAppPage = (e: MouseEvent) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
setSelectedApp(app.name) navigate(appPage)
render()
} }
return ( return (
<Tile <Tile
key={app.name} key={app.name}
href={running ? url : '#'} href={running ? url : appPage}
target={running ? '_blank' : undefined} target={running ? '_blank' : undefined}
onClick={running ? undefined : openAppPage}
> >
<TileStatus state={app.state} onClick={openAppPage} /> <TileStatus state={app.state} onClick={openAppPage} />
<TileIcon>{app.icon}</TileIcon> <TileIcon>{app.icon}</TileIcon>

View File

@ -1,7 +1,8 @@
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 { apps, getSelectedTab, selectedApp, setApps, setIsNarrow, setSelectedApp } from './state'
import { initModal } from './components/modal' import { initModal } from './components/modal'
import { initRouter, navigate } from './router'
import { apps, getSelectedTab, selectedApp, setApps, setIsNarrow } from './state'
import { initToolIframes, updateToolIframes } from './tool-iframes' import { initToolIframes, updateToolIframes } from './tool-iframes'
import { initUpdate } from './update' import { initUpdate } from './update'
@ -41,14 +42,16 @@ narrowQuery.addEventListener('change', e => {
render() render()
}) })
// Initialize router (sets initial state from URL and renders)
initRouter(render)
// SSE connection // SSE connection
const events = new EventSource('/api/apps/stream') const events = new EventSource('/api/apps/stream')
events.onmessage = e => { events.onmessage = e => {
const prev = apps
setApps(JSON.parse(e.data)) setApps(JSON.parse(e.data))
if (selectedApp && !apps.some(a => a.name === selectedApp)) { if (selectedApp && !apps.some(a => a.name === selectedApp)) {
setSelectedApp(null) navigate('/')
} }
render() render()

View File

@ -1,6 +1,7 @@
import type { App } from '../../shared/types' import type { App } from '../../shared/types'
import { closeModal, openModal, rerenderModal } from '../components/modal' import { closeModal, openModal, rerenderModal } from '../components/modal'
import { selectedApp, setSelectedApp } from '../state' import { navigate } from '../router'
import { selectedApp } from '../state'
import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles' import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles'
import { theme } from '../themes' import { theme } from '../themes'
@ -32,11 +33,11 @@ async function deleteApp(input: HTMLInputElement) {
throw new Error(`Failed to delete app: ${res.statusText}`) throw new Error(`Failed to delete app: ${res.statusText}`)
} }
// Success - close modal and clear selection // Success - close modal and navigate to dashboard
if (selectedApp === deleteAppTarget.name) {
setSelectedApp(null)
}
closeModal() closeModal()
if (selectedApp === deleteAppTarget.name) {
navigate('/')
}
} 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

View File

@ -1,5 +1,6 @@
import { closeModal, openModal, rerenderModal } from '../components/modal' import { closeModal, openModal, rerenderModal } from '../components/modal'
import { apps, setSelectedApp } from '../state' import { navigate } from '../router'
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'
type TemplateType = 'ssr' | 'spa' | 'bare' type TemplateType = 'ssr' | 'spa' | 'bare'
@ -48,9 +49,9 @@ async function createNewApp() {
throw new Error(data.error || 'Failed to create app') throw new Error(data.error || 'Failed to create app')
} }
// Success - close modal and select the new app // Success - close modal and navigate to the new app
setSelectedApp(name)
closeModal() closeModal()
navigate(`/app/${name}`)
} 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

View File

@ -1,6 +1,7 @@
import type { App } from '../../shared/types' import type { App } from '../../shared/types'
import { closeModal, openModal, rerenderModal } from '../components/modal' import { closeModal, openModal, rerenderModal } from '../components/modal'
import { apps, setSelectedApp } from '../state' import { navigate } from '../router'
import { apps } from '../state'
import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles' import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles'
let renameAppError = '' let renameAppError = ''
@ -58,9 +59,9 @@ async function doRenameApp(input: HTMLInputElement) {
throw new Error(data.error || 'Failed to rename app') throw new Error(data.error || 'Failed to rename app')
} }
// Success - update selection and close modal // Success - close modal and navigate to renamed app
setSelectedApp(data.name || newName)
closeModal() closeModal()
navigate(`/app/${data.name || newName}`)
} 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

59
src/client/router.ts Normal file
View File

@ -0,0 +1,59 @@
import { setCurrentView, setDashboardTab, setSelectedApp, setSelectedTab } from './state'
let _render: () => void
export function navigate(href: string) {
history.pushState(null, '', href)
route()
}
export function initRouter(render: () => void) {
_render = render
// Intercept link clicks
document.addEventListener('click', e => {
const a = (e.target as Element).closest('a')
if (!a || !a.href || a.origin !== location.origin || a.target === '_blank') return
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
e.preventDefault()
history.pushState(null, '', a.href)
route()
})
// Handle back/forward
window.addEventListener('popstate', route)
// Initial route from URL
route()
}
function route() {
const path = location.pathname
if (path.startsWith('/app/')) {
const rest = decodeURIComponent(path.slice(5))
const slashIdx = rest.indexOf('/')
const name = slashIdx === -1 ? rest : rest.slice(0, slashIdx)
const tab = slashIdx === -1 ? 'overview' : rest.slice(slashIdx + 1)
setSelectedApp(name)
setSelectedTab(name, tab)
setCurrentView('dashboard')
} else if (path === '/settings') {
setSelectedApp(null)
setCurrentView('settings')
} else if (path === '/logs') {
setSelectedApp(null)
setDashboardTab('logs')
setCurrentView('dashboard')
} else if (path === '/metrics') {
setSelectedApp(null)
setDashboardTab('metrics')
setCurrentView('dashboard')
} else {
setSelectedApp(null)
setDashboardTab('urls')
setCurrentView('dashboard')
}
_render()
}

View File

@ -5,21 +5,20 @@ export type DashboardTab = 'urls' | 'logs' | 'metrics'
// UI state (survives re-renders) // UI state (survives re-renders)
export let currentView: 'dashboard' | 'settings' = 'dashboard' export let currentView: 'dashboard' | 'settings' = 'dashboard'
export let isNarrow: boolean = window.matchMedia('(max-width: 768px)').matches export let isNarrow: boolean = window.matchMedia('(max-width: 768px)').matches
export let selectedApp: string | null = localStorage.getItem('selectedApp') export let selectedApp: string | null = null
export let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true' export let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true'
export let dashboardTab: DashboardTab = (localStorage.getItem('dashboardTab') as DashboardTab) || 'urls' export let dashboardTab: DashboardTab = 'urls'
export let sidebarSection: 'apps' | 'tools' = (localStorage.getItem('sidebarSection') as 'apps' | 'tools') || 'apps' export let sidebarSection: 'apps' | 'tools' = (localStorage.getItem('sidebarSection') as 'apps' | 'tools') || 'apps'
// Server state (from SSE) // Server state (from SSE)
export let apps: App[] = [] export let apps: App[] = []
// Tab state // Tab state
export let appTabs: Record<string, string> = JSON.parse(localStorage.getItem('appTabs') || '{}') export let appTabs: Record<string, string> = {}
// State setters // State setters
export function setDashboardTab(tab: DashboardTab) { export function setDashboardTab(tab: DashboardTab) {
dashboardTab = tab dashboardTab = tab
localStorage.setItem('dashboardTab', tab)
} }
export function setCurrentView(view: 'dashboard' | 'settings') { export function setCurrentView(view: 'dashboard' | 'settings') {
@ -28,11 +27,6 @@ export function setCurrentView(view: 'dashboard' | 'settings') {
export function setSelectedApp(name: string | null) { export function setSelectedApp(name: string | null) {
selectedApp = name selectedApp = name
if (name) {
localStorage.setItem('selectedApp', name)
} else {
localStorage.removeItem('selectedApp')
}
} }
export function setIsNarrow(narrow: boolean) { export function setIsNarrow(narrow: boolean) {
@ -59,5 +53,4 @@ export const getSelectedTab = (appName: string | null) =>
export function setSelectedTab(appName: string | null, tab: string) { export function setSelectedTab(appName: string | null, tab: string) {
if (!appName) return if (!appName) return
appTabs[appName] = tab appTabs[appName] = tab
localStorage.setItem('appTabs', JSON.stringify(appTabs))
} }

View File

@ -29,7 +29,10 @@ export const Logo = define('Logo', {
}) })
export const LogoLink = define('LogoLink', { export const LogoLink = define('LogoLink', {
base: 'a',
cursor: 'pointer', cursor: 'pointer',
color: 'inherit',
textDecoration: 'none',
borderRadius: theme('radius-md'), borderRadius: theme('radius-md'),
padding: '4px 8px', padding: '4px 8px',
margin: '-4px -8px', margin: '-4px -8px',
@ -112,6 +115,7 @@ export const AppList = define('AppList', {
}) })
export const AppItem = define('AppItem', { export const AppItem = define('AppItem', {
base: 'a',
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

View File

@ -7,6 +7,7 @@ import systemRouter from './api/system'
import { Hype } from '@because/hype' import { Hype } from '@because/hype'
import { cleanupStalePublishers } from './mdns' import { cleanupStalePublishers } from './mdns'
import { extractSubdomain, proxySubdomain, proxyWebSocket, websocket } from './proxy' import { extractSubdomain, proxySubdomain, proxyWebSocket, websocket } from './proxy'
import { Shell } from './shell'
import type { Server } from 'bun' import type { Server } from 'bun'
import type { WsData } from './proxy' import type { WsData } from './proxy'
@ -113,6 +114,13 @@ app.get('/dist/:file', async c => {
}) })
}) })
// SPA routes — serve the shell for all client-side paths
app.get('/app/:name/:tab', c => c.html(<Shell />))
app.get('/app/:name', c => c.html(<Shell />))
app.get('/logs', c => c.html(<Shell />))
app.get('/metrics', c => c.html(<Shell />))
app.get('/settings', c => c.html(<Shell />))
cleanupStalePublishers() cleanupStalePublishers()
await initApps() await initApps()