1069 lines
32 KiB
React
1069 lines
32 KiB
React
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 ? <ChevronIcon className="toggle-icon" /> : 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 (
|
|
<li className={`folder-node${isDragging ? ' is-dragging' : ''}`}>
|
|
<div
|
|
className={rowClasses.join(' ')}
|
|
draggable={canDrag}
|
|
onClick={() => 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);
|
|
}
|
|
}}
|
|
>
|
|
<span
|
|
className={`toggle${showChevron ? '' : ' invisible'}${isExpanded ? ' expanded' : ''}`}
|
|
onClick={handleToggleClick}
|
|
>
|
|
{icon}
|
|
</span>
|
|
<span className="name-wrap">
|
|
<FolderIcon className="folder-icon-image" size={16} />
|
|
<span className="name__label" title={node.name}>
|
|
{node.name}
|
|
</span>
|
|
</span>
|
|
{node.id !== 'root' && (
|
|
<div className="folder-row__actions">
|
|
<button
|
|
type="button"
|
|
className="icon-button ghost"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
if (!onRename) return;
|
|
const nextName = window.prompt('Rename folder', node.name);
|
|
if (!nextName) {
|
|
return;
|
|
}
|
|
const trimmed = nextName.trim();
|
|
if (!trimmed || trimmed === node.name) {
|
|
return;
|
|
}
|
|
onRename(node.id, trimmed);
|
|
}}
|
|
title="Rename folder"
|
|
aria-label={`Rename folder ${node.name}`}
|
|
>
|
|
<EditIcon className="icon-edit" size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="icon-button ghost"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onDelete(node.id);
|
|
}}
|
|
title="Delete folder"
|
|
aria-label={`Delete folder ${node.name}`}
|
|
>
|
|
<TrashIcon className="icon-trash" size={18} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{isExpanded && node.children.length > 0 && (
|
|
<ul className={`folder-children${depth === 0 ? ' folder-children--level1' : ''}`}>
|
|
{renderChildren(node.children, depth + 1)}
|
|
</ul>
|
|
)}
|
|
</li>
|
|
);
|
|
};
|
|
|
|
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'
|
|
? <MoonIcon size={16} />
|
|
: resolvedThemeMode === 'light'
|
|
? <SunIcon size={16} />
|
|
: <DesktopIcon size={16} />;
|
|
|
|
const handleThemeModeToggle = useCallback(() => {
|
|
cycleThemeMode();
|
|
}, [cycleThemeMode]);
|
|
|
|
const themeMenuSection =
|
|
typeof neutralHue === 'number' || typeof neutralHue === 'string'
|
|
? (
|
|
<div className="menu__section">
|
|
<div className="menu__heading menu__heading--with-actions">
|
|
<span>Theme</span>
|
|
<div className="menu__heading-actions">
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={handleThemeModeToggle}
|
|
aria-label={`Switch theme (next: ${nextThemeLabel})`}
|
|
title={`Theme: ${themeModeLabel} (next: ${nextThemeLabel})`}
|
|
>
|
|
{themeModeIcon}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={handleThemeAdjustmentsReset}
|
|
aria-label="Reset theme adjustments"
|
|
title="Reset theme adjustments"
|
|
>
|
|
<RestoreIcon size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<label className="menu__slider" htmlFor={neutralHueInputId}>
|
|
<span className="menu__slider-label">Hue</span>
|
|
<input
|
|
id={neutralHueInputId}
|
|
type="range"
|
|
min="0"
|
|
max="360"
|
|
step="1"
|
|
value={neutralHue}
|
|
onChange={(event) => handleNeutralHueChange(event.target.value)}
|
|
onDoubleClick={resetNeutralHue}
|
|
/>
|
|
<span className="menu__slider-value">{neutralHue}°</span>
|
|
</label>
|
|
<label className="menu__slider" htmlFor={neutralChromaInputId}>
|
|
<span className="menu__slider-label">Chroma</span>
|
|
<input
|
|
id={neutralChromaInputId}
|
|
type="range"
|
|
min="0"
|
|
max="1"
|
|
step="0.01"
|
|
value={neutralChroma}
|
|
onChange={(event) => handleNeutralChromaChange(event.target.value)}
|
|
onDoubleClick={resetNeutralChroma}
|
|
/>
|
|
<span className="menu__slider-value">{formatSliderValue(neutralChroma)}</span>
|
|
</label>
|
|
<label className="menu__slider" htmlFor={neutralContrastInputId}>
|
|
<span className="menu__slider-label">Contrast</span>
|
|
<input
|
|
id={neutralContrastInputId}
|
|
type="range"
|
|
min="0"
|
|
max="1"
|
|
step="0.01"
|
|
value={neutralContrast}
|
|
onChange={(event) => handleNeutralContrastChange(event.target.value)}
|
|
onDoubleClick={resetNeutralContrast}
|
|
/>
|
|
<span className="menu__slider-value">{formatSliderValue(neutralContrast)}</span>
|
|
</label>
|
|
</div>
|
|
)
|
|
: 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 (
|
|
<FolderNode
|
|
key={id}
|
|
node={node}
|
|
depth={depth}
|
|
isSelected={selectedFolder === id}
|
|
onToggle={() => 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 (
|
|
<aside className={sidebarClassNames.join(' ')} ref={sidebarRef}>
|
|
<button
|
|
type="button"
|
|
className={`sidebar__resize-handle${isResizingSidebar ? ' is-active' : ''}`}
|
|
aria-label="Resize sidebar"
|
|
onPointerDown={handleSidebarResizePointerDown}
|
|
onKeyDown={handleSidebarResizeKeyDown}
|
|
>
|
|
<span aria-hidden="true" />
|
|
</button>
|
|
<input
|
|
type="file"
|
|
ref={uploadInputRef}
|
|
style={{ display: 'none' }}
|
|
multiple
|
|
onChange={handleUploadInputChange}
|
|
/>
|
|
<PanelHeader
|
|
className="sidebar__header"
|
|
leading={(
|
|
<>
|
|
<button
|
|
type="button"
|
|
className={`sidebar__title-button${tenantMenuOpen ? ' is-open' : ''}`}
|
|
onClick={toggleTenantMenu}
|
|
aria-haspopup="true"
|
|
aria-expanded={tenantMenuOpen}
|
|
ref={tenantButtonRef}
|
|
>
|
|
<span className="sidebar__title">
|
|
Papercrate
|
|
{tenantName ? <span className="sidebar__tenant"> / {tenantName}</span> : null}
|
|
</span>
|
|
<ChevronDownIcon
|
|
className={`sidebar__title-chevron${tenantMenuOpen ? ' is-open' : ''}`}
|
|
size={16}
|
|
/>
|
|
</button>
|
|
{tenantMenuOpen
|
|
&& tenantMenuStyle
|
|
&& typeof document !== 'undefined'
|
|
? createPortal(
|
|
<div
|
|
className={menuClassName}
|
|
ref={tenantMenuRef}
|
|
role="menu"
|
|
aria-label="Account menu"
|
|
style={tenantMenuStyle}
|
|
>
|
|
{showTenantList ? (
|
|
<div className="menu__section">
|
|
<div className="menu__heading">Switch tenant</div>
|
|
<div className="menu__wrapper">
|
|
{tenants.map((tenant) => {
|
|
const tenantId = tenant?.id || null;
|
|
const isActive = tenantId === activeTenantId;
|
|
const tenantLabel = tenant?.name;
|
|
return (
|
|
<button
|
|
key={tenantId || tenantLabel}
|
|
type="button"
|
|
className={`menu__button${isActive ? ' active' : ''}`}
|
|
onClick={() => handleTenantSelect(tenant)}
|
|
role="menuitem"
|
|
>
|
|
<span className="menu__check-slot">
|
|
{isActive ? <CheckIcon size={16} /> : null}
|
|
</span>
|
|
<span className="menu__label">{tenantLabel}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<div className="menu__section">
|
|
<div className="menu__wrapper">
|
|
<button
|
|
type="button"
|
|
className="menu__button"
|
|
onClick={handleSettingsFromMenu}
|
|
>
|
|
<SettingsIcon size={16} />
|
|
Settings
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="menu__button menu__button--danger"
|
|
onClick={handleLogoutFromMenu}
|
|
>
|
|
<LogoutIcon size={16} />
|
|
Log out
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{themeMenuSection}
|
|
</div>,
|
|
document.body,
|
|
)
|
|
: null}
|
|
</>
|
|
)}
|
|
actions={(
|
|
<>
|
|
{handleCollapse ? (
|
|
<button
|
|
type="button"
|
|
className="icon-button sidebar__collapse-button"
|
|
onClick={handleCollapse}
|
|
aria-label="Collapse sidebar"
|
|
title="Collapse sidebar"
|
|
>
|
|
<SidebarCollapseIcon />
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={handleUploadButtonClick}
|
|
disabled={!onUploadFiles}
|
|
title="Upload documents"
|
|
aria-label="Upload documents"
|
|
>
|
|
<UploadIcon size={18} />
|
|
</button>
|
|
</>
|
|
)}
|
|
/>
|
|
<div className="panel-body sidebar__body">
|
|
{status && (
|
|
<div className="sidebar__status">
|
|
<div className={`status-banner ${status.variant}`}>{status.message}</div>
|
|
</div>
|
|
)}
|
|
{onSearchChange && (
|
|
<form className="sidebar__search" onSubmit={handleSearchFormSubmit}>
|
|
<input
|
|
type="search"
|
|
value={searchQuery}
|
|
onChange={handleSearchInputChange}
|
|
placeholder="Search documents"
|
|
aria-label="Search documents"
|
|
/>
|
|
{isFilterActive && (
|
|
<button type="button" onClick={handleSearchClear}>
|
|
Clear
|
|
</button>
|
|
)}
|
|
</form>
|
|
)}
|
|
<div className="sidebar-section sidebar-section--folders">
|
|
<div className="sidebar-section__header">
|
|
<h3>Folders</h3>
|
|
{onCreateFolder ? (
|
|
<div className="sidebar-section__actions">
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={handleCreateFolder}
|
|
aria-label="Create folder"
|
|
disabled={creatingFolder}
|
|
>
|
|
<FolderPlusIcon size={16} />
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<ul className="folder-tree">
|
|
{rootNode && renderNodes([rootNode.id], 0)}
|
|
</ul>
|
|
</div>
|
|
<div className="sidebar-section">
|
|
<div className="sidebar-section__header">
|
|
<h3>Tags</h3>
|
|
<div className="sidebar-section__actions">
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={handleCreateTag}
|
|
aria-label="Create tag"
|
|
>
|
|
<PlusIcon size={16} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={handleManageTags}
|
|
aria-label="Manage tags"
|
|
>
|
|
<SettingsIcon size={16} />
|
|
</button>
|
|
<span className="meta">{tags.length}</span>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={`sidebar-tag-cloud${
|
|
activeTagSet.size ? ' sidebar-tag-cloud--has-active' : ''
|
|
}`}
|
|
role="list"
|
|
>
|
|
{untaggedFilterId ? (
|
|
<button
|
|
type="button"
|
|
role="listitem"
|
|
className={`sidebar-tag-pill sidebar-tag-pill--untagged${
|
|
untaggedActive ? ' active' : ''
|
|
}`}
|
|
onClick={() => handleToggleTag(untaggedFilterId)}
|
|
aria-pressed={untaggedActive}
|
|
draggable={false}
|
|
>
|
|
No tag assigned
|
|
</button>
|
|
) : null}
|
|
{tags.map((tag) => {
|
|
const isActive = activeTagSet.has(tag.id);
|
|
const style = getTagColorStyle(tag.color);
|
|
const className = `sidebar-tag-pill${isActive ? ' active' : ''}`;
|
|
return (
|
|
<button
|
|
key={tag.id}
|
|
type="button"
|
|
role="listitem"
|
|
className={className}
|
|
style={style || undefined}
|
|
onClick={() => handleToggleTag(tag.id)}
|
|
aria-pressed={isActive}
|
|
draggable
|
|
onDragStart={(event) => {
|
|
try {
|
|
const payload = JSON.stringify({
|
|
id: tag.id,
|
|
label: tag.label,
|
|
color: tag.color || null,
|
|
});
|
|
event.dataTransfer.effectAllowed = 'copy';
|
|
event.dataTransfer.setData('application/x-papercrate-tag', payload);
|
|
event.dataTransfer.setData('text/papercrate-tag', payload);
|
|
} catch (error) {
|
|
console.warn('[sidebar] Failed to set tag drag payload', error);
|
|
}
|
|
}}
|
|
>
|
|
{tag.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div className="sidebar-section">
|
|
<div className="sidebar-section__header">
|
|
<h3>Correspondents</h3>
|
|
<div className="sidebar-section__actions">
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={handleCreateCorrespondent}
|
|
aria-label="Create correspondent"
|
|
>
|
|
<PlusIcon size={16} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={handleManageCorrespondents}
|
|
aria-label="Manage correspondents"
|
|
>
|
|
<SettingsIcon size={16} />
|
|
</button>
|
|
<span className="meta">{correspondents.length}</span>
|
|
</div>
|
|
</div>
|
|
<ul className="sidebar-correspondent-list">
|
|
{sortedCorrespondents.map((correspondent) => {
|
|
const isActive = activeCorrespondentSet.has(correspondent.id);
|
|
const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`;
|
|
const handleSelect = () => {
|
|
const nextId = isActive ? null : correspondent.id;
|
|
onToggleCorrespondentFilter?.(nextId);
|
|
};
|
|
return (
|
|
<li key={correspondent.id}>
|
|
<span
|
|
className={className}
|
|
role="button"
|
|
onClick={handleSelect}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
handleSelect();
|
|
}
|
|
}}
|
|
>
|
|
{correspondent.name}
|
|
</span>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
);
|
|
};
|
|
|
|
export default Sidebar;
|
|
export { FolderNode };
|