deckhearth/components/Layout.js
varutasu 334612ad79
feat(design-system): Liquid Glass redesign portfolio — foundation + primitives + Layout (#95)
* feat(design-system): Liquid Glass redesign portfolio — foundation + primitive kit + Layout shell

Operator-requested epic to migrate the UI from the current "warm panel + side-highlight + heavy gradient" visual language to a Liquid Glass aesthetic that retains Deck Hearth's fireplace warmth as accent / gradient / motion (not as panel fill). This squash carries the full 8-convoy portfolio drive-through; 5 sub-convoys reach merged state, 3 land architecture-only and queue impl for follow-up turns gated on dedicated visual-diff baseline re-seeds.

Sub-convoy #1 (liquid-glass-design-tokens) — MERGED. 29 CSS custom properties: glass-surface {low,mid,high} alpha ramp + blur/saturate + rim-light (inner/outer) + ember-rim (subtle/pronounced; RGB triple) + 3-tier elevation + modal-scrim, both light + dark themes with eye-perception-corrected alphas; @supports not (backdrop-filter) fallback collapsing surfaces toward solid (preserves ramp ordering). Authored docs/DESIGN_TOKENS.md (270 LOC reference with WCAG AA contrast tables, composite recipes, when-NOT-to-use-glass guidance, per-card grid GPU budget). AGENTS.md gains a § Visual language section as the new agent-contract surface.

Sub-convoy #2 (liquid-glass-modal-and-surface-primitive) — Brief 1 MERGED. Adds <GlassSurface> (forwardRef composable; tint / rim / elevation / blur props) and <Modal> primitive (focus-trap, ESC + backdrop close, body-scroll lock, ARIA dialog shape, built-in close button) consuming the token surface. lib/use-focus-trap.js — homegrown hook (~60 LOC, no dep). 10 new vitest cases covering open/close render, ARIA, ESC + closeOnEsc gate, backdrop gate, hideCloseButton, body-scroll lock + restore. 4 reference modal migrations as proof-of-pattern: ShareModal, CollectionDeleteModal, CollectionsCreateModal, CardDetailQuantityModal. Brief 2 (11 remaining modals) queued; CI grandfather list locks the pattern in.

Sub-convoy #3 (liquid-glass-form-primitives) — Brief 1 MERGED. Adds <Button> (primary ember-gradient with ember-rim-pronounced; secondary glass-mid; danger; ghost), <Input> (glass-high with ember focus ring + label + helperText + error + aria-invalid + describedby wiring + leadingIcon decorative + trailingAction interactive), <SearchBar> (composes Input with leading search icon + conditional clear button). 10 new vitest cases. pages/login.js + pages/signup.js fully migrated — 2 submit buttons + 7 inputs total; existing test/pages/login.test.js assertion ("Sign in to Deck Hearth" button text) preserved. Brief 2 (profile/settings + deck-builder + scanner + card-editor + collection-cluster modal forms) queued.

Sub-convoy #4 (liquid-glass-layout-shell) — MERGED. 6 shell surfaces glass-migrated: desktop sidebar rail (glass-mid + rim + ambient elevation), mobile drawer (glass-mid + pronounced elevation), mobile overlay scrim (modal-scrim + blur-high — visually consistent with <Modal>), search header strip (glass-mid + rim), UserProfileDropdown popover (glass-high + ember-rim-subtle + ambient — matches popover recipe), MobileNavigation bottom bar (replaces legacy mobile-nav-backdrop class). The 5 Layout regression-lock tests (logged-out CTA, no maintainer-email default, "Sign in" link present, supplied email renders, no "Guest" placeholder) all still pass — every edit preserved the documented contract.

Sub-convoy #5 (liquid-glass-card-surfaces) — ARCHITECTURE RATIFIED; implementation queued. Pixel-sensitive (rarity-glow reconciliation) so wants a dedicated visual-diff baseline re-seed PR. Pre-blocked on a fix-card3d-state convoy (Card3D has pre-existing state-management bug: state setters used without useState declarations).

Sub-convoy #6 (liquid-glass-public-and-auth) — ARCHITECTURE RATIFIED; partial impl shipped via #3 (login + signup form primitives migrated). Landing page editorial + public collection/deck views + login/signup outer-wrapper sweep queued.

Sub-convoy #7 (motion-system-pass) — MERGED. 8 motion tokens (5-tier duration taxonomy: instant/quick/default/slow/deliberate; 3 easings: ease-out default, spring for delight, linear for progress) added to the token surface. prefers-reduced-motion upgraded from a narrow nav-item rule to a site-wide universal sweep collapsing animation-duration + transition-duration to 0.01ms (preserves end states, no flicker); .motion-essential class is the opt-in escape hatch for state-meaningful animation (loading spinners, scan reticles). Authored docs/MOTION_SYSTEM.md with WCAG SC 2.3.3 contract, composition recipes, audit of existing keyframes, and adding-new-animation checklist.

Sub-convoy #8 (cleanup-legacy-design-css) — Brief 1 MERGED. Two new CI jobs in .github/workflows/ci.yml: (1) forbidden-modal-shell-without-primitive (BLOCKING) — fails build if any new file outside the 9 grandfathered legacy modals uses the fixed inset-0 bg-black bg-opacity- shell pattern; locks in the discipline that every modal must compose <Modal> from components/ui. (2) forbidden-deprecated-color-aliases (WARN-only) — audits pre-Deck-Hearth blue/purple/pink aliases (gradient-text-purple/pink/blue, glow-purple/pink/blue, gradient-bg-purple/blue/pink) as a baseline; graduates to FAIL after #8 Brief 2 sweeps consumers. .cursor/rules/ui-and-theming.mdc updated to document the components/ui/ primitive kit and point at the new canonical reference modals.

Verification: lint 0 errors (2 pre-existing warnings in unrelated CardEditorForm.js + CollectionsPageView.js — out of scope); vitest 104/104 passing (was 84 — +20 from new primitive tests: 10 Modal + 10 ui-primitives); ci.yml valid YAML; both new CI gates locally exercised and pass on the current tree.

Operator follow-ups documented in .convoys/ship-readiness.md § "Design-system redesign portfolio":
- Re-seed Linux visual-diff baselines via Docker workflow (AGENTS.md § 6) after this merges.
- preview-smoke.yml runs against the preview; auth + scanner specs touch the migrated surfaces.
- Vercel promote to production once smoke + visual gates pass.
- Queued follow-up implementer turns: #2 Brief 2 (11 modals), #3 Brief 2 (other forms), #5 Brief 1 (cards, after fix-card3d-state), #6 Brief 1 (landing editorial), #8 Brief 2 (legacy CSS deletion + WARN→FAIL graduation).

The user-visible promise — "modern fireplace aesthetic; modals blur the page behind them; reusable components" — is delivered TODAY by the merged work.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(use-focus-trap): preserve named useFocusTrap export for ScannerPageView

The portfolio squash inadvertently overwrote the pre-existing
lib/use-focus-trap.js (named `export function useFocusTrap(active)`
returning a ref — used by ScannerPageView, line 21) with a default-
only export shaped for the new `<Modal>` primitive. Vercel build
failed: "Export useFocusTrap doesn't exist in target module".

Fix: the file now exports BOTH —
- `useFocusTrap(active)` (named, original) — returns a ref;
  pre-Liquid-Glass call sites (ScannerPageView) keep working.
- `useFocusTrapContainer({ active, containerRef, ... })` (default,
  new) — takes a caller-owned ref so panel refs can forward through
  forwardRef chains (Modal.js consumes this shape).

Both hooks are commented to document which to use when. Modal.js
imports default already, so no change needed there.

Verified: npm run build passes (was failing in CI); lint 0 errors;
vitest 104/104 still green.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 20:12:33 -05:00

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" style={{ backgroundColor: 'var(--bg-primary)' }}>
{/* 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>
);
}