diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx
index e223b94..e7c6c6c 100644
--- a/web/src/components/Layout.tsx
+++ b/web/src/components/Layout.tsx
@@ -13,22 +13,20 @@ import { NotificationPrompt } from './NotificationPrompt.tsx';
import { MemberList } from './MemberList.tsx';
import { VoicePanel } from './VoicePanel.tsx';
import { ServerSettingsModal } from './ServerSettingsModal.tsx';
+
const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline'];
function statusColor(status: UserStatus): string {
switch (status) {
- case 'online':
- return 'bg-gb-green';
- case 'idle':
- return 'bg-gb-yellow';
- case 'dnd':
- return 'bg-gb-red';
- default:
- return 'bg-gb-gray';
+ case 'online': return 'bg-gb-green';
+ case 'idle': return 'bg-gb-yellow';
+ case 'dnd': return 'bg-gb-red';
+ default: return 'bg-gb-gray';
}
}
function statusLabel(status: UserStatus): string {
return status.toUpperCase();
}
+
export function Layout() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const isLoading = useAuthStore((state) => state.isLoading);
@@ -49,23 +47,22 @@ export function Layout() {
const [activeTab, setActiveTab] = useState<'chat' | 'voice'>('chat');
useEffect(() => {
- if (currentVoiceRoom) {
- setActiveTab('voice');
- } else {
- setActiveTab('chat');
- }
+ if (currentVoiceRoom) setActiveTab('voice');
+ else setActiveTab('chat');
}, [currentVoiceRoom]);
useEffect(() => {
wsConnect();
return () => { wsDisconnect(); };
}, [wsConnect, wsDisconnect]);
+
useEffect(() => {
if (!isLoading && !isAuthenticated && location.pathname !== '/login') {
navigate('/login', { replace: true });
}
}, [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(() => {
const unsub = useChannelStore.subscribe((state, prev) => {
if (state.activeChannelId !== prev.activeChannelId && state.activeChannelId) {
@@ -74,68 +71,52 @@ export function Layout() {
});
return unsub;
}, [setMobileView]);
- // Also switch to chat on mobile when navigating to a DM
+
useEffect(() => {
if (location.pathname.startsWith('/dm/') && location.pathname !== '/dm') {
setMobileView('chat');
}
}, [location.pathname, setMobileView]);
+
const handleStatusChange = async (status: UserStatus) => {
setShowStatusMenu(false);
- try {
- await updateProfile({ status });
- } catch (err) {
- // Error is surfaced via auth store; menu closes optimistically.
- }
+ try { await updateProfile({ status }); } catch { /* store surfaces error */ }
};
const currentStatus = user?.status ?? 'offline';
+
if (isLoading) {
- return (
-
- [booting...]
-
- );
- }
- if (!isAuthenticated) {
- return null;
+ return [booting...]
;
}
+ if (!isAuthenticated) return null;
+
return (
-
-
- {/* Top bar */}
-
+
+ {/* Outer terminal frame with safe area */}
+
+
+ {/* Top bar — minimal on mobile */}
+
- {/* Mobile: hamburger to show sidebar */}
- setMobileView(mobileView === 'sidebar' ? 'chat' : 'sidebar')}
- className="md:hidden terminal-button text-xs px-2 py-0.5 shrink-0"
- >
- {mobileView === 'sidebar' ? '✕' : '☰'}
-
- DUMPSTER
+ DUMPSTER
-
+
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"
>
-
- {user?.username || 'unknown'}
+
+ {user?.username || 'unknown'}
[{statusLabel(currentStatus)}]
{showStatusMenu && (
{STATUS_CYCLE.map((s) => (
- 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"
- >
+ 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">
{statusLabel(s)}
@@ -143,76 +124,44 @@ export function Layout() {
)}
-
[SETTINGS]
+
[SETTINGS]
{activeServerId && (
-
setShowServerSettings(true)}
- className="terminal-button text-xs hidden md:inline-flex"
- >
+ setShowServerSettings(true)}
+ className="terminal-button text-xs px-1.5 py-0.5 hidden md:inline-flex">
[SERVER SETTINGS]
)}
- {/* Mobile: members toggle */}
- {!isDM && (
- setMobileView(mobileView === 'members' ? 'chat' : 'members')}
- className="md:hidden terminal-button text-xs px-2 py-0.5"
- >
- {mobileView === 'members' ? '✕' : '👤'}
-
- )}
- logout().then(() => navigate('/login'))} className="terminal-button text-xs">
+ logout().then(() => navigate('/login'))}
+ className="terminal-button text-xs px-1.5 py-0.5">
[LOGOUT]
- {/* Main content area */}
+ {/* Main content */}
- {/* Sidebar: ServerBar + ChannelList/ConversationList */}
- {/* Desktop: always visible as left columns */}
- {/* Mobile: full-screen overlay when mobileView === 'sidebar' */}
-
+ {/* Sidebar — desktop: always visible, mobile: overlay when sidebar tab active */}
+
{isDM ?
:
}
- {/* Close sidebar on mobile after selection */}
-
setMobileView('chat')}
- />
+ {/* Close overlay on mobile by tapping background */}
+
setMobileView('chat')} />
- {/* Chat area: always in DOM, hidden on mobile when sidebar/members shown */}
-
+ {/* Chat */}
+
{currentVoiceRoom && (
- setActiveTab('chat')}
- >
- [CHAT]
-
- setActiveTab('voice')}
- >
- [VOICE/VIDEO]
-
+ setActiveTab('chat')}>[CHAT]
+ setActiveTab('voice')}>[VOICE/VIDEO]
)}
@@ -226,32 +175,31 @@ export function Layout() {
- {/* Members panel */}
- {/* Desktop: right column */}
- {/* Mobile: full-screen overlay when mobileView === 'members' */}
+ {/* Members — desktop: right column, mobile: overlay when members tab active */}
{!isDM && (
-
- {/* Tap background to close on mobile */}
-
setMobileView('chat')}
- />
+
+
setMobileView('chat')} />
)}
+ {/* Bottom nav — mobile only */}
+
+
{showServerSettings && activeServerId && (
setShowServerSettings(false)} />
)}
-
+
+ {/* Status bar — desktop only */}
+
TERM {__APP_VERSION__}
{new Date().toISOString().slice(0, 10)}
@@ -260,3 +208,50 @@ export function Layout() {
);
}
+
+// 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 (
+
+
onViewChange('sidebar')}>
+ [SERVERS]
+
+
onViewChange('chat')}>
+ [CHAT]
+
+ {!isDM && (
+
onViewChange('members')}>
+ [MEMBERS]
+
+ )}
+ {isConnected && (
+
+ )}
+
+ );
+}
+
+function NavTab({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}