fix(pwa): replace hamburger overlays with proper mobile bottom nav

- MobileBottomNav: [SERVERS] [CHAT] [MEMBERS] tab bar, always visible
- Servers tab opens sidebar overlay, chat/members switch views
- Removed hamburger + members toggle from mobile top bar
- Top bar compact on mobile (no redundant buttons)
- safe-area-inset-bottom on nav, clean inset on frame
- Desktop status bar hidden on mobile, preserved on desktop
- Dead MobileNav/MobileDrawer left in place (unused, can prune later)
This commit is contained in:
2026-07-15 17:21:10 -04:00
parent 191fe2a89f
commit f215f000b8
+116 -121
View File
@@ -13,22 +13,20 @@ import { NotificationPrompt } from './NotificationPrompt.tsx';
import { MemberList } from './MemberList.tsx'; import { MemberList } from './MemberList.tsx';
import { VoicePanel } from './VoicePanel.tsx'; import { VoicePanel } from './VoicePanel.tsx';
import { ServerSettingsModal } from './ServerSettingsModal.tsx'; import { ServerSettingsModal } from './ServerSettingsModal.tsx';
const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline']; const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline'];
function statusColor(status: UserStatus): string { function statusColor(status: UserStatus): string {
switch (status) { switch (status) {
case 'online': case 'online': return 'bg-gb-green';
return 'bg-gb-green'; case 'idle': return 'bg-gb-yellow';
case 'idle': case 'dnd': return 'bg-gb-red';
return 'bg-gb-yellow'; default: return 'bg-gb-gray';
case 'dnd':
return 'bg-gb-red';
default:
return 'bg-gb-gray';
} }
} }
function statusLabel(status: UserStatus): string { function statusLabel(status: UserStatus): string {
return status.toUpperCase(); return status.toUpperCase();
} }
export function Layout() { export function Layout() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const isLoading = useAuthStore((state) => state.isLoading); const isLoading = useAuthStore((state) => state.isLoading);
@@ -49,23 +47,22 @@ export function Layout() {
const [activeTab, setActiveTab] = useState<'chat' | 'voice'>('chat'); const [activeTab, setActiveTab] = useState<'chat' | 'voice'>('chat');
useEffect(() => { useEffect(() => {
if (currentVoiceRoom) { if (currentVoiceRoom) setActiveTab('voice');
setActiveTab('voice'); else setActiveTab('chat');
} else {
setActiveTab('chat');
}
}, [currentVoiceRoom]); }, [currentVoiceRoom]);
useEffect(() => { useEffect(() => {
wsConnect(); wsConnect();
return () => { wsDisconnect(); }; return () => { wsDisconnect(); };
}, [wsConnect, wsDisconnect]); }, [wsConnect, wsDisconnect]);
useEffect(() => { useEffect(() => {
if (!isLoading && !isAuthenticated && location.pathname !== '/login') { if (!isLoading && !isAuthenticated && location.pathname !== '/login') {
navigate('/login', { replace: true }); navigate('/login', { replace: true });
} }
}, [isLoading, isAuthenticated, location.pathname, navigate]); }, [isLoading, isAuthenticated, location.pathname, navigate]);
// Navigate to chat when a channel/DM is selected on mobile
// Navigate to chat view on mobile when a channel/DM is selected
useEffect(() => { useEffect(() => {
const unsub = useChannelStore.subscribe((state, prev) => { const unsub = useChannelStore.subscribe((state, prev) => {
if (state.activeChannelId !== prev.activeChannelId && state.activeChannelId) { if (state.activeChannelId !== prev.activeChannelId && state.activeChannelId) {
@@ -74,68 +71,52 @@ export function Layout() {
}); });
return unsub; return unsub;
}, [setMobileView]); }, [setMobileView]);
// Also switch to chat on mobile when navigating to a DM
useEffect(() => { useEffect(() => {
if (location.pathname.startsWith('/dm/') && location.pathname !== '/dm') { if (location.pathname.startsWith('/dm/') && location.pathname !== '/dm') {
setMobileView('chat'); setMobileView('chat');
} }
}, [location.pathname, setMobileView]); }, [location.pathname, setMobileView]);
const handleStatusChange = async (status: UserStatus) => { const handleStatusChange = async (status: UserStatus) => {
setShowStatusMenu(false); setShowStatusMenu(false);
try { try { await updateProfile({ status }); } catch { /* store surfaces error */ }
await updateProfile({ status });
} catch (err) {
// Error is surfaced via auth store; menu closes optimistically.
}
}; };
const currentStatus = user?.status ?? 'offline'; const currentStatus = user?.status ?? 'offline';
if (isLoading) { if (isLoading) {
return ( return <div className="h-full w-full flex items-center justify-center bg-gb-bg text-gb-fg-f font-mono">[booting...]</div>;
<div className="h-full w-full flex items-center justify-center bg-gb-bg text-gb-fg-f font-mono">
[booting...]
</div>
);
}
if (!isAuthenticated) {
return null;
} }
if (!isAuthenticated) return null;
return ( return (
<div className="h-full w-full bg-gb-bg text-gb-fg font-mono flex flex-col" style={{ padding: 'max(env(safe-area-inset-top), 0.5rem) max(env(safe-area-inset-right), 0.5rem) max(env(safe-area-inset-bottom), 0.5rem) max(env(safe-area-inset-left), 0.5rem)' }}> <div className="h-full w-full bg-gb-bg text-gb-fg font-mono flex flex-col">
<div className="flex-1 terminal-border bg-gb-bg-h flex flex-col min-h-0"> {/* Outer terminal frame with safe area */}
{/* Top bar */} <div className="flex-1 terminal-border bg-gb-bg-h flex flex-col min-h-0"
<div className="flex items-center justify-between px-2 md:px-3 py-1 border-b border-gb-bg-t bg-gb-bg-s gap-2"> style={{ padding: 'max(env(safe-area-inset-top), 0.25rem) max(env(safe-area-inset-right), 0) max(env(safe-area-inset-bottom), 0) max(env(safe-area-inset-left), 0)' }}>
{/* Top bar — minimal on mobile */}
<div className="flex items-center justify-between px-2 md:px-3 py-1 border-b border-gb-bg-t bg-gb-bg-s gap-1">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
{/* Mobile: hamburger to show sidebar */} <span className="text-gb-orange font-bold shrink-0 text-sm md:text-base">DUMPSTER</span>
<button
type="button"
onClick={() => setMobileView(mobileView === 'sidebar' ? 'chat' : 'sidebar')}
className="md:hidden terminal-button text-xs px-2 py-0.5 shrink-0"
>
{mobileView === 'sidebar' ? '✕' : '☰'}
</button>
<span className="text-gb-orange font-bold shrink-0">DUMPSTER</span>
</div> </div>
<div className="flex items-center gap-2 md:gap-4 text-xs text-gb-fg-s min-w-0"> <div className="flex items-center gap-1 md:gap-4 text-xs text-gb-fg-s min-w-0">
<div className="relative"> <div className="relative">
<button <button
type="button" type="button"
onClick={() => setShowStatusMenu((prev) => !prev)} onClick={() => setShowStatusMenu((prev) => !prev)}
className="flex items-center gap-1 md:gap-2 hover:text-gb-fg transition-colors" className="flex items-center gap-1 hover:text-gb-fg transition-colors"
title="Change status" title="Change status"
> >
<span className={`w-2.5 h-2.5 rounded-full ${statusColor(currentStatus)}`} /> <span className={`w-2 h-2 rounded-full ${statusColor(currentStatus)} shrink-0`} />
<span className="text-gb-aqua hidden sm:inline">{user?.username || 'unknown'}</span> <span className="text-gb-aqua hidden sm:inline truncate max-w-[80px]">{user?.username || 'unknown'}</span>
<span className="text-gb-fg-f hidden sm:inline">[{statusLabel(currentStatus)}]</span> <span className="text-gb-fg-f hidden sm:inline">[{statusLabel(currentStatus)}]</span>
</button> </button>
{showStatusMenu && ( {showStatusMenu && (
<div className="absolute right-0 top-full mt-1 z-50 w-32 bg-gb-bg-s border border-gb-bg-t shadow-lg"> <div className="absolute right-0 top-full mt-1 z-50 w-32 bg-gb-bg-s border border-gb-bg-t shadow-lg">
{STATUS_CYCLE.map((s) => ( {STATUS_CYCLE.map((s) => (
<button <button key={s} type="button" onClick={() => handleStatusChange(s)}
key={s} className="w-full px-2 py-1 text-left text-xs font-mono flex items-center gap-2 hover:bg-gb-orange hover:text-gb-bg transition-colors">
type="button"
onClick={() => handleStatusChange(s)}
className="w-full px-2 py-1 text-left text-xs font-mono flex items-center gap-2 hover:bg-gb-orange hover:text-gb-bg transition-colors"
>
<span className={`w-2 h-2 rounded-full ${statusColor(s)}`} /> <span className={`w-2 h-2 rounded-full ${statusColor(s)}`} />
<span>{statusLabel(s)}</span> <span>{statusLabel(s)}</span>
</button> </button>
@@ -143,76 +124,44 @@ export function Layout() {
</div> </div>
)} )}
</div> </div>
<Link to="/settings" className="terminal-button text-xs hidden sm:inline-flex">[SETTINGS]</Link> <Link to="/settings" className="terminal-button text-xs px-1.5 py-0.5 hidden sm:inline-flex">[SETTINGS]</Link>
{activeServerId && ( {activeServerId && (
<button <button onClick={() => setShowServerSettings(true)}
onClick={() => setShowServerSettings(true)} className="terminal-button text-xs px-1.5 py-0.5 hidden md:inline-flex">
className="terminal-button text-xs hidden md:inline-flex"
>
[SERVER SETTINGS] [SERVER SETTINGS]
</button> </button>
)} )}
{/* Mobile: members toggle */} <button onClick={() => logout().then(() => navigate('/login'))}
{!isDM && ( className="terminal-button text-xs px-1.5 py-0.5">
<button
type="button"
onClick={() => setMobileView(mobileView === 'members' ? 'chat' : 'members')}
className="md:hidden terminal-button text-xs px-2 py-0.5"
>
{mobileView === 'members' ? '✕' : '👤'}
</button>
)}
<button onClick={() => logout().then(() => navigate('/login'))} className="terminal-button text-xs">
[LOGOUT] [LOGOUT]
</button> </button>
</div> </div>
</div> </div>
{/* Main content area */} {/* Main content */}
<div className="flex-1 flex min-h-0 relative"> <div className="flex-1 flex min-h-0 relative">
{/* Sidebar: ServerBar + ChannelList/ConversationList */} {/* Sidebar — desktop: always visible, mobile: overlay when sidebar tab active */}
{/* Desktop: always visible as left columns */} <div className={`
{/* Mobile: full-screen overlay when mobileView === 'sidebar' */} ${mobileView === 'sidebar' ? 'flex' : 'hidden'} md:flex
<div absolute md:relative inset-0 md:inset-auto z-30 md:z-auto flex-shrink-0
className={` `}>
${mobileView === 'sidebar' ? 'flex' : 'hidden'}
md:flex
absolute md:relative inset-0 md:inset-auto z-30 md:z-auto
flex-shrink-0
`}
>
<ServerBar /> <ServerBar />
{isDM ? <ConversationList /> : <ChannelList />} {isDM ? <ConversationList /> : <ChannelList />}
{/* Close sidebar on mobile after selection */} {/* Close overlay on mobile by tapping background */}
<div <div className="flex-1 md:hidden" onClick={() => setMobileView('chat')} />
className="flex-1 md:hidden"
onClick={() => setMobileView('chat')}
/>
</div> </div>
{/* Chat area: always in DOM, hidden on mobile when sidebar/members shown */} {/* Chat */}
<div <div className={`
className={` ${mobileView === 'chat' ? 'flex' : 'hidden'} md:flex flex-1 min-w-0 flex-col
${mobileView === 'chat' ? 'flex' : 'hidden'} `}>
md:flex
flex-1 min-w-0 flex-col
`}
>
<div className="flex-1 min-h-0 flex flex-col overflow-hidden"> <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
{currentVoiceRoom && ( {currentVoiceRoom && (
<div className="flex bg-gb-bg-s border-b border-gb-bg-t"> <div className="flex bg-gb-bg-s border-b border-gb-bg-t">
<button <button className={`flex-1 py-1.5 text-xs text-center border-b-2 ${activeTab === 'chat' ? 'border-gb-fg-f text-gb-fg-f' : 'border-transparent text-gb-fg-s hover:text-gb-fg-f'}`}
className={`flex-1 py-2 text-xs text-center border-b-2 ${activeTab === 'chat' ? 'border-gb-fg-f text-gb-fg-f' : 'border-transparent text-gb-fg-s hover:text-gb-fg-f'}`} onClick={() => setActiveTab('chat')}>[CHAT]</button>
onClick={() => setActiveTab('chat')} <button className={`flex-1 py-1.5 text-xs text-center border-b-2 ${activeTab === 'voice' ? 'border-gb-fg-f text-gb-fg-f' : 'border-transparent text-gb-fg-s hover:text-gb-fg-f'}`}
> onClick={() => setActiveTab('voice')}>[VOICE/VIDEO]</button>
[CHAT]
</button>
<button
className={`flex-1 py-2 text-xs text-center border-b-2 ${activeTab === 'voice' ? 'border-gb-fg-f text-gb-fg-f' : 'border-transparent text-gb-fg-s hover:text-gb-fg-f'}`}
onClick={() => setActiveTab('voice')}
>
[VOICE/VIDEO]
</button>
</div> </div>
)} )}
<div className={`flex-1 min-h-0 flex-col ${activeTab === 'chat' || !currentVoiceRoom ? 'flex' : 'hidden'}`}> <div className={`flex-1 min-h-0 flex-col ${activeTab === 'chat' || !currentVoiceRoom ? 'flex' : 'hidden'}`}>
@@ -226,32 +175,31 @@ export function Layout() {
</div> </div>
</div> </div>
{/* Members panel */} {/* Members — desktop: right column, mobile: overlay when members tab active */}
{/* Desktop: right column */}
{/* Mobile: full-screen overlay when mobileView === 'members' */}
{!isDM && ( {!isDM && (
<div <div className={`
className={` ${mobileView === 'members' ? 'flex' : 'hidden'} md:flex
${mobileView === 'members' ? 'flex' : 'hidden'} absolute md:relative inset-0 md:inset-auto z-30 md:z-auto flex-shrink-0
md:flex `}>
absolute md:relative inset-0 md:inset-auto z-30 md:z-auto <div className="flex-1 md:hidden" onClick={() => setMobileView('chat')} />
flex-shrink-0
`}
>
{/* Tap background to close on mobile */}
<div
className="flex-1 md:hidden"
onClick={() => setMobileView('chat')}
/>
<MemberList /> <MemberList />
</div> </div>
)} )}
</div> </div>
{/* Bottom nav — mobile only */}
<MobileBottomNav
mobileView={mobileView}
onViewChange={setMobileView}
isDM={isDM}
/>
{showServerSettings && activeServerId && ( {showServerSettings && activeServerId && (
<ServerSettingsModal serverId={activeServerId} onClose={() => setShowServerSettings(false)} /> <ServerSettingsModal serverId={activeServerId} onClose={() => setShowServerSettings(false)} />
)} )}
<div className="px-2 md:px-3 py-1 border-t border-gb-bg-t text-xs text-gb-fg-f flex justify-between bg-gb-bg-s">
{/* Status bar — desktop only */}
<div className="hidden md:flex px-3 py-1 border-t border-gb-bg-t text-xs text-gb-fg-f justify-between bg-gb-bg-s">
<span>TERM {__APP_VERSION__}</span> <span>TERM {__APP_VERSION__}</span>
<span>{new Date().toISOString().slice(0, 10)}</span> <span>{new Date().toISOString().slice(0, 10)}</span>
</div> </div>
@@ -260,3 +208,50 @@ export function Layout() {
</div> </div>
); );
} }
// MobileBottomNav — tab bar for switching between sidebar / chat / members
function MobileBottomNav({
mobileView,
onViewChange,
isDM,
}: {
mobileView: string;
onViewChange: (v: 'sidebar' | 'chat' | 'members') => void;
isDM: boolean;
}) {
const isConnected = useVoiceStore((s) => s.isConnected);
return (
<div className="md:hidden flex items-center bg-gb-bg-h border-t border-gb-bg-t shrink-0"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
<NavTab active={mobileView === 'sidebar'} onClick={() => onViewChange('sidebar')}>
[SERVERS]
</NavTab>
<NavTab active={mobileView === 'chat'} onClick={() => onViewChange('chat')}>
[CHAT]
</NavTab>
{!isDM && (
<NavTab active={mobileView === 'members'} onClick={() => onViewChange('members')}>
[MEMBERS]
</NavTab>
)}
{isConnected && (
<div className="w-2 h-2 rounded-full bg-gb-green animate-pulse ml-auto mr-3" title="In voice" />
)}
</div>
);
}
function NavTab({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button
type="button"
onClick={onClick}
className={`flex-1 py-2.5 text-xs font-mono text-center transition-colors border-t-2 ${
active ? 'border-gb-orange text-gb-orange bg-gb-bg-s' : 'border-transparent text-gb-fg-f hover:text-gb-fg hover:bg-gb-bg'
}`}
>
{children}
</button>
);
}