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

1
.gitignore vendored
View File

@@ -21,6 +21,7 @@ build-daemon.sh
deploy-web.sh deploy-web.sh
deploy-daemon.sh deploy-daemon.sh
serve-web.sh serve-web.sh
push.sh
web/public/config.js web/public/config.js
cmd/app/frontend/dist/* cmd/app/frontend/dist/*
!cmd/app/frontend/dist/.gitkeep !cmd/app/frontend/dist/.gitkeep

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; } .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 { 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); } .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 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 { messages, historyCutoff, activeRoom, activeNetworkId, localPeer, connectedPeers, knownPeers, send } = useWaste()
const [draft, setDraft] = useState('') const [draft, setDraft] = useState('')
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
@@ -64,7 +64,10 @@ export function MessagePane() {
return ( return (
<main className="message-pane"> <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"> <div className="messages">
{dividerIdx === 0 && ( {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' }) return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
} }
export function Sidebar() { export function Sidebar({ onClose }: { onClose: () => void }) {
const { const {
localPeer, masterId, masterAlias, localPeer, masterId, masterAlias,
networks, activeNetworkId, activeRoom, networks, activeNetworkId, activeRoom,
@@ -118,6 +118,7 @@ export function Sidebar() {
<span className="peer-id">{displayId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim()}</span> <span className="peer-id">{displayId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim()}</span>
</div> </div>
<button className="sidebar-logout" onClick={handleLogout} title="Leave network"></button> <button className="sidebar-logout" onClick={handleLogout} title="Leave network"></button>
<button className="sidebar-close-mobile" onClick={onClose} title="Close"></button>
</div> </div>
<div className="sidebar-section"> <div className="sidebar-section">
@@ -134,7 +135,7 @@ export function Sidebar() {
<button <button
key={n.network_id} key={n.network_id}
className={`sidebar-item ${n.network_id === activeNetworkId ? 'active' : ''}`} className={`sidebar-item ${n.network_id === activeNetworkId ? 'active' : ''}`}
onClick={() => setActiveNetwork(n.network_id)} onClick={() => { setActiveNetwork(n.network_id); onClose() }}
> >
{n.network_name} {n.network_name}
</button> </button>
@@ -173,7 +174,7 @@ export function Sidebar() {
<button <button
key={r} key={r}
className={`sidebar-item ${r === activeRoom ? 'active' : ''}`} className={`sidebar-item ${r === activeRoom ? 'active' : ''}`}
onClick={() => setActiveRoom(r)} onClick={() => { setActiveRoom(r); onClose() }}
> >
{r.startsWith('dm:') ? `@ ${r.slice(3, 11)}` : `# ${r}`} {r.startsWith('dm:') ? `@ ${r.slice(3, 11)}` : `# ${r}`}
</button> </button>

View File

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