'use client' import * as React from 'react' import { PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react' import { cn } from '../../lib/utils' import { SplitPane } from './SplitPane' export type WorkspaceLayout = 'split' | 'stack' | 'auto' export interface DualPaneWorkspaceProps { left: React.ReactNode right: React.ReactNode /** Optional toolbar rendered above both panes (full width). */ toolbar?: React.ReactNode leftLabel?: string rightLabel?: string /** Initial size of the left pane as a percentage. Default 42. */ initialLeftSize?: number minLeftSize?: number maxLeftSize?: number /** Persist the split position under this key. */ storageKey?: string /** * `split` = resizable side-by-side, `stack` = vertically stacked, * `auto` = stack below `stackBreakpoint`, split above. Default `auto`. */ layout?: WorkspaceLayout /** Pixel width below which `auto` stacks. Default 768. */ stackBreakpoint?: number /** Show collapse/expand controls for each pane (split layout only). */ collapsible?: boolean className?: string } function useResolvedLayout(layout: WorkspaceLayout, stackBreakpoint: number): 'split' | 'stack' { const [isNarrow, setIsNarrow] = React.useState(false) React.useEffect(() => { if (layout !== 'auto' || typeof window === 'undefined') return const mql = window.matchMedia(`(max-width: ${stackBreakpoint - 1}px)`) const update = () => setIsNarrow(mql.matches) update() mql.addEventListener?.('change', update) return () => mql.removeEventListener?.('change', update) }, [layout, stackBreakpoint]) if (layout === 'split') return 'split' if (layout === 'stack') return 'stack' return isNarrow ? 'stack' : 'split' } interface PaneHeaderProps { label?: string side: 'left' | 'right' collapsible?: boolean collapsed: boolean onToggle: () => void } function PaneHeader({ label, side, collapsible, collapsed, onToggle }: PaneHeaderProps) { if (!label && !collapsible) return null const CollapseIcon = side === 'left' ? PanelLeftClose : PanelRightClose const ExpandIcon = side === 'left' ? PanelLeftOpen : PanelRightOpen const Icon = collapsed ? ExpandIcon : CollapseIcon const verb = collapsed ? 'Expand' : 'Collapse' return (
{label ? ( {label} ) : ( )} {collapsible && ( )}
) } /** * Opinionated two-pane workspace shell built on {@link SplitPane}: an optional * full-width toolbar, two labelled/collapsible panes side-by-side, and * automatic vertical stacking on narrow viewports. Fully app-agnostic — used by * present-web for chat | slides, but free of any presentation-specific logic. */ export function DualPaneWorkspace({ left, right, toolbar, leftLabel, rightLabel, initialLeftSize = 42, minLeftSize = 24, maxLeftSize = 72, storageKey, layout = 'auto', stackBreakpoint = 768, collapsible = false, className, }: DualPaneWorkspaceProps) { const resolved = useResolvedLayout(layout, stackBreakpoint) const [collapsed, setCollapsed] = React.useState(null) const leftHeader = ( setCollapsed((c) => (c === 'left' ? null : 'left'))} /> ) const rightHeader = ( setCollapsed((c) => (c === 'right' ? null : 'right'))} /> ) const leftPane = (
{leftHeader} {collapsed !== 'left' &&
{left}
}
) const rightPane = (
{rightHeader} {collapsed !== 'right' &&
{right}
}
) // When one pane is collapsed, the other takes the full width (no resizer). let body: React.ReactNode if (resolved === 'stack') { body = (
{leftPane}
{rightPane}
) } else if (collapsed === 'left') { body = (
{leftHeader}
{rightPane}
) } else if (collapsed === 'right') { body = (
{leftPane}
{rightHeader}
) } else { body = (
) } return (
{toolbar &&
{toolbar}
} {body}
) }