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>;
|
refreshCurrentFolder?: () => void | Promise<void>;
|
||||||
currentSubfolders?: unknown[];
|
currentSubfolders?: unknown[];
|
||||||
documents?: unknown[];
|
documents?: unknown[];
|
||||||
|
searchQuery?: string;
|
||||||
searchResultIds?: Identifier[] | null;
|
searchResultIds?: Identifier[] | null;
|
||||||
folderClickHandlers: FolderClickHandlers;
|
folderClickHandlers: FolderClickHandlers;
|
||||||
|
selectedFolder?: Identifier | 'root' | null;
|
||||||
selectFolder?: (...args: unknown[]) => void;
|
selectFolder?: (...args: unknown[]) => void;
|
||||||
handleFolderDragStart?: (...args: unknown[]) => void;
|
handleFolderDragStart?: (...args: unknown[]) => void;
|
||||||
handleFolderDragEnd?: (...args: unknown[]) => void;
|
handleFolderDragEnd?: (...args: unknown[]) => void;
|
||||||
@@ -41,6 +43,7 @@ export interface UseDocumentsPanelPropsArgs {
|
|||||||
handleDocumentDragEnd?: (...args: unknown[]) => void;
|
handleDocumentDragEnd?: (...args: unknown[]) => void;
|
||||||
searchLoading?: boolean;
|
searchLoading?: boolean;
|
||||||
tagLookupById?: unknown;
|
tagLookupById?: unknown;
|
||||||
|
activeTagFilters?: Identifier[];
|
||||||
activeCorrespondentFilters?: Identifier[];
|
activeCorrespondentFilters?: Identifier[];
|
||||||
ensureAssetUrl?: (...args: unknown[]) => void;
|
ensureAssetUrl?: (...args: unknown[]) => void;
|
||||||
getDocumentAsset?: (...args: unknown[]) => unknown;
|
getDocumentAsset?: (...args: unknown[]) => unknown;
|
||||||
@@ -80,8 +83,10 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
|
|||||||
refreshCurrentFolder,
|
refreshCurrentFolder,
|
||||||
currentSubfolders,
|
currentSubfolders,
|
||||||
documents,
|
documents,
|
||||||
|
searchQuery,
|
||||||
searchResultIds,
|
searchResultIds,
|
||||||
folderClickHandlers,
|
folderClickHandlers,
|
||||||
|
selectedFolder,
|
||||||
selectFolder,
|
selectFolder,
|
||||||
handleFolderDragStart,
|
handleFolderDragStart,
|
||||||
handleFolderDragEnd,
|
handleFolderDragEnd,
|
||||||
@@ -95,6 +100,7 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
|
|||||||
handleDocumentDragEnd,
|
handleDocumentDragEnd,
|
||||||
searchLoading,
|
searchLoading,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
|
activeTagFilters,
|
||||||
activeCorrespondentFilters,
|
activeCorrespondentFilters,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
@@ -131,7 +137,9 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
|
|||||||
onRefresh: refreshCurrentFolder,
|
onRefresh: refreshCurrentFolder,
|
||||||
subfolders: currentSubfolders,
|
subfolders: currentSubfolders,
|
||||||
documents,
|
documents,
|
||||||
|
searchQuery,
|
||||||
searchResultIds,
|
searchResultIds,
|
||||||
|
selectedFolder,
|
||||||
onFolderSelect: selectFolder,
|
onFolderSelect: selectFolder,
|
||||||
onFolderDrop: folderClickHandlers.onDrop,
|
onFolderDrop: folderClickHandlers.onDrop,
|
||||||
onFolderDragOver: folderClickHandlers.onDragOver,
|
onFolderDragOver: folderClickHandlers.onDragOver,
|
||||||
@@ -148,6 +156,8 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
|
|||||||
onDocumentDragEnd: handleDocumentDragEnd,
|
onDocumentDragEnd: handleDocumentDragEnd,
|
||||||
isSearchLoading: searchLoading,
|
isSearchLoading: searchLoading,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
|
activeTagFilters,
|
||||||
|
activeCorrespondentFilters,
|
||||||
activeCorrespondentIds: activeCorrespondentFilters,
|
activeCorrespondentIds: activeCorrespondentFilters,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
@@ -186,6 +196,7 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
|
|||||||
documentsSortDirection,
|
documentsSortDirection,
|
||||||
documentsSortField,
|
documentsSortField,
|
||||||
documentsViewMode,
|
documentsViewMode,
|
||||||
|
searchQuery,
|
||||||
documentLookup,
|
documentLookup,
|
||||||
draggedDocumentIds,
|
draggedDocumentIds,
|
||||||
draggedFolderId,
|
draggedFolderId,
|
||||||
@@ -214,8 +225,11 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
|
|||||||
refreshCurrentFolder,
|
refreshCurrentFolder,
|
||||||
searchLoading,
|
searchLoading,
|
||||||
searchResultIds,
|
searchResultIds,
|
||||||
|
selectedFolder,
|
||||||
selectFolder,
|
selectFolder,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
|
activeTagFilters,
|
||||||
|
activeCorrespondentFilters,
|
||||||
tags,
|
tags,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
|
|||||||
@@ -132,10 +132,10 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
|||||||
onBulkReanalyze,
|
onBulkReanalyze,
|
||||||
folderOptions,
|
folderOptions,
|
||||||
onMoveDocumentsToFolder,
|
onMoveDocumentsToFolder,
|
||||||
searchQuery,
|
searchQuery = '',
|
||||||
activeTagFilters,
|
activeTagFilters = [],
|
||||||
activeCorrespondentFilters,
|
activeCorrespondentFilters = [],
|
||||||
selectedFolder,
|
selectedFolder = null,
|
||||||
promoteSelectionOrder,
|
promoteSelectionOrder,
|
||||||
onDocumentTagDrop,
|
onDocumentTagDrop,
|
||||||
currentTenantId,
|
currentTenantId,
|
||||||
@@ -811,6 +811,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
|||||||
onTagClick: toggleTagFilter,
|
onTagClick: toggleTagFilter,
|
||||||
onCorrespondentClick: toggleCorrespondentFilter,
|
onCorrespondentClick: toggleCorrespondentFilter,
|
||||||
activeCorrespondentIdSet: activeCorrespondentIdSet,
|
activeCorrespondentIdSet: activeCorrespondentIdSet,
|
||||||
|
activeTagFilters,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
// Desk specific
|
// Desk specific
|
||||||
onDocumentStackSelect: handleDeskDocumentStackSelect,
|
onDocumentStackSelect: handleDeskDocumentStackSelect,
|
||||||
|
|||||||
@@ -350,6 +350,7 @@ const useDocumentsWorkspace = ({
|
|||||||
searchResultIds,
|
searchResultIds,
|
||||||
setSearchResultIds,
|
setSearchResultIds,
|
||||||
searchLoading,
|
searchLoading,
|
||||||
|
activeTagFilters,
|
||||||
setActiveTagFilters,
|
setActiveTagFilters,
|
||||||
activeCorrespondentFilters,
|
activeCorrespondentFilters,
|
||||||
setActiveCorrespondentFilters,
|
setActiveCorrespondentFilters,
|
||||||
@@ -966,7 +967,7 @@ const useDocumentsWorkspace = ({
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handlePromptCreateFolder = useCallback(async () => {
|
const handlePromptCreateFolder = useCallback(async (parentId?: Identifier | null) => {
|
||||||
if (creatingFolder) {
|
if (creatingFolder) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -981,7 +982,7 @@ const useDocumentsWorkspace = ({
|
|||||||
}
|
}
|
||||||
setCreatingFolder(true);
|
setCreatingFolder(true);
|
||||||
try {
|
try {
|
||||||
const success = await handleFolderCreate(trimmed);
|
const success = await handleFolderCreate(trimmed, parentId);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
setStatusMessage('Unable to create folder. Check the status message for details.', 'error');
|
setStatusMessage('Unable to create folder. Check the status message for details.', 'error');
|
||||||
}
|
}
|
||||||
@@ -1198,8 +1199,10 @@ const useDocumentsWorkspace = ({
|
|||||||
refreshCurrentFolder,
|
refreshCurrentFolder,
|
||||||
currentSubfolders,
|
currentSubfolders,
|
||||||
documents: viewDocuments,
|
documents: viewDocuments,
|
||||||
|
searchQuery,
|
||||||
searchResultIds,
|
searchResultIds,
|
||||||
folderClickHandlers,
|
folderClickHandlers,
|
||||||
|
selectedFolder,
|
||||||
handleFolderDragStart,
|
handleFolderDragStart,
|
||||||
handleFolderDragEnd,
|
handleFolderDragEnd,
|
||||||
draggedFolderId,
|
draggedFolderId,
|
||||||
@@ -1211,6 +1214,7 @@ const useDocumentsWorkspace = ({
|
|||||||
handleDocumentDragEnd,
|
handleDocumentDragEnd,
|
||||||
searchLoading,
|
searchLoading,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
|
activeTagFilters,
|
||||||
activeCorrespondentFilters,
|
activeCorrespondentFilters,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
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') {
|
if (!targetId || targetId === 'root') {
|
||||||
setFolderNodes((prev: Map<FolderId, FolderTreeNode>) => {
|
setFolderNodes((prev: Map<FolderId, FolderTreeNode>) => {
|
||||||
const root = prev.get('root');
|
const root = prev.get('root');
|
||||||
@@ -186,6 +187,13 @@ const useFolderTree = ({
|
|||||||
const next = new Map<FolderId, FolderTreeNode>(prev);
|
const next = new Map<FolderId, FolderTreeNode>(prev);
|
||||||
let currentId = targetId;
|
let currentId = targetId;
|
||||||
let guard = 0;
|
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) {
|
while (currentId && guard < 32) {
|
||||||
guard += 1;
|
guard += 1;
|
||||||
const node = next.get(currentId);
|
const node = next.get(currentId);
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ interface UseFolderTreeActionsOptions {
|
|||||||
setSelectedFolder: (folderId: FolderKey) => void;
|
setSelectedFolder: (folderId: FolderKey) => void;
|
||||||
ensureFolderData: (folderId: FolderKey, options?: EnsureFolderOptions) => Promise<any>;
|
ensureFolderData: (folderId: FolderKey, options?: EnsureFolderOptions) => Promise<any>;
|
||||||
ensureFolderAncestorsLoaded: (folderId: FolderKey) => Promise<void>;
|
ensureFolderAncestorsLoaded: (folderId: FolderKey) => Promise<void>;
|
||||||
expandFolderAncestors: (folderId: FolderKey) => void;
|
expandFolderAncestors: (folderId: FolderKey, options?: { includeSelf?: boolean }) => void;
|
||||||
applySelectedFolder: (folderId: FolderKey, contents: any) => void;
|
applySelectedFolder: (folderId: FolderKey, contents: any) => void;
|
||||||
notifyApiError: (error: unknown, message?: string) => void;
|
notifyApiError: (error: unknown, message?: string) => void;
|
||||||
setStatusMessage: (message: string, level?: string) => void;
|
setStatusMessage: (message: string, level?: string) => void;
|
||||||
@@ -250,7 +250,7 @@ const useFolderTreeActions = ({
|
|||||||
const targetId = folderId && folderId !== 'root' ? folderId : 'root';
|
const targetId = folderId && folderId !== 'root' ? folderId : 'root';
|
||||||
|
|
||||||
await ensureFolderAncestorsLoaded(targetId);
|
await ensureFolderAncestorsLoaded(targetId);
|
||||||
expandFolderAncestors(targetId);
|
expandFolderAncestors(targetId, { includeSelf: false });
|
||||||
|
|
||||||
if (!navigate || immediate) {
|
if (!navigate || immediate) {
|
||||||
await loadFolder(targetId, { preserveSearch: isFilterActive });
|
await loadFolder(targetId, { preserveSearch: isFilterActive });
|
||||||
@@ -327,7 +327,7 @@ const useFolderTreeActions = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleFolderCreate = useCallback(
|
const handleFolderCreate = useCallback(
|
||||||
async (name: string) => {
|
async (name: string, parentId?: FolderKey | null) => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setStatusMessage('Log in to create folders.', 'error');
|
setStatusMessage('Log in to create folders.', 'error');
|
||||||
return false;
|
return false;
|
||||||
@@ -336,9 +336,14 @@ const useFolderTreeActions = ({
|
|||||||
setStatusMessage('Folder name cannot be empty.', 'error');
|
setStatusMessage('Folder name cannot be empty.', 'error');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const targetParentId = parentId !== undefined
|
||||||
|
? (parentId === 'root' ? null : parentId)
|
||||||
|
: (selectedFolder === 'root' ? null : selectedFolder);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
parent_id: selectedFolder === 'root' ? null : selectedFolder,
|
parent_id: targetParentId,
|
||||||
};
|
};
|
||||||
setCreatingFolder(true);
|
setCreatingFolder(true);
|
||||||
let succeeded = false;
|
let succeeded = false;
|
||||||
@@ -372,7 +377,11 @@ const useFolderTreeActions = ({
|
|||||||
});
|
});
|
||||||
return next;
|
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 });
|
await selectFolder(folderData.id, { immediate: true });
|
||||||
succeeded = true;
|
succeeded = true;
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,110 +1,16 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useId } from 'react';
|
import React, { useRef } 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 { getTagColorStyle } from '../utils/colors';
|
|
||||||
import { useSidebarContext } from './SidebarContext';
|
|
||||||
import { usePanelManager, usePanelResizeBindings } from '../app/PanelManagerContext';
|
import { usePanelManager, usePanelResizeBindings } from '../app/PanelManagerContext';
|
||||||
import type { StatusMessage } from '../hooks/documents/store/useDocumentsStore';
|
import type { StatusMessage } from '../hooks/documents/store/useDocumentsStore';
|
||||||
import type { Identifier } from '../types/identifiers';
|
import type { Identifier } from '../types/identifiers';
|
||||||
import { THEME_MODE_LABELS, THEME_MODES } from '../constants/sidebar';
|
|
||||||
|
|
||||||
interface CommunityLink {
|
import SidebarFolderList from './components/SidebarFolderList';
|
||||||
label: string;
|
import SidebarTagList, { TagEntry } from './components/SidebarTagList';
|
||||||
href: string;
|
import SidebarCorrespondentList, { CorrespondentEntry } from './components/SidebarCorrespondentList';
|
||||||
title: string;
|
import { FolderIdentifier, FolderTreeNode } from './components/SidebarFolderNode';
|
||||||
Icon: typeof GithubIcon;
|
import { TenantOption } from './components/SidebarMenu';
|
||||||
}
|
import SidebarHeader from './components/SidebarHeader';
|
||||||
|
import SidebarSearch from './components/SidebarSearch';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
type UploadHandler = (
|
type UploadHandler = (
|
||||||
files: FileList | File[] | Iterable<File>,
|
files: FileList | File[] | Iterable<File>,
|
||||||
@@ -124,7 +30,7 @@ interface SidebarProps {
|
|||||||
onFolderDragStart?: (event: React.DragEvent<HTMLDivElement>, folderId: FolderIdentifier) => void;
|
onFolderDragStart?: (event: React.DragEvent<HTMLDivElement>, folderId: FolderIdentifier) => void;
|
||||||
onFolderDragEnd?: (event: React.DragEvent<HTMLDivElement>) => void;
|
onFolderDragEnd?: (event: React.DragEvent<HTMLDivElement>) => void;
|
||||||
draggedFolderId?: FolderIdentifier | null;
|
draggedFolderId?: FolderIdentifier | null;
|
||||||
onCreateFolder?: () => void;
|
onCreateFolder?: (parentId?: Identifier | null) => void;
|
||||||
creatingFolder?: boolean;
|
creatingFolder?: boolean;
|
||||||
tags?: TagEntry[];
|
tags?: TagEntry[];
|
||||||
untaggedFilterId?: Identifier | null;
|
untaggedFilterId?: Identifier | null;
|
||||||
@@ -143,133 +49,6 @@ interface SidebarProps {
|
|||||||
onUploadFiles?: UploadHandler;
|
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> = ({
|
const Sidebar: React.FC<SidebarProps> = ({
|
||||||
folderNodes,
|
folderNodes,
|
||||||
onToggle,
|
onToggle,
|
||||||
@@ -301,33 +80,10 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onUploadFiles,
|
onUploadFiles,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
|
||||||
neutralHue,
|
|
||||||
setNeutralHue,
|
|
||||||
resetNeutralHue,
|
|
||||||
neutralChroma,
|
|
||||||
setNeutralChroma,
|
|
||||||
resetNeutralChroma,
|
|
||||||
themeMode,
|
|
||||||
cycleThemeMode,
|
|
||||||
themeModes,
|
|
||||||
} = useSidebarContext();
|
|
||||||
const {
|
const {
|
||||||
sidebarSuppressed,
|
sidebarSuppressed,
|
||||||
collapseSidebar,
|
|
||||||
} = usePanelManager();
|
} = 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 sidebarRef = useRef<HTMLDivElement | null>(null);
|
||||||
const {
|
const {
|
||||||
panelStyle: sidebarStyle,
|
panelStyle: sidebarStyle,
|
||||||
@@ -335,358 +91,6 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
isPanelResizing: isResizingSidebar,
|
isPanelResizing: isResizingSidebar,
|
||||||
} = usePanelResizeBindings('sidebar', { panelRef: sidebarRef });
|
} = 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'];
|
const sidebarClassNames = ['sidebar'];
|
||||||
if (isResizingSidebar) {
|
if (isResizingSidebar) {
|
||||||
sidebarClassNames.push('sidebar--resizing');
|
sidebarClassNames.push('sidebar--resizing');
|
||||||
@@ -695,64 +99,6 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
sidebarClassNames.push('sidebar--suppressed');
|
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 (
|
return (
|
||||||
<aside className={sidebarClassNames.join(' ')} ref={sidebarRef} style={sidebarStyle}>
|
<aside className={sidebarClassNames.join(' ')} ref={sidebarRef} style={sidebarStyle}>
|
||||||
<button
|
<button
|
||||||
@@ -763,234 +109,59 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
>
|
>
|
||||||
<span aria-hidden="true" />
|
<span aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<input
|
|
||||||
type="file"
|
<SidebarHeader
|
||||||
ref={uploadInputRef}
|
tenantName={tenantName}
|
||||||
style={{ display: 'none' }}
|
tenants={tenants}
|
||||||
multiple
|
activeTenantId={activeTenantId}
|
||||||
onChange={handleUploadInputChange}
|
onSelectTenant={onSelectTenant}
|
||||||
/>
|
onOpenSettings={onOpenSettings}
|
||||||
<PanelHeader
|
onLogout={onLogout}
|
||||||
leading={(
|
onUploadFiles={onUploadFiles}
|
||||||
<>
|
selectedFolder={selectedFolder}
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="panel-body sidebar__body">
|
<div className="panel-body sidebar__body">
|
||||||
{status && (
|
{status && (
|
||||||
<div className="sidebar__status">
|
<div className="sidebar__status">
|
||||||
<div className={`status-banner ${status.variant}`}>{status.message}</div>
|
<div className={`status-banner ${status.variant}`}>{status.message}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<form className="sidebar__search" onSubmit={handleSearchFormSubmit}>
|
|
||||||
<input
|
<SidebarSearch />
|
||||||
type="search"
|
|
||||||
value={searchQuery}
|
<SidebarFolderList
|
||||||
onChange={handleSearchInputChange}
|
folderNodes={folderNodes}
|
||||||
placeholder="Search documents"
|
selectedFolder={selectedFolder}
|
||||||
aria-label="Search documents"
|
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}
|
||||||
/>
|
/>
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Sidebar;
|
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;
|
handleFolderDragStart: (event: DragEvent<HTMLElement>, folderId: Identifier) => void;
|
||||||
handleFolderDragEnd: (event: DragEvent<HTMLElement>) => void;
|
handleFolderDragEnd: (event: DragEvent<HTMLElement>) => void;
|
||||||
draggedFolderId: Identifier | null;
|
draggedFolderId: Identifier | null;
|
||||||
handlePromptCreateFolder?: () => void;
|
handlePromptCreateFolder?: (parentId?: Identifier | null) => void;
|
||||||
creatingFolder: boolean;
|
creatingFolder: boolean;
|
||||||
tags: TagOption[];
|
tags: TagOption[];
|
||||||
handleTagCreate: (payload: { label?: string }) => void | Promise<void>;
|
handleTagCreate: (payload: { label?: string }) => void | Promise<void>;
|
||||||
@@ -173,7 +173,7 @@ const useSidebarProps = ({
|
|||||||
onFolderDragStart: handleFolderDragStart,
|
onFolderDragStart: handleFolderDragStart,
|
||||||
onFolderDragEnd: handleFolderDragEnd,
|
onFolderDragEnd: handleFolderDragEnd,
|
||||||
draggedFolderId,
|
draggedFolderId,
|
||||||
onCreateFolder: handlePromptCreateFolder,
|
onCreateFolder: (parentId) => handlePromptCreateFolder?.(parentId),
|
||||||
creatingFolder,
|
creatingFolder,
|
||||||
tags,
|
tags,
|
||||||
untaggedFilterId: TAG_FILTER_UNTAGGED,
|
untaggedFilterId: TAG_FILTER_UNTAGGED,
|
||||||
|
|||||||
Reference in New Issue
Block a user