feat(web): app-like mobile dashboard — bottom tab bar, minimal top
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:
Marco Sadjadi 2026-05-25 23:15:44 +02:00
parent a8e6f4fabd
commit f80bd8afbe
2 changed files with 96 additions and 21 deletions

View File

@ -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>

View File

@ -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>
);
}