feat: Rework sidebar with dedicated components for menu, folders, tags, correspondents, and search, including tenant switching and theme controls.

This commit is contained in:
2025-11-25 15:09:20 +01:00
parent 2c42e63bab
commit d6206786f7
14 changed files with 1192 additions and 896 deletions
@@ -26,8 +26,10 @@ export interface UseDocumentsPanelPropsArgs {
refreshCurrentFolder?: () => void | Promise<void>;
currentSubfolders?: unknown[];
documents?: unknown[];
searchQuery?: string;
searchResultIds?: Identifier[] | null;
folderClickHandlers: FolderClickHandlers;
selectedFolder?: Identifier | 'root' | null;
selectFolder?: (...args: unknown[]) => void;
handleFolderDragStart?: (...args: unknown[]) => void;
handleFolderDragEnd?: (...args: unknown[]) => void;
@@ -41,6 +43,7 @@ export interface UseDocumentsPanelPropsArgs {
handleDocumentDragEnd?: (...args: unknown[]) => void;
searchLoading?: boolean;
tagLookupById?: unknown;
activeTagFilters?: Identifier[];
activeCorrespondentFilters?: Identifier[];
ensureAssetUrl?: (...args: unknown[]) => void;
getDocumentAsset?: (...args: unknown[]) => unknown;
@@ -80,8 +83,10 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
refreshCurrentFolder,
currentSubfolders,
documents,
searchQuery,
searchResultIds,
folderClickHandlers,
selectedFolder,
selectFolder,
handleFolderDragStart,
handleFolderDragEnd,
@@ -95,6 +100,7 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
handleDocumentDragEnd,
searchLoading,
tagLookupById,
activeTagFilters,
activeCorrespondentFilters,
ensureAssetUrl,
getDocumentAsset,
@@ -131,7 +137,9 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
onRefresh: refreshCurrentFolder,
subfolders: currentSubfolders,
documents,
searchQuery,
searchResultIds,
selectedFolder,
onFolderSelect: selectFolder,
onFolderDrop: folderClickHandlers.onDrop,
onFolderDragOver: folderClickHandlers.onDragOver,
@@ -148,6 +156,8 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
onDocumentDragEnd: handleDocumentDragEnd,
isSearchLoading: searchLoading,
tagLookupById,
activeTagFilters,
activeCorrespondentFilters,
activeCorrespondentIds: activeCorrespondentFilters,
ensureAssetUrl,
getDocumentAsset,
@@ -186,6 +196,7 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
documentsSortDirection,
documentsSortField,
documentsViewMode,
searchQuery,
documentLookup,
draggedDocumentIds,
draggedFolderId,
@@ -214,8 +225,11 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
refreshCurrentFolder,
searchLoading,
searchResultIds,
selectedFolder,
selectFolder,
tagLookupById,
activeTagFilters,
activeCorrespondentFilters,
tags,
ensureAssetUrl,
getDocumentAsset,
@@ -132,10 +132,10 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
onBulkReanalyze,
folderOptions,
onMoveDocumentsToFolder,
searchQuery,
activeTagFilters,
activeCorrespondentFilters,
selectedFolder,
searchQuery = '',
activeTagFilters = [],
activeCorrespondentFilters = [],
selectedFolder = null,
promoteSelectionOrder,
onDocumentTagDrop,
currentTenantId,
@@ -811,6 +811,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
onTagClick: toggleTagFilter,
onCorrespondentClick: toggleCorrespondentFilter,
activeCorrespondentIdSet: activeCorrespondentIdSet,
activeTagFilters,
scrollRef,
// Desk specific
onDocumentStackSelect: handleDeskDocumentStackSelect,
@@ -350,6 +350,7 @@ const useDocumentsWorkspace = ({
searchResultIds,
setSearchResultIds,
searchLoading,
activeTagFilters,
setActiveTagFilters,
activeCorrespondentFilters,
setActiveCorrespondentFilters,
@@ -966,7 +967,7 @@ const useDocumentsWorkspace = ({
const handlePromptCreateFolder = useCallback(async () => {
const handlePromptCreateFolder = useCallback(async (parentId?: Identifier | null) => {
if (creatingFolder) {
return;
}
@@ -981,7 +982,7 @@ const useDocumentsWorkspace = ({
}
setCreatingFolder(true);
try {
const success = await handleFolderCreate(trimmed);
const success = await handleFolderCreate(trimmed, parentId);
if (!success) {
setStatusMessage('Unable to create folder. Check the status message for details.', 'error');
}
@@ -1198,8 +1199,10 @@ const useDocumentsWorkspace = ({
refreshCurrentFolder,
currentSubfolders,
documents: viewDocuments,
searchQuery,
searchResultIds,
folderClickHandlers,
selectedFolder,
handleFolderDragStart,
handleFolderDragEnd,
draggedFolderId,
@@ -1211,6 +1214,7 @@ const useDocumentsWorkspace = ({
handleDocumentDragEnd,
searchLoading,
tagLookupById,
activeTagFilters,
activeCorrespondentFilters,
ensureAssetUrl,
getDocumentAsset,
@@ -170,7 +170,8 @@ const useFolderTree = ({
],
);
const expandFolderAncestors = useCallback((targetId: FolderId | null) => {
const expandFolderAncestors = useCallback((targetId: FolderId | null, options: { includeSelf?: boolean } = {}) => {
const { includeSelf = true } = options;
if (!targetId || targetId === 'root') {
setFolderNodes((prev: Map<FolderId, FolderTreeNode>) => {
const root = prev.get('root');
@@ -186,6 +187,13 @@ const useFolderTree = ({
const next = new Map<FolderId, FolderTreeNode>(prev);
let currentId = targetId;
let guard = 0;
// If not including self, start from parent
if (!includeSelf) {
const node = next.get(currentId);
currentId = node?.parentId ?? 'root';
}
while (currentId && guard < 32) {
guard += 1;
const node = next.get(currentId);
@@ -59,7 +59,7 @@ interface UseFolderTreeActionsOptions {
setSelectedFolder: (folderId: FolderKey) => void;
ensureFolderData: (folderId: FolderKey, options?: EnsureFolderOptions) => Promise<any>;
ensureFolderAncestorsLoaded: (folderId: FolderKey) => Promise<void>;
expandFolderAncestors: (folderId: FolderKey) => void;
expandFolderAncestors: (folderId: FolderKey, options?: { includeSelf?: boolean }) => void;
applySelectedFolder: (folderId: FolderKey, contents: any) => void;
notifyApiError: (error: unknown, message?: string) => void;
setStatusMessage: (message: string, level?: string) => void;
@@ -250,7 +250,7 @@ const useFolderTreeActions = ({
const targetId = folderId && folderId !== 'root' ? folderId : 'root';
await ensureFolderAncestorsLoaded(targetId);
expandFolderAncestors(targetId);
expandFolderAncestors(targetId, { includeSelf: false });
if (!navigate || immediate) {
await loadFolder(targetId, { preserveSearch: isFilterActive });
@@ -327,7 +327,7 @@ const useFolderTreeActions = ({
);
const handleFolderCreate = useCallback(
async (name: string) => {
async (name: string, parentId?: FolderKey | null) => {
if (!token) {
setStatusMessage('Log in to create folders.', 'error');
return false;
@@ -336,9 +336,14 @@ const useFolderTreeActions = ({
setStatusMessage('Folder name cannot be empty.', 'error');
return false;
}
const targetParentId = parentId !== undefined
? (parentId === 'root' ? null : parentId)
: (selectedFolder === 'root' ? null : selectedFolder);
const payload = {
name: name.trim(),
parent_id: selectedFolder === 'root' ? null : selectedFolder,
parent_id: targetParentId,
};
setCreatingFolder(true);
let succeeded = false;
@@ -372,7 +377,11 @@ const useFolderTreeActions = ({
});
return next;
});
await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 });
// Refresh the parent folder to ensure consistency
const refreshTarget = targetParentId || 'root';
await ensureFolderData(refreshTarget, { force: true, prefetchDepth: 1 });
await selectFolder(folderData.id, { immediate: true });
succeeded = true;
return true;
+53 -882
View File
@@ -1,110 +1,16 @@
import React, { useCallback, useEffect, useMemo, useRef, useId } from 'react';
import type { CSSProperties, ReactNode } from 'react';
import { createPortal } from 'react-dom';
import {
ChevronIcon,
TrashIcon,
EditIcon,
FolderIcon,
SidebarCollapseIcon,
LogoutIcon,
ChevronDownIcon,
SettingsIcon,
CheckIcon,
PlusIcon,
FolderPlusIcon,
RestoreIcon,
SunIcon,
MoonIcon,
DesktopIcon,
UploadIcon,
LogoIcon,
GithubIcon,
MatrixIcon,
WorldIcon,
} from '../ui/icons';
import PanelHeader from '../ui/PanelHeader';
import useFloatingMenu from '../ui/useFloatingMenu';
import { useDocumentsFilter } from '../documents/context/DocumentsFilterContext';
import React, { useRef } from 'react';
import { getTagColorStyle } from '../utils/colors';
import { useSidebarContext } from './SidebarContext';
import { usePanelManager, usePanelResizeBindings } from '../app/PanelManagerContext';
import type { StatusMessage } from '../hooks/documents/store/useDocumentsStore';
import type { Identifier } from '../types/identifiers';
import { THEME_MODE_LABELS, THEME_MODES } from '../constants/sidebar';
interface CommunityLink {
label: string;
href: string;
title: string;
Icon: typeof GithubIcon;
}
const COMMUNITY_LINKS: CommunityLink[] = [
{
label: 'GitHub',
href: 'https://github.com/papercrate-dms/papercrate',
title: 'Open Papercrate on GitHub',
Icon: GithubIcon,
},
{
label: 'Matrix',
href: 'https://matrix.to/#/#papercrate:matrix.org',
title: 'Join the Papercrate Matrix room',
Icon: MatrixIcon,
},
{
label: 'Website',
href: 'https://papercrate.org',
title: 'Visit papercrate.org',
Icon: WorldIcon,
},
];
type FolderIdentifier = Identifier | 'root';
interface FolderTreeNode {
id: FolderIdentifier;
name?: string;
children: FolderIdentifier[];
hasChildren?: boolean;
loaded?: boolean;
expanded?: boolean;
}
interface FolderNodeProps {
node: FolderTreeNode;
depth: number;
isSelected: boolean;
onToggle: (folderId: FolderIdentifier) => void;
onSelect: (folderId: FolderIdentifier) => void;
onDrop: (event: React.DragEvent<HTMLDivElement>, folderId: FolderIdentifier) => void;
onDragOver: (event: React.DragEvent<HTMLDivElement>, folderId: FolderIdentifier) => void;
onDragLeave: (event: React.DragEvent<HTMLDivElement>) => void;
onDelete: (folderId: FolderIdentifier) => void;
onRename?: (folderId: FolderIdentifier, name: string) => void;
renderChildren: (ids: FolderIdentifier[], depth: number) => ReactNode;
onFolderDragStart?: (event: React.DragEvent<HTMLDivElement>, folderId: FolderIdentifier) => void;
onFolderDragEnd?: (event: React.DragEvent<HTMLDivElement>) => void;
draggingFolderId?: FolderIdentifier | null;
}
interface TagEntry {
id: Identifier;
label: string;
color?: string | null;
}
interface CorrespondentEntry {
id: Identifier;
name: string;
}
interface TenantOption {
id?: Identifier | null;
name?: string | null;
}
import SidebarFolderList from './components/SidebarFolderList';
import SidebarTagList, { TagEntry } from './components/SidebarTagList';
import SidebarCorrespondentList, { CorrespondentEntry } from './components/SidebarCorrespondentList';
import { FolderIdentifier, FolderTreeNode } from './components/SidebarFolderNode';
import { TenantOption } from './components/SidebarMenu';
import SidebarHeader from './components/SidebarHeader';
import SidebarSearch from './components/SidebarSearch';
type UploadHandler = (
files: FileList | File[] | Iterable<File>,
@@ -124,7 +30,7 @@ interface SidebarProps {
onFolderDragStart?: (event: React.DragEvent<HTMLDivElement>, folderId: FolderIdentifier) => void;
onFolderDragEnd?: (event: React.DragEvent<HTMLDivElement>) => void;
draggedFolderId?: FolderIdentifier | null;
onCreateFolder?: () => void;
onCreateFolder?: (parentId?: Identifier | null) => void;
creatingFolder?: boolean;
tags?: TagEntry[];
untaggedFilterId?: Identifier | null;
@@ -143,133 +49,6 @@ interface SidebarProps {
onUploadFiles?: UploadHandler;
}
const FolderNode: React.FC<FolderNodeProps> = ({
node,
depth,
isSelected,
onToggle,
onSelect,
onDrop,
onDragOver,
onDragLeave,
onDelete,
onRename,
renderChildren,
onFolderDragStart,
onFolderDragEnd,
draggingFolderId,
}) => {
const isRoot = node.id === 'root';
const childIds = Array.isArray(node.children) ? node.children : [];
const hasChildren = Boolean(node.hasChildren || childIds.length);
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: React.MouseEvent<HTMLSpanElement>) => {
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) => {
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 && childIds.length > 0 && (
<ul className={`folder-children${depth === 0 ? ' folder-children--level1' : ''}`}>
{renderChildren(childIds, depth + 1)}
</ul>
)}
</li>
);
};
interface FloatingMenuControls {
isOpen: boolean;
toggle: () => void;
close: () => void;
menuRef: React.RefObject<HTMLDivElement>;
menuStyle: CSSProperties | null;
updatePosition: () => void;
}
const Sidebar: React.FC<SidebarProps> = ({
folderNodes,
onToggle,
@@ -301,33 +80,10 @@ const Sidebar: React.FC<SidebarProps> = ({
onOpenSettings,
onUploadFiles,
}) => {
const {
neutralHue,
setNeutralHue,
resetNeutralHue,
neutralChroma,
setNeutralChroma,
resetNeutralChroma,
themeMode,
cycleThemeMode,
themeModes,
} = useSidebarContext();
const {
sidebarSuppressed,
collapseSidebar,
} = usePanelManager();
const {
query: searchQuery,
activeTagIds = [],
activeCorrespondentIds = [],
isActive: isFilterActive,
setQuery: setFilterQuery,
submit: submitFilter,
clear: clearFilterState,
toggleTag: toggleTagFilter,
toggleCorrespondent: toggleCorrespondentFilter,
} = useDocumentsFilter();
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const sidebarRef = useRef<HTMLDivElement | null>(null);
const {
panelStyle: sidebarStyle,
@@ -335,358 +91,6 @@ const Sidebar: React.FC<SidebarProps> = ({
isPanelResizing: isResizingSidebar,
} = usePanelResizeBindings('sidebar', { panelRef: sidebarRef });
const handleCollapse = useCallback(() => {
collapseSidebar('sidebar');
}, [collapseSidebar]);
const neutralHueInputId = useId();
const neutralChromaInputId = useId();
const sortedCorrespondents = useMemo<CorrespondentEntry[]>(() => {
if (!Array.isArray(correspondents)) {
return [];
}
return correspondents
.filter((entry): entry is CorrespondentEntry & { name: string } => Boolean(entry?.name))
.slice()
.sort((a, b) => a.name!.localeCompare(b.name!, undefined, { sensitivity: 'base' }));
}, [correspondents]);
const activeCorrespondentSet = useMemo(
() => new Set<Identifier>(activeCorrespondentIds || []),
[activeCorrespondentIds],
);
const handleToggleTag = useCallback(
(tagId: Identifier | null) => {
if (tagId == null) {
return;
}
toggleTagFilter(tagId);
},
[toggleTagFilter],
);
const activeTagSet = useMemo(
() => new Set<Identifier | null>(activeTagIds || []),
[activeTagIds],
);
const untaggedActive = untaggedFilterId ? activeTagSet.has(untaggedFilterId) : false;
const untaggedButtonClassName = ['badge', 'tag-chip', 'tag-chip--untagged', untaggedActive ? 'active' : null]
.filter(Boolean)
.join(' ');
const getTagChipClassName = (isActive: boolean) =>
['badge', 'tag-chip', isActive ? 'active' : null]
.filter(Boolean)
.join(' ');
const handleManageTags = useCallback(() => {
onManageTags?.();
}, [onManageTags]);
const handleManageCorrespondents = useCallback(() => {
onManageCorrespondents?.();
}, [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: unknown) {
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: unknown) {
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: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target?.files;
if (files && files.length && onUploadFiles) {
onUploadFiles(files, selectedFolder ?? 'root');
}
if (event.target) {
event.target.value = '';
}
},
[onUploadFiles, selectedFolder],
);
const handleThemeAdjustmentsReset = useCallback(() => {
resetNeutralHue();
resetNeutralChroma();
}, [resetNeutralHue, resetNeutralChroma]);
const handleNeutralHueChange = useCallback(
(value: string) => {
if (value === '') {
resetNeutralHue();
return;
}
const parsed = Number(value);
if (Number.isNaN(parsed)) {
return;
}
setNeutralHue(parsed);
},
[resetNeutralHue, setNeutralHue],
);
const handleNeutralChromaChange = useCallback(
(value: string) => {
if (value === '') {
resetNeutralChroma();
return;
}
const parsed = Number(value);
if (Number.isNaN(parsed)) {
return;
}
setNeutralChroma(parsed);
},
[resetNeutralChroma, setNeutralChroma],
);
const formatSliderValue = useCallback((value: number | string) => {
const parsed = Number(value);
if (Number.isNaN(parsed)) {
return String(value);
}
return parsed.toFixed(2);
}, []);
const themeModeList = Array.isArray(themeModes) && themeModes.length ? themeModes : THEME_MODES;
const themeModeIndex = themeModeList.indexOf(themeMode as string);
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 =
neutralHue != null
? (
<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>
</div>
)
: null;
const communityMenuFooter = COMMUNITY_LINKS.length
? (
<div className="menu__footer">
{COMMUNITY_LINKS.map(({ href, label, title, Icon }) => (
<a
key={href}
href={href}
target="_blank"
rel="noreferrer noopener"
className="menu__footer-link"
title={title}
>
<Icon size={16} stroke={1.5} />
<span>{label}</span>
</a>
))}
</div>
)
: null;
const handleSearchInputChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setFilterQuery(event.target.value);
},
[setFilterQuery],
);
const handleSearchFormSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
submitFilter();
},
[submitFilter],
);
const handleSearchClear = useCallback(() => {
clearFilterState();
}, [clearFilterState]);
const tenantButtonRef = useRef<HTMLButtonElement | null>(null);
const {
isOpen: tenantMenuOpen,
toggle: toggleTenantMenuFloating,
close: closeTenantMenu,
menuRef: tenantMenuRef,
menuStyle: tenantMenuStyle,
updatePosition: refreshTenantMenuPosition,
} = useFloatingMenu({
anchorRef: tenantButtonRef,
minWidth: 220,
offset: 6,
}) as FloatingMenuControls;
const showTenantList = tenants.length > 1;
const menuClassName = `menu${!showTenantList && !themeMenuSection && !communityMenuFooter ? ' 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: TenantOption | null) => {
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: FolderIdentifier[], depth: number): ReactNode =>
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}
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');
@@ -695,64 +99,6 @@ const Sidebar: React.FC<SidebarProps> = ({
sidebarClassNames.push('sidebar--suppressed');
}
const tenantMenuContent = tenantMenuOpen && tenantMenuStyle
? 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 || tenantId || 'Tenant';
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}
{communityMenuFooter}
</div>,
document.body,
)
: null;
return (
<aside className={sidebarClassNames.join(' ')} ref={sidebarRef} style={sidebarStyle}>
<button
@@ -763,234 +109,59 @@ const Sidebar: React.FC<SidebarProps> = ({
>
<span aria-hidden="true" />
</button>
<input
type="file"
ref={uploadInputRef}
style={{ display: 'none' }}
multiple
onChange={handleUploadInputChange}
/>
<PanelHeader
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">
<LogoIcon className="sidebar__logo" variant="small" />
<span className="sidebar__title-text">Papercrate</span>
{tenantName ? <span className="sidebar__tenant"> / {tenantName}</span> : null}
</span>
<ChevronDownIcon
className={`sidebar__title-chevron${tenantMenuOpen ? ' is-open' : ''}`}
size={16}
/>
</button>
{tenantMenuContent}
</>
)}
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>
</>
)}
<SidebarHeader
tenantName={tenantName}
tenants={tenants}
activeTenantId={activeTenantId}
onSelectTenant={onSelectTenant}
onOpenSettings={onOpenSettings}
onLogout={onLogout}
onUploadFiles={onUploadFiles}
selectedFolder={selectedFolder}
/>
<div className="panel-body sidebar__body">
{status && (
<div className="sidebar__status">
<div className={`status-banner ${status.variant}`}>{status.message}</div>
</div>
)}
<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={untaggedButtonClassName}
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 = getTagChipClassName(isActive);
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;
toggleCorrespondentFilter(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>
<SidebarSearch />
<SidebarFolderList
folderNodes={folderNodes}
selectedFolder={selectedFolder}
onToggle={onToggle}
onSelect={onSelect}
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDeleteFolder={onDeleteFolder}
onRenameFolder={onRenameFolder}
onFolderDragStart={onFolderDragStart}
onFolderDragEnd={onFolderDragEnd}
draggedFolderId={draggedFolderId}
onCreateFolder={onCreateFolder}
creatingFolder={creatingFolder}
/>
<SidebarTagList
tags={tags}
untaggedFilterId={untaggedFilterId}
onCreateTag={onCreateTag}
onManageTags={onManageTags}
/>
<SidebarCorrespondentList
correspondents={correspondents}
onCreateCorrespondent={onCreateCorrespondent}
onManageCorrespondents={onManageCorrespondents}
/>
</div>
</aside>
);
};
export default Sidebar;
export { FolderNode };
@@ -0,0 +1,117 @@
import React, { useCallback, useMemo } from 'react';
import { PlusIcon, SettingsIcon } from '../../ui/icons';
import type { Identifier } from '../../types/identifiers';
import { useDocumentsFilter } from '../../documents/context/DocumentsFilterContext';
export interface CorrespondentEntry {
id: Identifier;
name: string;
}
interface SidebarCorrespondentListProps {
correspondents: CorrespondentEntry[];
onCreateCorrespondent?: (name: string) => Promise<void> | void;
onManageCorrespondents?: () => void;
}
const SidebarCorrespondentList: React.FC<SidebarCorrespondentListProps> = ({
correspondents,
onCreateCorrespondent,
onManageCorrespondents,
}) => {
const {
activeCorrespondentIds,
toggleCorrespondent: toggleCorrespondentFilter,
} = useDocumentsFilter();
const sortedCorrespondents = useMemo<CorrespondentEntry[]>(() => {
if (!Array.isArray(correspondents)) {
return [];
}
return correspondents
.filter((entry): entry is CorrespondentEntry & { name: string } => Boolean(entry?.name))
.slice()
.sort((a, b) => a.name!.localeCompare(b.name!, undefined, { sensitivity: 'base' }));
}, [correspondents]);
const activeCorrespondentSet = useMemo(
() => new Set<Identifier>(activeCorrespondentIds || []),
[activeCorrespondentIds],
);
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: unknown) {
console.error('[sidebar] failed to create correspondent', error);
}
}, [onCreateCorrespondent]);
const handleToggleCorrespondent = useCallback((correspondentId: Identifier | null) => {
toggleCorrespondentFilter(correspondentId);
}, [toggleCorrespondentFilter]);
return (
<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={onManageCorrespondents}
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;
handleToggleCorrespondent(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>
);
};
export default SidebarCorrespondentList;
@@ -0,0 +1,110 @@
import React, { useCallback } from 'react';
import type { ReactNode } from 'react';
import type { Identifier } from '../../types/identifiers';
import { FolderPlusIcon } from '../../ui/icons';
import FolderNode, { FolderIdentifier, FolderTreeNode } from './SidebarFolderNode';
interface SidebarFolderListProps {
folderNodes: Map<FolderIdentifier, FolderTreeNode>;
selectedFolder: FolderIdentifier | null;
onToggle: (folderId: FolderIdentifier) => void;
onSelect: (folderId: FolderIdentifier) => void;
onDrop: (event: React.DragEvent<HTMLDivElement>, folderId: FolderIdentifier) => void;
onDragOver: (event: React.DragEvent<HTMLDivElement>, folderId: FolderIdentifier) => void;
onDragLeave: (event: React.DragEvent<HTMLDivElement>) => void;
onDeleteFolder: (folderId: FolderIdentifier) => void;
onRenameFolder?: (folderId: FolderIdentifier, name: string) => void;
onFolderDragStart?: (event: React.DragEvent<HTMLDivElement>, folderId: FolderIdentifier) => void;
onFolderDragEnd?: (event: React.DragEvent<HTMLDivElement>) => void;
draggedFolderId?: FolderIdentifier | null;
onCreateFolder?: (parentId?: Identifier | null) => void;
creatingFolder?: boolean;
}
const SidebarFolderList: React.FC<SidebarFolderListProps> = ({
folderNodes,
selectedFolder,
onToggle,
onSelect,
onDrop,
onDragOver,
onDragLeave,
onDeleteFolder,
onRenameFolder,
onFolderDragStart,
onFolderDragEnd,
draggedFolderId,
onCreateFolder,
creatingFolder,
}) => {
const rootNode = folderNodes.get('root');
const renderNodes = useCallback(
(ids: FolderIdentifier[], depth: number): ReactNode =>
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}
onSelect={onSelect}
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDelete={onDeleteFolder}
onRename={onRenameFolder}
renderChildren={renderNodes}
onFolderDragStart={onFolderDragStart}
onFolderDragEnd={onFolderDragEnd}
draggingFolderId={draggedFolderId}
onCreateFolder={onCreateFolder}
/>
);
}),
[
folderNodes,
selectedFolder,
onToggle,
onSelect,
onDrop,
onDragOver,
onDragLeave,
onDeleteFolder,
onRenameFolder,
onFolderDragStart,
onFolderDragEnd,
draggedFolderId,
onCreateFolder,
],
);
return (
<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={() => onCreateFolder('root')}
aria-label="Create folder"
disabled={creatingFolder}
>
<FolderPlusIcon size={16} />
</button>
</div>
) : null}
</div>
<ul className="folder-tree">
{rootNode && renderNodes([rootNode.id], 0)}
</ul>
</div>
);
};
export default SidebarFolderList;
@@ -0,0 +1,174 @@
import React from 'react';
import type { ReactNode } from 'react';
import {
ChevronIcon,
TrashIcon,
EditIcon,
FolderIcon,
FolderPlusIcon,
} from '../../ui/icons';
import type { Identifier } from '../../types/identifiers';
export type FolderIdentifier = Identifier | 'root';
export interface FolderTreeNode {
id: FolderIdentifier;
name?: string;
children: FolderIdentifier[];
hasChildren?: boolean;
loaded?: boolean;
expanded?: boolean;
}
export interface FolderNodeProps {
node: FolderTreeNode;
depth: number;
isSelected: boolean;
onToggle: (folderId: FolderIdentifier) => void;
onSelect: (folderId: FolderIdentifier) => void;
onDrop: (event: React.DragEvent<HTMLDivElement>, folderId: FolderIdentifier) => void;
onDragOver: (event: React.DragEvent<HTMLDivElement>, folderId: FolderIdentifier) => void;
onDragLeave: (event: React.DragEvent<HTMLDivElement>) => void;
onDelete: (folderId: FolderIdentifier) => void;
onRename?: (folderId: FolderIdentifier, name: string) => void;
renderChildren: (ids: FolderIdentifier[], depth: number) => ReactNode;
onFolderDragStart?: (event: React.DragEvent<HTMLDivElement>, folderId: FolderIdentifier) => void;
onFolderDragEnd?: (event: React.DragEvent<HTMLDivElement>) => void;
draggingFolderId?: FolderIdentifier | null;
onCreateFolder?: (parentId?: Identifier | null) => void;
}
const FolderNode: React.FC<FolderNodeProps> = ({
node,
depth,
isSelected,
onToggle,
onSelect,
onDrop,
onDragOver,
onDragLeave,
onDelete,
onRename,
renderChildren,
onFolderDragStart,
onFolderDragEnd,
draggingFolderId,
onCreateFolder,
}) => {
const isRoot = node.id === 'root';
const childIds = Array.isArray(node.children) ? node.children : [];
const hasChildren = Boolean(node.hasChildren || childIds.length);
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: React.MouseEvent<HTMLSpanElement>) => {
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) => {
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>
<div className="folder-row__actions">
<button
type="button"
className="icon-button ghost"
onClick={(event) => {
event.stopPropagation();
onCreateFolder?.(node.id);
}}
title="Create subfolder"
aria-label={`Create subfolder in ${node.name}`}
>
<FolderPlusIcon className="icon-edit" size={18} />
</button>
{node.id !== 'root' && (
<>
<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 && childIds.length > 0 && (
<ul className={`folder-children${depth === 0 ? ' folder-children--level1' : ''}`}>
{renderChildren(childIds, depth + 1)}
</ul>
)}
</li>
);
};
export default FolderNode;
@@ -0,0 +1,176 @@
import React, { useCallback, useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
import {
SidebarCollapseIcon,
ChevronDownIcon,
UploadIcon,
LogoIcon,
} from '../../ui/icons';
import PanelHeader from '../../ui/PanelHeader';
import useFloatingMenu from '../../ui/useFloatingMenu';
import type { Identifier } from '../../types/identifiers';
import SidebarMenu, { TenantOption } from './SidebarMenu';
import { usePanelManager } from '../../app/PanelManagerContext';
import { FolderIdentifier } from './SidebarFolderNode';
type UploadHandler = (
files: FileList | File[] | Iterable<File>,
targetFolderId?: FolderIdentifier | null,
) => void;
interface SidebarHeaderProps {
tenantName?: string | null;
tenants: TenantOption[];
activeTenantId: Identifier | null;
onSelectTenant?: (tenant: TenantOption | null, options?: { refreshOnly?: boolean }) => void;
onOpenSettings?: () => void;
onLogout?: () => void;
onUploadFiles?: UploadHandler;
selectedFolder?: FolderIdentifier | null;
}
interface FloatingMenuControls {
isOpen: boolean;
toggle: () => void;
close: () => void;
menuRef: React.RefObject<HTMLDivElement>;
menuStyle: CSSProperties | null;
updatePosition: () => void;
}
const SidebarHeader: React.FC<SidebarHeaderProps> = ({
tenantName,
tenants,
activeTenantId,
onSelectTenant,
onOpenSettings,
onLogout,
onUploadFiles,
selectedFolder,
}) => {
const { collapseSidebar } = usePanelManager();
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const tenantButtonRef = useRef<HTMLButtonElement | null>(null);
const {
isOpen: tenantMenuOpen,
toggle: toggleTenantMenuFloating,
close: closeTenantMenu,
menuRef: tenantMenuRef,
menuStyle: tenantMenuStyle,
updatePosition: refreshTenantMenuPosition,
} = useFloatingMenu({
anchorRef: tenantButtonRef,
minWidth: 220,
offset: 6,
}) as FloatingMenuControls;
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 handleCollapse = useCallback(() => {
collapseSidebar('sidebar');
}, [collapseSidebar]);
const handleUploadButtonClick = useCallback(() => {
if (!onUploadFiles || !uploadInputRef.current) {
return;
}
uploadInputRef.current.click();
}, [onUploadFiles]);
const handleUploadInputChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target?.files;
if (files && files.length && onUploadFiles) {
onUploadFiles(files, selectedFolder ?? 'root');
}
if (event.target) {
event.target.value = '';
}
},
[onUploadFiles, selectedFolder],
);
return (
<>
<input
type="file"
ref={uploadInputRef}
style={{ display: 'none' }}
multiple
onChange={handleUploadInputChange}
/>
<PanelHeader
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">
<LogoIcon className="sidebar__logo" variant="small" />
<span className="sidebar__title-text">Papercrate</span>
{tenantName ? <span className="sidebar__tenant"> / {tenantName}</span> : null}
</span>
<ChevronDownIcon
className={`sidebar__title-chevron${tenantMenuOpen ? ' is-open' : ''}`}
size={16}
/>
</button>
<SidebarMenu
isOpen={tenantMenuOpen}
menuRef={tenantMenuRef}
style={tenantMenuStyle}
tenants={tenants}
activeTenantId={activeTenantId}
onSelectTenant={onSelectTenant}
onOpenSettings={onOpenSettings}
onLogout={onLogout}
onClose={closeTenantMenu}
/>
</>
)}
actions={(
<>
<button
type="button"
className="icon-button sidebar__collapse-button"
onClick={handleCollapse}
aria-label="Collapse sidebar"
title="Collapse sidebar"
>
<SidebarCollapseIcon />
</button>
<button
type="button"
className="icon-button"
onClick={handleUploadButtonClick}
disabled={!onUploadFiles}
title="Upload documents"
aria-label="Upload documents"
>
<UploadIcon size={18} />
</button>
</>
)}
/>
</>
);
};
export default SidebarHeader;
@@ -0,0 +1,316 @@
import React, { useCallback, useId } from 'react';
import type { CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import {
GithubIcon,
MatrixIcon,
WorldIcon,
CheckIcon,
SettingsIcon,
LogoutIcon,
RestoreIcon,
SunIcon,
MoonIcon,
DesktopIcon,
} from '../../ui/icons';
import { useSidebarContext } from '../SidebarContext';
import { THEME_MODE_LABELS, THEME_MODES } from '../../constants/sidebar';
import type { Identifier } from '../../types/identifiers';
export interface TenantOption {
id?: Identifier | null;
name?: string | null;
}
interface CommunityLink {
label: string;
href: string;
title: string;
Icon: typeof GithubIcon;
}
const COMMUNITY_LINKS: CommunityLink[] = [
{
label: 'GitHub',
href: 'https://github.com/papercrate-dms/papercrate',
title: 'Open Papercrate on GitHub',
Icon: GithubIcon,
},
{
label: 'Matrix',
href: 'https://matrix.to/#/#papercrate:matrix.org',
title: 'Join the Papercrate Matrix room',
Icon: MatrixIcon,
},
{
label: 'Website',
href: 'https://papercrate.org',
title: 'Visit papercrate.org',
Icon: WorldIcon,
},
];
interface SidebarMenuProps {
isOpen: boolean;
menuRef: React.RefObject<HTMLDivElement>;
style: CSSProperties | null;
tenants: TenantOption[];
activeTenantId: Identifier | null;
onSelectTenant?: (tenant: TenantOption | null, options?: { refreshOnly?: boolean }) => void;
onOpenSettings?: () => void;
onLogout?: () => void;
onClose: () => void;
}
const SidebarMenu: React.FC<SidebarMenuProps> = ({
isOpen,
menuRef,
style,
tenants,
activeTenantId,
onSelectTenant,
onOpenSettings,
onLogout,
onClose,
}) => {
const {
neutralHue,
setNeutralHue,
resetNeutralHue,
neutralChroma,
setNeutralChroma,
resetNeutralChroma,
themeMode,
cycleThemeMode,
themeModes,
} = useSidebarContext();
const neutralHueInputId = useId();
const neutralChromaInputId = useId();
const handleThemeAdjustmentsReset = useCallback(() => {
resetNeutralHue();
resetNeutralChroma();
}, [resetNeutralHue, resetNeutralChroma]);
const handleNeutralHueChange = useCallback(
(value: string) => {
if (value === '') {
resetNeutralHue();
return;
}
const parsed = Number(value);
if (Number.isNaN(parsed)) {
return;
}
setNeutralHue(parsed);
},
[resetNeutralHue, setNeutralHue],
);
const handleNeutralChromaChange = useCallback(
(value: string) => {
if (value === '') {
resetNeutralChroma();
return;
}
const parsed = Number(value);
if (Number.isNaN(parsed)) {
return;
}
setNeutralChroma(parsed);
},
[resetNeutralChroma, setNeutralChroma],
);
const formatSliderValue = useCallback((value: number | string) => {
const parsed = Number(value);
if (Number.isNaN(parsed)) {
return String(value);
}
return parsed.toFixed(2);
}, []);
const themeModeList = Array.isArray(themeModes) && themeModes.length ? themeModes : THEME_MODES;
const themeModeIndex = themeModeList.indexOf(themeMode as string);
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 handleTenantSelect = useCallback(
(tenant: TenantOption | null) => {
const targetId = tenant?.id || null;
if (!targetId) {
return;
}
onClose();
onSelectTenant?.(tenant);
},
[onClose, onSelectTenant],
);
const handleLogoutFromMenu = useCallback(() => {
onClose();
onLogout?.();
}, [onClose, onLogout]);
const handleSettingsFromMenu = useCallback(() => {
onClose();
onOpenSettings?.();
}, [onClose, onOpenSettings]);
const themeMenuSection =
neutralHue != null
? (
<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>
</div>
)
: null;
const communityMenuFooter = COMMUNITY_LINKS.length
? (
<div className="menu__footer">
{COMMUNITY_LINKS.map(({ href, label, title, Icon }) => (
<a
key={href}
href={href}
target="_blank"
rel="noreferrer noopener"
className="menu__footer-link"
title={title}
>
<Icon size={16} stroke={1.5} />
<span>{label}</span>
</a>
))}
</div>
)
: null;
const showTenantList = tenants.length > 1;
const menuClassName = `menu${!showTenantList && !themeMenuSection && !communityMenuFooter ? ' menu--simple' : ''}`;
if (!isOpen || !style) {
return null;
}
return createPortal(
<div
className={menuClassName}
ref={menuRef}
role="menu"
aria-label="Account menu"
style={style}
>
{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 || tenantId || 'Tenant';
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}
{communityMenuFooter}
</div>,
document.body,
);
};
export default SidebarMenu;
@@ -0,0 +1,51 @@
import React, { useCallback } from 'react';
import { useDocumentsFilter } from '../../documents/context/DocumentsFilterContext';
const SidebarSearch: React.FC = () => {
const {
query: searchQuery,
isActive: isFilterActive,
setQuery: setFilterQuery,
submit: submitFilter,
clear: clearFilterState,
} = useDocumentsFilter();
const handleSearchInputChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setFilterQuery(event.target.value);
},
[setFilterQuery],
);
const handleSearchFormSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
submitFilter();
},
[submitFilter],
);
const handleSearchClear = useCallback(() => {
clearFilterState();
}, [clearFilterState]);
return (
<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>
);
};
export default SidebarSearch;
@@ -0,0 +1,145 @@
import React, { useCallback, useMemo } from 'react';
import { PlusIcon, SettingsIcon } from '../../ui/icons';
import { getTagColorStyle } from '../../utils/colors';
import type { Identifier } from '../../types/identifiers';
import { useDocumentsFilter } from '../../documents/context/DocumentsFilterContext';
export interface TagEntry {
id: Identifier;
label: string;
color?: string | null;
}
interface SidebarTagListProps {
tags: TagEntry[];
untaggedFilterId: Identifier | null;
onCreateTag?: (label: string) => Promise<void> | void;
onManageTags?: () => void;
}
const SidebarTagList: React.FC<SidebarTagListProps> = ({
tags,
untaggedFilterId,
onCreateTag,
onManageTags,
}) => {
const {
activeTagIds,
toggleTag: toggleTagFilter,
} = useDocumentsFilter();
const activeTagSet = useMemo(
() => new Set<Identifier | null>(activeTagIds || []),
[activeTagIds],
);
const untaggedActive = untaggedFilterId ? activeTagSet.has(untaggedFilterId) : false;
const untaggedButtonClassName = ['badge', 'tag-chip', 'tag-chip--untagged', untaggedActive ? 'active' : null]
.filter(Boolean)
.join(' ');
const getTagChipClassName = (isActive: boolean) =>
['badge', 'tag-chip', isActive ? 'active' : null]
.filter(Boolean)
.join(' ');
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: unknown) {
console.error('[sidebar] failed to create tag', error);
}
}, [onCreateTag]);
const handleToggleTag = useCallback((tagId: Identifier | null) => {
if (tagId == null) return;
toggleTagFilter(tagId);
}, [toggleTagFilter]);
return (
<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={onManageTags}
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={untaggedButtonClassName}
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 = getTagChipClassName(isActive);
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>
);
};
export default SidebarTagList;
+2 -2
View File
@@ -76,7 +76,7 @@ interface UseSidebarPropsArgs {
handleFolderDragStart: (event: DragEvent<HTMLElement>, folderId: Identifier) => void;
handleFolderDragEnd: (event: DragEvent<HTMLElement>) => void;
draggedFolderId: Identifier | null;
handlePromptCreateFolder?: () => void;
handlePromptCreateFolder?: (parentId?: Identifier | null) => void;
creatingFolder: boolean;
tags: TagOption[];
handleTagCreate: (payload: { label?: string }) => void | Promise<void>;
@@ -173,7 +173,7 @@ const useSidebarProps = ({
onFolderDragStart: handleFolderDragStart,
onFolderDragEnd: handleFolderDragEnd,
draggedFolderId,
onCreateFolder: handlePromptCreateFolder,
onCreateFolder: (parentId) => handlePromptCreateFolder?.(parentId),
creatingFolder,
tags,
untaggedFilterId: TAG_FILTER_UNTAGGED,