feat: Rework sidebar with dedicated components for menu, folders, tags, correspondents, and search, including tenant switching and theme controls.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user