import { useState, useEffect } from 'react'; import { useRouter } from 'next/router'; import Link from 'next/link'; import { useTheme } from '../lib/theme-context'; import MobileNavigation from './MobileNavigation'; import DailyEmberWidget from './DailyEmberWidget'; import TopSearchBar from './ui/TopSearchBar'; import CommandPaletteModal from './ui/CommandPaletteModal'; // User Profile Dropdown Component function UserProfileDropdown({ user, onMobileMenuClose }) { // Hook order is fixed for both branches; do not move this below the // null-user early return — see rules-of-hooks (AGENTS.md Gotcha #11.5). const [isDropdownOpen, setIsDropdownOpen] = useState(false); // Logged-out: replace avatar + email + dropdown with a Sign-in CTA. if (!user) { return (
Sign in
); } const profileMenuItems = [ { name: 'Profile', href: '/profile', icon: 'user' }, { name: 'Settings', href: '/settings', icon: 'settings' }, ...(user?.role === 'admin' ? [ { name: 'Admin Panel', href: '/admin', icon: 'admin' } ] : []), { name: 'Logout', href: '/logout', icon: 'logout', isLogout: true } ]; const getProfileIcon = (iconName) => { const icons = { user: ( ), settings: ( ), admin: ( ), logout: ( ) }; return icons[iconName] || icons.user; }; return (
{/* Dropdown Menu */} {isDropdownOpen && ( <> {/* Backdrop */}
setIsDropdownOpen(false)} /> {/* Menu — Liquid Glass popover, high-tint w/ ember-subtle rim. */}
{profileMenuItems.map((item) => (
{ setIsDropdownOpen(false); onMobileMenuClose(); }} > {item.name}
))}
)} {/* Profile Button */}
); } // Navigation Content Component - Shared between desktop and mobile function NavigationContent({ user, router, onItemClick }) { const [isCommunityExpanded, setIsCommunityExpanded] = useState( router.pathname.startsWith('/community') ); const [lastCommunityPath, setLastCommunityPath] = useState(router.pathname); if ( router.pathname.startsWith('/community') && router.pathname !== lastCommunityPath ) { setLastCommunityPath(router.pathname); if (!isCommunityExpanded) { setIsCommunityExpanded(true); } } else if (router.pathname !== lastCommunityPath) { setLastCommunityPath(router.pathname); } // Navigation structure for authenticated users const authenticatedNavigation = user ? [ { name: 'Activity', href: '/activity', icon: 'activity', active: router.pathname === '/activity', isPlaceholder: true } ] : []; // My Collection section (only for authenticated users) const myCollectionNavigation = user ? { name: 'My Collection', href: '/dashboard', icon: 'collection', active: router.pathname === '/dashboard' || router.pathname === '/collections' || router.pathname === '/my-cards' || router.pathname === '/decks' || router.pathname === '/analytics', expanded: true, items: [ { name: 'Lists', href: '/collections', active: router.pathname === '/collections' }, { name: 'Cards', href: '/my-cards', active: router.pathname === '/my-cards' }, { name: 'Decks', href: '/decks', active: router.pathname === '/decks' }, { name: 'Analytics', href: '/analytics', active: router.pathname === '/analytics', isPlaceholder: true } ] } : null; // Always visible navigation (public + authenticated) const publicNavigation = [ { name: 'Cards', href: '/cards', icon: 'card', active: router.pathname === '/cards' }, { name: 'Scanner', href: '/scanner', icon: 'scanner', active: router.pathname === '/scanner' }, { name: 'Deck Builder', href: '/deck-builder', icon: 'deck', active: router.pathname === '/deck-builder', isPlaceholder: true } ]; // Admin navigation (only for admin users) const adminNavigation = (user?.role === 'admin') ? [ { name: 'Admin Tools', href: '/admin/card-editor', icon: 'admin', active: router.pathname.startsWith('/admin'), badge: 'ADMIN' } ] : []; const communityNavigation = { name: 'Community', icon: 'community', active: router.pathname.startsWith('/community'), expanded: isCommunityExpanded, items: [ { name: 'Lists', href: '/community/collections', active: router.pathname === '/community/collections' }, { name: 'Decks', href: '/community/decks', active: router.pathname === '/community/decks' }, { name: 'Forums', href: '/community/forums', active: router.pathname === '/community/forums' } ] }; const getIcon = (iconName) => { const icons = { grid: ( ), collection: ( ), card: ( ), deck: ( ), analytics: ( ), community: ( ), settings: ( ), logout: ( ), admin: ( ), notifications: ( ), activity: ( ), scanner: ( ) }; return icons[iconName] || icons.grid; }; return ( <> {/* Authenticated User Navigation - Activity */} {authenticatedNavigation.map((item) => (
{item.isPlaceholder ? (
{item.name}
Coming Soon
) : (
{item.name}
)}
))} {/* My Collection Section (only for authenticated users) */} {myCollectionNavigation && (
{/* My Collection Header - Clickable */}
{myCollectionNavigation.name}
{/* My Collection Sub-items */} {myCollectionNavigation.expanded && (
{myCollectionNavigation.items.map((subItem) => (
{subItem.isPlaceholder ? (
{subItem.name} Soon
) : (
{subItem.name}
)}
))}
)}
)} {/* Separator */}
{/* Public Navigation (always visible) */} {publicNavigation.map((item) => (
{item.isPlaceholder ? (
{item.name}
Coming Soon
) : (
{item.name}
)}
))} {/* Community Section with Sub-items */}
setIsCommunityExpanded(!isCommunityExpanded)} role="menuitem" aria-expanded={isCommunityExpanded} aria-haspopup="menu" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setIsCommunityExpanded(!isCommunityExpanded); } }} >
{communityNavigation.name}
{/* Community Sub-items */} {isCommunityExpanded && (
{communityNavigation.items.map((subItem) => (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onItemClick(); } }} > {subItem.name}
))}
)}
{/* Admin Navigation (if admin user) */} {adminNavigation.map((item) => (
{item.name}
{item.badge && ( {item.badge} )}
))} ); } export default function Layout({ children, user = null, showSearch = false }) { const router = useRouter(); const { theme, toggleTheme } = useTheme(); const [searchQuery, setSearchQuery] = useState(''); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); // Command palette (redesign-v2 sub-convoy #3, 2026-06-04). Opens // globally on ⌘K / Ctrl+K for authenticated users. The keyboard // listener is attached at Layout-scope (not _app.js) so it doesn't // fire on the public marketing pages that don't mount . const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); useEffect(() => { if (!user) return undefined; const handler = (event) => { const isShortcut = (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'k'; if (isShortcut) { event.preventDefault(); setIsCommandPaletteOpen((prev) => !prev); } }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [user]); // Mouse-tracking radial spotlight for nav items (redesign-v2 // spotlight-hover refinement, 2026-06-04). Delegated single // mousemove listener at the document level — cheaper than // attaching per-item React onMouseMove handlers to the 10+ // nav surfaces in NavigationContent. Writes --mouse-x and // --mouse-y as percentages onto the closest matching element; // the CSS ::before in globals.css consumes those vars to // position the radial-gradient. closest() returns null when // the cursor isn't over a nav item, which short-circuits 99% // of pointer events so the listener cost is negligible. // prefers-reduced-motion users still see the bg-color hover // tint (the ::before opacity transition is collapsed to 0.01ms // by the global reduced-motion sweep, but the spotlight itself // is visually static once the mouse stops moving — that's a // positional update, not an animation, so this respects WCAG // SC 2.3.3 without special-casing). useEffect(() => { const selector = '.nav-item, .nav-item-bottom, .nav-item-hover'; const handler = (event) => { const el = event.target.closest?.(selector); if (!el) return; const rect = el.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return; const x = ((event.clientX - rect.left) / rect.width) * 100; const y = ((event.clientY - rect.top) / rect.height) * 100; el.style.setProperty('--mouse-x', `${x}%`); el.style.setProperty('--mouse-y', `${y}%`); }; document.addEventListener('mousemove', handler); return () => document.removeEventListener('mousemove', handler); }, []); return (
{/* Mobile Navigation - Bottom bar for mobile */} setIsMobileMenuOpen(true)} /> {/* Mobile Overlay — Liquid Glass scrim consistent with . */} {isMobileMenuOpen && (
setIsMobileMenuOpen(false)} /> )} {/* Mobile Menu Drawer - Slides in from left when "More" is tapped */}
{/* Mobile Header with Close Button */}

