Merge pull request #48 from ArnasDon/feat/mobile-app-shell

feat(mobile): app shell drawer + responsive header
This commit is contained in:
Arnas Donauskas 2026-04-19 12:12:51 +03:00 committed by GitHub
commit d913e6c8c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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,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>
</>
); );
} }