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 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 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();
|
||||||
|
|||||||
@@ -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