From 98a1c1ad97659ced55e53ee3672ee03ba81abbcd Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Thu, 26 Feb 2026 11:40:50 -0800 Subject: [PATCH] Add client-side router, use URLs for navigation --- src/client/components/AppSelector.tsx | 17 +++----- src/client/components/DashboardLanding.tsx | 7 ++-- src/client/components/SettingsPage.tsx | 5 +-- src/client/components/Sidebar.tsx | 12 +----- src/client/components/Urls.tsx | 10 ++--- src/client/index.tsx | 9 +++-- src/client/modals/DeleteApp.tsx | 11 +++--- src/client/modals/NewApp.tsx | 7 ++-- src/client/modals/RenameApp.tsx | 7 ++-- src/client/router.ts | 45 ++++++++++++++++++++++ src/client/state.ts | 7 +--- src/client/styles/layout.ts | 4 ++ src/server/index.tsx | 5 +++ 13 files changed, 92 insertions(+), 54 deletions(-) create mode 100644 src/client/router.ts diff --git a/src/client/components/AppSelector.tsx b/src/client/components/AppSelector.tsx index 64aad9e..9bf025e 100644 --- a/src/client/components/AppSelector.tsx +++ b/src/client/components/AppSelector.tsx @@ -2,7 +2,6 @@ import type { CSSProperties } from 'hono/jsx' import { apps, selectedApp, - setSelectedApp, setSidebarSection, sidebarSection, } from '../state' @@ -17,19 +16,12 @@ import { interface AppSelectorProps { render: () => void onSelect?: () => void - onDashboard?: () => void collapsed?: boolean switcherStyle?: CSSProperties listStyle?: CSSProperties } -export function AppSelector({ render, onSelect, onDashboard, collapsed, switcherStyle, listStyle }: AppSelectorProps) { - const selectApp = (name: string) => { - setSelectedApp(name) - onSelect?.() - render() - } - +export function AppSelector({ render, onSelect, collapsed, switcherStyle, listStyle }: AppSelectorProps) { const switchSection = (section: 'apps' | 'tools') => { setSidebarSection(section) render() @@ -52,9 +44,9 @@ export function AppSelector({ render, onSelect, onDashboard, collapsed, switcher )} - {collapsed && onDashboard && ( + {collapsed && ( ( selectApp(app.name)} + href={`/app/${app.name}`} + onClick={onSelect} selected={app.name === selectedApp ? true : undefined} style={collapsed ? { justifyContent: 'center', padding: '10px 12px' } : undefined} title={collapsed ? app.name : undefined} diff --git a/src/client/components/DashboardLanding.tsx b/src/client/components/DashboardLanding.tsx index 1c5e5cc..a8dd5ac 100644 --- a/src/client/components/DashboardLanding.tsx +++ b/src/client/components/DashboardLanding.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'hono/jsx' import { openAppSelectorModal } from '../modals' -import { dashboardTab, isNarrow, setCurrentView, setDashboardTab, setSelectedApp } from '../state' +import { navigate } from '../router' +import { dashboardTab, isNarrow, setDashboardTab } from '../state' import { AppSelectorChevron, DashboardContainer, @@ -25,9 +26,7 @@ export function DashboardLanding({ render }: { render: () => void }) { const narrow = isNarrow || undefined const openSettings = () => { - setSelectedApp(null) - setCurrentView('settings') - render() + navigate('/settings') } const switchTab = (tab: typeof dashboardTab) => { diff --git a/src/client/components/SettingsPage.tsx b/src/client/components/SettingsPage.tsx index 442d345..cce6e10 100644 --- a/src/client/components/SettingsPage.tsx +++ b/src/client/components/SettingsPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'hono/jsx' import { getWifiConfig, saveWifiConfig } from '../api' -import { setCurrentView } from '../state' +import { navigate } from '../router' import { Button, DashboardInstallCmd, @@ -31,8 +31,7 @@ export function SettingsPage({ render }: { render: () => void }) { }, []) const goBack = () => { - setCurrentView('dashboard') - render() + navigate('/') } const handleSave = async (e: Event) => { diff --git a/src/client/components/Sidebar.tsx b/src/client/components/Sidebar.tsx index ea4bf25..b09a0d1 100644 --- a/src/client/components/Sidebar.tsx +++ b/src/client/components/Sidebar.tsx @@ -1,7 +1,5 @@ import { openNewAppModal } from '../modals' import { - setCurrentView, - setSelectedApp, setSidebarCollapsed, sidebarCollapsed, } from '../state' @@ -17,12 +15,6 @@ import { import { AppSelector } from './AppSelector' export function Sidebar({ render }: { render: () => void }) { - const goToDashboard = () => { - setSelectedApp(null) - setCurrentView('dashboard') - render() - } - const toggleSidebar = () => { setSidebarCollapsed(!sidebarCollapsed) render() @@ -40,7 +32,7 @@ export function Sidebar({ render }: { render: () => void }) { ) : ( - + 🐾 Toes @@ -50,7 +42,7 @@ export function Sidebar({ render }: { render: () => void }) { )} - + {!sidebarCollapsed && ( + New App diff --git a/src/client/components/Urls.tsx b/src/client/components/Urls.tsx index 5902f02..4a3f276 100644 --- a/src/client/components/Urls.tsx +++ b/src/client/components/Urls.tsx @@ -1,5 +1,6 @@ import { buildAppUrl } from '../../shared/urls' -import { apps, setSelectedApp } from '../state' +import { navigate } from '../router' +import { apps } from '../state' import { EmptyState, Tile, @@ -22,20 +23,19 @@ export function Urls({ render }: { render: () => void }) { {nonTools.map(app => { const url = buildAppUrl(app.name, location.origin) const running = app.state === 'running' + const appPage = `/app/${app.name}` const openAppPage = (e: MouseEvent) => { e.preventDefault() e.stopPropagation() - setSelectedApp(app.name) - render() + navigate(appPage) } return ( {app.icon} diff --git a/src/client/index.tsx b/src/client/index.tsx index 27070f3..209b405 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -1,7 +1,8 @@ import { render as renderApp } from 'hono/jsx/dom' import { Dashboard } from './components' -import { apps, getSelectedTab, selectedApp, setApps, setIsNarrow, setSelectedApp } from './state' 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 { initUpdate } from './update' @@ -41,14 +42,16 @@ narrowQuery.addEventListener('change', e => { render() }) +// Initialize router (sets initial state from URL and renders) +initRouter(render) + // SSE connection const events = new EventSource('/api/apps/stream') events.onmessage = e => { - const prev = apps setApps(JSON.parse(e.data)) if (selectedApp && !apps.some(a => a.name === selectedApp)) { - setSelectedApp(null) + navigate('/') } render() diff --git a/src/client/modals/DeleteApp.tsx b/src/client/modals/DeleteApp.tsx index dacd006..d07de9f 100644 --- a/src/client/modals/DeleteApp.tsx +++ b/src/client/modals/DeleteApp.tsx @@ -1,6 +1,7 @@ import type { App } from '../../shared/types' 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 { theme } from '../themes' @@ -32,11 +33,11 @@ async function deleteApp(input: HTMLInputElement) { throw new Error(`Failed to delete app: ${res.statusText}`) } - // Success - close modal and clear selection - if (selectedApp === deleteAppTarget.name) { - setSelectedApp(null) - } + // Success - close modal and navigate to dashboard closeModal() + if (selectedApp === deleteAppTarget.name) { + navigate('/') + } } catch (err) { deleteAppError = err instanceof Error ? err.message : 'Failed to delete app' deleteAppDeleting = false diff --git a/src/client/modals/NewApp.tsx b/src/client/modals/NewApp.tsx index 971439a..94f51d5 100644 --- a/src/client/modals/NewApp.tsx +++ b/src/client/modals/NewApp.tsx @@ -1,5 +1,6 @@ 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' type TemplateType = 'ssr' | 'spa' | 'bare' @@ -48,9 +49,9 @@ async function createNewApp() { throw new Error(data.error || 'Failed to create app') } - // Success - close modal and select the new app - setSelectedApp(name) + // Success - close modal and navigate to the new app closeModal() + navigate(`/app/${name}`) } catch (err) { newAppError = err instanceof Error ? err.message : 'Failed to create app' newAppCreating = false diff --git a/src/client/modals/RenameApp.tsx b/src/client/modals/RenameApp.tsx index 3a26633..53c0101 100644 --- a/src/client/modals/RenameApp.tsx +++ b/src/client/modals/RenameApp.tsx @@ -1,6 +1,7 @@ import type { App } from '../../shared/types' 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' let renameAppError = '' @@ -58,9 +59,9 @@ async function doRenameApp(input: HTMLInputElement) { throw new Error(data.error || 'Failed to rename app') } - // Success - update selection and close modal - setSelectedApp(data.name || newName) + // Success - close modal and navigate to renamed app closeModal() + navigate(`/app/${data.name || newName}`) } catch (err) { renameAppError = err instanceof Error ? err.message : 'Failed to rename app' renameAppRenaming = false diff --git a/src/client/router.ts b/src/client/router.ts new file mode 100644 index 0000000..67aacfd --- /dev/null +++ b/src/client/router.ts @@ -0,0 +1,45 @@ +import { setCurrentView, setSelectedApp } 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 + 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 name = decodeURIComponent(path.slice(5)) + setSelectedApp(name) + setCurrentView('dashboard') + } else if (path === '/settings') { + setSelectedApp(null) + setCurrentView('settings') + } else { + setSelectedApp(null) + setCurrentView('dashboard') + } + + _render() +} diff --git a/src/client/state.ts b/src/client/state.ts index f5c97b3..e994cd4 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -5,7 +5,7 @@ export type DashboardTab = 'urls' | 'logs' | 'metrics' // UI state (survives re-renders) export let currentView: 'dashboard' | 'settings' = 'dashboard' 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 dashboardTab: DashboardTab = (localStorage.getItem('dashboardTab') as DashboardTab) || 'urls' export let sidebarSection: 'apps' | 'tools' = (localStorage.getItem('sidebarSection') as 'apps' | 'tools') || 'apps' @@ -28,11 +28,6 @@ export function setCurrentView(view: 'dashboard' | 'settings') { export function setSelectedApp(name: string | null) { selectedApp = name - if (name) { - localStorage.setItem('selectedApp', name) - } else { - localStorage.removeItem('selectedApp') - } } export function setIsNarrow(narrow: boolean) { diff --git a/src/client/styles/layout.ts b/src/client/styles/layout.ts index d87581c..9ab7b01 100644 --- a/src/client/styles/layout.ts +++ b/src/client/styles/layout.ts @@ -29,7 +29,10 @@ export const Logo = define('Logo', { }) export const LogoLink = define('LogoLink', { + base: 'a', cursor: 'pointer', + color: 'inherit', + textDecoration: 'none', borderRadius: theme('radius-md'), padding: '4px 8px', margin: '-4px -8px', @@ -112,6 +115,7 @@ export const AppList = define('AppList', { }) export const AppItem = define('AppItem', { + base: 'a', display: 'flex', flexDirection: 'row', alignItems: 'center', diff --git a/src/server/index.tsx b/src/server/index.tsx index eb41e6b..71ae33d 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -7,6 +7,7 @@ import systemRouter from './api/system' import { Hype } from '@because/hype' import { cleanupStalePublishers } from './mdns' import { extractSubdomain, proxySubdomain, proxyWebSocket, websocket } from './proxy' +import { Shell } from './shell' import type { Server } from 'bun' import type { WsData } from './proxy' @@ -113,6 +114,10 @@ app.get('/dist/:file', async c => { }) }) +// SPA routes — serve the shell for all client-side paths +app.get('/app/:name', c => c.html()) +app.get('/settings', c => c.html()) + cleanupStalePublishers() await initApps()