CDP diagnostic on deckhearth.com/dashboard after PR #100 found that the body gradient was being applied but completely invisible. Root cause: Layout.js line 610's root flex container had style={{ backgroundColor: 'var(--bg-primary)' }} That paints an opaque warm-white over the entire viewport, directly on top of the body's hearth gradient. The gradient was painting and the body element confirmed it via getComputedStyle, but no pixel of it reached the screen because the 1118x984 Layout root sat opaquely above it. Fix part 1: remove the backgroundColor from Layout's root <div>. The desktop sidebar (lines 738-744) and the mobile drawer (lines 630-640) already have their own translucent glass backgrounds via var(--glass-surface-mid) + backdrop-filter, so they don't depend on the parent. The main content area (line 822) is intentionally transparent. Removing the opaque parent lets the body gradient show through everything except the explicit glass surfaces. Fix part 2: boost gradient intensity. The 8%/6% alpha radials from PR #100 are technically there but visually undetectable. Bumped to: - 28% ember at bottom-left (the seat of the fire) - 18% ember at bottom-right (keeps wide displays from feeling lopsided) - 16% gold top-right (smoke catching last light) And added a third linear-gradient color stop so the warmth ramps visibly from top to bottom rather than being a flat 2-step. Together these make the "fireplace warmth" actually perceptible, which gives every glass surface a non-uniform substrate to blur — finally fulfilling the original "Liquid Glass over hearth" brief that PRs #97/#98/#99/#100 each got partway toward. Lint + vitest + build all green locally. Co-authored-by: Cursor <cursoragent@cursor.com>
865 lines
No EOL
40 KiB
JavaScript
865 lines
No EOL
40 KiB
JavaScript
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';
|
|
|
|
// 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 (
|
|
<Link href="/login">
|
|
<div
|
|
className="w-full flex items-center px-4 py-3 rounded-2xl transition-all duration-200 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 nav-item-hover cursor-pointer"
|
|
style={{
|
|
backgroundColor: 'transparent',
|
|
color: 'var(--text-primary)',
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
}}
|
|
onClick={onMobileMenuClose}
|
|
>
|
|
<div className="h-8 w-8 logo-container mr-3 flex items-center justify-center">
|
|
<svg className="h-4 w-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
|
</svg>
|
|
</div>
|
|
<span className="font-medium text-sm">Sign in</span>
|
|
</div>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
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: (
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
|
</svg>
|
|
),
|
|
settings: (
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
),
|
|
admin: (
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
|
</svg>
|
|
),
|
|
logout: (
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
|
</svg>
|
|
)
|
|
};
|
|
return icons[iconName] || icons.user;
|
|
};
|
|
|
|
return (
|
|
<div className="relative">
|
|
{/* Dropdown Menu */}
|
|
{isDropdownOpen && (
|
|
<>
|
|
{/* Backdrop */}
|
|
<div
|
|
className="fixed inset-0 z-10"
|
|
onClick={() => setIsDropdownOpen(false)}
|
|
/>
|
|
|
|
{/* Menu — Liquid Glass popover, high-tint w/ ember-subtle rim. */}
|
|
<div
|
|
className="absolute bottom-full left-0 right-0 mb-2 rounded-xl z-20"
|
|
style={{
|
|
background: 'var(--glass-surface-high)',
|
|
backdropFilter: 'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
|
|
WebkitBackdropFilter: 'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
|
|
boxShadow: 'var(--rim-light-inner), var(--ember-rim-subtle), var(--elevation-ambient)',
|
|
}}
|
|
>
|
|
<div className="py-2">
|
|
{profileMenuItems.map((item) => (
|
|
<Link key={item.name} href={item.href}>
|
|
<div
|
|
className={`
|
|
flex items-center px-4 py-3 text-sm transition-all duration-200 cursor-pointer
|
|
hover:bg-gray-50 dark:hover:bg-gray-700
|
|
${item.isLogout ? 'text-red-600 dark:text-red-400' : ''}
|
|
`}
|
|
style={{
|
|
color: item.isLogout ? 'var(--accent-ember)' : 'var(--text-primary)'
|
|
}}
|
|
onClick={() => {
|
|
setIsDropdownOpen(false);
|
|
onMobileMenuClose();
|
|
}}
|
|
>
|
|
<span className="mr-3" aria-hidden="true">{getProfileIcon(item.icon)}</span>
|
|
<span className="font-medium">{item.name}</span>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Profile Button */}
|
|
<button
|
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
|
className="w-full flex items-center px-4 py-3 rounded-2xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 nav-item-hover"
|
|
style={{
|
|
backgroundColor: 'transparent',
|
|
color: 'var(--text-secondary)',
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
}}
|
|
aria-expanded={isDropdownOpen}
|
|
aria-haspopup="menu"
|
|
>
|
|
<div className="h-8 w-8 logo-container mr-3">
|
|
<span className="text-white text-xs font-medium">
|
|
{user?.email?.charAt(0).toUpperCase() || 'G'}
|
|
</span>
|
|
</div>
|
|
<div className="flex-1 text-left">
|
|
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>
|
|
{user?.email || 'Guest'}
|
|
</p>
|
|
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
{user?.role || 'visitor'}
|
|
</p>
|
|
</div>
|
|
<svg
|
|
className={`w-4 h-4 transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''}`}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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: (
|
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
|
|
),
|
|
collection: (
|
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
|
</svg>
|
|
),
|
|
card: (
|
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
|
|
),
|
|
deck: (
|
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
|
|
),
|
|
analytics: (
|
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
|
</svg>
|
|
),
|
|
community: (
|
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
|
|
</svg>
|
|
),
|
|
settings: (
|
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
),
|
|
logout: (
|
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
|
</svg>
|
|
),
|
|
admin: (
|
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
),
|
|
notifications: (
|
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-5 5v-5zM4.5 19.5L9 15m0 0l-4.5-4.5M9 15v5" />
|
|
</svg>
|
|
),
|
|
activity: (
|
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
</svg>
|
|
),
|
|
scanner: (
|
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
)
|
|
};
|
|
return icons[iconName] || icons.grid;
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* Authenticated User Navigation - Activity */}
|
|
{authenticatedNavigation.map((item) => (
|
|
<div key={item.name}>
|
|
{item.isPlaceholder ? (
|
|
<div
|
|
className="nav-item flex items-center justify-between px-4 py-3 rounded-2xl opacity-50 cursor-not-allowed"
|
|
style={{
|
|
backgroundColor: 'transparent',
|
|
color: 'var(--text-secondary)'
|
|
}}
|
|
>
|
|
<div className="flex items-center">
|
|
<span className="mr-3" aria-hidden="true">{getIcon(item.icon)}</span>
|
|
<span className="font-medium">{item.name}</span>
|
|
</div>
|
|
<span className="text-xs px-2 py-1 rounded-full" style={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}>
|
|
Coming Soon
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<Link href={item.href}>
|
|
<div
|
|
className={`
|
|
nav-item flex items-center justify-between px-4 py-3 rounded-2xl
|
|
transition-all duration-200 cursor-pointer
|
|
focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2
|
|
${item.active
|
|
? 'shadow-lg nav-item-active'
|
|
: 'hover:shadow-md nav-item-hover'
|
|
}
|
|
`}
|
|
style={{
|
|
backgroundColor: item.active ? 'var(--bg-tertiary)' : 'transparent',
|
|
color: item.active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
}}
|
|
onClick={onItemClick}
|
|
>
|
|
<div className="flex items-center">
|
|
<span className="mr-3" aria-hidden="true">{getIcon(item.icon)}</span>
|
|
<span className="font-medium">{item.name}</span>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{/* My Collection Section (only for authenticated users) */}
|
|
{myCollectionNavigation && (
|
|
<div className="space-y-1">
|
|
{/* My Collection Header - Clickable */}
|
|
<Link href={myCollectionNavigation.href}>
|
|
<div
|
|
className={`
|
|
nav-item flex items-center justify-between px-4 py-3 rounded-2xl
|
|
transition-all duration-200 cursor-pointer
|
|
focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2
|
|
${router.pathname === '/dashboard'
|
|
? 'shadow-lg nav-item-active'
|
|
: 'hover:shadow-md nav-item-hover'
|
|
}
|
|
`}
|
|
style={{
|
|
backgroundColor: router.pathname === '/dashboard' ? 'var(--bg-tertiary)' : 'transparent',
|
|
color: router.pathname === '/dashboard' ? 'var(--text-primary)' : 'var(--text-secondary)',
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
}}
|
|
onClick={onItemClick}
|
|
>
|
|
<div className="flex items-center">
|
|
<span className="mr-3" aria-hidden="true">{getIcon(myCollectionNavigation.icon)}</span>
|
|
<span className="font-medium">{myCollectionNavigation.name}</span>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
|
|
{/* My Collection Sub-items */}
|
|
{myCollectionNavigation.expanded && (
|
|
<div className="ml-4 space-y-1 border-l-2 pl-4" style={{ borderColor: 'var(--border)' }}>
|
|
{myCollectionNavigation.items.map((subItem) => (
|
|
<div key={subItem.name}>
|
|
{subItem.isPlaceholder ? (
|
|
<div
|
|
className="nav-item flex items-center justify-between px-4 py-2 rounded-xl opacity-50 cursor-not-allowed text-sm"
|
|
style={{
|
|
backgroundColor: 'transparent',
|
|
color: 'var(--text-secondary)'
|
|
}}
|
|
>
|
|
<span className="font-medium">{subItem.name}</span>
|
|
<span className="text-xs px-2 py-1 rounded-full" style={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}>
|
|
Soon
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<Link href={subItem.href}>
|
|
<div
|
|
className={`
|
|
nav-item flex items-center px-4 py-2 rounded-xl
|
|
transition-all duration-200 cursor-pointer text-sm
|
|
focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2
|
|
${subItem.active
|
|
? 'shadow-md nav-item-active'
|
|
: 'hover:shadow-sm nav-item-hover'
|
|
}
|
|
`}
|
|
style={{
|
|
backgroundColor: subItem.active ? 'var(--bg-tertiary)' : 'transparent',
|
|
color: subItem.active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
}}
|
|
onClick={onItemClick}
|
|
>
|
|
<span className="font-medium">{subItem.name}</span>
|
|
</div>
|
|
</Link>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Separator */}
|
|
<div className="border-t pt-2 mt-4" style={{ borderColor: 'var(--border)' }}></div>
|
|
|
|
{/* Public Navigation (always visible) */}
|
|
{publicNavigation.map((item) => (
|
|
<div key={item.name}>
|
|
{item.isPlaceholder ? (
|
|
<div
|
|
className="nav-item flex items-center justify-between px-4 py-3 rounded-2xl opacity-50 cursor-not-allowed"
|
|
style={{
|
|
backgroundColor: 'transparent',
|
|
color: 'var(--text-secondary)'
|
|
}}
|
|
>
|
|
<div className="flex items-center">
|
|
<span className="mr-3" aria-hidden="true">{getIcon(item.icon)}</span>
|
|
<span className="font-medium">{item.name}</span>
|
|
</div>
|
|
<span className="text-xs px-2 py-1 rounded-full" style={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}>
|
|
Coming Soon
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<Link href={item.href}>
|
|
<div
|
|
className={`
|
|
nav-item flex items-center justify-between px-4 py-3 rounded-2xl
|
|
transition-all duration-200 cursor-pointer
|
|
focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2
|
|
${item.active
|
|
? 'shadow-lg nav-item-active'
|
|
: 'hover:shadow-md nav-item-hover'
|
|
}
|
|
`}
|
|
style={{
|
|
backgroundColor: item.active ? 'var(--bg-tertiary)' : 'transparent',
|
|
color: item.active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
}}
|
|
onClick={onItemClick}
|
|
>
|
|
<div className="flex items-center">
|
|
<span className="mr-3" aria-hidden="true">{getIcon(item.icon)}</span>
|
|
<span className="font-medium">{item.name}</span>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{/* Community Section with Sub-items */}
|
|
<div className="space-y-1">
|
|
<div
|
|
className={`
|
|
nav-item flex items-center justify-between px-4 py-3 rounded-2xl
|
|
transition-all duration-200 cursor-pointer
|
|
focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2
|
|
${communityNavigation.active
|
|
? 'shadow-lg nav-item-active'
|
|
: 'hover:shadow-md nav-item-hover'
|
|
}
|
|
`}
|
|
style={{
|
|
backgroundColor: communityNavigation.active ? 'var(--bg-tertiary)' : 'transparent',
|
|
color: communityNavigation.active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
}}
|
|
onClick={() => setIsCommunityExpanded(!isCommunityExpanded)}
|
|
role="menuitem"
|
|
aria-expanded={isCommunityExpanded}
|
|
aria-haspopup="menu"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
setIsCommunityExpanded(!isCommunityExpanded);
|
|
}
|
|
}}
|
|
>
|
|
<div className="flex items-center">
|
|
<span className="mr-3" aria-hidden="true">{getIcon(communityNavigation.icon)}</span>
|
|
<span className="font-medium">{communityNavigation.name}</span>
|
|
</div>
|
|
<svg
|
|
className={`w-4 h-4 transition-transform duration-200 ${isCommunityExpanded ? 'rotate-90' : ''}`}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</div>
|
|
|
|
{/* Community Sub-items */}
|
|
{isCommunityExpanded && (
|
|
<div className="ml-4 space-y-1 border-l-2 pl-4" style={{ borderColor: 'var(--border)' }}>
|
|
{communityNavigation.items.map((subItem) => (
|
|
<Link key={subItem.name} href={subItem.href}>
|
|
<div
|
|
className={`
|
|
nav-item flex items-center px-4 py-2 rounded-xl
|
|
transition-all duration-200 cursor-pointer text-sm
|
|
focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2
|
|
${subItem.active
|
|
? 'shadow-md nav-item-active'
|
|
: 'hover:shadow-sm nav-item-hover'
|
|
}
|
|
`}
|
|
style={{
|
|
backgroundColor: subItem.active ? 'var(--bg-tertiary)' : 'transparent',
|
|
color: subItem.active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
}}
|
|
onClick={onItemClick}
|
|
role="menuitem"
|
|
aria-current={subItem.active ? 'page' : undefined}
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
onItemClick();
|
|
}
|
|
}}
|
|
>
|
|
<span className="font-medium">{subItem.name}</span>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Admin Navigation (if admin user) */}
|
|
{adminNavigation.map((item) => (
|
|
<Link key={item.name} href={item.href}>
|
|
<div
|
|
className={`
|
|
nav-item flex items-center justify-between px-4 py-3 rounded-2xl
|
|
transition-all duration-200 cursor-pointer
|
|
focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2
|
|
${item.active
|
|
? 'shadow-lg nav-item-active'
|
|
: 'hover:shadow-md nav-item-hover'
|
|
}
|
|
`}
|
|
style={{
|
|
backgroundColor: item.active ? 'var(--bg-tertiary)' : 'transparent',
|
|
color: item.active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
}}
|
|
onClick={onItemClick}
|
|
>
|
|
<div className="flex items-center">
|
|
<span className="mr-3" aria-hidden="true">{getIcon(item.icon)}</span>
|
|
<span className="font-medium">{item.name}</span>
|
|
</div>
|
|
{item.badge && (
|
|
<span
|
|
className="px-2 py-1 text-xs rounded-full font-medium"
|
|
style={{
|
|
backgroundColor: item.badge === 'ADMIN' ? 'var(--accent-ember)' : 'var(--accent-flame)',
|
|
color: 'white'
|
|
}}
|
|
>
|
|
{item.badge}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
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);
|
|
|
|
return (
|
|
<div className="flex h-screen">
|
|
{/* Mobile Navigation - Bottom bar for mobile */}
|
|
<MobileNavigation
|
|
onMenuOpen={() => setIsMobileMenuOpen(true)}
|
|
/>
|
|
|
|
{/* Mobile Overlay — Liquid Glass scrim consistent with <Modal>. */}
|
|
{isMobileMenuOpen && (
|
|
<div
|
|
className="md:hidden fixed inset-0 z-40"
|
|
style={{
|
|
background: 'var(--modal-scrim)',
|
|
backdropFilter: 'blur(var(--glass-blur-high)) saturate(var(--glass-saturate))',
|
|
WebkitBackdropFilter: 'blur(var(--glass-blur-high)) saturate(var(--glass-saturate))',
|
|
}}
|
|
onClick={() => setIsMobileMenuOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Mobile Menu Drawer - Slides in from left when "More" is tapped */}
|
|
<div
|
|
className={`
|
|
md:hidden fixed inset-y-0 left-0 z-50 w-64 transform transition-transform duration-300 ease-in-out
|
|
${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full'}
|
|
`}
|
|
style={{
|
|
background: 'var(--glass-surface-mid)',
|
|
backdropFilter: 'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
|
|
WebkitBackdropFilter: 'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
|
|
boxShadow: 'var(--rim-light-inner), var(--rim-light-outer), var(--elevation-pronounced)',
|
|
}}
|
|
>
|
|
<div className="p-4 h-full flex flex-col">
|
|
{/* Mobile Header with Close Button */}
|
|
<div className="flex justify-between items-center mb-4">
|
|
<div className="flex items-center">
|
|
<div className="w-10 h-10 logo-container mr-3">
|
|
<span className="text-white font-bold text-sm">DH</span>
|
|
</div>
|
|
<h1 className="text-xl font-bold" style={{ color: 'var(--text-primary)' }}>Deck Hearth</h1>
|
|
</div>
|
|
<button
|
|
onClick={() => setIsMobileMenuOpen(false)}
|
|
className="p-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2"
|
|
style={{
|
|
color: 'var(--text-secondary)',
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
}}
|
|
aria-label="Close navigation menu"
|
|
>
|
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Mobile Navigation Content */}
|
|
<nav className="space-y-2 flex-1" role="navigation" aria-label="Mobile navigation">
|
|
<NavigationContent
|
|
user={user}
|
|
router={router}
|
|
onItemClick={() => setIsMobileMenuOpen(false)}
|
|
/>
|
|
</nav>
|
|
|
|
{/* Mobile Bottom Section */}
|
|
<div className="mt-auto space-y-2">
|
|
{/* User Profile Dropdown */}
|
|
<UserProfileDropdown
|
|
user={user}
|
|
onMobileMenuClose={() => setIsMobileMenuOpen(false)}
|
|
/>
|
|
|
|
{/* Icon Buttons Row - Support and Dark Mode */}
|
|
<div className="flex justify-center space-x-4 pt-2">
|
|
{/* Support Icon Button */}
|
|
<Link href="/support">
|
|
<button
|
|
className="p-3 rounded-xl transition-all duration-200 hover:shadow-md cursor-pointer nav-item-hover focus:outline-none focus:ring-2 focus:ring-offset-2"
|
|
style={{
|
|
backgroundColor: 'transparent',
|
|
color: 'var(--text-secondary)',
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
}}
|
|
onClick={() => setIsMobileMenuOpen(false)}
|
|
aria-label="Support"
|
|
title="Support"
|
|
>
|
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</button>
|
|
</Link>
|
|
|
|
{/* Theme Toggle Icon Button */}
|
|
<button
|
|
onClick={toggleTheme}
|
|
className="p-3 rounded-xl transition-all duration-200 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 nav-item-hover"
|
|
style={{
|
|
backgroundColor: 'transparent',
|
|
color: 'var(--text-secondary)',
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
}}
|
|
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
|
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
|
>
|
|
{theme === 'light' ? (
|
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
|
</svg>
|
|
) : (
|
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Desktop Sidebar - Hidden on mobile. Liquid Glass mid-tint, ambient
|
|
elevation + rim-light edges; the page background visibly cools
|
|
through the rail. */}
|
|
<div
|
|
className="hidden md:flex md:static inset-y-0 left-0 w-64"
|
|
style={{
|
|
background: 'var(--glass-surface-mid)',
|
|
backdropFilter: 'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
|
|
WebkitBackdropFilter: 'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
|
|
boxShadow: 'var(--rim-light-inner), var(--rim-light-outer), var(--elevation-ambient)',
|
|
}}
|
|
>
|
|
<div className="p-4 h-full flex flex-col w-full">
|
|
{/* Desktop Header */}
|
|
<div className="flex items-center mb-8">
|
|
<div className="w-10 h-10 logo-container mr-3">
|
|
<span className="text-white font-bold text-sm">DH</span>
|
|
</div>
|
|
<h1 className="text-xl font-bold" style={{ color: 'var(--text-primary)' }}>Deck Hearth</h1>
|
|
</div>
|
|
|
|
{/* Desktop Navigation Content */}
|
|
<nav className="space-y-2 flex-1" role="navigation" aria-label="Main navigation">
|
|
<NavigationContent
|
|
user={user}
|
|
router={router}
|
|
onItemClick={() => {}}
|
|
/>
|
|
</nav>
|
|
|
|
{/* Desktop Bottom Section */}
|
|
<div className="mt-auto space-y-2">
|
|
{/* User Profile Dropdown */}
|
|
<UserProfileDropdown
|
|
user={user}
|
|
onMobileMenuClose={() => {}}
|
|
/>
|
|
|
|
{/* Icon Buttons Row - Support and Dark Mode */}
|
|
<div className="flex justify-center space-x-4 pt-2">
|
|
{/* Support Icon Button */}
|
|
<Link href="/support">
|
|
<button
|
|
className="p-3 rounded-xl transition-all duration-200 hover:shadow-md cursor-pointer nav-item-hover focus:outline-none focus:ring-2 focus:ring-offset-2"
|
|
style={{
|
|
backgroundColor: 'transparent',
|
|
color: 'var(--text-secondary)',
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
}}
|
|
aria-label="Support"
|
|
title="Support"
|
|
>
|
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</button>
|
|
</Link>
|
|
|
|
{/* Theme Toggle Icon Button */}
|
|
<button
|
|
onClick={toggleTheme}
|
|
className="p-3 rounded-xl transition-all duration-200 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 nav-item-hover"
|
|
style={{
|
|
backgroundColor: 'transparent',
|
|
color: 'var(--text-secondary)',
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
}}
|
|
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
|
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
|
>
|
|
{theme === 'light' ? (
|
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
|
</svg>
|
|
) : (
|
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Content */}
|
|
<div className="flex-1 flex flex-col pb-16 md:pb-0">
|
|
{/* Top Header - Only show search on dashboard */}
|
|
{showSearch && (
|
|
<header
|
|
className="p-6"
|
|
style={{
|
|
background: 'var(--glass-surface-mid)',
|
|
backdropFilter: 'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
|
|
WebkitBackdropFilter: 'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
|
|
boxShadow: 'var(--rim-light-inner), var(--rim-light-outer)',
|
|
}}
|
|
>
|
|
<div className="flex items-center">
|
|
<div className="flex-1 max-w-2xl">
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
placeholder="Search cards, decks, or lists..."
|
|
className="search-bar pr-12"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
/>
|
|
<div className="absolute inset-y-0 right-0 flex items-center pr-4">
|
|
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>⌘F</span>
|
|
</div>
|
|
<div className="absolute inset-y-0 left-0 flex items-center pl-4">
|
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: 'var(--text-secondary)' }}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
)}
|
|
|
|
{/* Page Content */}
|
|
<main className="flex-1 overflow-auto">
|
|
{children}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |