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:
@@ -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,22 +630,31 @@ 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">
|
||||||
{activeChannel
|
<button
|
||||||
? activeChannel.type === 'forum'
|
type="button"
|
||||||
? `■ ${activeChannel.name}`
|
onClick={() => useLayoutStore.getState().setMobileView('sidebar')}
|
||||||
: activeChannel.type === 'calendar'
|
className="md:hidden terminal-button text-xs px-2 py-0.5 shrink-0"
|
||||||
? `○ ${activeChannel.name}`
|
>
|
||||||
: activeChannel.type === 'docs'
|
←
|
||||||
? `☰ ${activeChannel.name}`
|
</button>
|
||||||
: activeChannel.type === 'list'
|
<span className="truncate">
|
||||||
? `☑ ${activeChannel.name}`
|
{activeChannel
|
||||||
: `# ${activeChannel.name}`
|
? activeChannel.type === 'forum'
|
||||||
: activeChannelId
|
? `■ ${activeChannel.name}`
|
||||||
? `#${activeChannelId}`
|
: activeChannel.type === 'calendar'
|
||||||
: "[NO CHANNEL SELECTED]"}
|
? `○ ${activeChannel.name}`
|
||||||
</span>
|
: activeChannel.type === 'docs'
|
||||||
|
? `☰ ${activeChannel.name}`
|
||||||
|
: activeChannel.type === 'list'
|
||||||
|
? `☑ ${activeChannel.name}`
|
||||||
|
: `# ${activeChannel.name}`
|
||||||
|
: activeChannelId
|
||||||
|
? `#${activeChannelId}`
|
||||||
|
: "[NO CHANNEL SELECTED]"}
|
||||||
|
</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
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
+102
-15
@@ -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">
|
|
||||||
<ServerBar />
|
{/* Main content area */}
|
||||||
{isDM ? <ConversationList /> : <ChannelList />}
|
<div className="flex-1 flex min-h-0 relative">
|
||||||
<div className="flex-1 min-w-0 flex flex-col">
|
{/* 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 />
|
||||||
|
{isDM ? <ConversationList /> : <ChannelList />}
|
||||||
|
{/* 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>
|
||||||
|
|||||||
@@ -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 }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user