Operator feedback 2026-06-04: "increase the gradient in the
background that goes from our nice warm red to a nice purpley
blue. And then let's have the corners or the edges of our sections
respond as if those two are light sources."
Two coordinated changes that together create the "lit by two light
sources" effect.
1) Body gradient pushed harder + diagonal warm↔cool story
(styles/globals.css body { ... } + [data-theme="dark"] body)
- Dark theme:
* Warm ember pool bottom-left: alpha 0.55 → 0.72, ellipse
enlarged from 75%/55% to 95%/70%.
* Top-right corner: switched from secondary ember + dim
purple-magenta (0.26) to a unified bright purpley-blue pool
(alpha 0.58, indigo→violet ramp, 90%/70% ellipse).
* Base linear-gradient: 180deg vertical wash → 45deg diagonal
(warm aubergine bottom-left → indigo top-right) so the whole
page reads as one continuous warm↔cool dialogue, not just
two corner pools.
- Light theme:
* Top-right gold accent replaced with purpley-blue (indigo
0.42 → violet 0.20 → light violet 0.08) so both themes share
the same diagonal story.
* Bottom-left ember boosted (0.42 → 0.55, ellipse enlarged).
* Base linear-gradient: 180deg → 45deg diagonal (warm-cream
→ cool-violet).
2) Edge-light tokens on every floating chip's shadow stack
(styles/globals.css + components/Layout.js + components/ui/TopSearchBar.js)
- New design tokens in :root and [data-theme="dark"]:
--edge-light-warm (offset -X +Y → glow on the chip's
bottom-left edge, matching the warm
ember light source)
--edge-light-cool (offset +X -Y → glow on the chip's
top-right edge, matching the cool
purpley-blue light source)
Light: 0.16 warm / 0.12 cool. Dark: 0.34 warm / 0.28 cool.
- .glass-panel and .glass-panel-strong shadow stacks updated to
prepend the two edge-light tokens. Every glass surface in the
app now picks up the directional rim glows from the body
gradient without per-component changes (DailyEmberWidget,
CommandPaletteModal popover, the logged-out sidebar Support/
Theme tray, UserMenu dropdown, etc).
- components/Layout.js nav-chip and components/ui/TopSearchBar.js
inline boxShadow strings prepended with the same two tokens
(they use inline styles rather than the .glass-panel class so
they need the explicit shadow stack).
Result: the body now visibly transitions from warm ember (bottom-
left) through a neutral midpoint to purpley-blue (top-right). Every
floating chip's bottom-left and top-right edges pick up subtle
directional glow from the matching corner, reading as light wrapping
around the chip's rim from the two off-screen-ish light sources.
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>
1011 lines
No EOL
48 KiB
JavaScript
1011 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 two-light-source pass: prepended the
|
|
two edge-light tokens so the nav chip's bottom-left and
|
|
top-right edges pick up warm + cool directional glows from
|
|
the matching corners of the body gradient — the chip
|
|
visually "responds" to the two light sources the operator
|
|
asked for. The chip is positioned at the top-left of the
|
|
viewport so it sits closer to the warm light source; that's
|
|
a coincidence of layout, not something the token reacts to,
|
|
but the warm glow does naturally read stronger on this chip
|
|
because the warm source is right there. */}
|
|
<div
|
|
className="flex-1 flex flex-col md:rounded-3xl md:overflow-hidden min-h-0"
|
|
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(--edge-light-warm), var(--edge-light-cool), 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>
|
|
);
|
|
} |