Deck Hearth

{/* Mobile Navigation Content */} {/* Mobile Bottom Section. redesign-v2 refinements 2026-06-04: UserProfileDropdown removed for authenticated users (the TopSearchBar's user-menu chip is the canonical entry point now). For logged-out visitors, the Sign-in CTA still renders here so the drawer surfaces the auth path. */}
{!user && ( setIsMobileMenuOpen(false)} /> )} {/* Icon Buttons Row - Support and Dark Mode */}
{/* Support Icon Button */} {/* Theme Toggle Icon Button */}
{/* Desktop Sidebar - Hidden on mobile. Liquid Glass mid-tint, ambient elevation + rim-light edges; the page background visibly cools through the rail. */}
{/* Desktop Header — flame logomark + two-tone wordmark. redesign-v2 sub-convoy #2 (2026-06-04): replaces the prior "DH" monogram + plain text. Mockup shows a gradient flame icon on the left and "Deck" + "Hearth" where "Hearth" is gradient-text-flame so the wordmark reads as a single brand unit with the ember accent. */}

Deck Hearth

{/* Desktop Navigation Content */} {/* Desktop Bottom Section. redesign-v2 refinements 2026-06-04: the UserProfileDropdown that used to live here has moved to the TopSearchBar's user-menu chip (top-right). Keeping both was redundant. For logged-out visitors, render the Sign-in CTA here so the sidebar still surfaces the auth path (the top bar renders null when user is null). */}
{user && } {!user && ( {}} /> )} {/* Icon Buttons Row - Support and Dark Mode */}
{/* Support Icon Button */} {/* Theme Toggle Icon Button */}
{/* Main Content */}
{/* Global top bar — redesign-v2 sub-convoy #3 (2026-06-04). Rendered for ALL authenticated pages so the search + notifications + user-menu chrome is consistent everywhere (operator decision § 7.3 of the umbrella convoy: "sweep to all authenticated pages in this convoy"). Unauthenticated visitors see no top bar — public landing has its own marketing header. The legacy `showSearch` prop is honored via fall-through but no longer drives visibility; it can be removed in a cleanup convoy along with the dead `searchQuery` state. */} {user && ( setIsCommandPaletteOpen(true)} /> )} {/* Page Content */}
{children}
setIsCommandPaletteOpen(false)} />
); }