Operator feedback 2026-06-04 (after PR #115's two-light-source pass): "A lot of gradients happening, though I want the gradient in the background to be the focus, and then the border gets a highlight, perhaps, in a corner that's closest to the background gradients... the borders are reflecting the color, not necessarily the drop shadows behind the cards. In fact, you could probably reduce those box shadows quite a bit. At least they shouldn't span the entire edge of the card. Just a subtle bit in the corner." PR #115's outer directional drop-shadows (`--edge-light-warm` / `--edge-light-cool`) DID make the chips respond to the two light sources but spanned the entire bottom-left + top-right edges, which competed visually with the body gradient. This PR moves the warm/cool response onto the chip's 1px BORDER, concentrated in the two corners closest to the matching light sources, and cuts the directional drop-shadows entirely (just rim-light-inner + a single neutral elevation-ambient remain). Technique: the modern gradient-border pattern. background: /* surface fill, clipped to padding-box */ linear-gradient(surface, surface) padding-box, /* warm catch-light at bottom-left, border-box-clipped */ radial-gradient(at 0% 100%, warm 0%, transparent 42%) border-box, /* cool catch-light at top-right, border-box-clipped */ radial-gradient(at 100% 0%, cool 0%, transparent 42%) border-box, /* faint diagonal white tint for the rest of the rim */ chip-border-base border-box; border: 1px solid transparent; The transparent border is what exposes the border-box-clipped gradient layers as the visible rim. Token changes (styles/globals.css): - Retired: --edge-light-warm, --edge-light-cool (both themes). These were complete box-shadow declarations with offsets spanning the full bottom-left + top-right edges of every chip. - Added: --corner-light-warm, --corner-light-cool — plain RGBA colors for radial-gradient consumption. Light: 0.50 warm / 0.38 cool. Dark: 0.68 warm / 0.58 cool (bumped so catch-lights read against the deep substrate). - Added: --chip-border-base — faint 135deg white tint so the rest of the rim isn't invisible (sensible chip edge far from either light source). Lower alpha on dark. Application sites: - styles/globals.css `.glass-panel` and `.glass-panel-strong` — updated to multi-layer background + transparent border. Picks up DailyEmberWidget, CommandPaletteModal popover, UserMenu dropdown, the logged-out sidebar Support/Theme tray, etc. - components/Layout.js nav-chip inline style — same treatment. - components/ui/TopSearchBar.js inline style — same treatment. Shadow stacks across all four sites are now just: var(--rim-light-inner), var(--elevation-ambient) (or elevation-pronounced for .glass-panel-strong). The body gradient stays unchanged from PR #115 — it remains the dominant gradient story per operator's "I want the gradient in the background to be the focus" framing. Tests: - npm run test:run: 113/113 - npm run lint: 0 errors (1 pre-existing warning) - npm run build: green Co-authored-by: Cursor <cursoragent@cursor.com>
1013 lines
No EOL
48 KiB
JavaScript
1013 lines
No EOL
48 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';
|
|
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 (
|
|
<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 ? undefined : 'transparent',
|
|
color: item.active ? undefined : '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' ? undefined : 'transparent',
|
|
color: router.pathname === '/dashboard' ? undefined : '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 ? undefined : 'transparent',
|
|
color: subItem.active ? undefined : '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 ? undefined : 'transparent',
|
|
color: item.active ? undefined : '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 ? undefined : 'transparent',
|
|
color: subItem.active ? undefined : '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 ? undefined : 'transparent',
|
|
color: item.active ? undefined : '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);
|
|
// 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 <Layout>.
|
|
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.
|
|
<div className="flex h-screen md:p-4 md:gap-4">
|
|
{/* 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 mr-3 rounded-2xl flex items-center justify-center"
|
|
style={{
|
|
background:
|
|
'linear-gradient(135deg, rgb(255, 140, 30) 0%, rgb(216, 67, 21) 100%)',
|
|
boxShadow:
|
|
'0 2px 8px -1px rgba(255, 110, 0, 0.50), inset 0 1px 0 rgba(255,255,255,0.25)',
|
|
}}
|
|
aria-hidden="true"
|
|
>
|
|
<svg
|
|
className="h-5 w-5"
|
|
viewBox="0 0 24 24"
|
|
fill="rgb(255, 255, 255)"
|
|
>
|
|
<path d="M12 2c-.5 3.5-3.5 5.5-3.5 9 0 2.5 1.5 4 3.5 4s3.5-1.5 3.5-4c0-1.5-1-3-2-4 1 2 .5 4-.5 5-1 1-2-1-1-3 .5-1 1.5-3 0-7z" />
|
|
<path d="M7 13c0 4 2.2 7 5 7s5-3 5-7c0-1-.3-2-.7-2.8-.3 3-1.8 4.8-4.3 4.8-2 0-3.6-1.5-4.3-4.5-.4 1-.7 1.7-.7 2.5z" />
|
|
</svg>
|
|
</div>
|
|
<h1 className="text-xl font-bold flex items-center gap-1">
|
|
<span style={{ color: 'var(--text-primary)' }}>Deck</span>
|
|
<span className="gradient-text-flame">Hearth</span>
|
|
</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. 2026-06-04 dropdown-migration pass:
|
|
Support + Theme moved into the TopSearchBar <UserMenu>
|
|
dropdown for authenticated users (the avatar chip in the
|
|
top bar is reachable from mobile too — the chevron/name
|
|
are hidden via `md:` but the chip itself is always
|
|
visible). Logged-out visitors keep the Sign-in CTA +
|
|
Support + Theme here since they have no top-bar menu. */}
|
|
{!user && (
|
|
<div className="mt-auto space-y-2">
|
|
<UserProfileDropdown
|
|
user={user}
|
|
onMobileMenuClose={() => setIsMobileMenuOpen(false)}
|
|
/>
|
|
|
|
<div className="flex justify-center space-x-4 pt-2">
|
|
<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>
|
|
|
|
<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. 2026-06-04 split-into-two-
|
|
chips pass: per operator feedback ("make the navigation its own
|
|
section, separated from the badges and progress"), the sidebar
|
|
column now holds TWO independent floating glass chips with a
|
|
visible body-gradient gap between them:
|
|
1. nav-chip (flex-1) — logo, wordmark, NavigationContent,
|
|
and the Sign-in CTA when logged-out.
|
|
2. bottom-chip — for authenticated users this is the
|
|
<DailyEmberWidget /> (which is already a glass-panel and
|
|
renders as its own chip without further wrapping). For
|
|
logged-out visitors, this is a small glass-panel that
|
|
holds the Support + Theme icon row (since logged-out
|
|
users have no TopSearchBar dropdown to host those).
|
|
Support + Theme were removed from the authenticated path
|
|
entirely — they live in TopSearchBar's <UserMenu> dropdown
|
|
now. The shared `boxShadow` stack is rim-light-inner +
|
|
elevation-ambient (matches the prior floating-chrome pass —
|
|
rim-light-outer stays dropped so there's no 1px ring that
|
|
would re-introduce a divider against the body gradient). */}
|
|
<aside
|
|
className="hidden md:flex md:flex-col md:gap-4 w-64"
|
|
aria-label="Sidebar"
|
|
>
|
|
{/* Nav chip. 2026-06-04 corner-border pass: same gradient-
|
|
border technique as `.glass-panel` — the chip's fill is
|
|
clipped to padding-box, and three border-box-clipped
|
|
gradient layers expose the warm bottom-left + cool top-
|
|
right catch-lights through a 1px transparent border. The
|
|
warm/cool drop shadows that lived here through PR #115
|
|
are gone — the directional response is on the border
|
|
now, not extending into the body gradient. */}
|
|
<div
|
|
className="flex-1 flex flex-col md:rounded-3xl md:overflow-hidden min-h-0"
|
|
style={{
|
|
background:
|
|
'linear-gradient(var(--glass-surface-mid), var(--glass-surface-mid)) padding-box, ' +
|
|
'radial-gradient(at 0% 100%, var(--corner-light-warm) 0%, transparent 42%) border-box, ' +
|
|
'radial-gradient(at 100% 0%, var(--corner-light-cool) 0%, transparent 42%) border-box, ' +
|
|
'var(--chip-border-base) border-box',
|
|
border: '1px solid transparent',
|
|
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(--elevation-ambient)',
|
|
}}
|
|
>
|
|
<div className="p-4 h-full flex flex-col w-full">
|
|
{/* Desktop Header — flame logomark + two-tone wordmark.
|
|
redesign-v2 sub-convoy #2 (2026-06-04). */}
|
|
<div className="flex items-center mb-8">
|
|
<div
|
|
className="w-10 h-10 mr-3 rounded-2xl flex items-center justify-center"
|
|
style={{
|
|
background:
|
|
'linear-gradient(135deg, rgb(255, 140, 30) 0%, rgb(216, 67, 21) 100%)',
|
|
boxShadow:
|
|
'0 2px 8px -1px rgba(255, 110, 0, 0.50), inset 0 1px 0 rgba(255,255,255,0.25)',
|
|
}}
|
|
aria-hidden="true"
|
|
>
|
|
<svg
|
|
className="h-5 w-5"
|
|
viewBox="0 0 24 24"
|
|
fill="rgb(255, 255, 255)"
|
|
>
|
|
<path d="M12 2c-.5 3.5-3.5 5.5-3.5 9 0 2.5 1.5 4 3.5 4s3.5-1.5 3.5-4c0-1.5-1-3-2-4 1 2 .5 4-.5 5-1 1-2-1-1-3 .5-1 1.5-3 0-7z" />
|
|
<path d="M7 13c0 4 2.2 7 5 7s5-3 5-7c0-1-.3-2-.7-2.8-.3 3-1.8 4.8-4.3 4.8-2 0-3.6-1.5-4.3-4.5-.4 1-.7 1.7-.7 2.5z" />
|
|
</svg>
|
|
</div>
|
|
<h1 className="text-xl font-bold flex items-center gap-1">
|
|
<span style={{ color: 'var(--text-primary)' }}>Deck</span>
|
|
<span className="gradient-text-flame">Hearth</span>
|
|
</h1>
|
|
</div>
|
|
|
|
{/* Desktop Navigation Content. flex-1 fills the chip; the nav
|
|
rows themselves don't overflow at the current item count,
|
|
but min-h-0 on the chip + overflow-y-auto here would be
|
|
added if the nav grew long enough to clip. */}
|
|
<nav
|
|
className="space-y-2 flex-1"
|
|
role="navigation"
|
|
aria-label="Main navigation"
|
|
>
|
|
<NavigationContent user={user} router={router} onItemClick={() => {}} />
|
|
</nav>
|
|
|
|
{/* Sign-in CTA for logged-out visitors only. Authenticated
|
|
users have no UserProfileDropdown footer here — the
|
|
TopSearchBar's <UserMenu> chip is the canonical entry
|
|
point. */}
|
|
{!user && (
|
|
<div className="mt-auto">
|
|
<UserProfileDropdown user={user} onMobileMenuClose={() => {}} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom chip — authenticated shows the Daily Ember widget
|
|
(already a self-contained .glass-panel rounded-2xl). For
|
|
logged-out visitors, render a small glass-panel with the
|
|
Support + Theme icon row so those affordances remain
|
|
reachable when there's no TopSearchBar dropdown. */}
|
|
{user ? (
|
|
<DailyEmberWidget />
|
|
) : (
|
|
<div className="glass-panel rounded-2xl p-3 flex justify-center space-x-4">
|
|
<Link href="/support">
|
|
<button
|
|
className="p-3 rounded-xl transition-all duration-200 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>
|
|
<button
|
|
onClick={toggleTheme}
|
|
className="p-3 rounded-xl 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-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>
|
|
)}
|
|
</aside>
|
|
|
|
{/* 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. */}
|
|
<div className="flex-1 flex flex-col pb-16 md:pb-0 md:gap-4 min-w-0">
|
|
{/* 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 && (
|
|
<TopSearchBar
|
|
user={user}
|
|
notificationCount={user?.unreadNotifications ?? 0}
|
|
onOpenCommandPalette={() => setIsCommandPaletteOpen(true)}
|
|
/>
|
|
)}
|
|
|
|
{/* Page Content */}
|
|
<main className="flex-1 overflow-auto">
|
|
{children}
|
|
</main>
|
|
</div>
|
|
|
|
<CommandPaletteModal
|
|
open={isCommandPaletteOpen}
|
|
onClose={() => setIsCommandPaletteOpen(false)}
|
|
/>
|
|
</div>
|
|
);
|
|
} |