feat: mobile-responsive layout

- Mobile (<md): single-panel view, sidebar/chat/members as overlays
- Hamburger button toggles sidebar overlay (ServerBar + ChannelList)
- Members button toggles MemberList overlay
- ← back button in ChatArea and DMChat headers returns to sidebar
- Auto-switch to chat view when channel or DM is selected
- Tap empty area to dismiss overlays
- Desktop layout unchanged (all panels side by side)
- Status/settings buttons hidden on small screens to save space
This commit is contained in:
2026-07-02 13:58:15 -04:00
parent 0b645fe765
commit b6d4614881
4 changed files with 144 additions and 33 deletions
+12 -2
View File
@@ -22,6 +22,7 @@ import { FeatureRequestsPanel } from "./FeatureRequestsPanel.tsx";
import { ReactionBar } from "./ReactionBar.tsx"; import { ReactionBar } from "./ReactionBar.tsx";
import { EmojiPicker } from "./EmojiPicker.tsx"; import { EmojiPicker } from "./EmojiPicker.tsx";
import { ReplyBar } from "./ReplyBar.tsx"; import { ReplyBar } from "./ReplyBar.tsx";
import { useLayoutStore } from "../stores/layout.ts";
import { ForumView } from "./ForumView.tsx"; import { ForumView } from "./ForumView.tsx";
import { CalendarView } from "./CalendarView.tsx"; import { CalendarView } from "./CalendarView.tsx";
import { DocsView } from "./DocsView.tsx"; import { DocsView } from "./DocsView.tsx";
@@ -629,8 +630,16 @@ export function ChatArea() {
return ( return (
<div className="flex flex-col h-full bg-gb-bg"> <div className="flex flex-col h-full bg-gb-bg">
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s flex items-center justify-between"> <div className="terminal-border border-t-0 border-x-0 px-2 md:px-3 py-2 text-gb-fg-s flex items-center justify-between gap-2">
<span> <div className="flex items-center gap-2 min-w-0">
<button
type="button"
onClick={() => useLayoutStore.getState().setMobileView('sidebar')}
className="md:hidden terminal-button text-xs px-2 py-0.5 shrink-0"
>
</button>
<span className="truncate">
{activeChannel {activeChannel
? activeChannel.type === 'forum' ? activeChannel.type === 'forum'
? `${activeChannel.name}` ? `${activeChannel.name}`
@@ -645,6 +654,7 @@ export function ChatArea() {
? `#${activeChannelId}` ? `#${activeChannelId}`
: "[NO CHANNEL SELECTED]"} : "[NO CHANNEL SELECTED]"}
</span> </span>
</div>
{activeChannelId && activeChannel?.type === 'text' && ( {activeChannelId && activeChannel?.type === 'text' && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
+10 -2
View File
@@ -3,6 +3,7 @@ import { useParams } from "react-router-dom";
import { useConversationStore, type ConversationMessage } from "../stores/conversation.ts"; import { useConversationStore, type ConversationMessage } from "../stores/conversation.ts";
import { useAuthStore } from "../stores/auth.ts"; import { useAuthStore } from "../stores/auth.ts";
import { useTypingStore } from "../stores/typing.ts"; import { useTypingStore } from "../stores/typing.ts";
import { useLayoutStore } from "../stores/layout.ts";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
@@ -132,8 +133,15 @@ export function DMChat() {
return ( return (
<div className="flex flex-col h-full bg-gb-bg"> <div className="flex flex-col h-full bg-gb-bg">
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s"> <div className="terminal-border border-t-0 border-x-0 px-2 md:px-3 py-2 text-gb-fg-s flex items-center gap-2">
@ {title} <button
type="button"
onClick={() => useLayoutStore.getState().setMobileView('sidebar')}
className="md:hidden terminal-button text-xs px-2 py-0.5 shrink-0"
>
</button>
<span className="truncate">@ {title}</span>
</div> </div>
<div ref={scrollContainerRef} onScroll={handleScroll} className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm"> <div ref={scrollContainerRef} onScroll={handleScroll} className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
{isLoadingOlder && <p className="text-center text-gb-fg-f text-xs">[loading older messages...]</p>} {isLoadingOlder && <p className="text-center text-gb-fg-f text-xs">[loading older messages...]</p>}
+100 -13
View File
@@ -38,8 +38,11 @@ export function Layout() {
const location = useLocation(); const location = useLocation();
const [showStatusMenu, setShowStatusMenu] = useState(false); const [showStatusMenu, setShowStatusMenu] = useState(false);
const isDM = useLayoutStore((s) => s.isDM); const isDM = useLayoutStore((s) => s.isDM);
const mobileView = useLayoutStore((s) => s.mobileView);
const setMobileView = useLayoutStore((s) => s.setMobileView);
const [showServerSettings, setShowServerSettings] = useState(false); const [showServerSettings, setShowServerSettings] = useState(false);
const activeServerId = useServerStore((s) => s.activeServerId); const activeServerId = useServerStore((s) => s.activeServerId);
const activeChannelId = useServerStore((s) => s.activeChannelId);
useEffect(() => { useEffect(() => {
wsConnect(); wsConnect();
return () => { wsDisconnect(); }; return () => { wsDisconnect(); };
@@ -49,6 +52,21 @@ export function Layout() {
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
useEffect(() => {
const unsub = useServerStore.subscribe((state, prev) => {
if (state.activeChannelId !== prev.activeChannelId && state.activeChannelId) {
setMobileView('chat');
}
});
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) => { const handleStatusChange = async (status: UserStatus) => {
setShowStatusMenu(false); setShowStatusMenu(false);
try { try {
@@ -69,21 +87,32 @@ export function Layout() {
return null; return null;
} }
return ( return (
<div className="h-full w-full bg-gb-bg text-gb-fg font-mono flex flex-col p-2"> <div className="h-full w-full bg-gb-bg text-gb-fg font-mono flex flex-col p-1 md:p-2">
<div className="flex-1 terminal-border bg-gb-bg-h flex flex-col min-h-0"> <div className="flex-1 terminal-border bg-gb-bg-h flex flex-col min-h-0">
<div className="flex items-center justify-between px-3 py-1 border-b border-gb-bg-t bg-gb-bg-s"> {/* Top bar */}
<span className="text-gb-orange font-bold">DUMPSTER</span> <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">
<div className="flex items-center gap-4 text-xs text-gb-fg-s"> <div className="flex items-center gap-2 min-w-0">
{/* Mobile: hamburger to show sidebar */}
<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 className="flex items-center gap-2 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-2 hover:text-gb-fg transition-colors" className="flex items-center gap-1 md:gap-2 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.5 h-2.5 rounded-full ${statusColor(currentStatus)}`} />
<span className="text-gb-aqua">{user?.username || 'unknown'}</span> <span className="text-gb-aqua hidden sm:inline">{user?.username || 'unknown'}</span>
<span className="text-gb-fg-f">[{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">
@@ -101,24 +130,61 @@ export function Layout() {
</div> </div>
)} )}
</div> </div>
<Link to="/settings" className="terminal-button text-xs">[SETTINGS]</Link> <Link to="/settings" className="terminal-button text-xs hidden sm:inline-flex">[SETTINGS]</Link>
{activeServerId && ( {activeServerId && (
<button <button
onClick={() => setShowServerSettings(true)} onClick={() => setShowServerSettings(true)}
className="terminal-button text-xs" className="terminal-button text-xs hidden md:inline-flex"
> >
[SERVER SETTINGS] [SERVER SETTINGS]
</button> </button>
)} )}
{/* Mobile: members toggle */}
{!isDM && (
<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"> <button onClick={() => logout().then(() => navigate('/login'))} className="terminal-button text-xs">
[LOGOUT] [LOGOUT]
</button> </button>
</div> </div>
</div> </div>
<div className="flex-1 flex min-h-0">
{/* Main content area */}
<div className="flex-1 flex min-h-0 relative">
{/* Sidebar: ServerBar + ChannelList/ConversationList */}
{/* Desktop: always visible as left columns */}
{/* Mobile: full-screen overlay when mobileView === 'sidebar' */}
<div
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 />}
<div className="flex-1 min-w-0 flex flex-col"> {/* Close sidebar on mobile after selection */}
<div
className="flex-1 md:hidden"
onClick={() => setMobileView('chat')}
/>
</div>
{/* Chat area: always in DOM, hidden on mobile when sidebar/members shown */}
<div
className={`
${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">
<VoicePanel /> <VoicePanel />
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
@@ -126,12 +192,33 @@ export function Layout() {
</div> </div>
</div> </div>
</div> </div>
{!isDM && <MemberList />}
{/* Members panel */}
{/* Desktop: right column */}
{/* Mobile: full-screen overlay when mobileView === 'members' */}
{!isDM && (
<div
className={`
${mobileView === 'members' ? 'flex' : 'hidden'}
md:flex
absolute md:relative inset-0 md:inset-auto z-30 md:z-auto
flex-shrink-0
`}
>
{/* Tap background to close on mobile */}
<div
className="flex-1 md:hidden"
onClick={() => setMobileView('chat')}
/>
<MemberList />
</div> </div>
)}
</div>
{showServerSettings && activeServerId && ( {showServerSettings && activeServerId && (
<ServerSettingsModal serverId={activeServerId} onClose={() => setShowServerSettings(false)} /> <ServerSettingsModal serverId={activeServerId} onClose={() => setShowServerSettings(false)} />
)} )}
<div className="px-3 py-1 border-t border-gb-bg-t text-xs text-gb-fg-f flex justify-between bg-gb-bg-s"> <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">
<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>
+6
View File
@@ -1,11 +1,17 @@
import { create } from 'zustand'; import { create } from 'zustand';
export type MobileView = 'sidebar' | 'chat' | 'members';
interface LayoutState { interface LayoutState {
isDM: boolean; isDM: boolean;
setDM: (value: boolean) => void; setDM: (value: boolean) => void;
mobileView: MobileView;
setMobileView: (view: MobileView) => void;
} }
export const useLayoutStore = create<LayoutState>((set) => ({ export const useLayoutStore = create<LayoutState>((set) => ({
isDM: false, isDM: false,
setDM: (value) => set({ isDM: value }), setDM: (value) => set({ isDM: value }),
mobileView: 'chat',
setMobileView: (view) => set({ mobileView: view }),
})); }));