Some checks failed
Visual diff / Should run? (pull_request) Has been cancelled
CI / Lint (pull_request) Has been cancelled
CI / Schema map up to date (pull_request) Has been cancelled
CI / Forbidden patterns (9 checks) (pull_request) Has been cancelled
CI / Migrations apply (node-pg-migrate) (pull_request) Has been cancelled
CI / Unit tests (vitest) (pull_request) Has been cancelled
Convoy metrics gate / Require role-event telemetry on convoy PRs (pull_request) Has been cancelled
PR Health rollup / Aggregate gate status (pull_request) Has been cancelled
Preview smoke / Should run? (pull_request) Has been cancelled
Visual diff / Screenshot diff (pull_request) Has been cancelled
Preview smoke / Playwright smoke (pull_request) Has been cancelled
- styles/globals.css: add overflow-x: hidden + max-width: 100vw to body (global safety net) - components/CollectionPageView.js: flex-wrap filter row, responsive search width (w-full sm:w-64) - pages/my-cards.js: replace min-w-[200px] with w-full sm:min-w-[200px] on filter search - pages/designer.js: remove shrink-0 from live preview sidebar to allow flex collapse - components/DeckDetailView.js: add flex-wrap + gap to header flex row - components/CardItem.js: remove whitespace-nowrap from expanding add buttons (3 instances) - components/DashboardRecentActivity.js: whitespace-nowrap → whitespace-normal on timestamp - components/CollaboratorFacepile.js: tooltips use whitespace-normal + break-words + max-w - components/MobileNavigation.js: tooltip whitespace-nowrap → whitespace-normal Fixes horizontal scroll / layout breakage on mobile (<375px) across the site.
176 lines
6.9 KiB
JavaScript
176 lines
6.9 KiB
JavaScript
import { useRouter } from 'next/router';
|
|
import Link from 'next/link';
|
|
import { GlassSurface } from './ui';
|
|
import { VOCAB } from '../lib/collection-vocabulary.js';
|
|
|
|
export default function MobileNavigation({ onMenuOpen }) {
|
|
const router = useRouter();
|
|
|
|
// Bottom bar — dashboard-home-realignment (2026-08-15). Primary hub
|
|
// is Dashboard only; Collection / Scanner / Decks are siblings.
|
|
// Lists + catalog + community live in the drawer via More.
|
|
const navigationItems = [
|
|
{
|
|
name: VOCAB.MY_COLLECTION,
|
|
href: '/my-cards',
|
|
icon: 'collection',
|
|
active: router.pathname === '/my-cards',
|
|
},
|
|
{
|
|
name: 'Scanner',
|
|
href: '/scanner',
|
|
icon: 'scanner',
|
|
active: router.pathname === '/scanner',
|
|
},
|
|
{
|
|
name: 'Dashboard',
|
|
href: '/dashboard',
|
|
icon: 'dashboard',
|
|
active: router.pathname === '/dashboard',
|
|
isPrimary: true,
|
|
},
|
|
{
|
|
name: 'Decks',
|
|
href: '/decks',
|
|
icon: 'decks',
|
|
active:
|
|
router.pathname === '/decks' ||
|
|
router.pathname.startsWith('/deck/'),
|
|
},
|
|
{
|
|
name: 'More',
|
|
href: '#',
|
|
icon: 'more',
|
|
onClick: onMenuOpen,
|
|
active: false,
|
|
},
|
|
];
|
|
|
|
const getIcon = (iconName, isActive = false, isPrimary = false) => {
|
|
const iconClass = isPrimary ? 'h-7 w-7' : 'h-6 w-6';
|
|
const strokeWidth = isActive ? 2.5 : 2;
|
|
|
|
const icons = {
|
|
collection: (
|
|
<svg className={iconClass} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
|
</svg>
|
|
),
|
|
scanner: (
|
|
<svg className={iconClass} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
),
|
|
cards: (
|
|
<svg className={iconClass} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
|
</svg>
|
|
),
|
|
decks: (
|
|
<svg className={iconClass} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
|
</svg>
|
|
),
|
|
dashboard: (
|
|
<svg className={iconClass} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
|
</svg>
|
|
),
|
|
more: (
|
|
<svg className={iconClass} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d="M4 6h16M4 12h16M4 18h16" />
|
|
</svg>
|
|
),
|
|
};
|
|
|
|
return icons[iconName] || icons.dashboard;
|
|
};
|
|
|
|
return (
|
|
<div className="md:hidden fixed bottom-0 left-0 right-0 z-50">
|
|
<GlassSurface
|
|
className="absolute inset-0"
|
|
tint="mid"
|
|
blur="mid"
|
|
rim="subtle"
|
|
elevation="flat"
|
|
cornerLights="chrome"
|
|
/>
|
|
|
|
<div className="relative px-4 py-2">
|
|
<div className="flex items-center justify-around">
|
|
{navigationItems.map((item) => {
|
|
const label = item.name;
|
|
|
|
if (item.isPrimary) {
|
|
return (
|
|
<Link key={item.name} href={item.href}>
|
|
<div className="relative">
|
|
<div
|
|
className="absolute inset-0 rounded-full shadow-lg transform -translate-y-2"
|
|
style={{
|
|
background: item.active
|
|
? 'var(--gradient-primary)'
|
|
: 'var(--gradient-secondary)',
|
|
width: '56px',
|
|
height: '56px',
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="relative flex flex-col items-center justify-center w-14 h-14 transform -translate-y-2 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2"
|
|
style={{
|
|
color: 'white',
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
'--tw-ring-offset-color': 'var(--bg-primary)',
|
|
}}
|
|
aria-label={item.name}
|
|
>
|
|
{getIcon(item.icon, item.active, true)}
|
|
</button>
|
|
<span
|
|
className="absolute top-12 left-1/2 transform -translate-x-1/2 text-xs font-medium whitespace-normal"
|
|
style={{
|
|
color: item.active
|
|
? 'var(--accent-ember)'
|
|
: 'var(--text-secondary)',
|
|
}}
|
|
>
|
|
{label}
|
|
</span>
|
|
</div>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
const ButtonComponent = item.onClick ? 'button' : Link;
|
|
const buttonProps = item.onClick
|
|
? { onClick: item.onClick, type: 'button' }
|
|
: { href: item.href };
|
|
|
|
return (
|
|
<ButtonComponent key={item.name} {...buttonProps}>
|
|
<div
|
|
className="flex flex-col items-center justify-center py-2 px-3 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg"
|
|
style={{
|
|
color: item.active
|
|
? 'var(--accent-ember)'
|
|
: 'var(--text-secondary)',
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)',
|
|
}}
|
|
>
|
|
<div className="mb-1">{getIcon(item.icon, item.active)}</div>
|
|
<span className="text-xs font-medium">{label}</span>
|
|
</div>
|
|
</ButtonComponent>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="h-safe-area-inset-bottom" />
|
|
</div>
|
|
);
|
|
}
|