mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
6a38fce6f6
commit
08eb6e192d
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,115 +31,177 @@ 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
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500">
|
part of the main flex row there. */}
|
||||||
<MessageSquare className="h-4 w-4 text-white" />
|
<button
|
||||||
</div>
|
type="button"
|
||||||
<span className="text-lg font-semibold text-white">WaCRM</span>
|
aria-label="Close menu"
|
||||||
</div>
|
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",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Main navigation */}
|
<aside
|
||||||
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
className={cn(
|
||||||
<ul className="flex flex-col gap-1">
|
// Mobile: fixed drawer that slides in from the left.
|
||||||
{navItems.map((item) => {
|
"fixed inset-y-0 left-0 z-40 flex h-full w-64 flex-col border-r border-slate-800 bg-slate-900",
|
||||||
const isActive =
|
"transition-transform duration-200 ease-out will-change-transform",
|
||||||
pathname === item.href ||
|
open ? "translate-x-0" : "-translate-x-full",
|
||||||
(item.href !== "/dashboard" && pathname.startsWith(item.href));
|
// Desktop: static, always visible — reset all the mobile framing.
|
||||||
|
"lg:static lg:z-0 lg:w-60 lg:translate-x-0 lg:transition-none",
|
||||||
// Green dot on the Inbox entry when there are unread
|
)}
|
||||||
// conversations AND the user isn't currently in /inbox (they'd
|
aria-label="Primary"
|
||||||
// be seeing the unread badges on the conversation list there,
|
>
|
||||||
// so the sidebar dot would be redundant).
|
{/* Logo row. On mobile we put a close button here; on desktop the
|
||||||
const showUnreadDot =
|
close button is hidden since the sidebar is always-visible. */}
|
||||||
item.href === "/inbox" && totalUnread > 0 && !isActive;
|
<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">
|
||||||
return (
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500">
|
||||||
<li key={item.href}>
|
<MessageSquare className="h-4 w-4 text-white" />
|
||||||
<Link
|
</div>
|
||||||
href={item.href}
|
<span className="text-lg font-semibold text-white">WaCRM</span>
|
||||||
className={cn(
|
</Link>
|
||||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
|
||||||
isActive
|
|
||||||
? "bg-emerald-500/10 text-emerald-500"
|
|
||||||
: "text-slate-400 hover:bg-slate-800 hover:text-white"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<item.icon className="h-4 w-4" />
|
|
||||||
<span className="flex-1">{item.label}</span>
|
|
||||||
{showUnreadDot && (
|
|
||||||
<span
|
|
||||||
aria-label={`${totalUnread} unread conversation${totalUnread === 1 ? "" : "s"}`}
|
|
||||||
className="relative flex h-2 w-2"
|
|
||||||
>
|
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
|
||||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="my-4 border-t border-slate-800" />
|
|
||||||
|
|
||||||
{/* Bottom nav items */}
|
|
||||||
<ul className="flex flex-col gap-1">
|
|
||||||
{bottomNavItems.map((item) => {
|
|
||||||
const isActive = pathname.startsWith(item.href);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li key={item.href}>
|
|
||||||
<Link
|
|
||||||
href={item.href}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
|
||||||
isActive
|
|
||||||
? "bg-emerald-500/10 text-emerald-500"
|
|
||||||
: "text-slate-400 hover:bg-slate-800 hover:text-white"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<item.icon className="h-4 w-4" />
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* User section */}
|
|
||||||
<div className="border-t border-slate-800 p-3">
|
|
||||||
<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">
|
|
||||||
{profile?.full_name?.charAt(0)?.toUpperCase() ?? "U"}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate text-sm font-medium text-white">
|
|
||||||
{profile?.full_name ?? "User"}
|
|
||||||
</p>
|
|
||||||
<p className="truncate text-xs text-slate-400">
|
|
||||||
{profile?.email ?? ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={signOut}
|
type="button"
|
||||||
className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
|
onClick={onClose}
|
||||||
title="Sign out"
|
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"
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</aside>
|
{/* Main navigation */}
|
||||||
|
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive =
|
||||||
|
pathname === item.href ||
|
||||||
|
(item.href !== "/dashboard" && pathname.startsWith(item.href));
|
||||||
|
|
||||||
|
const showUnreadDot =
|
||||||
|
item.href === "/inbox" && totalUnread > 0 && !isActive;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.href}>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
// 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
|
||||||
|
? "bg-emerald-500/10 text-emerald-500"
|
||||||
|
: "text-slate-400 hover:bg-slate-800 hover:text-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4" />
|
||||||
|
<span className="flex-1">{item.label}</span>
|
||||||
|
{showUnreadDot && (
|
||||||
|
<span
|
||||||
|
aria-label={`${totalUnread} unread conversation${totalUnread === 1 ? "" : "s"}`}
|
||||||
|
className="relative flex h-2 w-2"
|
||||||
|
>
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="my-4 border-t border-slate-800" />
|
||||||
|
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{bottomNavItems.map((item) => {
|
||||||
|
const isActive = pathname.startsWith(item.href);
|
||||||
|
return (
|
||||||
|
<li key={item.href}>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors lg:py-2",
|
||||||
|
isActive
|
||||||
|
? "bg-emerald-500/10 text-emerald-500"
|
||||||
|
: "text-slate-400 hover:bg-slate-800 hover:text-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User section */}
|
||||||
|
<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 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"}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-white">
|
||||||
|
{profile?.full_name ?? "User"}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-slate-400">
|
||||||
|
{profile?.email ?? ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={signOut}
|
||||||
|
className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
|
||||||
|
title="Sign out"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user