Add mobile bottom tab bar (elmi-portal pattern) — desktop keeps top nav, mobile gets fixed bottom icons + Ещё overflow for admin settings

This commit is contained in:
ogrechko committed 2026-08-29 12:14:31 +00:00
1 parent 47b003bbe3
commit b6c604feaf
5 files changed
+141 -19

No files matched your search

+12 -9
View File
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { CalendarCheck } from "lucide-react"; import { CalendarCheck } from "lucide-react";
import { getCurrentSession } from "@/lib/auth/session"; import { getCurrentSession } from "@/lib/auth/session";
import { NavLinks } from "./nav-links"; import { NavShell } from "./nav-shell";
import { ThemeToggle } from "./theme-toggle"; import { ThemeToggle } from "./theme-toggle";
import { LogoutButton } from "./logout-button"; import { LogoutButton } from "./logout-button";
@@ -16,25 +16,28 @@ export default async function AdminLayout({ children }: { children: React.ReactN
return ( return (
<div className="flex min-h-screen flex-col bg-bg"> <div className="flex min-h-screen flex-col bg-bg">
<header className="border-b border-border bg-surface"> <header className="border-b border-border bg-surface pt-[env(safe-area-inset-top)]">
<div className="mx-auto flex max-w-[1800px] flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3 sm:px-6"> <div className="mx-auto flex max-w-[1800px] items-center gap-3 px-4 py-2.5 sm:gap-6 sm:px-6 sm:py-3">
<Link href="/" className="flex items-center gap-2 font-display text-[15px] font-bold tracking-tight"> <Link href="/" className="flex shrink-0 items-center gap-2 font-display text-[15px] font-bold tracking-tight">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-accent text-white"> <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-accent text-white">
<CalendarCheck size={15} strokeWidth={2.5} /> <CalendarCheck size={15} strokeWidth={2.5} />
</div> </div>
top-reservation <span className="hidden sm:inline">top-reservation</span>
</Link> </Link>
<div className="order-last w-full overflow-x-auto sm:order-none sm:w-auto sm:flex-1 sm:overflow-visible sm:flex sm:justify-center"> <div className="flex flex-1 justify-center overflow-x-auto">
<NavLinks isAdmin={isAdmin} /> <NavShell isAdmin={isAdmin} />
</div> </div>
<div className="ml-auto flex items-center gap-2 sm:ml-0 sm:gap-3"> <div className="ml-auto flex shrink-0 items-center gap-2 sm:ml-0 sm:gap-3">
<ThemeToggle /> <ThemeToggle />
<span className="hidden text-sm text-text-muted sm:inline">{session.user.name}</span> <span className="hidden text-sm text-text-muted sm:inline">{session.user.name}</span>
<LogoutButton /> <LogoutButton />
</div> </div>
</div> </div>
</header> </header>
<main className="mx-auto w-full max-w-[1800px] flex-1 px-4 py-6 sm:px-6">{children}</main> {/* pb: clears the fixed mobile bottom-tab bar (~52px + safe-area) so it never covers the last bit of scrolled content */}
<main className="mx-auto w-full max-w-[1800px] flex-1 px-4 py-6 pb-[calc(4.25rem+env(safe-area-inset-bottom))] sm:px-6 lg:pb-6">
{children}
</main>
</div> </div>
); );
} }
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { MoreHorizontal, X } from "lucide-react";
import { links, PRIMARY_COUNT } from "./nav-data";
export function MobileNav({ isAdmin }: { isAdmin: boolean }) {
const pathname = usePathname();
const [moreOpen, setMoreOpen] = useState(false);
const visibleLinks = links.filter((link) => !link.adminOnly || isAdmin);
const primary = visibleLinks.slice(0, PRIMARY_COUNT);
const overflow = visibleLinks.slice(PRIMARY_COUNT);
const overflowActive = overflow.some((l) => pathname.startsWith(l.href));
function isActive(link: (typeof links)[number]) {
return link.exact ? pathname === link.href : pathname.startsWith(link.href);
}
return (
<>
{moreOpen && (
<div className="fixed inset-0 z-40 bg-black/40 lg:hidden" onClick={() => setMoreOpen(false)}>
<div
className="absolute inset-x-0 bottom-0 rounded-t-xl border-t border-border bg-surface p-2 pb-[calc(0.5rem+env(safe-area-inset-bottom))]"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-1 flex items-center justify-between px-2 py-1.5">
<span className="text-sm font-semibold">Ещё</span>
<button onClick={() => setMoreOpen(false)} className="rounded-md p-1 text-text-faint hover:bg-surface-hover">
<X size={16} />
</button>
</div>
{overflow.map((link) => {
const active = isActive(link);
return (
<Link
key={link.href}
href={link.href}
onClick={() => setMoreOpen(false)}
className={`flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium ${
active ? "bg-accent-soft text-accent-soft-text" : "text-text-muted hover:bg-surface-hover hover:text-text"
}`}
>
<link.icon size={17} />
{link.label}
</Link>
);
})}
</div>
</div>
)}
<nav
className="fixed inset-x-0 bottom-0 z-30 flex border-t border-border bg-surface pb-[env(safe-area-inset-bottom)] lg:hidden"
aria-label="Основная навигация"
>
{primary.map((link) => {
const active = isActive(link);
return (
<Link
key={link.href}
href={link.href}
className={`flex flex-1 flex-col items-center gap-0.5 py-2 text-[11px] font-medium ${
active ? "text-accent" : "text-text-faint"
}`}
>
<link.icon size={20} />
{link.shortLabel ?? link.label}
</Link>
);
})}
{overflow.length > 0 && (
<button
onClick={() => setMoreOpen(true)}
className={`flex flex-1 flex-col items-center gap-0.5 py-2 text-[11px] font-medium ${
overflowActive ? "text-accent" : "text-text-faint"
}`}
>
<MoreHorizontal size={20} />
Ещё
</button>
)}
</nav>
</>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { LayoutDashboard, CalendarPlus, ListChecks, BarChart3, Building2, Users } from "lucide-react";
export interface NavLink {
href: string;
label: string;
/** Shorter label for the mobile bottom-bar slot, where space is tight — falls back to `label` if unset. */
shortLabel?: string;
icon: typeof LayoutDashboard;
adminOnly: boolean;
exact: boolean;
}
// The first 4 are the mobile bottom bar's fixed slots (thumb-reachable,
// matches exactly what a non-admin sees — no overflow for them at all);
// admin-only extras live behind "Ещё" on mobile, same as elmi-portal.
export const links: NavLink[] = [
{ href: "/", label: "Дашборд", icon: LayoutDashboard, adminOnly: false, exact: true },
{ href: "/bookings/new", label: "Новое бронирование", shortLabel: "Создать", icon: CalendarPlus, adminOnly: false, exact: true },
{ href: "/bookings", label: "Бронирования", shortLabel: "Брони", icon: ListChecks, adminOnly: false, exact: false },
{ href: "/statistics", label: "Статистика", icon: BarChart3, adminOnly: false, exact: false },
{ href: "/settings/objects", label: "Объекты", icon: Building2, adminOnly: true, exact: false },
{ href: "/settings/users", label: "Сотрудники", icon: Users, adminOnly: true, exact: false },
];
export const PRIMARY_COUNT = 4;
+1 -10
View File
@@ -2,16 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { LayoutDashboard, CalendarPlus, ListChecks, BarChart3, Building2, Users } from "lucide-react"; import { links } from "./nav-data";
const links = [
{ href: "/", label: "Дашборд", icon: LayoutDashboard, adminOnly: false, exact: true },
{ href: "/bookings/new", label: "Новое бронирование", icon: CalendarPlus, adminOnly: false, exact: true },
{ href: "/bookings", label: "Бронирования", icon: ListChecks, adminOnly: false, exact: false },
{ href: "/statistics", label: "Статистика", icon: BarChart3, adminOnly: false, exact: false },
{ href: "/settings/objects", label: "Объекты", icon: Building2, adminOnly: true, exact: false },
{ href: "/settings/users", label: "Сотрудники", icon: Users, adminOnly: true, exact: false },
];
export function NavLinks({ isAdmin }: { isAdmin: boolean }) { export function NavLinks({ isAdmin }: { isAdmin: boolean }) {
const pathname = usePathname(); const pathname = usePathname();
+14
View File
@@ -0,0 +1,14 @@
import { NavLinks } from "./nav-links";
import { MobileNav } from "./mobile-nav";
/** Desktop top nav (>=lg) + the mobile bottom tab bar (<lg) — see mobile-nav.tsx, mirrors elmi-portal's pattern. */
export function NavShell({ isAdmin }: { isAdmin: boolean }) {
return (
<>
<div className="hidden lg:flex">
<NavLinks isAdmin={isAdmin} />
</div>
<MobileNav isAdmin={isAdmin} />
</>
);
}