import React, { useCallback, useEffect, useMemo, useRef, useState, useId } from 'react'; import { createPortal } from 'react-dom'; import { ChevronIcon, TrashIcon, EditIcon, FolderIcon, SidebarCollapseIcon, LogoutIcon, ChevronDownIcon, SettingsIcon, CheckIcon, PlusIcon, FolderPlusIcon, RestoreIcon, SunIcon, MoonIcon, DesktopIcon, UploadIcon, } from '../ui/icons'; import PanelHeader from '../ui/PanelHeader'; import useFloatingMenu from '../ui/useFloatingMenu'; import { getTagColorStyle } from '../utils/colors'; import { useSidebarContext } from './SidebarContext'; const SIDEBAR_WIDTH_STORAGE_KEY = 'documentsSidebarWidth'; const SIDEBAR_WIDTH_EVENT = 'sidebar-width-change'; const MIN_SIDEBAR_WIDTH = 240; const MAX_SIDEBAR_WIDTH = 640; const isBrowser = typeof window !== 'undefined'; const isDocumentAvailable = typeof document !== 'undefined'; const notifySidebarWidthChange = (width) => { if (!isBrowser) { return; } try { window.dispatchEvent( new CustomEvent(SIDEBAR_WIDTH_EVENT, { detail: { width }, }), ); } catch (error) { console.warn('Failed to dispatch sidebar width change', error); } }; const getSidebarBounds = () => { if (!isBrowser) { return { min: MIN_SIDEBAR_WIDTH, max: MAX_SIDEBAR_WIDTH, }; } const viewportWidth = Math.max(window.innerWidth, 1); const minFractionWidth = viewportWidth / 6; const maxFractionWidth = viewportWidth / 3; const rawMin = Math.max(MIN_SIDEBAR_WIDTH, minFractionWidth); const rawMax = Math.min(MAX_SIDEBAR_WIDTH, maxFractionWidth); if (rawMin >= rawMax) { const fallback = Math.min(Math.max(rawMin, viewportWidth / 4), MAX_SIDEBAR_WIDTH); return { min: fallback, max: fallback }; } return { min: rawMin, max: rawMax }; }; const clampSidebarWidth = (value) => { if (!Number.isFinite(value) || value <= 0) { return null; } const { min, max } = getSidebarBounds(); return Math.min(Math.max(value, min), max); }; const loadStoredSidebarWidth = () => { if (!isBrowser) { return null; } try { const raw = window.localStorage?.getItem(SIDEBAR_WIDTH_STORAGE_KEY); if (!raw) { return null; } const parsed = parseInt(raw, 10); return Number.isFinite(parsed) ? parsed : null; } catch (error) { console.warn('Failed to read sidebar width', error); return null; } }; const applySidebarWidth = (width) => { if (!isDocumentAvailable || width == null) { return; } document.documentElement.style.setProperty('--sidebar-width', `${width}px`); notifySidebarWidthChange(width); }; const FolderNode = ({ node, depth, isSelected, onToggle, onSelect, onDrop, onDragOver, onDragLeave, onDelete, onRename, renderChildren, onFolderDragStart, onFolderDragEnd, draggingFolderId, }) => { const isRoot = node.id === 'root'; const hasChildren = Boolean(node.hasChildren); const canToggle = hasChildren || !node.loaded; const showChevron = canToggle; const icon = showChevron ? : null; const canDrag = !isRoot; const isDragging = draggingFolderId === node.id; const isExpanded = Boolean(node.expanded); const rowClasses = ['folder-row']; if (isSelected) { rowClasses.push('active'); } const handleToggleClick = (event) => { event.stopPropagation(); if (canToggle) { onToggle(node.id); } }; return (
  • onSelect(node.id)} onDoubleClick={() => { if (canToggle) { onToggle(node.id); } }} onDragOver={(event) => onDragOver(event, node.id)} onDragLeave={onDragLeave} onDrop={(event) => onDrop(event, node.id)} onDragStart={(event) => { if (!canDrag || !onFolderDragStart) return; onFolderDragStart(event, node.id); }} onDragEnd={(event) => { if (onFolderDragEnd) { onFolderDragEnd(event); } }} > {icon} {node.name} {node.id !== 'root' && (
    )}
    {isExpanded && node.children.length > 0 && ( )}
  • ); }; const THEME_MODES = ['system', 'light', 'dark']; const THEME_MODE_LABELS = { system: 'System', light: 'Light', dark: 'Dark', }; const Sidebar = ({ folderNodes, onToggle, onSelect, onDrop, onDragOver, onDragLeave, onDeleteFolder, onRenameFolder, selectedFolder, onFolderDragStart, onFolderDragEnd, draggedFolderId, onCreateFolder, creatingFolder = false, tags = [], untaggedFilterId = null, activeTagIds = [], onToggleTagFilter, correspondents = [], activeCorrespondentIds = [], onToggleCorrespondentFilter, onManageTags, onManageCorrespondents, onCreateTag, onCreateCorrespondent, searchQuery = '', onSearchChange, onSearchSubmit, onSearchClear, isFilterActive, onLogout, status, tenantName, tenants = [], activeTenantId = null, onSelectTenant, onOpenSettings, onUploadFiles, }) => { const { setCollapsed, neutralHue, setNeutralHue, resetNeutralHue, neutralChroma, setNeutralChroma, resetNeutralChroma, neutralContrast, setNeutralContrast, resetNeutralContrast, themeMode, cycleThemeMode, themeModes, sidebarSuppressed, } = useSidebarContext(); const uploadInputRef = useRef(null); const sidebarRef = useRef(null); const pendingWidthRef = useRef(null); const [isResizingSidebar, setIsResizingSidebar] = useState(false); const [sidebarWidthState, setSidebarWidthState] = useState(() => { if (!isBrowser) { return null; } const stored = loadStoredSidebarWidth(); return stored != null ? clampSidebarWidth(stored) : null; }); useEffect(() => { if (sidebarWidthState != null) { const clamped = clampSidebarWidth(sidebarWidthState); if (clamped != null) { applySidebarWidth(clamped); } } }, [sidebarWidthState]); useEffect(() => { if (!isBrowser) { return undefined; } const handleResize = () => { setSidebarWidthState((prev) => { if (prev == null) { return prev; } const clamped = clampSidebarWidth(prev); if (clamped != null && clamped !== prev) { applySidebarWidth(clamped); try { window.localStorage?.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(Math.round(clamped))); } catch (error) { console.warn('Failed to persist sidebar width', error); } return clamped; } return prev; }); }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); const handleSidebarResizePointerDown = useCallback((event) => { if (!isBrowser || !sidebarRef.current) { return; } event.preventDefault(); event.stopPropagation(); const pointerId = event.pointerId; const target = event.currentTarget; target.setPointerCapture?.(pointerId); setIsResizingSidebar(true); const rect = sidebarRef.current.getBoundingClientRect(); const startWidth = rect.width; const startX = event.clientX; const updateWidth = (nextWidth) => { const clamped = clampSidebarWidth(nextWidth); if (clamped != null) { pendingWidthRef.current = clamped; applySidebarWidth(clamped); } }; const handlePointerMove = (moveEvent) => { if (moveEvent.pointerId !== pointerId) { return; } const delta = moveEvent.clientX - startX; updateWidth(startWidth + delta); }; const handlePointerUp = (upEvent) => { if (upEvent.pointerId !== pointerId) { return; } target.releasePointerCapture?.(pointerId); window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); setIsResizingSidebar(false); if (pendingWidthRef.current != null) { const finalizedWidth = pendingWidthRef.current; pendingWidthRef.current = null; setSidebarWidthState(finalizedWidth); try { window.localStorage?.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(Math.round(finalizedWidth))); } catch (error) { console.warn('Failed to persist sidebar width', error); } } }; window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', handlePointerUp); }, []); const handleSidebarResizeKeyDown = useCallback((event) => { if (!isBrowser) { return; } if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { return; } const baseWidth = pendingWidthRef.current ?? sidebarWidthState ?? sidebarRef.current?.getBoundingClientRect().width; if (!Number.isFinite(baseWidth)) { return; } event.preventDefault(); const step = event.shiftKey ? 40 : 20; const delta = event.key === 'ArrowRight' ? step : -step; const nextWidth = clampSidebarWidth(baseWidth + delta); if (nextWidth == null) { return; } setSidebarWidthState(nextWidth); try { window.localStorage?.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(Math.round(nextWidth))); } catch (error) { console.warn('Failed to persist sidebar width', error); } }, [sidebarWidthState]); const handleCollapse = useCallback(() => { setCollapsed(true); }, [setCollapsed]); const neutralHueInputId = useId(); const neutralChromaInputId = useId(); const neutralContrastInputId = useId(); const sortedCorrespondents = useMemo(() => { if (!Array.isArray(correspondents)) { return []; } return correspondents .filter((entry) => entry?.name) .slice() .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); }, [correspondents]); const activeCorrespondentSet = useMemo( () => new Set(activeCorrespondentIds || []), [activeCorrespondentIds], ); const handleToggleTag = onToggleTagFilter || (() => {}); const activeTagSet = new Set(activeTagIds); const untaggedActive = untaggedFilterId ? activeTagSet.has(untaggedFilterId) : false; const handleManageTags = onManageTags || (() => {}); const handleManageCorrespondents = onManageCorrespondents || (() => {}); const handleCreateTag = useCallback(async () => { const input = window.prompt('New tag name'); if (!input) { return; } const trimmed = input.trim(); if (!trimmed) { return; } try { await onCreateTag?.(trimmed); } catch (error) { console.error('[sidebar] failed to create tag', error); } }, [onCreateTag]); const handleCreateCorrespondent = useCallback(async () => { const input = window.prompt('New correspondent name'); if (!input) { return; } const trimmed = input.trim(); if (!trimmed) { return; } try { await onCreateCorrespondent?.(trimmed); } catch (error) { console.error('[sidebar] failed to create correspondent', error); } }, [onCreateCorrespondent]); const handleCreateFolder = useCallback(() => { if (creatingFolder) { return; } onCreateFolder?.(); }, [creatingFolder, onCreateFolder]); const handleUploadButtonClick = useCallback(() => { if (!onUploadFiles || !uploadInputRef.current) { return; } uploadInputRef.current.click(); }, [onUploadFiles]); const handleUploadInputChange = useCallback( (event) => { const files = event.target?.files; if (files && files.length && onUploadFiles) { onUploadFiles(files, selectedFolder); } if (event.target) { event.target.value = ''; } }, [onUploadFiles, selectedFolder], ); const handleThemeAdjustmentsReset = useCallback(() => { resetNeutralHue(); resetNeutralChroma(); resetNeutralContrast(); }, [resetNeutralHue, resetNeutralChroma, resetNeutralContrast]); const handleNeutralHueChange = useCallback( (value) => { if (value === '') { resetNeutralHue(); return; } const parsed = Number(value); if (Number.isNaN(parsed)) { return; } setNeutralHue(parsed); }, [resetNeutralHue, setNeutralHue], ); const handleNeutralChromaChange = useCallback( (value) => { if (value === '') { resetNeutralChroma(); return; } const parsed = Number(value); if (Number.isNaN(parsed)) { return; } setNeutralChroma(parsed); }, [resetNeutralChroma, setNeutralChroma], ); const handleNeutralContrastChange = useCallback( (value) => { if (value === '') { resetNeutralContrast(); return; } const parsed = Number(value); if (Number.isNaN(parsed)) { return; } setNeutralContrast(parsed); }, [resetNeutralContrast, setNeutralContrast], ); const formatSliderValue = useCallback((value) => { const parsed = Number(value); if (Number.isNaN(parsed)) { return value; } return parsed.toFixed(2); }, []); const themeModeList = themeModes && themeModes.length ? themeModes : THEME_MODES; const themeModeIndex = themeModeList.indexOf(themeMode); const safeThemeModeIndex = themeModeIndex === -1 ? 0 : themeModeIndex; const resolvedThemeMode = themeModeList[safeThemeModeIndex] || THEME_MODES[0]; const nextThemeMode = themeModeList[(safeThemeModeIndex + 1) % themeModeList.length]; const themeModeLabel = THEME_MODE_LABELS[resolvedThemeMode] || THEME_MODE_LABELS.system; const nextThemeLabel = THEME_MODE_LABELS[nextThemeMode] || THEME_MODE_LABELS.system; const themeModeIcon = resolvedThemeMode === 'dark' ? : resolvedThemeMode === 'light' ? : ; const handleThemeModeToggle = useCallback(() => { cycleThemeMode(); }, [cycleThemeMode]); const themeMenuSection = typeof neutralHue === 'number' || typeof neutralHue === 'string' ? (
    Theme
    ) : null; const handleSearchInputChange = useCallback( (event) => { onSearchChange?.(event.target.value); }, [onSearchChange], ); const handleSearchFormSubmit = useCallback( (event) => { event.preventDefault(); onSearchSubmit?.(); }, [onSearchSubmit], ); const handleSearchClear = useCallback(() => { onSearchClear?.(); }, [onSearchClear]); const tenantButtonRef = useRef(null); const { isOpen: tenantMenuOpen, toggle: toggleTenantMenuFloating, close: closeTenantMenu, menuRef: tenantMenuRef, menuStyle: tenantMenuStyle, updatePosition: refreshTenantMenuPosition, } = useFloatingMenu({ anchorRef: tenantButtonRef, minWidth: 220, offset: 6, }); const showTenantList = tenants.length > 1; const menuClassName = `menu${!showTenantList && !themeMenuSection ? ' menu--simple' : ''}`; const toggleTenantMenu = useCallback(() => { if (!tenantMenuOpen && tenants.length === 0 && onSelectTenant) { onSelectTenant(null, { refreshOnly: true }); } toggleTenantMenuFloating(); }, [tenantMenuOpen, tenants.length, onSelectTenant, toggleTenantMenuFloating]); useEffect(() => { if (tenantMenuOpen) { refreshTenantMenuPosition(); } }, [tenantMenuOpen, refreshTenantMenuPosition, tenants.length]); const handleTenantSelect = useCallback( (tenant) => { const targetId = tenant?.id || null; if (!targetId) { return; } closeTenantMenu(); onSelectTenant?.(tenant); }, [closeTenantMenu, onSelectTenant], ); const handleLogoutFromMenu = useCallback(() => { closeTenantMenu(); onLogout?.(); }, [closeTenantMenu, onLogout]); const handleSettingsFromMenu = useCallback(() => { closeTenantMenu(); onOpenSettings?.(); }, [closeTenantMenu, onOpenSettings]); const renderNodes = useCallback( (ids, depth) => ids.map((id) => { const node = folderNodes.get(id); if (!node) return null; return ( onToggle(id)} onSelect={onSelect} onDrop={onDrop} onDragOver={onDragOver} onDragLeave={onDragLeave} onDelete={onDeleteFolder} onRename={onRenameFolder} renderChildren={renderNodes} onFolderDragStart={onFolderDragStart} onFolderDragEnd={onFolderDragEnd} draggingFolderId={draggedFolderId} /> ); }), [ folderNodes, selectedFolder, onToggle, onSelect, onDrop, onDragOver, onDragLeave, onDeleteFolder, onRenameFolder, onFolderDragStart, onFolderDragEnd, draggedFolderId, ], ); const rootNode = folderNodes.get('root'); const sidebarClassNames = ['sidebar']; if (isResizingSidebar) { sidebarClassNames.push('sidebar--resizing'); } if (sidebarSuppressed) { sidebarClassNames.push('sidebar--suppressed'); } return ( ); }; export default Sidebar; export { FolderNode };