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="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">
|
<div className="flex min-w-0 items-center gap-2 sm:gap-6">
|
||||||
<Logo />
|
<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} />}>
|
<NavLink href="/dashboard" icon={<LayoutGrid size={13} />}>
|
||||||
Overview
|
Overview
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|||||||
@ -1,35 +1,108 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import { LayoutGrid, Package, PlusCircle, Server, Settings } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
// Routes where the global "+ New server" CTA makes no sense — the wizard is
|
interface Tab {
|
||||||
// itself the create flow and owns its own primary action.
|
href: string;
|
||||||
const HIDDEN_PATHS: readonly string[] = ['/servers/new'];
|
label: string;
|
||||||
|
icon: React.ComponentType<{ size?: number }>;
|
||||||
|
prominent?: boolean;
|
||||||
|
matchExact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mobile-only sticky bottom action bar. On phones the dashboard top header is
|
* Mobile bottom navigation — replaces the top nav on small screens with a
|
||||||
* already tight with the nav icons; the primary action belongs in the thumb
|
* native-app-style tab bar. Five destinations, "Create" as a prominent
|
||||||
* zone, not crammed top-right. Hidden from `sm:` upward where the header has
|
* FAB-style centre button. The top header drops to just Logo + UserMenu on
|
||||||
* room for the same button inline.
|
* 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.
|
||||||
*/
|
*/
|
||||||
export function MobileActionBar() {
|
const TABS: Tab[] = [
|
||||||
const pathname = usePathname();
|
{ href: '/dashboard', label: 'Overview', icon: LayoutGrid, matchExact: true },
|
||||||
if (HIDDEN_PATHS.includes(pathname)) return null;
|
{ 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 (
|
return (
|
||||||
<div
|
pathname === '/servers' ||
|
||||||
className="fixed inset-x-0 bottom-0 z-40 border-t border-[--color-border] bg-[--color-bg-elevated]/95 backdrop-blur sm:hidden"
|
(pathname.startsWith('/servers/') && pathname !== '/servers/new')
|
||||||
style={{ paddingBottom: 'max(env(safe-area-inset-bottom), 0.5rem)' }}
|
);
|
||||||
>
|
}
|
||||||
<div className="mx-auto flex max-w-7xl px-4 pt-2.5">
|
return pathname.startsWith(tab.href);
|
||||||
<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]"
|
|
||||||
>
|
export function MobileActionBar() {
|
||||||
+ New server
|
const pathname = usePathname();
|
||||||
</Link>
|
const isActive = useIsActive(pathname);
|
||||||
</div>
|
|
||||||
</div>
|
return (
|
||||||
|
<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)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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