495 lines
15 KiB
React
495 lines
15 KiB
React
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
ChevronIcon,
|
|
TrashIcon,
|
|
EditIcon,
|
|
FolderIcon,
|
|
ChevronsLeftIcon,
|
|
LogoutIcon,
|
|
ChevronDownIcon,
|
|
} from '../ui/icons';
|
|
|
|
import { getTagColorStyle } from '../utils/colors';
|
|
|
|
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)}
|
|
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">
|
|
<FolderIcon className="folder-icon-image" size={16} />
|
|
{node.name}
|
|
</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 Sidebar = ({
|
|
folderNodes,
|
|
onToggle,
|
|
onSelect,
|
|
onDrop,
|
|
onDragOver,
|
|
onDragLeave,
|
|
onDeleteFolder,
|
|
onRenameFolder,
|
|
selectedFolder,
|
|
onFolderDragStart,
|
|
onFolderDragEnd,
|
|
draggedFolderId,
|
|
tags = [],
|
|
activeTagIds = [],
|
|
onToggleTagFilter,
|
|
correspondents = [],
|
|
activeCorrespondentIds = [],
|
|
onToggleCorrespondentFilter,
|
|
onManageTags,
|
|
onManageCorrespondents,
|
|
searchQuery = '',
|
|
onSearchChange,
|
|
onSearchSubmit,
|
|
onSearchClear,
|
|
isFilterActive,
|
|
appStatus,
|
|
loading,
|
|
previewActive,
|
|
onLogout,
|
|
status,
|
|
onCollapse,
|
|
tenantSlug,
|
|
tenants = [],
|
|
activeTenantId = null,
|
|
onSelectTenant,
|
|
}) => {
|
|
const sortedCorrespondents = useMemo(
|
|
() =>
|
|
[...correspondents].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 handleManageTags = onManageTags || (() => {});
|
|
const handleManageCorrespondents = onManageCorrespondents || (() => {});
|
|
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 tenantMenuRef = useRef(null);
|
|
const [tenantMenuOpen, setTenantMenuOpen] = useState(false);
|
|
|
|
const toggleTenantMenu = useCallback(() => {
|
|
const next = !tenantMenuOpen;
|
|
setTenantMenuOpen(next);
|
|
if (next && tenants.length === 0 && onSelectTenant) {
|
|
onSelectTenant(null, { refreshOnly: true });
|
|
}
|
|
}, [tenantMenuOpen, tenants.length, onSelectTenant]);
|
|
|
|
useEffect(() => {
|
|
if (!tenantMenuOpen) {
|
|
return undefined;
|
|
}
|
|
const handlePointer = (event) => {
|
|
const menuNode = tenantMenuRef.current;
|
|
const buttonNode = tenantButtonRef.current;
|
|
if (!menuNode) return;
|
|
if (menuNode.contains(event.target)) return;
|
|
if (buttonNode && buttonNode.contains(event.target)) return;
|
|
setTenantMenuOpen(false);
|
|
};
|
|
const handleKeyDown = (event) => {
|
|
if (event.key === 'Escape') {
|
|
setTenantMenuOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handlePointer);
|
|
document.addEventListener('touchstart', handlePointer);
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => {
|
|
document.removeEventListener('mousedown', handlePointer);
|
|
document.removeEventListener('touchstart', handlePointer);
|
|
document.removeEventListener('keydown', handleKeyDown);
|
|
};
|
|
}, [tenantMenuOpen]);
|
|
|
|
const handleTenantSelect = useCallback(
|
|
(tenant) => {
|
|
const targetId = tenant?.tenant_id || tenant?.tenantId || tenant?.id || null;
|
|
if (!targetId) {
|
|
return;
|
|
}
|
|
setTenantMenuOpen(false);
|
|
onSelectTenant?.(tenant);
|
|
},
|
|
[onSelectTenant],
|
|
);
|
|
|
|
const handleLogoutFromMenu = useCallback(() => {
|
|
setTenantMenuOpen(false);
|
|
onLogout?.();
|
|
}, [onLogout]);
|
|
|
|
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');
|
|
return (
|
|
<aside className="sidebar">
|
|
<div className="panel-header sidebar__header">
|
|
<div className="panel-actions">
|
|
<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
|
|
{tenantSlug ? <span className="sidebar__tenant"> / {tenantSlug}</span> : null}
|
|
</span>
|
|
<ChevronDownIcon
|
|
className={`sidebar__title-chevron${tenantMenuOpen ? ' is-open' : ''}`}
|
|
size={16}
|
|
/>
|
|
</button>
|
|
{tenantMenuOpen ? (
|
|
<div className="menu" ref={tenantMenuRef} role="menu">
|
|
<div className="menu__list">
|
|
{tenants.length === 0 ? (
|
|
<span className="menu__empty">No tenants available.</span>
|
|
) : (
|
|
tenants.map((tenant) => {
|
|
const tenantId = tenant?.tenant_id || tenant?.tenantId || tenant?.id || null;
|
|
const isActive = tenantId === activeTenantId;
|
|
return (
|
|
<button
|
|
key={tenantId || tenant?.slug || tenant?.name}
|
|
type="button"
|
|
className={`menu__item${isActive ? ' active' : ''}`}
|
|
onClick={() => handleTenantSelect(tenant)}
|
|
role="menuitem"
|
|
>
|
|
<span className="menu__label">
|
|
{tenant?.slug || tenant?.name || tenantId || 'Tenant'}
|
|
</span>
|
|
{isActive ? <span className="menu__active-indicator">Active</span> : null}
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
<div className="menu__footer">
|
|
<button
|
|
type="button"
|
|
className="menu__logout"
|
|
onClick={handleLogoutFromMenu}
|
|
>
|
|
<LogoutIcon size={16} />
|
|
Log out
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<div className="spacer" />
|
|
{onCollapse ? (
|
|
<button
|
|
type="button"
|
|
className="icon-button ghost"
|
|
onClick={onCollapse}
|
|
aria-label="Collapse sidebar"
|
|
title="Collapse sidebar"
|
|
>
|
|
<ChevronsLeftIcon />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<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>
|
|
</div>
|
|
<ul className="folder-tree">
|
|
{rootNode && renderNodes([rootNode.id], 0)}
|
|
</ul>
|
|
</div>
|
|
<div className="sidebar-section">
|
|
<div className="sidebar-section__header">
|
|
<button
|
|
type="button"
|
|
className="sidebar-section__title"
|
|
onClick={handleManageTags}
|
|
>
|
|
<h3>Tags</h3>
|
|
<span className="meta">{tags.length}</span>
|
|
</button>
|
|
</div>
|
|
<div
|
|
className={`sidebar-tag-cloud${
|
|
activeTagSet.size ? ' sidebar-tag-cloud--has-active' : ''
|
|
}`}
|
|
role="list"
|
|
>
|
|
{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 (
|
|
// eslint-disable-next-line no-empty
|
|
error
|
|
) {}
|
|
}}
|
|
>
|
|
{tag.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div className="sidebar-section">
|
|
<div className="sidebar-section__header">
|
|
<button
|
|
type="button"
|
|
className="sidebar-section__title"
|
|
onClick={handleManageCorrespondents}
|
|
>
|
|
<h3>Correspondents</h3>
|
|
<span className="meta">{correspondents.length}</span>
|
|
</button>
|
|
</div>
|
|
<ul className="sidebar-correspondent-list">
|
|
{sortedCorrespondents.map((correspondent) => {
|
|
const isActive = activeCorrespondentSet.has(correspondent.id);
|
|
const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`;
|
|
const label = correspondent.name || 'Unnamed';
|
|
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();
|
|
}
|
|
}}
|
|
>
|
|
{label}
|
|
</span>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
);
|
|
};
|
|
|
|
export default Sidebar;
|
|
export { FolderNode };
|