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:
1 parent
47b003bbe3
commit
b6c604feaf
5 files changed
+141
-19
No files matched your search
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { CalendarCheck } from "lucide-react";
|
||||
import { getCurrentSession } from "@/lib/auth/session";
|
||||
import { NavLinks } from "./nav-links";
|
||||
import { NavShell } from "./nav-shell";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { LogoutButton } from "./logout-button";
|
||||
|
||||
@@ -16,25 +16,28 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-bg">
|
||||
<header className="border-b border-border bg-surface">
|
||||
<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">
|
||||
<Link href="/" className="flex items-center gap-2 font-display text-[15px] font-bold tracking-tight">
|
||||
<header className="border-b border-border bg-surface pt-[env(safe-area-inset-top)]">
|
||||
<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 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">
|
||||
<CalendarCheck size={15} strokeWidth={2.5} />
|
||||
</div>
|
||||
top-reservation
|
||||
<span className="hidden sm:inline">top-reservation</span>
|
||||
</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">
|
||||
<NavLinks isAdmin={isAdmin} />
|
||||
<div className="flex flex-1 justify-center overflow-x-auto">
|
||||
<NavShell isAdmin={isAdmin} />
|
||||
</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 />
|
||||
<span className="hidden text-sm text-text-muted sm:inline">{session.user.name}</span>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -2,16 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { LayoutDashboard, CalendarPlus, ListChecks, BarChart3, Building2, Users } from "lucide-react";
|
||||
|
||||
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 },
|
||||
];
|
||||
import { links } from "./nav-data";
|
||||
|
||||
export function NavLinks({ isAdmin }: { isAdmin: boolean }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user