mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
feat(mobile): redesign menu with sidebar integration
- Move mobile nav controls to left side (hamburger, search, theme) - Add sidebar TOC to mobile menu when page has sidebar layout - Hide desktop sidebar on mobile since accessible via hamburger - Standardize mobile menu typography with CSS variables - Use font-family inherit for consistent fonts across menu elements
This commit is contained in:
27
src/App.tsx
27
src/App.tsx
@@ -6,6 +6,7 @@ import Blog from "./pages/Blog";
|
||||
import Write from "./pages/Write";
|
||||
import Layout from "./components/Layout";
|
||||
import { usePageTracking } from "./hooks/usePageTracking";
|
||||
import { SidebarProvider } from "./context/SidebarContext";
|
||||
import siteConfig from "./config/siteConfig";
|
||||
|
||||
function App() {
|
||||
@@ -19,18 +20,20 @@ function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/stats" element={<Stats />} />
|
||||
{/* Blog page route - only enabled when blogPage.enabled is true */}
|
||||
{siteConfig.blogPage.enabled && (
|
||||
<Route path="/blog" element={<Blog />} />
|
||||
)}
|
||||
{/* Catch-all for post/page slugs - must be last */}
|
||||
<Route path="/:slug" element={<Post />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
<SidebarProvider>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/stats" element={<Stats />} />
|
||||
{/* Blog page route - only enabled when blogPage.enabled is true */}
|
||||
{siteConfig.blogPage.enabled && (
|
||||
<Route path="/blog" element={<Blog />} />
|
||||
)}
|
||||
{/* Catch-all for post/page slugs - must be last */}
|
||||
<Route path="/:slug" element={<Post />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -405,6 +405,14 @@ export default function BlogPost({ content }: BlogPostProps) {
|
||||
</h5>
|
||||
);
|
||||
},
|
||||
h6({ children }) {
|
||||
const id = generateSlug(getTextContent(children));
|
||||
return (
|
||||
<h6 id={id} className="blog-h6">
|
||||
{children}
|
||||
</h6>
|
||||
);
|
||||
},
|
||||
ul({ children }) {
|
||||
return <ul className="blog-ul">{children}</ul>;
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import ThemeToggle from "./ThemeToggle";
|
||||
import SearchModal from "./SearchModal";
|
||||
import MobileMenu, { HamburgerButton } from "./MobileMenu";
|
||||
import ScrollToTop, { ScrollToTopConfig } from "./ScrollToTop";
|
||||
import { useSidebarOptional } from "../context/SidebarContext";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
|
||||
// Scroll-to-top configuration - enabled by default
|
||||
@@ -28,6 +29,11 @@ export default function Layout({ children }: LayoutProps) {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
// Get sidebar headings from context (if available)
|
||||
const sidebarContext = useSidebarOptional();
|
||||
const sidebarHeadings = sidebarContext?.headings || [];
|
||||
const sidebarActiveId = sidebarContext?.activeId;
|
||||
|
||||
// Open search modal
|
||||
const openSearch = useCallback(() => {
|
||||
setIsSearchOpen(true);
|
||||
@@ -116,12 +122,23 @@ export default function Layout({ children }: LayoutProps) {
|
||||
<div className="layout">
|
||||
{/* Top navigation bar with page links, search, and theme toggle */}
|
||||
<div className="top-nav">
|
||||
{/* Hamburger button for mobile menu (visible on mobile/tablet only) */}
|
||||
<div className="mobile-menu-trigger">
|
||||
<HamburgerButton
|
||||
onClick={openMobileMenu}
|
||||
isOpen={isMobileMenuOpen}
|
||||
/>
|
||||
{/* Mobile left controls: hamburger, search, theme (visible on mobile/tablet only) */}
|
||||
<div className="mobile-nav-controls">
|
||||
{/* Hamburger button for mobile menu */}
|
||||
<HamburgerButton onClick={openMobileMenu} isOpen={isMobileMenuOpen} />
|
||||
{/* Search button with icon */}
|
||||
<button
|
||||
onClick={openSearch}
|
||||
className="search-button"
|
||||
aria-label="Search (⌘K)"
|
||||
title="Search (⌘K)"
|
||||
>
|
||||
<MagnifyingGlass size={18} weight="bold" />
|
||||
</button>
|
||||
{/* Theme toggle */}
|
||||
<div className="theme-toggle-container">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page navigation links (visible on desktop only) */}
|
||||
@@ -138,23 +155,31 @@ export default function Layout({ children }: LayoutProps) {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Search button with icon */}
|
||||
<button
|
||||
onClick={openSearch}
|
||||
className="search-button"
|
||||
aria-label="Search (⌘K)"
|
||||
title="Search (⌘K)"
|
||||
>
|
||||
<MagnifyingGlass size={18} weight="bold" />
|
||||
</button>
|
||||
{/* Theme toggle */}
|
||||
<div className="theme-toggle-container">
|
||||
<ThemeToggle />
|
||||
{/* Desktop search and theme (visible on desktop only) */}
|
||||
<div className="desktop-controls desktop-only">
|
||||
{/* Search button with icon */}
|
||||
<button
|
||||
onClick={openSearch}
|
||||
className="search-button"
|
||||
aria-label="Search (⌘K)"
|
||||
title="Search (⌘K)"
|
||||
>
|
||||
<MagnifyingGlass size={18} weight="bold" />
|
||||
</button>
|
||||
{/* Theme toggle */}
|
||||
<div className="theme-toggle-container">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu drawer */}
|
||||
<MobileMenu isOpen={isMobileMenuOpen} onClose={closeMobileMenu}>
|
||||
<MobileMenu
|
||||
isOpen={isMobileMenuOpen}
|
||||
onClose={closeMobileMenu}
|
||||
sidebarHeadings={sidebarHeadings}
|
||||
sidebarActiveId={sidebarActiveId}
|
||||
>
|
||||
{/* Page navigation links in mobile menu (same order as desktop) */}
|
||||
<nav className="mobile-nav-links">
|
||||
{navItems.map((item) => (
|
||||
@@ -171,7 +196,11 @@ export default function Layout({ children }: LayoutProps) {
|
||||
</MobileMenu>
|
||||
|
||||
{/* Use wider layout for stats page, normal layout for other pages */}
|
||||
<main className={location.pathname === "/stats" ? "main-content-wide" : "main-content"}>
|
||||
<main
|
||||
className={
|
||||
location.pathname === "/stats" ? "main-content-wide" : "main-content"
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { ReactNode, useEffect, useRef } from "react";
|
||||
import { ReactNode, useEffect, useRef, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Heading } from "../utils/extractHeadings";
|
||||
|
||||
interface MobileMenuProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
sidebarHeadings?: Heading[];
|
||||
sidebarActiveId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -16,8 +20,11 @@ export default function MobileMenu({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
sidebarHeadings = [],
|
||||
sidebarActiveId,
|
||||
}: MobileMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const hasSidebar = sidebarHeadings.length > 0;
|
||||
|
||||
// Handle escape key to close menu
|
||||
useEffect(() => {
|
||||
@@ -56,6 +63,30 @@ export default function MobileMenu({
|
||||
}
|
||||
};
|
||||
|
||||
// Navigate to heading and close menu
|
||||
const navigateToHeading = useCallback(
|
||||
(id: string) => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
// Close menu first
|
||||
onClose();
|
||||
// Scroll after menu closes
|
||||
setTimeout(() => {
|
||||
const headerOffset = 80;
|
||||
const elementTop =
|
||||
element.getBoundingClientRect().top + window.scrollY;
|
||||
const targetPosition = elementTop - headerOffset;
|
||||
window.scrollTo({
|
||||
top: Math.max(0, targetPosition),
|
||||
behavior: "smooth",
|
||||
});
|
||||
window.history.pushState(null, "", `#${id}`);
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop overlay */}
|
||||
@@ -102,7 +133,30 @@ export default function MobileMenu({
|
||||
</div>
|
||||
|
||||
{/* Menu content */}
|
||||
<div className="mobile-menu-content">{children}</div>
|
||||
<div className="mobile-menu-content">
|
||||
{children}
|
||||
|
||||
{/* Table of contents from sidebar (if page has sidebar) */}
|
||||
{hasSidebar && (
|
||||
<div className="mobile-menu-toc">
|
||||
<div className="mobile-menu-toc-title">On this page</div>
|
||||
<nav className="mobile-menu-toc-links">
|
||||
{sidebarHeadings.map((heading) => (
|
||||
<button
|
||||
key={heading.id}
|
||||
onClick={() => navigateToHeading(heading.id)}
|
||||
className={`mobile-menu-toc-link mobile-menu-toc-level-${heading.level} ${
|
||||
sidebarActiveId === heading.id ? "active" : ""
|
||||
}`}
|
||||
>
|
||||
<ChevronRight size={12} className="mobile-menu-toc-icon" />
|
||||
{heading.text}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,27 +1,318 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
|
||||
import { Heading } from "../utils/extractHeadings";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
interface PageSidebarProps {
|
||||
headings: Heading[];
|
||||
activeId?: string;
|
||||
}
|
||||
|
||||
interface HeadingNode extends Heading {
|
||||
children: HeadingNode[];
|
||||
}
|
||||
|
||||
// Build a tree structure from flat headings array
|
||||
function buildHeadingTree(headings: Heading[]): HeadingNode[] {
|
||||
const tree: HeadingNode[] = [];
|
||||
const stack: HeadingNode[] = [];
|
||||
|
||||
headings.forEach((heading) => {
|
||||
const node: HeadingNode = { ...heading, children: [] };
|
||||
|
||||
// Pop stack until we find the parent (heading with lower level)
|
||||
while (stack.length > 0 && stack[stack.length - 1].level >= heading.level) {
|
||||
stack.pop();
|
||||
}
|
||||
|
||||
if (stack.length === 0) {
|
||||
// Root level heading
|
||||
tree.push(node);
|
||||
} else {
|
||||
// Child of the last heading in stack
|
||||
stack[stack.length - 1].children.push(node);
|
||||
}
|
||||
|
||||
stack.push(node);
|
||||
});
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
// Load expanded state from localStorage
|
||||
function loadExpandedState(headings: Heading[]): Set<string> {
|
||||
const stored = localStorage.getItem("page-sidebar-expanded-state");
|
||||
if (stored) {
|
||||
try {
|
||||
const storedIds = new Set(JSON.parse(stored));
|
||||
// Only return stored IDs that still exist in headings
|
||||
return new Set(
|
||||
headings.filter((h) => storedIds.has(h.id)).map((h) => h.id),
|
||||
);
|
||||
} catch {
|
||||
// If parse fails, return empty (collapsed)
|
||||
}
|
||||
}
|
||||
// Default: all headings collapsed
|
||||
return new Set();
|
||||
}
|
||||
|
||||
// Save expanded state to localStorage
|
||||
function saveExpandedState(expanded: Set<string>): void {
|
||||
localStorage.setItem(
|
||||
"page-sidebar-expanded-state",
|
||||
JSON.stringify(Array.from(expanded)),
|
||||
);
|
||||
}
|
||||
|
||||
// Get absolute top position of an element
|
||||
function getElementTop(element: HTMLElement): number {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.top + window.scrollY;
|
||||
}
|
||||
|
||||
// Render a heading node recursively
|
||||
function HeadingItem({
|
||||
node,
|
||||
activeId,
|
||||
expanded,
|
||||
onToggle,
|
||||
onNavigate,
|
||||
depth = 0,
|
||||
}: {
|
||||
node: HeadingNode;
|
||||
activeId?: string;
|
||||
expanded: Set<string>;
|
||||
onToggle: (id: string) => void;
|
||||
onNavigate: (id: string) => void;
|
||||
depth?: number;
|
||||
}) {
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isExpanded = expanded.has(node.id);
|
||||
const isActive = activeId === node.id;
|
||||
|
||||
return (
|
||||
<li className="page-sidebar-item">
|
||||
<div className="page-sidebar-item-wrapper">
|
||||
{hasChildren && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggle(node.id);
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
// Prevent link click when clicking button
|
||||
e.preventDefault();
|
||||
}}
|
||||
className={`page-sidebar-expand ${isExpanded ? "expanded" : ""}`}
|
||||
aria-label={isExpanded ? "Collapse" : "Expand"}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
)}
|
||||
{!hasChildren && <span className="page-sidebar-spacer" />}
|
||||
<a
|
||||
href={`#${node.id}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onNavigate(node.id);
|
||||
}}
|
||||
className={`page-sidebar-link page-sidebar-item-level-${node.level} ${
|
||||
isActive ? "active" : ""
|
||||
}`}
|
||||
>
|
||||
{node.text}
|
||||
</a>
|
||||
</div>
|
||||
{hasChildren && isExpanded && (
|
||||
<ul className="page-sidebar-sublist">
|
||||
{node.children.map((child) => (
|
||||
<HeadingItem
|
||||
key={child.id}
|
||||
node={child}
|
||||
activeId={activeId}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
onNavigate={onNavigate}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PageSidebar({ headings, activeId }: PageSidebarProps) {
|
||||
const [activeHeading, setActiveHeading] = useState<string | undefined>(activeId);
|
||||
const [activeHeading, setActiveHeading] = useState<string | undefined>(
|
||||
activeId,
|
||||
);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() =>
|
||||
loadExpandedState(headings),
|
||||
);
|
||||
|
||||
// Track if we're currently navigating to prevent scroll handler interference
|
||||
const isNavigatingRef = useRef(false);
|
||||
|
||||
// Build tree structure from headings
|
||||
const headingTree = useMemo(() => buildHeadingTree(headings), [headings]);
|
||||
|
||||
// Get all heading IDs for scroll tracking
|
||||
const allHeadingIds = useMemo(() => headings.map((h) => h.id), [headings]);
|
||||
|
||||
// Create a map for quick heading ID validation
|
||||
const headingIdSet = useMemo(() => new Set(allHeadingIds), [allHeadingIds]);
|
||||
|
||||
// Find path to a heading ID in the tree (for expanding ancestors)
|
||||
const findPathToId = useCallback(
|
||||
(
|
||||
nodes: HeadingNode[],
|
||||
targetId: string,
|
||||
path: HeadingNode[] = [],
|
||||
): HeadingNode[] | null => {
|
||||
for (const node of nodes) {
|
||||
const currentPath = [...path, node];
|
||||
if (node.id === targetId) {
|
||||
return currentPath;
|
||||
}
|
||||
const found = findPathToId(node.children, targetId, currentPath);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Expand ancestors to make a heading visible in sidebar
|
||||
const expandAncestors = useCallback(
|
||||
(targetId: string) => {
|
||||
const path = findPathToId(headingTree, targetId);
|
||||
if (path && path.length > 1) {
|
||||
const newExpanded = new Set(expanded);
|
||||
let changed = false;
|
||||
// Expand all ancestors (not the target itself)
|
||||
path.slice(0, -1).forEach((node) => {
|
||||
if (!newExpanded.has(node.id)) {
|
||||
newExpanded.add(node.id);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) {
|
||||
setExpanded(newExpanded);
|
||||
saveExpandedState(newExpanded);
|
||||
}
|
||||
}
|
||||
},
|
||||
[expanded, headingTree, findPathToId],
|
||||
);
|
||||
|
||||
// Toggle expand/collapse
|
||||
const toggleExpand = useCallback((id: string) => {
|
||||
setExpanded((prev) => {
|
||||
const newExpanded = new Set(prev);
|
||||
if (newExpanded.has(id)) {
|
||||
newExpanded.delete(id);
|
||||
} else {
|
||||
newExpanded.add(id);
|
||||
}
|
||||
saveExpandedState(newExpanded);
|
||||
return newExpanded;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Navigate to heading - scroll to element and update state
|
||||
const navigateToHeading = useCallback(
|
||||
(id: string) => {
|
||||
// Expand ancestors first so sidebar shows the target
|
||||
expandAncestors(id);
|
||||
|
||||
// Use requestAnimationFrame to ensure DOM updates are complete
|
||||
requestAnimationFrame(() => {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set flag to prevent scroll handler from changing active heading
|
||||
isNavigatingRef.current = true;
|
||||
|
||||
// Calculate scroll position with offset for fixed header (80px)
|
||||
const headerOffset = 80;
|
||||
const elementTop = getElementTop(element);
|
||||
const targetPosition = elementTop - headerOffset;
|
||||
|
||||
// Scroll to the target position
|
||||
window.scrollTo({
|
||||
top: Math.max(0, targetPosition),
|
||||
behavior: "smooth",
|
||||
});
|
||||
|
||||
// Update URL hash
|
||||
window.history.pushState(null, "", `#${id}`);
|
||||
|
||||
// Update active heading
|
||||
setActiveHeading(id);
|
||||
|
||||
// Reset navigation flag after scroll completes
|
||||
setTimeout(() => {
|
||||
isNavigatingRef.current = false;
|
||||
}, 1000);
|
||||
});
|
||||
},
|
||||
[expandAncestors],
|
||||
);
|
||||
|
||||
// Handle initial URL hash on page load
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (hash && headingIdSet.has(hash)) {
|
||||
// Delay to ensure DOM is ready and headings are rendered
|
||||
const timeoutId = setTimeout(() => {
|
||||
navigateToHeading(hash);
|
||||
}, 200);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [headingIdSet, navigateToHeading]);
|
||||
|
||||
// Handle hash changes (back/forward navigation)
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (hash && headingIdSet.has(hash)) {
|
||||
navigateToHeading(hash);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("hashchange", handleHashChange);
|
||||
return () => window.removeEventListener("hashchange", handleHashChange);
|
||||
}, [headingIdSet, navigateToHeading]);
|
||||
|
||||
// Update active heading on scroll
|
||||
useEffect(() => {
|
||||
if (headings.length === 0) return;
|
||||
if (allHeadingIds.length === 0) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const scrollPosition = window.scrollY + 100; // Offset for header
|
||||
// Don't update if we're in the middle of navigating
|
||||
if (isNavigatingRef.current) return;
|
||||
|
||||
const scrollPosition = window.scrollY + 120; // Offset for header
|
||||
|
||||
// Find the heading that's currently in view
|
||||
for (let i = headings.length - 1; i >= 0; i--) {
|
||||
const element = document.getElementById(headings[i].id);
|
||||
if (element && element.offsetTop <= scrollPosition) {
|
||||
setActiveHeading(headings[i].id);
|
||||
break;
|
||||
for (let i = allHeadingIds.length - 1; i >= 0; i--) {
|
||||
const element = document.getElementById(allHeadingIds[i]);
|
||||
if (element) {
|
||||
const elementTop = getElementTop(element);
|
||||
if (elementTop <= scrollPosition) {
|
||||
const newActiveId = allHeadingIds[i];
|
||||
setActiveHeading((prev) => {
|
||||
// Only update if different - don't expand ancestors on scroll
|
||||
// User can manually collapse/expand sections
|
||||
return prev !== newActiveId ? newActiveId : prev;
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -30,39 +321,31 @@ export default function PageSidebar({ headings, activeId }: PageSidebarProps) {
|
||||
handleScroll(); // Initial check
|
||||
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, [headings]);
|
||||
}, [allHeadingIds]);
|
||||
|
||||
// Auto-expand to show active heading from props
|
||||
useEffect(() => {
|
||||
if (activeId) {
|
||||
expandAncestors(activeId);
|
||||
}
|
||||
}, [activeId, expandAncestors]);
|
||||
|
||||
if (headings.length === 0) return null;
|
||||
|
||||
return (
|
||||
<nav className="page-sidebar">
|
||||
<ul className="page-sidebar-list">
|
||||
{headings.map((heading) => (
|
||||
<li
|
||||
key={heading.id}
|
||||
className={`page-sidebar-item page-sidebar-item-level-${heading.level} ${
|
||||
activeHeading === heading.id ? "active" : ""
|
||||
}`}
|
||||
>
|
||||
<a
|
||||
href={`#${heading.id}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const element = document.getElementById(heading.id);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
// Update URL without scrolling
|
||||
window.history.pushState(null, "", `#${heading.id}`);
|
||||
}
|
||||
}}
|
||||
className="page-sidebar-link"
|
||||
>
|
||||
{heading.text}
|
||||
</a>
|
||||
</li>
|
||||
{headingTree.map((node) => (
|
||||
<HeadingItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
activeId={activeHeading}
|
||||
expanded={expanded}
|
||||
onToggle={toggleExpand}
|
||||
onNavigate={navigateToHeading}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
38
src/context/SidebarContext.tsx
Normal file
38
src/context/SidebarContext.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createContext, useContext, useState, ReactNode } from "react";
|
||||
import { Heading } from "../utils/extractHeadings";
|
||||
|
||||
interface SidebarContextType {
|
||||
headings: Heading[];
|
||||
setHeadings: (headings: Heading[]) => void;
|
||||
activeId: string | undefined;
|
||||
setActiveId: (id: string | undefined) => void;
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
|
||||
|
||||
export function SidebarProvider({ children }: { children: ReactNode }) {
|
||||
const [headings, setHeadings] = useState<Heading[]>([]);
|
||||
const [activeId, setActiveId] = useState<string | undefined>(undefined);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider
|
||||
value={{ headings, setHeadings, activeId, setActiveId }}
|
||||
>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSidebar() {
|
||||
const context = useContext(SidebarContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Optional hook that returns undefined if not within provider (for Layout)
|
||||
export function useSidebarOptional() {
|
||||
return useContext(SidebarContext);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import BlogPost from "../components/BlogPost";
|
||||
import CopyPageDropdown from "../components/CopyPageDropdown";
|
||||
import PageSidebar from "../components/PageSidebar";
|
||||
import { extractHeadings } from "../utils/extractHeadings";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { ArrowLeft, Link as LinkIcon, Twitter, Rss } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
@@ -18,6 +19,7 @@ export default function Post() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { setHeadings, setActiveId } = useSidebar();
|
||||
// Check for page first, then post
|
||||
const page = useQuery(api.pages.getPageBySlug, slug ? { slug } : "skip");
|
||||
const post = useQuery(api.posts.getPostBySlug, slug ? { slug } : "skip");
|
||||
@@ -40,6 +42,33 @@ export default function Post() {
|
||||
return () => clearTimeout(timer);
|
||||
}, [location.hash, page, post]);
|
||||
|
||||
// Update sidebar context with headings for mobile menu
|
||||
useEffect(() => {
|
||||
// Extract headings for pages with sidebar layout
|
||||
if (page && page.layout === "sidebar") {
|
||||
const pageHeadings = extractHeadings(page.content);
|
||||
setHeadings(pageHeadings);
|
||||
setActiveId(location.hash.slice(1) || undefined);
|
||||
}
|
||||
// Extract headings for posts with sidebar layout
|
||||
else if (post && post.layout === "sidebar") {
|
||||
const postHeadings = extractHeadings(post.content);
|
||||
setHeadings(postHeadings);
|
||||
setActiveId(location.hash.slice(1) || undefined);
|
||||
}
|
||||
// Clear headings when no sidebar
|
||||
else if (page !== undefined || post !== undefined) {
|
||||
setHeadings([]);
|
||||
setActiveId(undefined);
|
||||
}
|
||||
|
||||
// Cleanup: clear headings when leaving page
|
||||
return () => {
|
||||
setHeadings([]);
|
||||
setActiveId(undefined);
|
||||
};
|
||||
}, [page, post, location.hash, setHeadings, setActiveId]);
|
||||
|
||||
// Update page title for static pages
|
||||
useEffect(() => {
|
||||
if (!page) return;
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
--font-size-blog-h3: var(--font-size-xl);
|
||||
--font-size-blog-h4: var(--font-size-base);
|
||||
--font-size-blog-h5: var(--font-size-md);
|
||||
--font-size-blog-h6: var(--font-size-sm);
|
||||
|
||||
/* Table font sizes */
|
||||
--font-size-table: var(--font-size-md);
|
||||
@@ -114,6 +115,8 @@
|
||||
/* Mobile menu font sizes */
|
||||
--font-size-mobile-nav-link: var(--font-size-base);
|
||||
--font-size-mobile-home-link: var(--font-size-md);
|
||||
--font-size-mobile-toc-title: var(--font-size-2xs);
|
||||
--font-size-mobile-toc-link: var(--font-size-sm);
|
||||
|
||||
/* Copy dropdown font sizes */
|
||||
--font-size-copy-trigger: var(--font-size-sm);
|
||||
@@ -327,6 +330,20 @@ body {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Mobile nav controls (search, theme, hamburger) - shown on left on mobile */
|
||||
.mobile-nav-controls {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Desktop controls (search, theme) - shown on right on desktop */
|
||||
.desktop-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Page navigation links (About, Projects, Contact, etc.) */
|
||||
.page-nav {
|
||||
display: flex;
|
||||
@@ -802,12 +819,54 @@ body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page-sidebar-item-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-sidebar-expand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.page-sidebar-expand:hover {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.page-sidebar-expand svg {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.page-sidebar-expand.expanded svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.page-sidebar-spacer {
|
||||
width: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.page-sidebar-link {
|
||||
display: block;
|
||||
padding: 6px 12px;
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: inherit;
|
||||
font-size: var(--font-size-md);
|
||||
line-height: 1.5;
|
||||
border-radius: 4px;
|
||||
transition:
|
||||
@@ -820,24 +879,35 @@ body {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.page-sidebar-item.active .page-sidebar-link {
|
||||
.page-sidebar-link.active {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-hover);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Indentation for nested headings */
|
||||
.page-sidebar-item-level-1 .page-sidebar-link {
|
||||
padding-left: 12px;
|
||||
.page-sidebar-link.page-sidebar-item-level-1 {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.page-sidebar-item-level-2 .page-sidebar-link {
|
||||
padding-left: 24px;
|
||||
.page-sidebar-sublist {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.page-sidebar-item-level-3 .page-sidebar-link {
|
||||
padding-left: 36px;
|
||||
.page-sidebar-sublist .page-sidebar-link.page-sidebar-item-level-2 {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.page-sidebar-sublist .page-sidebar-link.page-sidebar-item-level-3 {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.page-sidebar-sublist .page-sidebar-link.page-sidebar-item-level-4,
|
||||
.page-sidebar-sublist .page-sidebar-link.page-sidebar-item-level-5,
|
||||
.page-sidebar-sublist .page-sidebar-link.page-sidebar-item-level-6 {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
@@ -854,6 +924,11 @@ body {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* Hide sidebar on mobile - it's now in the hamburger menu */
|
||||
.post-sidebar-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.post-sidebar-left {
|
||||
position: static;
|
||||
width: 100%;
|
||||
@@ -887,11 +962,27 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-sidebar-item-level-1 .page-sidebar-link,
|
||||
.page-sidebar-item-level-2 .page-sidebar-link,
|
||||
.page-sidebar-item-level-3 .page-sidebar-link {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
.page-sidebar-expand {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.page-sidebar-expand svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.page-sidebar-spacer {
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
.page-sidebar-link {
|
||||
padding: 4px 6px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.page-sidebar-sublist {
|
||||
margin-left: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -992,6 +1083,13 @@ body {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.blog-h6 {
|
||||
font-size: var(--font-size-blog-h6);
|
||||
font-weight: 300;
|
||||
margin: 16px 0 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.blog-link {
|
||||
color: var(--link-color);
|
||||
text-decoration: underline;
|
||||
@@ -1499,7 +1597,7 @@ body {
|
||||
/* Responsive styles */
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
padding: 40px 16px 16px 24px;
|
||||
padding: 48px 16px 16px 24px;
|
||||
}
|
||||
|
||||
.top-nav {
|
||||
@@ -1572,6 +1670,10 @@ body {
|
||||
font-size: var(--font-size-blog-h5);
|
||||
}
|
||||
|
||||
.blog-h6 {
|
||||
font-size: var(--font-size-blog-h6);
|
||||
}
|
||||
|
||||
/* Table mobile styles */
|
||||
.blog-table {
|
||||
font-size: var(--font-size-table);
|
||||
@@ -3441,8 +3543,8 @@ body {
|
||||
Left-side drawer for mobile/tablet navigation
|
||||
=========================================== */
|
||||
|
||||
/* Hide hamburger on desktop, show on mobile/tablet */
|
||||
.mobile-menu-trigger {
|
||||
/* Hide mobile controls on desktop, show on mobile/tablet */
|
||||
.mobile-nav-controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -3452,9 +3554,18 @@ body {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-menu-trigger {
|
||||
/* Move top-nav to left side on mobile */
|
||||
.top-nav {
|
||||
right: auto;
|
||||
left: 13px;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.mobile-nav-controls {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
@@ -3553,7 +3664,7 @@ body {
|
||||
/* Menu content area */
|
||||
.mobile-menu-content {
|
||||
flex: 1;
|
||||
padding: 60px 24px 24px;
|
||||
padding: 1px 16px 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -3561,17 +3672,18 @@ body {
|
||||
.mobile-nav-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mobile-nav-link {
|
||||
display: block;
|
||||
padding: 12px 16px;
|
||||
padding: 8px 12px;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
font-family: inherit;
|
||||
font-size: var(--font-size-mobile-nav-link);
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
@@ -3581,9 +3693,93 @@ body {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Mobile menu table of contents */
|
||||
.mobile-menu-toc {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.mobile-menu-toc-title {
|
||||
font-family: inherit;
|
||||
font-size: var(--font-size-mobile-toc-title);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 12px 8px;
|
||||
}
|
||||
|
||||
.mobile-menu-toc-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.mobile-menu-toc-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
font-family: inherit;
|
||||
font-size: var(--font-size-mobile-toc-link);
|
||||
text-align: left;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.mobile-menu-toc-link:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mobile-menu-toc-link.active {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mobile-menu-toc-icon {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* TOC level indentation and font sizes */
|
||||
.mobile-menu-toc-level-1 {
|
||||
padding-left: 12px;
|
||||
font-size: var(--font-size-mobile-toc-link);
|
||||
}
|
||||
.mobile-menu-toc-level-2 {
|
||||
padding-left: 20px;
|
||||
font-size: var(--font-size-mobile-toc-link);
|
||||
}
|
||||
.mobile-menu-toc-level-3 {
|
||||
padding-left: 28px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
.mobile-menu-toc-level-4,
|
||||
.mobile-menu-toc-level-5,
|
||||
.mobile-menu-toc-level-6 {
|
||||
padding-left: 36px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
.mobile-menu-toc-level-5 {
|
||||
padding-left: 44px;
|
||||
}
|
||||
.mobile-menu-toc-level-6 {
|
||||
padding-left: 52px;
|
||||
}
|
||||
|
||||
/* Menu header with home link */
|
||||
.mobile-menu-header {
|
||||
padding: 16px 24px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
@@ -3597,11 +3793,12 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
padding: 8px 12px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-family: inherit;
|
||||
font-size: var(--font-size-mobile-home-link);
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
@@ -3652,20 +3849,29 @@ body {
|
||||
}
|
||||
|
||||
.mobile-menu-content {
|
||||
padding: 56px 20px 20px;
|
||||
padding: 1px 12px 12px;
|
||||
}
|
||||
|
||||
.mobile-menu-footer {
|
||||
padding: 12px 20px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.mobile-nav-link {
|
||||
padding: 6px 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mobile-menu-toc-link {
|
||||
padding: 5px 10px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop - hide mobile menu components */
|
||||
@media (min-width: 769px) {
|
||||
.mobile-menu-trigger,
|
||||
.mobile-nav-controls,
|
||||
.mobile-menu-backdrop,
|
||||
.mobile-menu-drawer,
|
||||
.hamburger-button {
|
||||
.mobile-menu-drawer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export interface Heading {
|
||||
level: number; // 1, 2, or 3
|
||||
level: number; // 1, 2, 3, 4, 5, or 6
|
||||
text: string;
|
||||
id: string;
|
||||
}
|
||||
@@ -14,17 +14,57 @@ function generateSlug(text: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Extract headings from markdown content
|
||||
// Remove code blocks from content to avoid extracting headings from examples
|
||||
function removeCodeBlocks(content: string): string {
|
||||
// Remove fenced code blocks (``` or ~~~)
|
||||
let result = content.replace(/```[\s\S]*?```/g, "");
|
||||
result = result.replace(/~~~[\s\S]*?~~~/g, "");
|
||||
|
||||
// Remove indented code blocks (lines starting with 4+ spaces after a blank line)
|
||||
// This is a simplified approach - we remove lines with 4+ leading spaces that aren't list items
|
||||
const lines = result.split("\n");
|
||||
const cleanedLines: string[] = [];
|
||||
let inCodeBlock = false;
|
||||
let prevLineBlank = true;
|
||||
|
||||
for (const line of lines) {
|
||||
const isIndented = /^( |\t)/.test(line) && !line.trim().startsWith("-");
|
||||
const isBlank = line.trim() === "";
|
||||
|
||||
if (isBlank) {
|
||||
inCodeBlock = false;
|
||||
prevLineBlank = true;
|
||||
cleanedLines.push(line);
|
||||
} else if (isIndented && prevLineBlank) {
|
||||
inCodeBlock = true;
|
||||
// Skip indented code block line
|
||||
} else if (inCodeBlock && isIndented) {
|
||||
// Skip continued indented code block line
|
||||
} else {
|
||||
inCodeBlock = false;
|
||||
prevLineBlank = false;
|
||||
cleanedLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return cleanedLines.join("\n");
|
||||
}
|
||||
|
||||
// Extract headings from markdown content (supports H1-H6)
|
||||
// Ignores headings inside code blocks
|
||||
export function extractHeadings(content: string): Heading[] {
|
||||
const headingRegex = /^(#{1,3})\s+(.+)$/gm;
|
||||
// First remove code blocks to avoid extracting headings from code examples
|
||||
const cleanContent = removeCodeBlocks(content);
|
||||
|
||||
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
|
||||
const headings: Heading[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = headingRegex.exec(content)) !== null) {
|
||||
while ((match = headingRegex.exec(cleanContent)) !== null) {
|
||||
const level = match[1].length;
|
||||
const text = match[2].trim();
|
||||
const id = generateSlug(text);
|
||||
|
||||
|
||||
headings.push({ level, text, id });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user