Add mobile bottom nav + PWA installability
Same shell pattern as top-buh/top-finance/thbd-portal: desktop top nav stays >=lg, a fixed bottom tab bar takes over below it. Only 2 nav links here so both fit directly, no overflow menu needed. Also adds manifest.webmanifest + 192/512 icons (rendered from the existing icon.svg) and appleWebApp/viewport metadata so the app can be installed to a phone's home screen. Nudged ticket-toasts.tsx's fixed bottom offset so it clears the new bottom nav bar on mobile instead of overlapping it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
This commit is contained in:
1 parent
f5e49b5c53
commit
e42fd4697f
10 files changed
+109
-17
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "top-tickets",
|
||||
"short_name": "top-tickets",
|
||||
"description": "Helpdesk for support@top-sysops.ru, Telegram, and the web.",
|
||||
"start_url": "/dashboard",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f8f7fb",
|
||||
"theme_color": "#6d28d9",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }
|
||||
]
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import Link from "next/link";
|
||||
import { Ticket } from "lucide-react";
|
||||
import { getCurrentSession } from "@/lib/auth/session";
|
||||
import { LogoutButton } from "./logout-button";
|
||||
import { NavLinks } from "./nav-links";
|
||||
import { NavShell } from "./nav-shell";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { SettingsMenu } from "./settings-menu";
|
||||
import { NotificationToggle } from "./notification-toggle";
|
||||
@@ -21,18 +21,18 @@ 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="/dashboard" 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="/dashboard" 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">
|
||||
<Ticket size={15} strokeWidth={2.5} />
|
||||
</div>
|
||||
top-tickets
|
||||
<span className="hidden sm:inline">top-tickets</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">
|
||||
<NotificationToggle />
|
||||
<ThemeToggle />
|
||||
{isAdmin && <SettingsMenu isAdmin={isAdmin} />}
|
||||
@@ -41,7 +41,10 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
</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>
|
||||
<DesktopNotifications currentUser={currentUser} />
|
||||
<TicketToasts currentUser={currentUser} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { links } from "./nav-data";
|
||||
|
||||
/** Bottom tab bar for mobile — mirrors top-buh/top-finance's pattern. Only 2 links here, both fit as primary tabs, no overflow "Ещё" needed. */
|
||||
export function MobileNav({ isAdmin }: { isAdmin: boolean }) {
|
||||
const pathname = usePathname();
|
||||
const visibleLinks = links.filter((link) => !link.adminOnly || isAdmin);
|
||||
|
||||
return (
|
||||
<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="Основная навигация"
|
||||
>
|
||||
{visibleLinks.map(({ href, label, icon: Icon }) => {
|
||||
const active = pathname.startsWith(href);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`flex flex-1 flex-col items-center gap-0.5 py-2 text-[11px] font-medium ${
|
||||
active ? "text-accent" : "text-text-faint"
|
||||
}`}
|
||||
>
|
||||
<Icon size={20} />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { LayoutDashboard, BarChart3 } from "lucide-react";
|
||||
|
||||
export interface NavLink {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: typeof LayoutDashboard;
|
||||
adminOnly: boolean;
|
||||
}
|
||||
|
||||
export const links: NavLink[] = [
|
||||
{ href: "/dashboard", label: "Заявки", icon: LayoutDashboard, adminOnly: false },
|
||||
{ href: "/stats", label: "Статистика", icon: BarChart3, adminOnly: true },
|
||||
];
|
||||
@@ -2,12 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { LayoutDashboard, BarChart3 } from "lucide-react";
|
||||
|
||||
const links = [
|
||||
{ href: "/dashboard", label: "Заявки", icon: LayoutDashboard, adminOnly: false },
|
||||
{ href: "/stats", label: "Статистика", icon: BarChart3, adminOnly: true },
|
||||
];
|
||||
import { links } from "./nav-data";
|
||||
|
||||
export function NavLinks({ isAdmin }: { isAdmin: boolean }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { NavLinks } from "./nav-links";
|
||||
import { MobileNav } from "./mobile-nav";
|
||||
|
||||
/** Desktop top nav (>=lg) + mobile bottom tab bar (<lg), same split as top-buh/top-finance/top-reservation. */
|
||||
export function NavShell({ isAdmin }: { isAdmin: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<div className="hidden lg:flex">
|
||||
<NavLinks isAdmin={isAdmin} />
|
||||
</div>
|
||||
<MobileNav isAdmin={isAdmin} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -103,7 +103,7 @@ export function TicketToasts({ currentUser }: { currentUser: { id: string; role:
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2">
|
||||
<div className="pointer-events-none fixed bottom-[calc(4.25rem+env(safe-area-inset-bottom)+0.5rem)] right-4 z-50 flex w-full max-w-sm flex-col gap-2 lg:bottom-4">
|
||||
<AnimatePresence initial={false}>
|
||||
{toasts.map((toast) => (
|
||||
<motion.div
|
||||
|
||||
+18
-1
@@ -1,4 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Onest, Unbounded, JetBrains_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
@@ -21,6 +21,23 @@ const mono = JetBrains_Mono({
|
||||
export const metadata: Metadata = {
|
||||
title: "top-tickets",
|
||||
description: "Helpdesk for support@top-sysops.ru, Telegram, and the web.",
|
||||
manifest: "/manifest.webmanifest",
|
||||
appleWebApp: {
|
||||
// iOS ignores the web manifest's `display: standalone` — this pair of
|
||||
// meta tags is the separate (older, Apple-specific) mechanism it
|
||||
// actually reads for "hide Safari's chrome when launched from the
|
||||
// home screen icon".
|
||||
capable: true,
|
||||
statusBarStyle: "black-translucent",
|
||||
title: "top-tickets",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
themeColor: "#6d28d9",
|
||||
};
|
||||
|
||||
// Reads the persisted theme before hydration so there's no flash of the
|
||||
|
||||
Reference in new issue
Block a user