diff --git a/cloud/app/components/blocks/loading-content.tsx b/cloud/app/components/blocks/loading-content.tsx new file mode 100644 index 0000000000..9ca3825458 --- /dev/null +++ b/cloud/app/components/blocks/loading-content.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface LoadingContentProps { + className?: string; + spinnerClassName?: string; + fullHeight?: boolean; +} + +/** + * LoadingContent - A reusable loading spinner component + * + * Shows a centered loading spinner with configurable container and spinner styles + */ +const LoadingContent: React.FC = ({ + className = "", + spinnerClassName = "h-12 w-12", + fullHeight = true, +}) => { + return ( +
+
+
+ ); +}; + +export default LoadingContent; diff --git a/cloud/app/components/blocks/page-sidebar.tsx b/cloud/app/components/blocks/page-sidebar.tsx new file mode 100644 index 0000000000..ee396cc51a --- /dev/null +++ b/cloud/app/components/blocks/page-sidebar.tsx @@ -0,0 +1,753 @@ +import React from "react"; +import { Link, useRouterState } from "@tanstack/react-router"; +import { cn } from "@/app/lib/utils"; +import { ChevronDown, ChevronRight } from "lucide-react"; + +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "@tanstack/react-router"; + +// Breakpoint definitions - matching Tailwind's defaults +export const BREAKPOINTS = { + md: 768, // Medium breakpoint (tablet) + sm: 640, // Small breakpoint + lg: 1024, // Large breakpoint +}; + +// Shared media query strings +export const MEDIA_QUERIES = { + mdAndUp: `(min-width: ${BREAKPOINTS.md}px)`, + mdAndDown: `(max-width: ${BREAKPOINTS.md - 1}px)`, +}; + +// Helper functions for responsive checks +export const isMobileView = (): boolean => { + return ( + typeof window !== "undefined" && + window.matchMedia(MEDIA_QUERIES.mdAndDown).matches + ); +}; + +export const isDesktopView = (): boolean => { + return ( + typeof window !== "undefined" && + window.matchMedia(MEDIA_QUERIES.mdAndUp).matches + ); +}; + +export interface UseSidebarOptions { + /** + * Function to call when this sidebar opens (to close another one) + */ + onOpen?: () => void; + + /** + * Whether to close the sidebar when the route changes + * @default true + */ + closeOnRouteChange?: boolean; +} + +/** + * Simple hook for managing mobile sidebar state + * Desktop visibility is handled purely with CSS + */ +export function useSidebar({ + onOpen, + closeOnRouteChange = true, +}: UseSidebarOptions = {}) { + // Only tracks the mobile state - desktop visibility is CSS-driven + const [isOpen, setIsOpen] = useState(false); + + // Refs for focus management + const closeBtnRef = useRef(null); + const previouslyFocusedElementRef = useRef(null); + + // Router for navigation tracking + const router = useRouter(); + + // Close on route change (mobile only) + useEffect(() => { + if (!closeOnRouteChange) return; + + const unsubscribe = router.subscribe("onResolved", () => { + // Always close on navigation, but only on mobile + if (isMobileView() && isOpen) { + setIsOpen(false); + } + }); + + return () => { + unsubscribe(); + }; + }, [router, isOpen, closeOnRouteChange]); + + // Handle toggling with focus management + const toggle = () => { + // Save the currently focused element when opening + if (!isOpen) { + previouslyFocusedElementRef.current = + document.activeElement as HTMLElement; + + // Notify when opening + if (onOpen) { + onOpen(); + } + } + + const newState = !isOpen; + setIsOpen(newState); + + // Manage focus + if (newState) { + // Focus the close button when sidebar opens + setTimeout(() => { + closeBtnRef.current?.focus(); + }, 100); + } else { + // Restore focus when sidebar closes + setTimeout(() => { + previouslyFocusedElementRef.current?.focus(); + }, 100); + } + }; + + // Force open/close functions + const open = () => { + if (!isOpen) toggle(); + }; + + const close = () => { + if (isOpen) setIsOpen(false); + }; + + // Auto-close on ESC key (mobile only) + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && isOpen) { + setIsOpen(false); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isOpen]); + + return { + isOpen, + setIsOpen, + toggle, + open, + close, + closeBtnRef, + previouslyFocusedElementRef, + }; +} + +/** + * Generic section item data structure for sidebar navigation + */ +export interface SidebarItem { + slug: string; // URL-friendly identifier + label: string; // Display label for sidebar + routePath?: string; // Optional explicit route path (overrides constructed path) + items?: Record; // Nested items + hasContent: boolean; // If true, this item has its own content page (not just a folder) +} + +/** + * Group of related sidebar items + */ +export interface SidebarGroup { + slug: string; // URL-friendly identifier for path construction + label: string; // Display label + items: Record; +} + +/** + * Section of sidebar (like "API", "Guides", etc.) + */ +export interface SidebarSection { + slug: string; + label: string; + basePath: string; + items?: Record; + groups?: Record; +} + +/** + * Main sidebar configuration with theme support + */ +export interface SidebarConfig { + label?: string; + sections: SidebarSection[]; +} + +interface SidebarProps { + config: SidebarConfig; + headerContent?: React.ReactNode; + footerContent?: React.ReactNode; +} + +/** + * Reusable link component for sidebar items + */ +const SidebarLink = ({ + to, + isActive, + className = "", + style, + params, + children, +}: { + to: string; + isActive: boolean; + className?: string; + style?: React.CSSProperties; + params?: Record; + children: React.ReactNode; +}) => { + const activeClass = `bg-primary text-primary-foreground font-medium`; + const inactiveClass = `text-muted-foreground hover:bg-muted hover:text-muted-foreground`; + + return ( + + {children} + + ); +}; + +/** + * Tab for selecting sections + */ +const SectionTab = ({ + to, + isActive, + className = "", + params, + children, +}: { + to: string; + isActive: boolean; + className?: string; + params?: Record; + children: React.ReactNode; +}) => { + const activeClass = `bg-button-primary text-white font-medium`; + const inactiveClass = `text-muted-foreground hover:bg-muted hover:text-muted-foreground`; + + return ( + + {children} + + ); +}; + +/** + * Group label header + */ +const GroupLabel = ({ label }: { label: string }) => { + return ( +
+ {label} +
+ ); +}; + +/** + * Chevron button component for expand/collapse + */ +const ChevronButton = ({ + isExpanded, + onClick, +}: { + isExpanded: boolean; + onClick: () => void; +}) => { + return ( + + ); +}; + +/** + * Hook for managing expansion state with auto-expand on active + */ +const useExpansion = (isActive: boolean) => { + const [isExpanded, setIsExpanded] = React.useState(isActive); + + // Auto-expand when active + React.useEffect(() => { + if (isActive && !isExpanded) { + setIsExpanded(true); + } + }, [isActive]); + + const toggleExpand = () => setIsExpanded(!isExpanded); + + return { isExpanded, toggleExpand }; +}; + +/** + * Component for rendering nested items (folders) + */ +interface NestedItemsProps { + items: Record; + basePath: string; + isActivePath: (path: string, routePath?: string) => boolean; + indentLevel?: number; +} + +/** + * Regular item component (with optional nested items for hybrid items) + */ +const SidebarItemLink = ({ + itemSlug, + item, + basePath, + isActivePath, + indentLevel, +}: { + itemSlug: string; + item: SidebarItem; + basePath: string; + isActivePath: (path: string, routePath?: string) => boolean; + indentLevel: number; +}) => { + // For navigation: Use routePath if provided, otherwise construct the path + const navigationUrl = item.routePath || `${basePath}/${itemSlug}`; + // For active state determination: Use the logical path which may include /index + const logicalPath = `${basePath}/${itemSlug}`; + // Determine if this item is active + const isActive = isActivePath(logicalPath, item.routePath); + + // Check if this item has children (hybrid case) + const hasChildren = item.items && Object.keys(item.items).length > 0; + const { isExpanded, toggleExpand } = useExpansion(isActive); + + if (!hasChildren) { + // Simple link without children + return ( + + {item.label} + + ); + } + + // Hybrid: link with children + return ( +
+
+ + {item.label} + + +
+ + {/* Render nested items if expanded */} + {isExpanded && ( +
+ +
+ )} +
+ ); +}; + +/** + * Folder component (with nested items) + */ +const NestedFolder = ({ + itemSlug, + item, + basePath, + isActivePath, + indentLevel, +}: { + itemSlug: string; + item: SidebarItem; + basePath: string; + isActivePath: (path: string, routePath?: string) => boolean; + indentLevel: number; +}) => { + // For active state determination: Use the logical path which may include /index + const logicalPath = `${basePath}/${itemSlug}`; + // Get children items + const children = item.items || {}; + + // Determine if this folder or any of its children are active + const isActive = isActivePath(logicalPath, item.routePath); + + // Use expansion hook + const { isExpanded, toggleExpand } = useExpansion(isActive); + + return ( +
+
+ + + {/* Folder label button */} + +
+ + {/* Render nested items if expanded */} + {isExpanded && ( +
+ +
+ )} +
+ ); +}; + +/** + * Individual nested item component - renders either a folder (no content) or a link (with optional children) + */ +const NestedItem = ({ + itemSlug, + item, + basePath, + isActivePath, + indentLevel, +}: { + itemSlug: string; + item: SidebarItem; + basePath: string; + isActivePath: (path: string, routePath?: string) => boolean; + indentLevel: number; +}) => { + // Items with content render as links (even if they have children) + // Items without content render as pure folders + return item.hasContent ? ( + + ) : ( + + ); +}; + +/** + * Container component for a group of nested items + */ +const NestedItems = ({ + items, + basePath, + isActivePath, + indentLevel = 0, +}: NestedItemsProps) => { + // Ensure we always have an object, even if items is undefined + const safeItems = items || {}; + + return ( +
0 ? "ml-3" : ""}`}> + {Object.entries(safeItems).map(([itemSlug, item]) => ( + + ))} +
+ ); +}; + +/** + * Renders content for a specific section + */ +const SectionContent = ({ + section, + isActivePath, +}: { + section: SidebarSection; + isActivePath: (path: string) => boolean; +}) => { + if (!section) return null; + + return ( + <> + {/* Section top-level items */} + {section.items && Object.keys(section.items).length > 0 && ( + + )} + + {/* Section groups */} + {section.groups && + Object.entries(section.groups).map(([groupSlug, group]) => { + return ( +
+ {/* Group label - not selectable/highlightable */} + + + {/* Group items */} + +
+ ); + })} + + ); +}; + +/** + * Generic sidebar component that can be used for any navigation structure + */ +const Sidebar = ({ config, headerContent, footerContent }: SidebarProps) => { + // Get current route from TanStack Router + const router = useRouterState(); + const currentPath = router.location.pathname; + + // Store and restore scroll position + const sidebarRef = React.useRef(null); + const sidebarId = config.label + ? config.label.toLowerCase().replace(/\s+/g, "-") + : "sidebar"; + + // Use state to track the last path, to know when navigation happens + const [lastPath, setLastPath] = React.useState(currentPath); + + // When the current path changes, update lastPath state + React.useEffect(() => { + // Save the current scroll position before changing paths + if (lastPath !== currentPath && sidebarRef.current) { + const scrollKey = `sidebar-scroll-${sidebarId}`; + sessionStorage.setItem( + scrollKey, + sidebarRef.current.scrollTop.toString(), + ); + } + + setLastPath(currentPath); + }, [currentPath, lastPath, sidebarId]); + + // Restore scroll position on mount and after page changes + React.useEffect(() => { + const scrollKey = `sidebar-scroll-${sidebarId}`; + + // Restore scroll position with minimal delay to ensure rendering is complete + // Use requestAnimationFrame for the best timing with browser rendering cycle + const timer = requestAnimationFrame(() => { + const savedScroll = sessionStorage.getItem(scrollKey); + if (savedScroll && sidebarRef.current) { + sidebarRef.current.scrollTop = parseInt(savedScroll, 10); + } + }); + + return () => cancelAnimationFrame(timer); + }, [sidebarId, currentPath]); + + // Helper function to check if a path matches the current path + const isActivePath = (path: string, routePath?: string) => { + // If a routePath is provided, check that first + if (routePath && routePath === currentPath) { + return true; + } + + // For index pages, their logical path is e.g. /docs/product/index + // but the current path will be /docs/product/ in the browser + // Check for direct match with current path (as-is) + if (path === currentPath) { + return true; + } + + // Then check for index-specific matches + if (path.endsWith("/index")) { + // Remove 'index' to get the parent path with trailing slash + const parentPath = path.slice(0, -5); + + // Check if current path equals the parent path (with or without trailing slash) + if (parentPath === currentPath || parentPath === currentPath + "/") { + return true; + } + } + + // If it's not an index page, check parent/child relationship + // Normalize paths by ensuring they end with a slash + const normalizedPath = path.endsWith("/") ? path : `${path}/`; + const normalizedCurrentPath = currentPath.endsWith("/") + ? currentPath + : `${currentPath}/`; + + // Special case to avoid matching the root path with everything + if (normalizedPath === "/") { + return normalizedCurrentPath === "/"; + } + + return normalizedCurrentPath.startsWith(normalizedPath); + }; + + // Find active section by checking which section's base path matches the current path + // Preserve the original order from config, but find the most specific match (longest path) + const matchingSections = config.sections + .filter((section) => currentPath.startsWith(section.basePath)) + .sort((a, b) => b.basePath.length - a.basePath.length); + + // Use the most specific match (if any), otherwise undefined + const activeSection = + matchingSections.length > 0 ? matchingSections[0].slug : undefined; + + return ( + + ); +}; + +export default Sidebar; diff --git a/cloud/app/components/blog-page.tsx b/cloud/app/components/blog-page.tsx new file mode 100644 index 0000000000..2f862861c8 --- /dev/null +++ b/cloud/app/components/blog-page.tsx @@ -0,0 +1,327 @@ +import { Link } from "@tanstack/react-router"; +import { useState, type ReactNode } from "react"; +import { type BlogMeta } from "@/app/lib/content/types"; +import LoadingContent from "@/app/components/blocks/loading-content"; + +import { useIsMobile } from "@/app/hooks/is-mobile"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/app/components/ui/pagination"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +export interface BlogPaginationProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; +} + +function BlogPagination({ + currentPage, + totalPages, + onPageChange, +}: BlogPaginationProps) { + const isMobile = useIsMobile(); + + // Logic to determine which page numbers to show + const renderPageNumbers = () => { + const items = []; + // Show fewer pages on mobile + const delta = isMobile ? 0 : 3; + + // Always include first page + items.push( + + onPageChange(1)} + isActive={currentPage === 1} + className="cursor-pointer" + > + 1 + + , + ); + + // Add ellipsis if needed between first page and delta range + if (currentPage > 2 + delta) { + items.push( + + + , + ); + } + + // Add pages around current page based on delta + for ( + let i = Math.max(2, currentPage - delta); + i <= Math.min(totalPages - 1, currentPage + delta); + i++ + ) { + // Skip if already added as first or last page + if (i === 1 || i === totalPages) continue; + + items.push( + + onPageChange(i)} + isActive={currentPage === i} + className="cursor-pointer" + > + {i} + + , + ); + } + + // Add ellipsis if needed between delta range and last page + if (currentPage < totalPages - 1 - delta) { + items.push( + + + , + ); + } + + // Always include last page if there's more than one page + if (totalPages > 1) { + items.push( + + onPageChange(totalPages)} + isActive={currentPage === totalPages} + className="cursor-pointer" + > + {totalPages} + + , + ); + } + + return items; + }; + + // Custom Previous button that's just an arrow on mobile + const CustomPrev = () => { + if (isMobile) { + return ( + + ); + } + + return ( + onPageChange(currentPage - 1)} + tabIndex={currentPage === 1 ? -1 : 0} + className={ + currentPage === 1 + ? "pointer-events-none opacity-50" + : "cursor-pointer" + } + /> + ); + }; + + // Custom Next button that's just an arrow on mobile + const CustomNext = () => { + if (isMobile) { + return ( + + ); + } + + return ( + onPageChange(currentPage + 1)} + tabIndex={currentPage === totalPages ? -1 : 0} + className={ + currentPage === totalPages + ? "pointer-events-none opacity-50" + : "cursor-pointer" + } + /> + ); + }; + + return ( + + + + + + + {renderPageNumbers()} + + + + + + + ); +} + +// Posts per page +const POSTS_PER_PAGE = 4; + +/** + * Blog layout component that provides the common structure for both the blog index and loading state + */ +interface BlogLayoutProps { + children: ReactNode; +} + +function BlogLayout({ children }: BlogLayoutProps) { + return ( +
+
+
+
+
+

+ Blog +

+

+ The latest news, updates, and insights about +
+ Mirascope and LLM application development. +

+
+ {children} +
+
+
+
+ ); +} + +/** + * Blog loading component that shows a spinner while content is loading + */ +export function BlogLoadingState() { + return ( + +
+ +
+
+ ); +} + +interface BlogPageProps { + /** + * Blog posts metadata + */ + posts: BlogMeta[]; +} + +export function BlogPage({ posts }: BlogPageProps) { + const [currentPage, setCurrentPage] = useState(1); + + const totalPages = Math.ceil(posts.length / POSTS_PER_PAGE); + + // Get posts for the current page + const startIdx = (currentPage - 1) * POSTS_PER_PAGE; + const endIdx = startIdx + POSTS_PER_PAGE; + const currentPosts = posts.slice(startIdx, endIdx); + + const handlePageChange = (page: number) => { + if (page >= 1 && page <= totalPages) { + setCurrentPage(page); + } + }; + + return ( + <> + +
+ {posts.length === 0 ? ( +
+

+ No posts found +

+

+ Check back soon for new content! +

+
+ ) : ( +
+ {currentPosts.map((post) => ( + +
+
+
+

+ {post.title} +

+

+ {post.date} · {post.readTime} · By {post.author} +

+

+ {post.description} +

+
+ + Read more + +
+
+ + ))} + + {/* Spacer elements to maintain grid layout when fewer than POSTS_PER_PAGE posts */} + {Array.from({ + length: Math.max(0, POSTS_PER_PAGE - currentPosts.length), + }).map((_, index) => ( +
+ ))} +
+ )} +
+ + {posts.length > 0 && ( +
+ +
+ )} + + + ); +} diff --git a/cloud/app/components/blog-post-page.tsx b/cloud/app/components/blog-post-page.tsx new file mode 100644 index 0000000000..43a1025e99 --- /dev/null +++ b/cloud/app/components/blog-post-page.tsx @@ -0,0 +1,171 @@ +import { ButtonLink } from "@/app/components/ui/button-link"; +import { ChevronLeft } from "lucide-react"; +// import { MDXRenderer } from "@/app/components/mdx/renderer"; +// import { CopyMarkdownButton } from "@/app/components/blocks/copy-markdown-button"; +import LoadingContent from "@/app/components/blocks/loading-content"; +// import { TableOfContents } from "@/app/components/table-of-contents"; +// import { PagefindMeta } from "@/app/components/pagefind-meta"; +import type { BlogContent } from "@/app/lib/content/types"; +import PageLayout from "@/app/components/page-layout"; +import { MDXRenderer } from "./mdx/renderer"; + +// Reusable component for "Back to Blog" button +function BackToBlogLink() { + return ( +
+ + + Back to Blog + +
+ ); +} + +type BlogPostPageProps = { + post: BlogContent; + slug: string; + isLoading?: boolean; +}; + +export function BlogPostPage({ + post, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + slug: _slug, // Temporarily unused - will be used for PageMeta/CopyMarkdownButton + isLoading = false, +}: BlogPostPageProps) { + // TODO: Re-enable when PageMeta is implemented + // const [ogImage, setOgImage] = useState(undefined); + + // Find the first available image in the blog post directory + // TODO: Re-enable when PageMeta is implemented + // useEffect(() => { + // if (isLoading) return; + + // const findOgImage = async () => { + // try { + // const response = await fetch(`/assets/blog/${slug}/`); + // if (response.ok) { + // const text = await response.text(); + // const parser = new DOMParser(); + // const doc = parser.parseFromString(text, "text/html"); + // const links = Array.from(doc.querySelectorAll("a")) + // .map((a) => a.getAttribute("href")) + // .filter((href) => href && /\.(png|jpg|jpeg|gif)$/i.test(href)); + + // if (links.length > 0) { + // setOgImage(`/assets/blog/${slug}/${links[0]}`); + // } + // } + // } catch (err) { + // console.error("Error finding OG image:", err); + // } + // }; + + // findOgImage(); + // }, [slug, isLoading]); + + // Extract metadata for easier access + const { title, date, readTime, author, lastUpdated } = post.meta; + + // Main content + const mainContent = isLoading ? ( + + ) : ( +
+
+
+

+ {title} +

+

+ {date} · {readTime} · By {author} +

+ {lastUpdated && ( +

+ Last updated: {lastUpdated} +

+ )} +
+
+ {post.mdx ? ( + + ) : ( + + )} + {/* {post.mdx ? ( + + + + ) : ( + + )} */} +
+
+
+ ); + + // Right sidebar content - loading state or actual content + const rightSidebarContent = isLoading ? ( +
+
+
+ ) : ( +
+
+ {/* */} + +

+ On this page +

+
+ +
+ {/* */} +
+
+ ); + + return ( + <> + {/* */} + + +
+ +
+
+ + {mainContent} + + + {rightSidebarContent} + +
+ + ); +} diff --git a/cloud/app/components/page-layout.tsx b/cloud/app/components/page-layout.tsx new file mode 100644 index 0000000000..9097ea78b5 --- /dev/null +++ b/cloud/app/components/page-layout.tsx @@ -0,0 +1,392 @@ +import type { ReactNode, RefObject } from "react"; +import { createContext, useContext, useEffect } from "react"; +import { cn } from "@/app/lib/utils"; +import { Button } from "@/app/components/ui/button"; +import { ChevronLeft, ChevronRight, X, type LucideIcon } from "lucide-react"; +import { useSidebar, isMobileView } from "@/app/components/blocks/page-sidebar"; + +// Shared positioning for sidebar toggle buttons +const SIDEBAR_TOGGLE_POSITION = "calc(var(--header-height) - 1.63rem)"; + +// Create a context to coordinate sidebar states +type SidebarContextType = { + leftSidebar: ReturnType; + rightSidebar: ReturnType; +}; + +const SidebarContext = createContext({ + leftSidebar: { + isOpen: false, + setIsOpen: () => {}, + toggle: () => {}, + open: () => {}, + close: () => {}, + closeBtnRef: { current: null }, + previouslyFocusedElementRef: { current: null }, + }, + rightSidebar: { + isOpen: false, + setIsOpen: () => {}, + toggle: () => {}, + open: () => {}, + close: () => {}, + closeBtnRef: { current: null }, + previouslyFocusedElementRef: { current: null }, + }, +}); + +/** + * Toggle button for controlling sidebars with consistent styling + */ +interface SidebarToggleProps { + isOpen: boolean; + onClick: () => void; + position: "left" | "right"; + className?: string; + ariaLabel: string; + ariaControls: string; + buttonRef?: RefObject; +} + +const SidebarToggle = ({ + isOpen, + onClick, + position, + className, + ariaLabel, + ariaControls, + buttonRef, +}: SidebarToggleProps) => { + // Choose icon based on position and state + const getIcon = (): LucideIcon => { + if (isOpen) return X; + return position === "left" ? ChevronRight : ChevronLeft; + }; + + const Icon = getIcon(); + + return ( + + ); +}; + +/** + * PageLayout - Comprehensive layout component with composable parts + * + * Provides a consistent page structure with main content area and + * optional sidebars. Sidebars use fixed positioning for scrolling behavior. + * Manages responsive behavior for both left and right sidebars. + * Header spacing is handled by the root layout. + * + * Usage example: + * ```tsx + * + * Left sidebar content + * Main content + * Right sidebar content + * + * ``` + */ +const PageLayout = ({ children }: { children: ReactNode }) => { + // Create sidebar controllers with coordinated behavior + // The hook now handles only mobile behavior + const leftSidebar = useSidebar({ + onOpen: () => rightSidebar.close(), + }); + + const rightSidebar = useSidebar({ + onOpen: () => leftSidebar.close(), + }); + + // Manage body scroll lock when sidebars are open on mobile + useEffect(() => { + if (isMobileView() && (leftSidebar.isOpen || rightSidebar.isOpen)) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + + return () => { + document.body.style.overflow = ""; + }; + }, [leftSidebar.isOpen, rightSidebar.isOpen]); + + return ( + +
+
{children}
+
+
+ ); +}; + +interface SidebarProps { + children: ReactNode; + className?: string; + collapsible?: boolean; +} + +interface RightSidebarProps extends SidebarProps { + mobileCollapsible?: boolean; + mobileTitle?: string; +} + +/** + * Shared backdrop component for mobile sidebar overlays + */ +const SidebarBackdrop = ({ + isOpen, + onClick, +}: { + isOpen: boolean; + onClick: () => void; +}) => ( +