feat(web): app-like mobile dashboard — bottom tab bar, minimal top
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
Top header on mobile was cramped: Logo + 5 icon-only nav buttons + avatar crammed into a 48px-tall row. Felt like a desktop nav shrunk down. Pivot to native-mobile-app pattern: - Top mobile: just Logo (left) + UserMenu avatar (right). Desktop top nav is `hidden sm:flex` so it disappears on phones. - Bottom: full tab bar replacing the single-button MobileActionBar. Five destinations: Overview · Servers · Create (FAB-style center) · Market · Settings. - "Create" is a raised FAB-style button (round accent fill, -mt-3 to overlap the bar border) — same prominent-action pattern as Instagram / Notion mobile. - Active tab gets accent color + aria-current=page. - Audit demoted from primary nav on mobile (low frequency); still reachable via direct /audit URL. Desktop unchanged — top nav stays. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a8e6f4fabd
commit
f80bd8afbe
@ -12,7 +12,9 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
<div className="mx-auto flex h-12 max-w-7xl items-center justify-between gap-2 px-4 sm:px-6">
|
||||
<div className="flex min-w-0 items-center gap-2 sm:gap-6">
|
||||
<Logo />
|
||||
<nav className="flex items-center gap-0.5 sm:gap-1">
|
||||
{/* Desktop nav — on mobile this is hidden and destinations live
|
||||
in the bottom MobileActionBar tab-bar instead. */}
|
||||
<nav className="hidden items-center gap-0.5 sm:flex sm:gap-1">
|
||||
<NavLink href="/dashboard" icon={<LayoutGrid size={13} />}>
|
||||
Overview
|
||||
</NavLink>
|
||||
|
||||
@ -1,35 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/cn';
|
||||
import { LayoutGrid, Package, PlusCircle, Server, Settings } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
// Routes where the global "+ New server" CTA makes no sense — the wizard is
|
||||
// itself the create flow and owns its own primary action.
|
||||
const HIDDEN_PATHS: readonly string[] = ['/servers/new'];
|
||||
interface Tab {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ size?: number }>;
|
||||
prominent?: boolean;
|
||||
matchExact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile-only sticky bottom action bar. On phones the dashboard top header is
|
||||
* already tight with the nav icons; the primary action belongs in the thumb
|
||||
* zone, not crammed top-right. Hidden from `sm:` upward where the header has
|
||||
* room for the same button inline.
|
||||
* Mobile bottom navigation — replaces the top nav on small screens with a
|
||||
* native-app-style tab bar. Five destinations, "Create" as a prominent
|
||||
* FAB-style centre button. The top header drops to just Logo + UserMenu on
|
||||
* mobile (see (dashboard)/layout.tsx) so there's no duplicated nav.
|
||||
*
|
||||
* Desktop is unchanged: top nav for destinations, "+ New server" pill in
|
||||
* the header. The whole component is `sm:hidden`.
|
||||
*
|
||||
* Audit is intentionally demoted on mobile (low-frequency); still reachable
|
||||
* via the direct /audit URL or via the Account section.
|
||||
*/
|
||||
const TABS: Tab[] = [
|
||||
{ href: '/dashboard', label: 'Overview', icon: LayoutGrid, matchExact: true },
|
||||
{ href: '/servers', label: 'Servers', icon: Server },
|
||||
{ href: '/servers/new', label: 'Create', icon: PlusCircle, prominent: true, matchExact: true },
|
||||
{ href: '/templates', label: 'Market', icon: Package },
|
||||
{ href: '/settings', label: 'Settings', icon: Settings },
|
||||
];
|
||||
|
||||
function useIsActive(pathname: string) {
|
||||
return (tab: Tab): boolean => {
|
||||
if (tab.matchExact) return pathname === tab.href;
|
||||
// /servers matches /servers and /servers/<id> but NOT /servers/new
|
||||
// (that's the dedicated Create tab).
|
||||
if (tab.href === '/servers') {
|
||||
return (
|
||||
pathname === '/servers' ||
|
||||
(pathname.startsWith('/servers/') && pathname !== '/servers/new')
|
||||
);
|
||||
}
|
||||
return pathname.startsWith(tab.href);
|
||||
};
|
||||
}
|
||||
|
||||
export function MobileActionBar() {
|
||||
const pathname = usePathname();
|
||||
if (HIDDEN_PATHS.includes(pathname)) return null;
|
||||
const isActive = useIsActive(pathname);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-x-0 bottom-0 z-40 border-t border-[--color-border] bg-[--color-bg-elevated]/95 backdrop-blur sm:hidden"
|
||||
style={{ paddingBottom: 'max(env(safe-area-inset-bottom), 0.5rem)' }}
|
||||
<nav
|
||||
aria-label="Primary"
|
||||
className="fixed inset-x-0 bottom-0 z-40 flex border-t backdrop-blur-md sm:hidden"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in oklab, var(--color-bg-elevated) 90%, transparent)',
|
||||
borderColor: 'var(--color-border)',
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto flex max-w-7xl px-4 pt-2.5">
|
||||
<Link
|
||||
href="/servers/new"
|
||||
className="inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-md bg-[--color-accent] text-[14px] font-medium text-white transition-colors duration-200 active:bg-[#5557e8]"
|
||||
>
|
||||
+ New server
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const active = isActive(tab);
|
||||
|
||||
if (tab.prominent) {
|
||||
// FAB-style centre button — raised above the bar, accent fill.
|
||||
// The negative top margin (-mt-3) lifts it so it overlaps the
|
||||
// border-top of the bar, mimicking iOS/Material tab-bar FABs.
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
aria-label={tab.label}
|
||||
className="relative flex flex-1 items-center justify-center"
|
||||
>
|
||||
<span
|
||||
className="-mt-3 flex size-12 items-center justify-center rounded-full text-white shadow-lg shadow-black/40 transition-transform active:scale-95"
|
||||
style={{ backgroundColor: 'var(--color-accent)' }}
|
||||
>
|
||||
<Icon size={22} />
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex flex-1 flex-col items-center justify-center gap-0.5 py-2 text-[10.5px] transition-colors',
|
||||
active ? 'text-[--color-accent]' : 'text-[--color-fg-muted]',
|
||||
)}
|
||||
style={active ? { color: 'var(--color-accent)' } : undefined}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span className="font-medium">{tab.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user