Replace app selector modal with mobile sidebar state

This commit is contained in:
Chris Wanstrath 2026-02-26 20:37:50 -08:00
parent 7ab27f2767
commit 671f51ca0c
11 changed files with 84 additions and 42 deletions

View File

@ -2,8 +2,8 @@ import { define } from '@because/forge'
import type { App } from '../../shared/types'
import { buildAppUrl } from '../../shared/urls'
import { restartApp, shareApp, startApp, stopApp, unshareApp } from '../api'
import { openAppSelectorModal, openDeleteAppModal, openRenameAppModal } from '../modals'
import { apps, getSelectedTab, isNarrow } from '../state'
import { openDeleteAppModal, openRenameAppModal } from '../modals'
import { apps, getSelectedTab, isNarrow, setMobileSidebar } from '../state'
import {
ActionBar,
Button,
@ -53,16 +53,15 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
<Main>
<MainHeader>
<MainTitle>
<OpenEmojiPicker app={app} render={render}>{app.icon}</OpenEmojiPicker>
&nbsp;
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
{isNarrow && (
<HamburgerButton onClick={() => openAppSelectorModal(render)} title="Show apps">
<HamburgerButton onClick={() => { setMobileSidebar(true); render() }} title="Show apps">
<HamburgerLine />
<HamburgerLine />
<HamburgerLine />
</HamburgerButton>
)}
<OpenEmojiPicker app={app} render={render}>{app.icon}</OpenEmojiPicker>
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
</MainTitle>
<HeaderActions>
{!app.tool && (

View File

@ -17,11 +17,12 @@ interface AppSelectorProps {
render: () => void
onSelect?: () => void
collapsed?: boolean
large?: boolean
switcherStyle?: CSSProperties
listStyle?: CSSProperties
}
export function AppSelector({ render, onSelect, collapsed, switcherStyle, listStyle }: AppSelectorProps) {
export function AppSelector({ render, onSelect, collapsed, large, switcherStyle, listStyle }: AppSelectorProps) {
const switchSection = (section: 'apps' | 'tools') => {
setSidebarSection(section)
render()
@ -35,10 +36,10 @@ export function AppSelector({ render, onSelect, collapsed, switcherStyle, listSt
<>
{!collapsed && toolApps.length > 0 && (
<SectionSwitcher style={switcherStyle}>
<SectionTab active={sidebarSection === 'apps' ? true : undefined} onClick={() => switchSection('apps')}>
<SectionTab active={sidebarSection === 'apps' ? true : undefined} large={large || undefined} onClick={() => switchSection('apps')}>
Apps
</SectionTab>
<SectionTab active={sidebarSection === 'tools' ? true : undefined} onClick={() => switchSection('tools')}>
<SectionTab active={sidebarSection === 'tools' ? true : undefined} large={large || undefined} onClick={() => switchSection('tools')}>
Tools
</SectionTab>
</SectionSwitcher>
@ -59,6 +60,7 @@ export function AppSelector({ render, onSelect, collapsed, switcherStyle, listSt
key={app.name}
href={`/app/${app.name}`}
onClick={onSelect}
large={large || undefined}
selected={app.name === selectedApp ? true : undefined}
style={collapsed ? { justifyContent: 'center', padding: '10px 12px' } : undefined}
title={collapsed ? app.name : undefined}
@ -67,7 +69,7 @@ export function AppSelector({ render, onSelect, collapsed, switcherStyle, listSt
<span style={{ fontSize: 18 }}>{app.icon}</span>
) : (
<>
<span style={{ fontSize: 14 }}>{app.icon}</span>
<span style={{ fontSize: large ? 20 : 14 }}>{app.icon}</span>
{app.name}
<StatusDot state={app.state} data-app={app.name} data-state={app.state} style={{ marginLeft: 'auto' }} />
</>

View File

@ -1,13 +1,48 @@
import { Styles } from '@because/forge'
import { apps, currentView, isNarrow, selectedApp } from '../state'
import { Layout } from '../styles'
import { openNewAppModal } from '../modals'
import { apps, currentView, isNarrow, mobileSidebar, selectedApp, setMobileSidebar } from '../state'
import {
HamburgerButton,
HamburgerLine,
Layout,
Main,
MainContent as MainContentContainer,
MainHeader,
MainTitle,
NewAppButton,
} from '../styles'
import { AppDetail } from './AppDetail'
import { AppSelector } from './AppSelector'
import { DashboardLanding } from './DashboardLanding'
import { Modal } from './modal'
import { SettingsPage } from './SettingsPage'
import { Sidebar } from './Sidebar'
function MobileSidebar({ render }: { render: () => void }) {
return (
<Main>
<MainHeader>
<MainTitle>
<HamburgerButton onClick={() => { setMobileSidebar(false); render() }} title="Hide apps">
<HamburgerLine />
<HamburgerLine />
<HamburgerLine />
</HamburgerButton>
<a href="/" style={{ textDecoration: 'none', color: 'inherit' }}>🐾 Toes</a>
</MainTitle>
</MainHeader>
<MainContentContainer>
<AppSelector render={render} large />
<div style={{ padding: '12px 16px' }}>
<NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>
</div>
</MainContentContainer>
</Main>
)
}
function MainContent({ render }: { render: () => void }) {
if (isNarrow && mobileSidebar) return <MobileSidebar render={render} />
const selected = apps.find(a => a.name === selectedApp)
if (selected) return <AppDetail app={selected} render={render} />
if (currentView === 'settings') return <SettingsPage render={render} />

View File

@ -1,7 +1,6 @@
import { useEffect } from 'hono/jsx'
import { openAppSelectorModal } from '../modals'
import { navigate } from '../router'
import { dashboardTab, isNarrow } from '../state'
import { dashboardTab, isNarrow, setMobileSidebar } from '../state'
import {
HamburgerButton,
HamburgerLine,
@ -44,16 +43,20 @@ export function DashboardLanding({ render }: { render: () => void }) {
>
</SettingsGear>
<DashboardHeader>
<DashboardTitle narrow={narrow}>
🐾 Toes
{isNarrow && (
<HamburgerButton onClick={() => openAppSelectorModal(render)} title="Show apps">
<HamburgerButton
onClick={() => { setMobileSidebar(true); render() }}
title="Show apps"
style={{ position: 'absolute', top: 16, left: 16 }}
>
<HamburgerLine />
<HamburgerLine />
<HamburgerLine />
</HamburgerButton>
)}
<DashboardHeader>
<DashboardTitle narrow={narrow}>
🐾 Toes
</DashboardTitle>
</DashboardHeader>

View File

@ -1,6 +1,6 @@
import { buildAppUrl } from '../../shared/urls'
import { navigate } from '../router'
import { apps } from '../state'
import { apps, isNarrow } from '../state'
import {
EmptyState,
Tile,
@ -19,7 +19,7 @@ export function Urls({ render }: { render: () => void }) {
}
return (
<TileGrid>
<TileGrid narrow={isNarrow || undefined}>
{nonTools.map(app => {
const url = buildAppUrl(app.name, location.origin)
const running = app.state === 'running'
@ -36,6 +36,7 @@ export function Urls({ render }: { render: () => void }) {
key={app.name}
href={running ? url : appPage}
target={running ? '_blank' : undefined}
narrow={isNarrow || undefined}
>
<TileStatus state={app.state} onClick={openAppPage} />
<TileIcon>{app.icon}</TileIcon>

View File

@ -1,17 +0,0 @@
import { closeModal, openModal } from '../components/modal'
import { AppSelector } from '../components/AppSelector'
let renderFn: () => void
export function openAppSelectorModal(render: () => void) {
renderFn = render
openModal('Select App', () => (
<AppSelector
render={renderFn}
onSelect={closeModal}
switcherStyle={{ padding: '0 0 12px', marginLeft: -20, marginRight: -20, paddingLeft: 20, paddingRight: 20, marginBottom: 8 }}
listStyle={{ maxHeight: 300, overflow: 'auto' }}
/>
))
}

View File

@ -1,4 +1,3 @@
export { openAppSelectorModal } from './AppSelector'
export { openDeleteAppModal } from './DeleteApp'
export { openNewAppModal } from './NewApp'
export { openRenameAppModal } from './RenameApp'

View File

@ -1,4 +1,4 @@
import { setCurrentView, setDashboardTab, setSelectedApp, setSelectedTab } from './state'
import { setCurrentView, setDashboardTab, setMobileSidebar, setSelectedApp, setSelectedTab } from './state'
let _render: () => void
@ -28,6 +28,7 @@ export function initRouter(render: () => void) {
}
function route() {
setMobileSidebar(false)
const path = location.pathname
if (path.startsWith('/app/')) {

View File

@ -8,6 +8,7 @@ export let isNarrow: boolean = window.matchMedia('(max-width: 768px)').matches
export let selectedApp: string | null = null
export let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true'
export let dashboardTab: DashboardTab = 'urls'
export let mobileSidebar: boolean = false
export let sidebarSection: 'apps' | 'tools' = (localStorage.getItem('sidebarSection') as 'apps' | 'tools') || 'apps'
// Server state (from SSE)
@ -33,6 +34,10 @@ export function setIsNarrow(narrow: boolean) {
isNarrow = narrow
}
export function setMobileSidebar(open: boolean) {
mobileSidebar = open
}
export function setSidebarCollapsed(collapsed: boolean) {
sidebarCollapsed = collapsed
localStorage.setItem('sidebarCollapsed', String(collapsed))

View File

@ -210,6 +210,9 @@ export const TileGrid = define('TileGrid', {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: 20,
variants: {
narrow: { gridTemplateColumns: '1fr' },
},
})
export const Tile = define('Tile', {
@ -231,6 +234,14 @@ export const Tile = define('Tile', {
borderColor: theme('colors-textFaint'),
},
},
variants: {
narrow: {
flexDirection: 'row',
alignItems: 'center',
padding: '16px 20px',
gap: 16,
},
},
})
export const TileIcon = define('TileIcon', {

View File

@ -106,6 +106,7 @@ export const SectionTab = define('SectionTab', {
background: theme('colors-bgSelected'),
color: theme('colors-text'),
},
large: { fontSize: 14, padding: '8px 12px' },
},
})
@ -129,6 +130,7 @@ export const AppItem = define('AppItem', {
'&:hover': { background: theme('colors-bgHover'), color: theme('colors-text') },
},
variants: {
large: { fontSize: 18, padding: '12px 16px', gap: 12 },
selected: { background: theme('colors-bgSelected'), color: theme('colors-text'), fontWeight: 500 },
},
})
@ -223,6 +225,7 @@ export const DashboardContainer = define('DashboardContainer', {
export const DashboardHeader = define('DashboardHeader', {
textAlign: 'center',
width: '100%',
})
export const DashboardTitle = define('DashboardTitle', {