feat: responsive mobile layout with slide-over sidebar

On screens ≤600px the sidebar collapses off-screen. A hamburger button
in the message header opens it as a slide-over drawer with a dim
overlay; tapping a room/network or the ✕ button closes it. Desktop
layout is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-06-29 18:52:26 +02:00
parent 1d9c9d1524
commit 32a6f46481
5 changed files with 76 additions and 8 deletions

View File

@@ -164,3 +164,64 @@ details summary { color: var(--muted); font-size: 12px; cursor: pointer; }
.file-entry-icon { font-size: 12px; flex-shrink: 0; }
.history-divider { display: flex; align-items: center; gap: 8px; margin: 10px 0 6px; color: var(--muted); font-size: 11px; }
.history-divider::before, .history-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
/* ── mobile hamburger / close buttons ── */
.menu-btn-mobile { display: none; background: none; color: var(--muted); font-size: 18px; padding: 0 8px 0 0; line-height: 1; }
.menu-btn-mobile:hover { color: var(--text); background: none; }
.sidebar-close-mobile { display: none; background: none; color: var(--muted); font-size: 14px; padding: 2px 4px; }
.sidebar-close-mobile:hover { color: var(--text); background: none; }
/* ── responsive layout ── */
@media (max-width: 600px) {
:root { --sidebar-w: 80vw; }
.chat-layout {
grid-template-columns: 1fr;
position: relative;
}
/* sidebar slides in over the top */
.sidebar {
position: fixed;
top: 0; left: 0;
width: var(--sidebar-w);
height: 100%;
z-index: 100;
transform: translateX(-100%);
transition: transform 0.22s ease;
box-shadow: 4px 0 24px rgba(0,0,0,0.5);
}
.chat-layout.sidebar-open .sidebar {
transform: translateX(0);
}
/* dim overlay behind open sidebar */
.chat-layout.sidebar-open::before {
content: '';
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
z-index: 99;
}
.menu-btn-mobile { display: inline-block; }
.sidebar-close-mobile { display: inline-block; }
/* message pane fills full width */
.chat-layout > .message-pane { grid-column: 1; }
/* file browser stacks below on mobile */
.chat-layout.has-file-browser { grid-template-columns: 1fr; }
.chat-layout.has-file-browser > .file-browser { border-left: none; border-top: 1px solid var(--border); max-height: 40vh; overflow-y: auto; }
/* slightly larger tap targets */
.sidebar-item { padding: 8px 12px; font-size: 14px; }
.peer-row { padding: 6px 12px; }
.peer-row-actions { opacity: 1; }
.peer-action { font-size: 18px; padding: 2px 6px; }
/* message layout: stack alias above text on very narrow screens */
.message { flex-wrap: wrap; }
.message-ts { width: auto; min-width: 56px; }
.message-alias { width: auto; }
}

View File

@@ -12,7 +12,7 @@ function formatTs(ts: number): string {
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + time
}
export function MessagePane() {
export function MessagePane({ onMenuClick }: { onMenuClick: () => void }) {
const { messages, historyCutoff, activeRoom, activeNetworkId, localPeer, connectedPeers, knownPeers, send } = useWaste()
const [draft, setDraft] = useState('')
const bottomRef = useRef<HTMLDivElement>(null)
@@ -64,7 +64,10 @@ export function MessagePane() {
return (
<main className="message-pane">
<div className="message-pane-header">{roomLabel}</div>
<div className="message-pane-header">
<button className="menu-btn-mobile" onClick={onMenuClick} aria-label="Menu"></button>
{roomLabel}
</div>
<div className="messages">
{dividerIdx === 0 && (

View File

@@ -26,7 +26,7 @@ function formatTs(ts: number | undefined): string {
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
export function Sidebar() {
export function Sidebar({ onClose }: { onClose: () => void }) {
const {
localPeer, masterId, masterAlias,
networks, activeNetworkId, activeRoom,
@@ -118,6 +118,7 @@ export function Sidebar() {
<span className="peer-id">{displayId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim()}</span>
</div>
<button className="sidebar-logout" onClick={handleLogout} title="Leave network"></button>
<button className="sidebar-close-mobile" onClick={onClose} title="Close"></button>
</div>
<div className="sidebar-section">
@@ -134,7 +135,7 @@ export function Sidebar() {
<button
key={n.network_id}
className={`sidebar-item ${n.network_id === activeNetworkId ? 'active' : ''}`}
onClick={() => setActiveNetwork(n.network_id)}
onClick={() => { setActiveNetwork(n.network_id); onClose() }}
>
{n.network_name}
</button>
@@ -173,7 +174,7 @@ export function Sidebar() {
<button
key={r}
className={`sidebar-item ${r === activeRoom ? 'active' : ''}`}
onClick={() => setActiveRoom(r)}
onClick={() => { setActiveRoom(r); onClose() }}
>
{r.startsWith('dm:') ? `@ ${r.slice(3, 11)}` : `# ${r}`}
</button>

View File

@@ -1,3 +1,4 @@
import { useState } from 'react'
import { Sidebar } from '../components/Sidebar'
import { MessagePane } from '../components/MessagePane'
import { FileBrowser } from '../components/FileBrowser'
@@ -5,10 +6,11 @@ import { useWaste } from '../store'
export function Chat() {
const { activeFilePeer } = useWaste()
const [sidebarOpen, setSidebarOpen] = useState(false)
return (
<div className={`chat-layout${activeFilePeer ? ' has-file-browser' : ''}`}>
<Sidebar />
<MessagePane />
<div className={`chat-layout${activeFilePeer ? ' has-file-browser' : ''}${sidebarOpen ? ' sidebar-open' : ''}`}>
<Sidebar onClose={() => setSidebarOpen(false)} />
<MessagePane onMenuClick={() => setSidebarOpen(v => !v)} />
{activeFilePeer && <FileBrowser />}
</div>
)