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 { 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>
|
||||||
|
|||||||
@@ -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 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();
|
||||||
|
|||||||
@@ -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 (
|
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
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user