Files
papercrate/frontend/src/sidebar/Sidebar.jsx
T
2025-11-12 02:24:46 +01:00

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 };