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

View File

@ -17,11 +17,12 @@ interface AppSelectorProps {
render: () => void render: () => void
onSelect?: () => void onSelect?: () => void
collapsed?: boolean collapsed?: boolean
large?: boolean
switcherStyle?: CSSProperties switcherStyle?: CSSProperties
listStyle?: 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') => { const switchSection = (section: 'apps' | 'tools') => {
setSidebarSection(section) setSidebarSection(section)
render() render()
@ -35,10 +36,10 @@ export function AppSelector({ render, onSelect, collapsed, switcherStyle, listSt
<> <>
{!collapsed && toolApps.length > 0 && ( {!collapsed && toolApps.length > 0 && (
<SectionSwitcher style={switcherStyle}> <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 Apps
</SectionTab> </SectionTab>
<SectionTab active={sidebarSection === 'tools' ? true : undefined} onClick={() => switchSection('tools')}> <SectionTab active={sidebarSection === 'tools' ? true : undefined} large={large || undefined} onClick={() => switchSection('tools')}>
Tools Tools
</SectionTab> </SectionTab>
</SectionSwitcher> </SectionSwitcher>
@ -59,6 +60,7 @@ export function AppSelector({ render, onSelect, collapsed, switcherStyle, listSt
key={app.name} key={app.name}
href={`/app/${app.name}`} href={`/app/${app.name}`}
onClick={onSelect} onClick={onSelect}
large={large || undefined}
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}
@ -67,7 +69,7 @@ export function AppSelector({ render, onSelect, collapsed, switcherStyle, listSt
<span style={{ fontSize: 18 }}>{app.icon}</span> <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} {app.name}
<StatusDot state={app.state} data-app={app.name} data-state={app.state} style={{ marginLeft: 'auto' }} /> <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 { Styles } from '@because/forge'
import { apps, currentView, isNarrow, selectedApp } from '../state' import { openNewAppModal } from '../modals'
import { Layout } from '../styles' 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 { AppDetail } from './AppDetail'
import { AppSelector } from './AppSelector'
import { DashboardLanding } from './DashboardLanding' import { DashboardLanding } from './DashboardLanding'
import { Modal } from './modal' import { Modal } from './modal'
import { SettingsPage } from './SettingsPage' import { SettingsPage } from './SettingsPage'
import { Sidebar } from './Sidebar' 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 }) { function MainContent({ render }: { render: () => void }) {
if (isNarrow && mobileSidebar) return <MobileSidebar render={render} />
const selected = apps.find(a => a.name === selectedApp) const selected = apps.find(a => a.name === selectedApp)
if (selected) return <AppDetail app={selected} render={render} /> if (selected) return <AppDetail app={selected} render={render} />
if (currentView === 'settings') return <SettingsPage render={render} /> if (currentView === 'settings') return <SettingsPage render={render} />

View File

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

View File

@ -1,6 +1,6 @@
import { buildAppUrl } from '../../shared/urls' import { buildAppUrl } from '../../shared/urls'
import { navigate } from '../router' import { navigate } from '../router'
import { apps } from '../state' import { apps, isNarrow } from '../state'
import { import {
EmptyState, EmptyState,
Tile, Tile,
@ -19,7 +19,7 @@ export function Urls({ render }: { render: () => void }) {
} }
return ( return (
<TileGrid> <TileGrid narrow={isNarrow || undefined}>
{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'
@ -36,6 +36,7 @@ export function Urls({ render }: { render: () => void }) {
key={app.name} key={app.name}
href={running ? url : appPage} href={running ? url : appPage}
target={running ? '_blank' : undefined} target={running ? '_blank' : undefined}
narrow={isNarrow || undefined}
> >
<TileStatus state={app.state} onClick={openAppPage} /> <TileStatus state={app.state} onClick={openAppPage} />
<TileIcon>{app.icon}</TileIcon> <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 { openDeleteAppModal } from './DeleteApp'
export { openNewAppModal } from './NewApp' export { openNewAppModal } from './NewApp'
export { openRenameAppModal } from './RenameApp' 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 let _render: () => void
@ -28,6 +28,7 @@ export function initRouter(render: () => void) {
} }
function route() { function route() {
setMobileSidebar(false)
const path = location.pathname const path = location.pathname
if (path.startsWith('/app/')) { 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 selectedApp: string | null = null
export let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true' export let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true'
export let dashboardTab: DashboardTab = 'urls' export let dashboardTab: DashboardTab = 'urls'
export let mobileSidebar: boolean = false
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)
@ -33,6 +34,10 @@ export function setIsNarrow(narrow: boolean) {
isNarrow = narrow isNarrow = narrow
} }
export function setMobileSidebar(open: boolean) {
mobileSidebar = open
}
export function setSidebarCollapsed(collapsed: boolean) { export function setSidebarCollapsed(collapsed: boolean) {
sidebarCollapsed = collapsed sidebarCollapsed = collapsed
localStorage.setItem('sidebarCollapsed', String(collapsed)) localStorage.setItem('sidebarCollapsed', String(collapsed))

View File

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

View File

@ -106,6 +106,7 @@ export const SectionTab = define('SectionTab', {
background: theme('colors-bgSelected'), background: theme('colors-bgSelected'),
color: theme('colors-text'), 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') }, '&:hover': { background: theme('colors-bgHover'), color: theme('colors-text') },
}, },
variants: { variants: {
large: { fontSize: 18, padding: '12px 16px', gap: 12 },
selected: { background: theme('colors-bgSelected'), color: theme('colors-text'), fontWeight: 500 }, 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', { export const DashboardHeader = define('DashboardHeader', {
textAlign: 'center', textAlign: 'center',
width: '100%',
}) })
export const DashboardTitle = define('DashboardTitle', { export const DashboardTitle = define('DashboardTitle', {