2025-12-17 22:02:52 -08:00
|
|
|
import { ReactNode, useState, useEffect, useCallback } from "react";
|
2025-12-20 11:05:38 -08:00
|
|
|
import { Link, useLocation } from "react-router-dom";
|
2025-12-14 11:30:22 -08:00
|
|
|
import { useQuery } from "convex/react";
|
|
|
|
|
import { api } from "../../convex/_generated/api";
|
2025-12-17 22:02:52 -08:00
|
|
|
import { MagnifyingGlass } from "@phosphor-icons/react";
|
2025-12-14 11:30:22 -08:00
|
|
|
import ThemeToggle from "./ThemeToggle";
|
2025-12-17 22:02:52 -08:00
|
|
|
import SearchModal from "./SearchModal";
|
2025-12-20 11:05:38 -08:00
|
|
|
import MobileMenu, { HamburgerButton } from "./MobileMenu";
|
|
|
|
|
import ScrollToTop, { ScrollToTopConfig } from "./ScrollToTop";
|
2025-12-20 16:34:48 -08:00
|
|
|
import siteConfig from "../config/siteConfig";
|
2025-12-20 11:05:38 -08:00
|
|
|
|
|
|
|
|
// Scroll-to-top configuration - enabled by default
|
|
|
|
|
// Customize threshold (pixels) to control when button appears
|
|
|
|
|
const scrollToTopConfig: Partial<ScrollToTopConfig> = {
|
|
|
|
|
enabled: true, // Set to false to disable
|
|
|
|
|
threshold: 300, // Show after scrolling 300px
|
|
|
|
|
smooth: true, // Smooth scroll animation
|
|
|
|
|
};
|
2025-12-14 11:30:22 -08:00
|
|
|
|
|
|
|
|
interface LayoutProps {
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function Layout({ children }: LayoutProps) {
|
|
|
|
|
// Fetch published pages for navigation
|
|
|
|
|
const pages = useQuery(api.pages.getAllPages);
|
2025-12-17 22:02:52 -08:00
|
|
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
2025-12-20 11:05:38 -08:00
|
|
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
|
|
|
|
const location = useLocation();
|
2025-12-17 22:02:52 -08:00
|
|
|
|
|
|
|
|
// Open search modal
|
|
|
|
|
const openSearch = useCallback(() => {
|
|
|
|
|
setIsSearchOpen(true);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Close search modal
|
|
|
|
|
const closeSearch = useCallback(() => {
|
|
|
|
|
setIsSearchOpen(false);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-12-20 11:05:38 -08:00
|
|
|
// Mobile menu handlers
|
|
|
|
|
const openMobileMenu = useCallback(() => {
|
|
|
|
|
setIsMobileMenuOpen(true);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const closeMobileMenu = useCallback(() => {
|
|
|
|
|
setIsMobileMenuOpen(false);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Close mobile menu on route change
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setIsMobileMenuOpen(false);
|
|
|
|
|
}, [location.pathname]);
|
|
|
|
|
|
2025-12-17 22:02:52 -08:00
|
|
|
// Handle Command+K / Ctrl+K keyboard shortcut
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
|
|
|
// Command+K on Mac, Ctrl+K on Windows/Linux
|
|
|
|
|
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setIsSearchOpen((prev) => !prev);
|
|
|
|
|
}
|
|
|
|
|
// Also close on Escape
|
|
|
|
|
if (e.key === "Escape" && isSearchOpen) {
|
|
|
|
|
setIsSearchOpen(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
|
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
|
|
|
}, [isSearchOpen]);
|
2025-12-14 11:30:22 -08:00
|
|
|
|
2025-12-20 16:34:48 -08:00
|
|
|
// Check if Blog link should be shown in nav
|
|
|
|
|
const showBlogInNav =
|
|
|
|
|
siteConfig.blogPage.enabled && siteConfig.blogPage.showInNav;
|
|
|
|
|
|
|
|
|
|
// Combine Blog link with pages and sort by order
|
|
|
|
|
// This allows Blog to be positioned anywhere in the nav via siteConfig.blogPage.order
|
|
|
|
|
type NavItem = {
|
|
|
|
|
slug: string;
|
|
|
|
|
title: string;
|
|
|
|
|
order: number;
|
|
|
|
|
isBlog?: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const navItems: NavItem[] = [];
|
|
|
|
|
|
|
|
|
|
// Add Blog link if enabled
|
|
|
|
|
if (showBlogInNav) {
|
|
|
|
|
navItems.push({
|
|
|
|
|
slug: "blog",
|
|
|
|
|
title: siteConfig.blogPage.title,
|
|
|
|
|
order: siteConfig.blogPage.order ?? 0,
|
|
|
|
|
isBlog: true,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add pages from Convex
|
|
|
|
|
if (pages && pages.length > 0) {
|
|
|
|
|
pages.forEach((page) => {
|
|
|
|
|
navItems.push({
|
|
|
|
|
slug: page.slug,
|
|
|
|
|
title: page.title,
|
|
|
|
|
order: page.order ?? 999,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sort by order (lower numbers first), then alphabetically by title
|
|
|
|
|
navItems.sort((a, b) => {
|
|
|
|
|
if (a.order !== b.order) return a.order - b.order;
|
|
|
|
|
return a.title.localeCompare(b.title);
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-14 11:30:22 -08:00
|
|
|
return (
|
|
|
|
|
<div className="layout">
|
2025-12-17 22:02:52 -08:00
|
|
|
{/* Top navigation bar with page links, search, and theme toggle */}
|
2025-12-14 11:30:22 -08:00
|
|
|
<div className="top-nav">
|
2025-12-20 11:05:38 -08:00
|
|
|
{/* Hamburger button for mobile menu (visible on mobile/tablet only) */}
|
|
|
|
|
<div className="mobile-menu-trigger">
|
|
|
|
|
<HamburgerButton
|
|
|
|
|
onClick={openMobileMenu}
|
|
|
|
|
isOpen={isMobileMenuOpen}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Page navigation links (visible on desktop only) */}
|
2025-12-20 16:34:48 -08:00
|
|
|
<nav className="page-nav desktop-only">
|
|
|
|
|
{/* Nav links sorted by order (Blog + pages combined) */}
|
|
|
|
|
{navItems.map((item) => (
|
|
|
|
|
<Link
|
|
|
|
|
key={item.slug}
|
|
|
|
|
to={`/${item.slug}`}
|
|
|
|
|
className="page-nav-link"
|
|
|
|
|
>
|
|
|
|
|
{item.title}
|
|
|
|
|
</Link>
|
|
|
|
|
))}
|
|
|
|
|
</nav>
|
|
|
|
|
|
2025-12-17 22:02:52 -08:00
|
|
|
{/* Search button with icon */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={openSearch}
|
|
|
|
|
className="search-button"
|
|
|
|
|
aria-label="Search (⌘K)"
|
|
|
|
|
title="Search (⌘K)"
|
|
|
|
|
>
|
|
|
|
|
<MagnifyingGlass size={18} weight="bold" />
|
|
|
|
|
</button>
|
2025-12-14 11:30:22 -08:00
|
|
|
{/* Theme toggle */}
|
|
|
|
|
<div className="theme-toggle-container">
|
|
|
|
|
<ThemeToggle />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-12-17 22:02:52 -08:00
|
|
|
|
2025-12-20 11:05:38 -08:00
|
|
|
{/* Mobile menu drawer */}
|
|
|
|
|
<MobileMenu isOpen={isMobileMenuOpen} onClose={closeMobileMenu}>
|
2025-12-20 16:34:48 -08:00
|
|
|
{/* Page navigation links in mobile menu (same order as desktop) */}
|
|
|
|
|
<nav className="mobile-nav-links">
|
|
|
|
|
{navItems.map((item) => (
|
|
|
|
|
<Link
|
|
|
|
|
key={item.slug}
|
|
|
|
|
to={`/${item.slug}`}
|
|
|
|
|
className="mobile-nav-link"
|
|
|
|
|
onClick={closeMobileMenu}
|
|
|
|
|
>
|
|
|
|
|
{item.title}
|
|
|
|
|
</Link>
|
|
|
|
|
))}
|
|
|
|
|
</nav>
|
2025-12-20 11:05:38 -08:00
|
|
|
</MobileMenu>
|
|
|
|
|
|
2025-12-14 11:30:22 -08:00
|
|
|
<main className="main-content">{children}</main>
|
2025-12-17 22:02:52 -08:00
|
|
|
|
|
|
|
|
{/* Search modal */}
|
|
|
|
|
<SearchModal isOpen={isSearchOpen} onClose={closeSearch} />
|
2025-12-20 11:05:38 -08:00
|
|
|
|
|
|
|
|
{/* Scroll to top button */}
|
|
|
|
|
<ScrollToTop config={scrollToTopConfig} />
|
2025-12-14 11:30:22 -08:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|