Files
boris/src/components/ThreePaneLayout.tsx

285 lines
9.5 KiB
TypeScript

import React, { useEffect, useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBars } from '@fortawesome/free-solid-svg-icons'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { BookmarkList } from './BookmarkList'
import ContentPanel from './ContentPanel'
import { HighlightsPanel } from './HighlightsPanel'
import Settings from './Settings'
import Toast from './Toast'
import { HighlightButton } from './HighlightButton'
import { RelayStatusIndicator } from './RelayStatusIndicator'
import { ViewMode } from './Bookmarks'
import { Bookmark } from '../types/bookmarks'
import { Highlight } from '../types/highlights'
import { ReadableContent } from '../services/readerService'
import { UserSettings } from '../services/settingsService'
import { HighlightVisibility } from './HighlightsPanel'
import { HighlightButtonRef } from './HighlightButton'
import { BookmarkReference } from '../utils/contentLoader'
import { useIsMobile } from '../hooks/useMediaQuery'
interface ThreePaneLayoutProps {
// Layout state
isCollapsed: boolean
isHighlightsCollapsed: boolean
isSidebarOpen: boolean
showSettings: boolean
showExplore?: boolean
// Bookmarks pane
bookmarks: Bookmark[]
bookmarksLoading: boolean
viewMode: ViewMode
isRefreshing: boolean
lastFetchTime?: number | null
onToggleSidebar: () => void
onLogout: () => void
onViewModeChange: (mode: ViewMode) => void
onOpenSettings: () => void
onRefresh: () => void
relayPool: RelayPool | null
eventStore: IEventStore | null
// Content pane
readerLoading: boolean
readerContent?: ReadableContent
selectedUrl?: string
settings: UserSettings
onSaveSettings: (settings: UserSettings) => Promise<void>
onCloseSettings: () => void
classifiedHighlights: Highlight[]
showHighlights: boolean
selectedHighlightId?: string
highlightVisibility: HighlightVisibility
onHighlightClick: (id: string) => void
onTextSelection: (text: string) => void
onClearSelection: () => void
currentUserPubkey?: string
followedPubkeys: Set<string>
// Highlights pane
highlights: Highlight[]
highlightsLoading: boolean
onToggleHighlightsPanel: () => void
onSelectUrl: (url: string, bookmark?: BookmarkReference) => void
onToggleHighlights: (show: boolean) => void
onRefreshHighlights: () => void
onHighlightVisibilityChange: (visibility: HighlightVisibility) => void
// Highlight button
highlightButtonRef: React.RefObject<HighlightButtonRef>
onCreateHighlight: (text: string) => void
hasActiveAccount: boolean
// Toast
toastMessage?: string
toastType?: 'success' | 'error'
onClearToast: () => void
// Optional Explore content
explore?: React.ReactNode
}
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
const isMobile = useIsMobile()
const sidebarRef = useRef<HTMLDivElement>(null)
// Lock body scroll when mobile sidebar is open
useEffect(() => {
if (isMobile && props.isSidebarOpen) {
document.body.classList.add('mobile-sidebar-open')
} else {
document.body.classList.remove('mobile-sidebar-open')
}
return () => {
document.body.classList.remove('mobile-sidebar-open')
}
}, [isMobile, props.isSidebarOpen])
// Handle ESC key to close sidebar
useEffect(() => {
if (!isMobile || !props.isSidebarOpen) return
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
props.onToggleSidebar()
}
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [isMobile, props.isSidebarOpen, props.onToggleSidebar])
// Trap focus in sidebar when open on mobile
useEffect(() => {
if (!isMobile || !props.isSidebarOpen || !sidebarRef.current) return
const sidebar = sidebarRef.current
const focusableElements = sidebar.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
const handleTab = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault()
lastElement?.focus()
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault()
firstElement?.focus()
}
}
}
sidebar.addEventListener('keydown', handleTab)
firstElement?.focus()
return () => {
sidebar.removeEventListener('keydown', handleTab)
}
}, [isMobile, props.isSidebarOpen])
const handleBackdropClick = () => {
if (isMobile && props.isSidebarOpen) {
props.onToggleSidebar()
}
}
return (
<>
{/* Mobile hamburger button */}
{isMobile && !props.isSidebarOpen && (
<button
className="mobile-hamburger-btn"
onClick={props.onToggleSidebar}
aria-label="Open sidebar"
aria-expanded={props.isSidebarOpen}
>
<FontAwesomeIcon icon={faBars} />
</button>
)}
{/* Mobile backdrop */}
{isMobile && (
<div
className={`mobile-sidebar-backdrop ${props.isSidebarOpen ? 'visible' : ''}`}
onClick={handleBackdropClick}
aria-hidden="true"
/>
)}
<div className={`three-pane ${props.isCollapsed ? 'sidebar-collapsed' : ''} ${props.isHighlightsCollapsed ? 'highlights-collapsed' : ''}`}>
<div
ref={sidebarRef}
className={`pane sidebar ${isMobile && props.isSidebarOpen ? 'mobile-open' : ''}`}
aria-hidden={isMobile && !props.isSidebarOpen}
>
<BookmarkList
bookmarks={props.bookmarks}
onSelectUrl={props.onSelectUrl}
isCollapsed={props.isCollapsed}
onToggleCollapse={props.onToggleSidebar}
onLogout={props.onLogout}
viewMode={props.viewMode}
onViewModeChange={props.onViewModeChange}
selectedUrl={props.selectedUrl}
onOpenSettings={props.onOpenSettings}
onRefresh={props.onRefresh}
isRefreshing={props.isRefreshing}
lastFetchTime={props.lastFetchTime}
loading={props.bookmarksLoading}
relayPool={props.relayPool}
settings={props.settings}
isMobile={isMobile}
/>
</div>
<div className="pane main">
{props.showSettings ? (
<Settings
settings={props.settings}
onSave={props.onSaveSettings}
onClose={props.onCloseSettings}
relayPool={props.relayPool}
/>
) : props.showExplore && props.explore ? (
// Render Explore inside the main pane to keep side panels
<>
{props.explore}
</>
) : (
<ContentPanel
loading={props.readerLoading}
title={props.readerContent?.title}
html={props.readerContent?.html}
markdown={props.readerContent?.markdown}
image={props.readerContent?.image}
summary={props.readerContent?.summary}
published={props.readerContent?.published}
selectedUrl={props.selectedUrl}
highlights={props.classifiedHighlights}
showHighlights={props.showHighlights}
highlightStyle={props.settings.highlightStyle || 'marker'}
highlightColor={props.settings.highlightColor || '#ffff00'}
onHighlightClick={props.onHighlightClick}
selectedHighlightId={props.selectedHighlightId}
highlightVisibility={props.highlightVisibility}
onTextSelection={props.onTextSelection}
onClearSelection={props.onClearSelection}
currentUserPubkey={props.currentUserPubkey}
followedPubkeys={props.followedPubkeys}
settings={props.settings}
/>
)}
</div>
<div className="pane highlights">
<HighlightsPanel
highlights={props.highlights}
loading={props.highlightsLoading}
isCollapsed={props.isHighlightsCollapsed}
onToggleCollapse={props.onToggleHighlightsPanel}
onSelectUrl={props.onSelectUrl}
selectedUrl={props.selectedUrl}
onToggleHighlights={props.onToggleHighlights}
selectedHighlightId={props.selectedHighlightId}
onRefresh={props.onRefreshHighlights}
onHighlightClick={props.onHighlightClick}
currentUserPubkey={props.currentUserPubkey}
highlightVisibility={props.highlightVisibility}
onHighlightVisibilityChange={props.onHighlightVisibilityChange}
followedPubkeys={props.followedPubkeys}
relayPool={props.relayPool}
eventStore={props.eventStore}
/>
</div>
</div>
{props.hasActiveAccount && (
<HighlightButton
ref={props.highlightButtonRef}
onHighlight={props.onCreateHighlight}
highlightColor={props.settings.highlightColor || '#ffff00'}
/>
)}
<RelayStatusIndicator relayPool={props.relayPool} />
{props.toastMessage && (
<Toast
message={props.toastMessage}
type={props.toastType}
onClose={props.onClearToast}
/>
)}
</>
)
}
export default ThreePaneLayout