refactor: Extract folder assignment logic into a new SelectionFolderMenu component, adding new icons and updating related styles.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { DEFAULT_FOLDER_NAME } from '../constants/workspace';
|
||||
import { getFolderTree } from '../lib/apiClient';
|
||||
import {
|
||||
TrashIcon,
|
||||
@@ -9,24 +10,17 @@ import {
|
||||
CorrespondentIcon,
|
||||
} from '../ui/icons';
|
||||
import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './SelectionAssignmentMenu';
|
||||
import SelectionFolderMenu from './SelectionFolderMenu';
|
||||
import SelectionSummary from './SelectionSummary';
|
||||
import { useAppState } from '../app/appState';
|
||||
import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext';
|
||||
import { DEFAULT_FOLDER_NAME } from '../constants/workspace';
|
||||
import type { DocumentId } from '../types/identifiers';
|
||||
import type { FolderTreeNode } from '../lib/apiTypes';
|
||||
|
||||
type NullableDocumentId = DocumentId | null;
|
||||
|
||||
type SelectedIdList = NullableDocumentId[] | null;
|
||||
|
||||
type FolderTreeNode = {
|
||||
id?: DocumentId;
|
||||
name?: string;
|
||||
label?: string;
|
||||
value?: DocumentId;
|
||||
children?: FolderTreeNode[];
|
||||
};
|
||||
|
||||
interface TagOption {
|
||||
id?: DocumentId;
|
||||
label?: string;
|
||||
@@ -67,7 +61,6 @@ export interface SelectionFloatingActionsProps {
|
||||
tags?: TagOption[] | null;
|
||||
tagLookupById?: Map<DocumentId, TagOption> | null;
|
||||
correspondents?: CorrespondentOption[] | null;
|
||||
folderOptions?: SelectionAssignmentMenuItem[] | null;
|
||||
onBulkTagAdd?: (args: BulkTagMutationArgs) => Promise<void> | void;
|
||||
onBulkTagRemove?: (args: BulkTagMutationArgs) => Promise<void> | void;
|
||||
onBulkCorrespondentAdd?: (args: BulkCorrespondentAddArgs) => Promise<void> | void;
|
||||
@@ -75,7 +68,7 @@ export interface SelectionFloatingActionsProps {
|
||||
onBulkReanalyze?: (documentIds: DocumentId[]) => Promise<void> | void;
|
||||
onDeleteSelection?: () => void;
|
||||
onClearSelection?: () => void;
|
||||
onMoveDocumentsToFolder?: (documentIds: DocumentId[], folderId: DocumentId) => Promise<void> | void;
|
||||
onMoveDocumentsToFolder?: (documentIds: DocumentId[], folderId: DocumentId | null) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const normalizeDocumentList = (selectedIds?: SelectedIdList): DocumentId[] =>
|
||||
@@ -83,52 +76,6 @@ const normalizeDocumentList = (selectedIds?: SelectedIdList): DocumentId[] =>
|
||||
? selectedIds.filter((value): value is DocumentId => value !== null && value !== undefined)
|
||||
: [];
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => value != null && Object(value) === value;
|
||||
|
||||
const splitLabelSegments = (input: unknown): string[] => {
|
||||
const text = `${input ?? ''}`.trim();
|
||||
return text ? text.split('/') : [];
|
||||
};
|
||||
|
||||
const buildFolderTreeOptions = (tree?: FolderTreeNode[] | null): SelectionAssignmentMenuItem[] => {
|
||||
const entries: SelectionAssignmentMenuItem[] = [];
|
||||
|
||||
const traverse = (nodes: FolderTreeNode[] | null, parentSegments: string[]) => {
|
||||
if (!Array.isArray(nodes) || nodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
nodes.forEach((node) => {
|
||||
if (!node || !node.id) {
|
||||
return;
|
||||
}
|
||||
const trimmedName = node.name?.trim?.();
|
||||
const name = trimmedName?.length ? trimmedName : 'Folder';
|
||||
const nextSegments = parentSegments.concat([name]);
|
||||
const label = nextSegments.join('/');
|
||||
entries.push({
|
||||
id: node.id,
|
||||
label,
|
||||
state: 'none',
|
||||
payload: {
|
||||
id: node.id,
|
||||
label,
|
||||
segments: nextSegments,
|
||||
depth: Math.max(nextSegments.length - 1, 0),
|
||||
},
|
||||
});
|
||||
if (Array.isArray(node.children) && node.children.length) {
|
||||
traverse(node.children, nextSegments);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
traverse(Array.isArray(tree) ? tree : [], [DEFAULT_FOLDER_NAME]);
|
||||
|
||||
entries.sort((a, b) => (a.label || '').localeCompare(b.label || '', undefined, { sensitivity: 'base' }));
|
||||
|
||||
return [{ id: 'root', label: DEFAULT_FOLDER_NAME, state: 'none', payload: { id: 'root' } }, ...entries];
|
||||
};
|
||||
|
||||
const buildTagAssignments = (
|
||||
selectedDocuments: Document[],
|
||||
tagLookupById: Map<DocumentId, TagOption> | null,
|
||||
@@ -261,7 +208,6 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
|
||||
tags = [],
|
||||
tagLookupById,
|
||||
correspondents = [],
|
||||
folderOptions = [],
|
||||
onBulkTagAdd,
|
||||
onBulkTagRemove,
|
||||
onBulkCorrespondentAdd,
|
||||
@@ -271,7 +217,7 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
|
||||
onClearSelection = null,
|
||||
onMoveDocumentsToFolder,
|
||||
}) => {
|
||||
const { token, tenant } = useAppState() as { token?: string; tenant?: { id?: DocumentId } | null };
|
||||
const { token, tenant } = useAppState() as { token?: string; tenant?: { id?: DocumentId; name?: string } | null };
|
||||
const tenantId = tenant?.id ?? null;
|
||||
|
||||
const documentLookupMap = useMemo(() => (
|
||||
@@ -279,22 +225,22 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
|
||||
), [documentLookup]);
|
||||
const tagLookupMap = tagLookupById instanceof Map ? tagLookupById : null;
|
||||
|
||||
const [remoteFolderOptions, setRemoteFolderOptions] = useState<SelectionAssignmentMenuItem[] | null>(null);
|
||||
const folderTreeFetchRef = useRef<Promise<SelectionAssignmentMenuItem[]> | null>(null);
|
||||
const [remoteFolderTree, setRemoteFolderTree] = useState<FolderTreeNode[] | null>(null);
|
||||
const folderTreeFetchRef = useRef<Promise<FolderTreeNode[]> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setRemoteFolderOptions(null);
|
||||
setRemoteFolderTree(null);
|
||||
folderTreeFetchRef.current = null;
|
||||
}, [tenantId, token]);
|
||||
|
||||
const requestFolderTree = useCallback(async (): Promise<SelectionAssignmentMenuItem[]> => {
|
||||
const requestFolderTree = useCallback(async (): Promise<FolderTreeNode[]> => {
|
||||
if (!token) {
|
||||
setRemoteFolderOptions([]);
|
||||
setRemoteFolderTree([]);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(remoteFolderOptions)) {
|
||||
return remoteFolderOptions;
|
||||
if (Array.isArray(remoteFolderTree)) {
|
||||
return remoteFolderTree;
|
||||
}
|
||||
|
||||
if (folderTreeFetchRef.current) {
|
||||
@@ -304,12 +250,11 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
|
||||
const fetchPromise = (async () => {
|
||||
try {
|
||||
const data = await getFolderTree();
|
||||
const options = buildFolderTreeOptions(data);
|
||||
setRemoteFolderOptions(options);
|
||||
return options;
|
||||
setRemoteFolderTree(data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.warn('[selection] Failed to load folder tree', error);
|
||||
setRemoteFolderOptions([]);
|
||||
setRemoteFolderTree([]);
|
||||
return [];
|
||||
} finally {
|
||||
folderTreeFetchRef.current = null;
|
||||
@@ -318,19 +263,12 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
|
||||
|
||||
folderTreeFetchRef.current = fetchPromise;
|
||||
return fetchPromise;
|
||||
}, [remoteFolderOptions, token]);
|
||||
}, [remoteFolderTree, token]);
|
||||
|
||||
const handleMoveMenuOpen = useCallback(() => {
|
||||
requestFolderTree();
|
||||
}, [requestFolderTree]);
|
||||
|
||||
const effectiveFolderOptions = useMemo<SelectionAssignmentMenuItem[]>(() => {
|
||||
if (remoteFolderOptions !== null) {
|
||||
return remoteFolderOptions;
|
||||
}
|
||||
return Array.isArray(folderOptions) ? folderOptions : [];
|
||||
}, [remoteFolderOptions, folderOptions]);
|
||||
|
||||
const documentIdList = useMemo<DocumentId[]>(
|
||||
() => normalizeDocumentList(selectedDocumentIds),
|
||||
[selectedDocumentIds],
|
||||
@@ -356,34 +294,6 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
|
||||
|
||||
const selectedDocCount = selectedDocuments.length;
|
||||
|
||||
const moveAssignments = useMemo<SelectionAssignmentMenuItem[]>(() => {
|
||||
if (!Array.isArray(effectiveFolderOptions)) {
|
||||
return [];
|
||||
}
|
||||
return effectiveFolderOptions
|
||||
.map<SelectionAssignmentMenuItem | null>((option) => {
|
||||
const id = (option?.id ?? option?.payload?.id ?? option?.value) as DocumentId | undefined;
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
const label = option?.label || option?.payload?.label || option?.name || String(id);
|
||||
const segments = splitLabelSegments(label);
|
||||
const depth = Math.max(segments.length - 1, 0);
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
state: 'none',
|
||||
payload: {
|
||||
id,
|
||||
label,
|
||||
segments,
|
||||
depth,
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is SelectionAssignmentMenuItem => Boolean(entry));
|
||||
}, [effectiveFolderOptions]);
|
||||
|
||||
const tagAssignments = useMemo(
|
||||
() => buildTagAssignments(selectedDocuments, tagLookupMap, tags, selectedDocCount),
|
||||
[selectedDocuments, tagLookupMap, tags, selectedDocCount],
|
||||
@@ -394,34 +304,6 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
|
||||
[selectedDocuments, correspondents, selectedDocCount],
|
||||
);
|
||||
|
||||
const renderFolderLabel = useCallback((item: SelectionAssignmentMenuItem) => {
|
||||
const payload = (item?.payload as { segments?: string[]; depth?: number }) || {};
|
||||
const segments = payload.segments || splitLabelSegments(item.label);
|
||||
const depth = payload.depth ?? Math.max(segments.length - 1, 0);
|
||||
const clampedDepth = Math.min(depth, 6);
|
||||
const indentWidth = clampedDepth > 0 ? clampedDepth * 0.9 : 0;
|
||||
const name = segments.length ? segments[segments.length - 1] : item?.label || 'Folder';
|
||||
const parentPath = segments.length > 1 ? segments.slice(0, -1).join(' / ') : '';
|
||||
|
||||
return (
|
||||
<>
|
||||
{indentWidth ? (
|
||||
<span
|
||||
className="selection-assignment__indent"
|
||||
style={{ width: `${indentWidth}rem` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<span className="selection-assignment__folder-label">
|
||||
<span className="selection-assignment__folder-name">{name}</span>
|
||||
{parentPath ? (
|
||||
<span className="selection-assignment__folder-path">{parentPath}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleToggleTagAssignment = useCallback(
|
||||
async (item: SelectionAssignmentMenuItem) => {
|
||||
if (!selectedDocCount || !item) {
|
||||
@@ -477,18 +359,11 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
|
||||
);
|
||||
|
||||
const handleMoveSelectionToFolder = useCallback(
|
||||
async (option: unknown) => {
|
||||
async (folderId: DocumentId | null) => {
|
||||
if (!documentIdList.length || !onMoveDocumentsToFolder) {
|
||||
return;
|
||||
}
|
||||
const candidate = option as { id?: DocumentId; value?: DocumentId } | DocumentId | null;
|
||||
const value = isRecord(candidate)
|
||||
? (candidate?.id ?? candidate?.value ?? null)
|
||||
: candidate;
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
await onMoveDocumentsToFolder(documentIdList, value as DocumentId);
|
||||
await onMoveDocumentsToFolder(documentIdList, folderId);
|
||||
},
|
||||
[documentIdList, onMoveDocumentsToFolder],
|
||||
);
|
||||
@@ -503,8 +378,12 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
|
||||
|
||||
const showPrimaryButtons = Boolean(onBulkReanalyze || onDeleteSelection || onClearSelection);
|
||||
|
||||
const rootTitle = (remoteFolderTree && remoteFolderTree.length === 1)
|
||||
? remoteFolderTree[0].name
|
||||
: DEFAULT_FOLDER_NAME;
|
||||
|
||||
const moveMenu = onMoveDocumentsToFolder ? (
|
||||
<SelectionAssignmentMenu
|
||||
<SelectionFolderMenu
|
||||
label="Move"
|
||||
triggerContent={(
|
||||
<span className="quick-add__chip-label" title="Move">
|
||||
@@ -512,16 +391,13 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
|
||||
<span className="quick-add__chip-text" aria-hidden="true">Move</span>
|
||||
</span>
|
||||
)}
|
||||
items={moveAssignments}
|
||||
folderTree={remoteFolderTree || []}
|
||||
placeholder="Search folders…"
|
||||
emptyMessage="No folders"
|
||||
onToggle={(item) => handleMoveSelectionToFolder(item?.payload || item)}
|
||||
onSelectFolder={handleMoveSelectionToFolder}
|
||||
disabled={!documentCount}
|
||||
createLabel={null}
|
||||
showStateIndicators={false}
|
||||
showCounts={false}
|
||||
onOpenMenu={handleMoveMenuOpen}
|
||||
renderItemLabel={renderFolderLabel}
|
||||
rootTitle={rootTitle}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import React, { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import useFloatingMenu from '../ui/useFloatingMenu';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
FolderIcon,
|
||||
FolderMoveIcon,
|
||||
} from '../ui/icons';
|
||||
import type { FolderTreeNode } from '../lib/apiTypes';
|
||||
import type { DocumentId } from '../types/identifiers';
|
||||
|
||||
export interface SelectionFolderMenuProps {
|
||||
label: React.ReactNode;
|
||||
folderTree?: FolderTreeNode[];
|
||||
onSelectFolder?: (folderId: DocumentId | null) => Promise<void> | void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
triggerContent?: React.ReactNode;
|
||||
triggerClassName?: string;
|
||||
placeholder?: string;
|
||||
emptyMessage?: string;
|
||||
onOpenMenu?: () => void;
|
||||
positionStrategy?: 'absolute' | 'fixed';
|
||||
rootTitle?: string;
|
||||
}
|
||||
|
||||
const SelectionFolderMenu: React.FC<SelectionFolderMenuProps> = ({
|
||||
label,
|
||||
folderTree = [],
|
||||
onSelectFolder,
|
||||
disabled = false,
|
||||
className,
|
||||
triggerContent = null,
|
||||
triggerClassName = 'quick-add__chip quick-add__trigger panel-floating-actions__trigger',
|
||||
placeholder = 'Search folders…',
|
||||
emptyMessage = 'No folders',
|
||||
onOpenMenu,
|
||||
positionStrategy = 'absolute',
|
||||
rootTitle = 'Folders',
|
||||
}) => {
|
||||
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [currentFolderId, setCurrentFolderId] = useState<DocumentId | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
toggle,
|
||||
close,
|
||||
menuRef,
|
||||
menuStyle,
|
||||
updatePosition,
|
||||
} = useFloatingMenu({
|
||||
anchorRef,
|
||||
align: 'center',
|
||||
positionStrategy,
|
||||
minWidth: 260,
|
||||
}) as {
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
close: () => void;
|
||||
menuRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
menuStyle: CSSProperties | null;
|
||||
updatePosition: () => void;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled && isOpen) {
|
||||
close();
|
||||
}
|
||||
}, [disabled, isOpen, close]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return undefined;
|
||||
}
|
||||
setQuery('');
|
||||
setCurrentFolderId(null);
|
||||
setPending(false);
|
||||
const frame = requestAnimationFrame(() => {
|
||||
updatePosition();
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [isOpen, updatePosition]);
|
||||
|
||||
// Build a flat map for easy lookup
|
||||
const { nodeMap, parentMap } = useMemo(() => {
|
||||
const nMap = new Map<DocumentId, FolderTreeNode>();
|
||||
const pMap = new Map<DocumentId, DocumentId>();
|
||||
|
||||
const traverse = (nodes: FolderTreeNode[], parentId: DocumentId | null) => {
|
||||
nodes.forEach((node) => {
|
||||
nMap.set(node.id, node);
|
||||
if (parentId) {
|
||||
pMap.set(node.id, parentId);
|
||||
}
|
||||
if (node.children) {
|
||||
traverse(node.children, node.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
traverse(folderTree, null);
|
||||
return { nodeMap: nMap, parentMap: pMap };
|
||||
}, [folderTree]);
|
||||
|
||||
const currentChildren = useMemo(() => {
|
||||
const currentFolder = currentFolderId ? nodeMap.get(currentFolderId) : null;
|
||||
return currentFolder ? currentFolder.children || [] : folderTree;
|
||||
}, [currentFolderId, nodeMap, folderTree]);
|
||||
|
||||
const currentFolder = currentFolderId ? nodeMap.get(currentFolderId) : null;
|
||||
|
||||
// Filter items based on search query
|
||||
// If searching, we might want to show flattened results matching the query?
|
||||
// Or just filter current level?
|
||||
// Usually, search implies searching the whole tree.
|
||||
const isSearching = query.trim().length > 0;
|
||||
|
||||
const displayedItems = useMemo(() => {
|
||||
if (isSearching) {
|
||||
const search = query.trim().toLowerCase();
|
||||
const results: FolderTreeNode[] = [];
|
||||
nodeMap.forEach((node) => {
|
||||
if (node.name.toLowerCase().includes(search)) {
|
||||
results.push(node);
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}
|
||||
return currentChildren;
|
||||
}, [isSearching, query, currentChildren, nodeMap]);
|
||||
|
||||
const handleTriggerClick = useCallback(() => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
if (!isOpen) {
|
||||
onOpenMenu?.();
|
||||
}
|
||||
toggle();
|
||||
}, [disabled, isOpen, onOpenMenu, toggle]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (folderId: DocumentId | null) => {
|
||||
if (!onSelectFolder) return;
|
||||
setPending(true);
|
||||
try {
|
||||
await onSelectFolder(folderId);
|
||||
close();
|
||||
} catch (error) {
|
||||
console.error('Failed to move to folder', error);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
},
|
||||
[onSelectFolder, close]
|
||||
);
|
||||
|
||||
const handleNavigate = (folderId: DocumentId) => {
|
||||
setCurrentFolderId(folderId);
|
||||
setQuery(''); // Clear search on navigation
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
if (!currentFolderId) return;
|
||||
const parentId = parentMap.get(currentFolderId) || null;
|
||||
setCurrentFolderId(parentId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className ? `selection-assignment ${className}` : 'selection-assignment'}>
|
||||
<button
|
||||
type="button"
|
||||
ref={anchorRef}
|
||||
className={triggerClassName}
|
||||
onClick={handleTriggerClick}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen}
|
||||
disabled={disabled}
|
||||
>
|
||||
{triggerContent ? triggerContent : (
|
||||
<span className="quick-add__chip-label">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div
|
||||
className="menu menu--floating selection-assignment__menu"
|
||||
ref={menuRef}
|
||||
style={menuStyle || undefined}
|
||||
role="menu"
|
||||
data-floating-position
|
||||
>
|
||||
<div className="selection-assignment__header">
|
||||
{/* Search Bar */}
|
||||
<div className="selection-assignment__form">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder}
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Navigation Header (only if not searching) */}
|
||||
{!isSearching && (
|
||||
<div className="selection-assignment__header-nav">
|
||||
<div className="selection-assignment__nav-title">
|
||||
{currentFolderId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={handleUp}
|
||||
aria-label="Go up"
|
||||
title="Go up"
|
||||
>
|
||||
<ArrowLeftIcon size="1em" />
|
||||
</button>
|
||||
) : null}
|
||||
<span
|
||||
className="selection-assignment__folder-name"
|
||||
>
|
||||
{currentFolder ? currentFolder.name : rootTitle}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="selection-assignment__nav-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={() => handleSelect(currentFolderId)}
|
||||
disabled={pending}
|
||||
title="Move here"
|
||||
aria-label="Move here"
|
||||
>
|
||||
<FolderMoveIcon size="1em" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="selection-assignment__list" role="presentation">
|
||||
{displayedItems.length ? (
|
||||
displayedItems.map((item) => {
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="menu__item selection-assignment__item"
|
||||
role="menuitem"
|
||||
>
|
||||
{/* Clickable area to navigate down */}
|
||||
<button
|
||||
type="button"
|
||||
className="selection-assignment__item-content"
|
||||
onClick={() => hasChildren && handleNavigate(item.id)}
|
||||
style={{ cursor: hasChildren ? 'pointer' : 'default' }}
|
||||
>
|
||||
<FolderIcon className="selection-assignment__icon" aria-hidden="true" />
|
||||
<span className="selection-assignment__folder-name">
|
||||
{item.name}
|
||||
{isSearching && parentMap.get(item.id) && (
|
||||
<span className="selection-assignment__folder-path">
|
||||
(in {nodeMap.get(parentMap.get(item.id)!)?.name})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Move Button for this specific folder */}
|
||||
<div className="selection-assignment__item-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelect(item.id);
|
||||
}}
|
||||
title={`Move to ${item.name}`}
|
||||
aria-label={`Move to ${item.name}`}
|
||||
>
|
||||
<FolderMoveIcon size="1em" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="menu__empty selection-assignment__empty">{emptyMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectionFolderMenu;
|
||||
@@ -102,7 +102,7 @@
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
.panel-header > .panel-header__title:first-child {
|
||||
.panel-header>.panel-header__title:first-child {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -307,6 +307,7 @@
|
||||
max-height: max(240px, 50vh);
|
||||
overflow-y: auto;
|
||||
padding: 0 0.25rem 0.25rem;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.selection-assignment__item {
|
||||
@@ -315,6 +316,7 @@
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
font-weight: 400;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.selection-assignment__label {
|
||||
@@ -627,8 +629,7 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.document-summary__details {
|
||||
}
|
||||
.document-summary__details {}
|
||||
|
||||
.document-summary__details-list {
|
||||
margin: 0;
|
||||
@@ -744,7 +745,7 @@
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.detail-meta__row > .icon-button {
|
||||
.detail-meta__row>.icon-button {
|
||||
margin: -0.25rem;
|
||||
}
|
||||
|
||||
@@ -800,205 +801,49 @@
|
||||
margin: 0.6rem 0;
|
||||
}
|
||||
|
||||
.bulk-move select {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.preview-stack {
|
||||
position: relative;
|
||||
.selection-assignment__header-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-stack--stacked {
|
||||
width: 100%;
|
||||
min-height: 420px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-stack--empty {
|
||||
width: 100%;
|
||||
min-height: 420px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-stack__item {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 120ms ease;
|
||||
filter: drop-shadow(0 2px 6px var(--shadow-soft));
|
||||
transform-origin: center;
|
||||
border-radius: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.preview-stack .preview-stack__item.orientation-portrait {
|
||||
width: 80%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.preview-stack .preview-stack__item.orientation-landscape {
|
||||
width: 100%;
|
||||
height: 80%;
|
||||
}
|
||||
|
||||
.preview-pane__unsupported {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-subtle);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.preview-pane__unsupported-message {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.preview-pane__unsupported-filename {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.preview-pane__unsupported-download {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.preview-pane__unsupported-download svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.preview-pane--stack {
|
||||
min-height: 460px;
|
||||
position: relative;
|
||||
--preview-nav-scale: 1;
|
||||
}
|
||||
|
||||
.preview-pane__nav-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.2em;
|
||||
height: 2.2em;
|
||||
padding: 0.45em;
|
||||
border-radius: 999px;
|
||||
background: var(--preview-nav-bg);
|
||||
color: var(--preview-nav-fg);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, opacity 0.15s ease;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.preview-pane__nav-button:hover:not([disabled]) {
|
||||
background: var(--preview-nav-bg-hover);
|
||||
}
|
||||
|
||||
.preview-pane__nav-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.preview-pane__nav-button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.preview-pane__nav {
|
||||
gap: 0.5rem;
|
||||
justify-content: space-between;
|
||||
margin-top: 0.5rem;
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.selection-assignment__nav-title .icon-button {
|
||||
margin-left: -0.25rem;
|
||||
}
|
||||
|
||||
.selection-assignment__nav-title {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.preview-pane__nav--overlay {
|
||||
position: absolute;
|
||||
bottom: 0.75rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) scale(var(--preview-nav-scale, 1));
|
||||
margin-top: 0;
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.preview-pane__nav--overlay .preview-pane__nav-button {
|
||||
pointer-events: auto;
|
||||
transform: scale(calc(1 / var(--preview-nav-scale, 1)));
|
||||
}
|
||||
|
||||
.preview-pane__media:hover .preview-pane__nav--overlay,
|
||||
.desk-item__card:hover .preview-pane__nav--overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
.bulk-tags {
|
||||
.selection-assignment__nav-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin: 0.4rem 0 0.6rem;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.detail-field {
|
||||
margin: 0.9rem 0;
|
||||
}
|
||||
|
||||
.detail-field__label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.detail-field__value {
|
||||
.selection-assignment__item-content {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.5rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-field__value .meta {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.detail-panel dl {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.
|
||||
|
||||
.detail-panel .tag-list,
|
||||
.document-summary .tag-list,
|
||||
.correspondent-list {
|
||||
.selection-assignment__item-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tag-list__empty {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.detail-metadata__block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.75rem 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
gap: 0.25rem;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
IconTextScan2,
|
||||
IconFolderPlus,
|
||||
IconFolder,
|
||||
IconFolderUp,
|
||||
IconFolders,
|
||||
IconFoldersOff,
|
||||
IconRefresh,
|
||||
@@ -567,6 +568,40 @@ export const ChevronDownIcon: TablerIconComponent = ({ className, size = '1em',
|
||||
/>
|
||||
);
|
||||
|
||||
export const FolderUpIcon: TablerIconComponent = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconFolderUp
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const FolderMoveIcon: TablerIconComponent = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={composeClassName('icon', className)}
|
||||
{...rest}
|
||||
>
|
||||
<g transform="translate(2, 0)">
|
||||
<path d="M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-2m0 -6v-3a2 2 0 0 1 2 -2" />
|
||||
</g>
|
||||
<g transform="translate(-4, 0)">
|
||||
<path d="M5 12l11 0"></path>
|
||||
<path d="M13 16l4 -4"></path>
|
||||
<path d="M13 8l4 4"></path>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default {
|
||||
ChevronIcon,
|
||||
TrashIcon,
|
||||
@@ -589,6 +624,8 @@ export default {
|
||||
MinusVerticalIcon,
|
||||
LogoutIcon,
|
||||
ChevronDownIcon,
|
||||
FolderUpIcon,
|
||||
FolderMoveIcon,
|
||||
};
|
||||
|
||||
export const TextScanIcon: TablerIconComponent = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
|
||||
Reference in New Issue
Block a user