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 ( // Outer shell — `md:p-4 md:gap-4` pulls both the sidebar and the // main column in from the viewport edges so the sidebar and the // top header read as floating chrome chips (operator feedback // 2026-06-04: "pull out the left nav to make it look like it is // its own floating section versus attached to the top-left"). The // `md:gap-4` on this row also creates the visible page-body // background between the sidebar's right edge and the main // column, which replaces the previous hard divider — "all feels // like one large page" is achieved by the hearth gradient // showing through the gap. Mobile (< 768px) keeps the current // flush layout because the sidebar is `hidden md:flex` anyway.
{/* 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. 2026-06-04 floating-chrome pass: rounded-3xl + overflow-hidden so the rail reads as its own floating chip; `var(--rim-light-outer)` dropped from the box-shadow stack because that 1px outer ring was the "vertical divider between the sidebar and the right side" the operator wanted gone. The chip's edge is now defined solely by `var(--elevation-ambient)` (drop shadow) + the glass-surface-mid tint against the body gradient that shows through `md:gap-4` on the outer flex. */}
{/* 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. `md:gap-4` adds vertical breathing room between the floating TopSearchBar header and the page content so they read as two distinct floating chrome elements over the body gradient (rather than the header sitting flush against the content). `min-w-0` prevents flex children from blowing past the column width when long card titles or table cells refuse to wrap. */}
{/* 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)} />
); }