feat(mobile): app shell drawer + responsive header

First of a series to make the app mobile-friendly. The sidebar used
to be a fixed 240px column always visible, eating most of a 375px
phone screen and leaving ~135px for content. Now:

- Sidebar becomes a slide-in drawer on <lg, static on ≥lg. Controlled
  via a drawer-open prop wired from the shell; transform + duration
  feels native on touch devices.
- Backdrop overlay closes it on tap; Escape closes it; body scroll
  locks while open; route changes auto-close it.
- Header gains a 44×44 hamburger button on <lg that opens the drawer,
  hidden on ≥lg. Title shrinks slightly on narrow widths so the
  hamburger + avatar + sign-out row never wraps.
- Nav rows are taller (py-2.5) on mobile for comfortable touch
  targets, slim (py-2) back on desktop.
- Main content padding is p-4 on mobile, p-6 on desktop so cards
  have room to breathe without shouldering into the viewport edge.

No desktop changes visible beyond tighter padding on very narrow
lg breakpoints (main content width is unchanged).

Next PRs in this series will adapt:
- Inbox (3-pane → single-pane with back)
- Pipelines (kanban snap-scroll)
- Automation builder (card widths + bottom-sheet edit)
- Tables + forms polish

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-04-19 11:39:08 +03:00
parent 6a38fce6f6
commit 08eb6e192d
No known key found for this signature in database
3 changed files with 202 additions and 113 deletions

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect } from "react"; import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AuthProvider, useAuth } from "@/hooks/use-auth"; import { AuthProvider, useAuth } from "@/hooks/use-auth";
import { Sidebar } from "@/components/layout/sidebar"; import { Sidebar } from "@/components/layout/sidebar";
@ -14,6 +14,11 @@ function DashboardShellInner({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
const router = useRouter(); const router = useRouter();
// Sidebar drawer state — only used on mobile. On lg+ the sidebar is
// always visible and this stays at `false` (ignored by the component).
const [sidebarOpen, setSidebarOpen] = useState(false);
const closeSidebar = useCallback(() => setSidebarOpen(false), []);
useEffect(() => { useEffect(() => {
if (!loading && !user) { if (!loading && !user) {
router.push("/login"); router.push("/login");
@ -35,10 +40,11 @@ function DashboardShellInner({ children }: { children: React.ReactNode }) {
return ( return (
<div className="flex h-screen overflow-hidden bg-slate-950"> <div className="flex h-screen overflow-hidden bg-slate-950">
<Sidebar /> <Sidebar open={sidebarOpen} onClose={closeSidebar} />
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<Header /> <Header onOpenSidebar={() => setSidebarOpen(true)} />
<main className="flex-1 overflow-y-auto p-6">{children}</main> {/* Thinner horizontal padding on mobile so cards have room to breathe. */}
<main className="flex-1 overflow-y-auto p-4 sm:p-6">{children}</main>
</div> </div>
</div> </div>
); );

View File

@ -2,7 +2,7 @@
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { LogOut } from "lucide-react"; import { LogOut, Menu } from "lucide-react";
const pageTitles: Record<string, string> = { const pageTitles: Record<string, string> = {
"/dashboard": "Dashboard", "/dashboard": "Dashboard",
@ -16,24 +16,42 @@ const pageTitles: Record<string, string> = {
function getPageTitle(pathname: string): string { function getPageTitle(pathname: string): string {
if (pageTitles[pathname]) return pageTitles[pathname]; if (pageTitles[pathname]) return pageTitles[pathname];
const match = Object.entries(pageTitles).find(([path]) => const match = Object.entries(pageTitles).find(([path]) =>
pathname.startsWith(path) pathname.startsWith(path),
); );
return match ? match[1] : "Dashboard"; return match ? match[1] : "Dashboard";
} }
export function Header() { interface HeaderProps {
/** Wired to the shell's drawer state. Used only on mobile — the
* hamburger button is hidden on lg+. */
onOpenSidebar?: () => void;
}
export function Header({ onOpenSidebar }: HeaderProps) {
const pathname = usePathname(); const pathname = usePathname();
const { profile, signOut } = useAuth(); const { profile, signOut } = useAuth();
const title = getPageTitle(pathname); const title = getPageTitle(pathname);
return ( return (
<header className="flex h-14 shrink-0 items-center justify-between border-b border-slate-800 bg-slate-950 px-6"> <header className="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-slate-800 bg-slate-950 px-4 lg:px-6">
<h1 className="text-lg font-semibold text-white">{title}</h1> <div className="flex min-w-0 items-center gap-2">
{/* Hamburger — mobile only. 44×44 hit target per Apple HIG. */}
<button
type="button"
onClick={onOpenSidebar}
aria-label="Open menu"
className="flex h-10 w-10 items-center justify-center rounded-md text-slate-300 transition-colors hover:bg-slate-800 hover:text-white lg:hidden"
>
<Menu className="h-5 w-5" />
</button>
<h1 className="truncate text-base font-semibold text-white sm:text-lg">
{title}
</h1>
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-2 sm:gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2 sm:gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-500/10 text-sm font-medium text-emerald-500"> <div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-500/10 text-sm font-medium text-emerald-500">
{profile?.full_name?.charAt(0)?.toUpperCase() ?? "U"} {profile?.full_name?.charAt(0)?.toUpperCase() ?? "U"}
</div> </div>
@ -45,8 +63,9 @@ export function Header() {
</div> </div>
<button <button
onClick={signOut} onClick={signOut}
className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-slate-800 hover:text-white" className="flex h-10 w-10 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
title="Sign out" title="Sign out"
aria-label="Sign out"
> >
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
</button> </button>

View File

@ -2,6 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { useTotalUnread } from "@/hooks/use-total-unread"; import { useTotalUnread } from "@/hooks/use-total-unread";
@ -14,6 +15,7 @@ import {
Zap, Zap,
Settings, Settings,
LogOut, LogOut,
X,
} from "lucide-react"; } from "lucide-react";
const navItems = [ const navItems = [
@ -29,19 +31,86 @@ const bottomNavItems = [
{ href: "/settings", label: "Settings", icon: Settings }, { href: "/settings", label: "Settings", icon: Settings },
]; ];
export function Sidebar() { interface SidebarProps {
/** Controlled on mobile by the Header's hamburger button. Ignored on lg+. */
open?: boolean;
onClose?: () => void;
}
export function Sidebar({ open = false, onClose }: SidebarProps) {
const pathname = usePathname(); const pathname = usePathname();
const { profile, signOut } = useAuth(); const { profile, signOut } = useAuth();
const totalUnread = useTotalUnread(); const totalUnread = useTotalUnread();
// Close the drawer when route changes — users opened it to navigate,
// so once they pick a destination the drawer should get out of the way.
useEffect(() => {
onClose?.();
// Only pathname drives this — onClose identity doesn't need to re-run it.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
// Lock body scroll and allow Escape to close while the drawer is open on
// mobile. No-ops on desktop because the sidebar isn't positioned there.
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose?.();
};
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prev;
window.removeEventListener("keydown", onKey);
};
}, [open, onClose]);
return ( return (
<aside className="flex h-screen w-60 flex-col border-r border-slate-800 bg-slate-900"> <>
{/* Logo */} {/* Backdrop — only exists on mobile and only when open. Clicking
<div className="flex h-14 items-center gap-2 border-b border-slate-800 px-4"> it closes the drawer. Hidden from lg+ since the sidebar is
part of the main flex row there. */}
<button
type="button"
aria-label="Close menu"
onClick={onClose}
className={cn(
"fixed inset-0 z-30 bg-slate-950/70 backdrop-blur-sm transition-opacity lg:hidden",
open
? "pointer-events-auto opacity-100"
: "pointer-events-none opacity-0",
)}
/>
<aside
className={cn(
// Mobile: fixed drawer that slides in from the left.
"fixed inset-y-0 left-0 z-40 flex h-full w-64 flex-col border-r border-slate-800 bg-slate-900",
"transition-transform duration-200 ease-out will-change-transform",
open ? "translate-x-0" : "-translate-x-full",
// Desktop: static, always visible — reset all the mobile framing.
"lg:static lg:z-0 lg:w-60 lg:translate-x-0 lg:transition-none",
)}
aria-label="Primary"
>
{/* Logo row. On mobile we put a close button here; on desktop the
close button is hidden since the sidebar is always-visible. */}
<div className="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-slate-800 px-4">
<Link href="/dashboard" className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500"> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500">
<MessageSquare className="h-4 w-4 text-white" /> <MessageSquare className="h-4 w-4 text-white" />
</div> </div>
<span className="text-lg font-semibold text-white">WaCRM</span> <span className="text-lg font-semibold text-white">WaCRM</span>
</Link>
<button
type="button"
onClick={onClose}
aria-label="Close menu"
className="flex h-9 w-9 items-center justify-center rounded-md text-slate-400 hover:bg-slate-800 hover:text-white lg:hidden"
>
<X className="h-5 w-5" />
</button>
</div> </div>
{/* Main navigation */} {/* Main navigation */}
@ -52,10 +121,6 @@ export function Sidebar() {
pathname === item.href || pathname === item.href ||
(item.href !== "/dashboard" && pathname.startsWith(item.href)); (item.href !== "/dashboard" && pathname.startsWith(item.href));
// Green dot on the Inbox entry when there are unread
// conversations AND the user isn't currently in /inbox (they'd
// be seeing the unread badges on the conversation list there,
// so the sidebar dot would be redundant).
const showUnreadDot = const showUnreadDot =
item.href === "/inbox" && totalUnread > 0 && !isActive; item.href === "/inbox" && totalUnread > 0 && !isActive;
@ -64,10 +129,11 @@ export function Sidebar() {
<Link <Link
href={item.href} href={item.href}
className={cn( className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors", // Taller on mobile so fingers can hit the row reliably (≥44px).
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors lg:py-2",
isActive isActive
? "bg-emerald-500/10 text-emerald-500" ? "bg-emerald-500/10 text-emerald-500"
: "text-slate-400 hover:bg-slate-800 hover:text-white" : "text-slate-400 hover:bg-slate-800 hover:text-white",
)} )}
> >
<item.icon className="h-4 w-4" /> <item.icon className="h-4 w-4" />
@ -87,23 +153,20 @@ export function Sidebar() {
})} })}
</ul> </ul>
{/* Divider */}
<div className="my-4 border-t border-slate-800" /> <div className="my-4 border-t border-slate-800" />
{/* Bottom nav items */}
<ul className="flex flex-col gap-1"> <ul className="flex flex-col gap-1">
{bottomNavItems.map((item) => { {bottomNavItems.map((item) => {
const isActive = pathname.startsWith(item.href); const isActive = pathname.startsWith(item.href);
return ( return (
<li key={item.href}> <li key={item.href}>
<Link <Link
href={item.href} href={item.href}
className={cn( className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors", "flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors lg:py-2",
isActive isActive
? "bg-emerald-500/10 text-emerald-500" ? "bg-emerald-500/10 text-emerald-500"
: "text-slate-400 hover:bg-slate-800 hover:text-white" : "text-slate-400 hover:bg-slate-800 hover:text-white",
)} )}
> >
<item.icon className="h-4 w-4" /> <item.icon className="h-4 w-4" />
@ -116,7 +179,7 @@ export function Sidebar() {
</nav> </nav>
{/* User section */} {/* User section */}
<div className="border-t border-slate-800 p-3"> <div className="shrink-0 border-t border-slate-800 p-3">
<div className="flex items-center gap-3 rounded-lg px-3 py-2"> <div className="flex items-center gap-3 rounded-lg px-3 py-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-emerald-500/10 text-sm font-medium text-emerald-500"> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-emerald-500/10 text-sm font-medium text-emerald-500">
{profile?.full_name?.charAt(0)?.toUpperCase() ?? "U"} {profile?.full_name?.charAt(0)?.toUpperCase() ?? "U"}
@ -139,5 +202,6 @@ export function Sidebar() {
</div> </div>
</div> </div>
</aside> </aside>
</>
); );
} }