Files
papercrate/frontend/src/sidebar/Sidebar.jsx
T
2025-10-31 13:39:06 +01:00

696 lines
21 KiB
React

import React, { useCallback, useEffect, useMemo, useRef, useState, useId } from 'react';
import {
ChevronIcon,
TrashIcon,
EditIcon,
FolderIcon,
ChevronsLeftIcon,
LogoutIcon,
ChevronDownIcon,
SettingsIcon,
CheckIcon,
PlusIcon,
FolderPlusIcon,
RestoreIcon,
SunIcon,
MoonIcon,
DesktopIcon,
} from '../ui/icons';
import PanelHeader from '../ui/PanelHeader';
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 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,
onNeutralHueChange,
neutralHue,
themeMode = 'system',
onThemeModeChange,
tags = [],
activeTagIds = [],
onToggleTagFilter,
correspondents = [],
activeCorrespondentIds = [],
onToggleCorrespondentFilter,
onManageTags,
onManageCorrespondents,
onCreateTag,
onCreateCorrespondent,
searchQuery = '',
onSearchChange,
onSearchSubmit,
onSearchClear,
isFilterActive,
onLogout,
status,
onCollapse,
tenantName,
tenants = [],
activeTenantId = null,
onSelectTenant,
onOpenSettings,
}) => {
const neutralHueInputId = 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 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 handleNeutralHueReset = useCallback(() => {
onNeutralHueChange?.('');
}, [onNeutralHueChange]);
const themeModeIndex = THEME_MODES.indexOf(themeMode);
const safeThemeModeIndex = themeModeIndex === -1 ? 0 : themeModeIndex;
const resolvedThemeMode = THEME_MODES[safeThemeModeIndex];
const nextThemeMode = THEME_MODES[(safeThemeModeIndex + 1) % THEME_MODES.length];
const themeModeLabel = THEME_MODE_LABELS[resolvedThemeMode];
const nextThemeLabel = THEME_MODE_LABELS[nextThemeMode];
const themeModeIcon = resolvedThemeMode === 'dark'
? <MoonIcon size={16} />
: resolvedThemeMode === 'light'
? <SunIcon size={16} />
: <DesktopIcon size={16} />;
const handleThemeModeToggle = useCallback(() => {
if (!onThemeModeChange) {
return;
}
const currentIndex = THEME_MODES.indexOf(themeMode);
const safeIndex = currentIndex === -1 ? 0 : currentIndex;
const nextMode = THEME_MODES[(safeIndex + 1) % THEME_MODES.length];
onThemeModeChange(nextMode);
}, [onThemeModeChange, themeMode]);
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 showTenantList = tenants.length > 1;
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?.id || null;
if (!targetId) {
return;
}
setTenantMenuOpen(false);
onSelectTenant?.(tenant);
},
[onSelectTenant],
);
const handleLogoutFromMenu = useCallback(() => {
setTenantMenuOpen(false);
onLogout?.();
}, [onLogout]);
const handleSettingsFromMenu = useCallback(() => {
setTenantMenuOpen(false);
onOpenSettings?.();
}, [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 themeControls =
typeof neutralHue === 'number' || typeof neutralHue === 'string'
? (
<div className="sidebar-section sidebar-theme">
<div className="sidebar-section__header">
<h3>Theme</h3>
<div className="sidebar-section__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={handleNeutralHueReset}
aria-label="Reset neutral hue"
title="Reset neutral hue"
>
<RestoreIcon size={16} />
</button>
</div>
</div>
<label className="sidebar-slider" htmlFor={neutralHueInputId}>
<span className="sidebar-slider__label">Hue</span>
<input
id={neutralHueInputId}
type="range"
min="0"
max="360"
step="1"
value={neutralHue}
onChange={(event) => onNeutralHueChange?.(event.target.value)}
/>
<span className="sidebar-slider__value">{neutralHue}°</span>
</label>
</div>
)
: null;
return (
<aside className="sidebar">
<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 ? (
<div
className={`menu${showTenantList ? '' : ' menu--simple'}`}
ref={tenantMenuRef}
role="menu"
aria-label="Account menu"
>
{showTenantList ? (
<>
<div className="menu__heading">Switch tenant</div>
<div className="menu__list">
{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__item${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>
</>
) : null}
<div className="menu__footer">
<button
type="button"
className="menu__settings"
onClick={handleSettingsFromMenu}
>
<SettingsIcon size={16} />
Settings
</button>
<button
type="button"
className="menu__logout"
onClick={handleLogoutFromMenu}
>
<LogoutIcon size={16} />
Log out
</button>
</div>
</div>
) : null}
</>
)}
actions={
onCollapse
? [
(
<button
key="collapse"
type="button"
className="icon-button"
onClick={onCollapse}
aria-label="Collapse sidebar"
title="Collapse sidebar"
>
<ChevronsLeftIcon />
</button>
),
]
: null
}
/>
<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"
>
{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 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>
{themeControls ? <div className="sidebar__footer">{themeControls}</div> : null}
</aside>
);
};
export default Sidebar;
export { FolderNode };