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:
ogrechkoandClaude Sonnet 5 committed 2026-09-07 07:58:52 +00:00
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

+14
View File
@@ -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" }
]
}
+12 -9
View File
@@ -3,7 +3,7 @@ import Link from "next/link";
import { Ticket } from "lucide-react"; import { Ticket } from "lucide-react";
import { getCurrentSession } from "@/lib/auth/session"; import { getCurrentSession } from "@/lib/auth/session";
import { LogoutButton } from "./logout-button"; import { LogoutButton } from "./logout-button";
import { NavLinks } from "./nav-links"; import { NavShell } from "./nav-shell";
import { ThemeToggle } from "./theme-toggle"; import { ThemeToggle } from "./theme-toggle";
import { SettingsMenu } from "./settings-menu"; import { SettingsMenu } from "./settings-menu";
import { NotificationToggle } from "./notification-toggle"; import { NotificationToggle } from "./notification-toggle";
@@ -21,18 +21,18 @@ 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="/dashboard" className="flex items-center gap-2 font-display text-[15px] font-bold tracking-tight"> <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"> <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} /> <Ticket size={15} strokeWidth={2.5} />
</div> </div>
top-tickets <span className="hidden sm:inline">top-tickets</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">
<NotificationToggle /> <NotificationToggle />
<ThemeToggle /> <ThemeToggle />
{isAdmin && <SettingsMenu isAdmin={isAdmin} />} {isAdmin && <SettingsMenu isAdmin={isAdmin} />}
@@ -41,7 +41,10 @@ export default async function AdminLayout({ children }: { children: React.ReactN
</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>
<DesktopNotifications currentUser={currentUser} /> <DesktopNotifications currentUser={currentUser} />
<TicketToasts currentUser={currentUser} /> <TicketToasts currentUser={currentUser} />
</div> </div>
+34
View File
@@ -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>
);
}
+13
View File
@@ -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 },
];
+1 -6
View File
@@ -2,12 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { LayoutDashboard, BarChart3 } from "lucide-react"; import { links } from "./nav-data";
const links = [
{ href: "/dashboard", label: "Заявки", icon: LayoutDashboard, adminOnly: false },
{ href: "/stats", label: "Статистика", icon: BarChart3, adminOnly: true },
];
export function NavLinks({ isAdmin }: { isAdmin: boolean }) { export function NavLinks({ isAdmin }: { isAdmin: boolean }) {
const pathname = usePathname(); const pathname = usePathname();
+16
View File
@@ -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} />
</>
);
}
+1 -1
View File
@@ -103,7 +103,7 @@ export function TicketToasts({ currentUser }: { currentUser: { id: string; role:
} }
return ( 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}> <AnimatePresence initial={false}>
{toasts.map((toast) => ( {toasts.map((toast) => (
<motion.div <motion.div
+18 -1
View File
@@ -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 { Onest, Unbounded, JetBrains_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
@@ -21,6 +21,23 @@ const mono = JetBrains_Mono({
export const metadata: Metadata = { export const metadata: Metadata = {
title: "top-tickets", title: "top-tickets",
description: "Helpdesk for support@top-sysops.ru, Telegram, and the web.", 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 // Reads the persisted theme before hydration so there's no flash of the