refactor: Extract folder assignment logic into a new SelectionFolderMenu component, adding new icons and updating related styles.

This commit is contained in:
2025-11-28 19:28:12 +01:00
parent 970490d9c8
commit 34604c5842
4 changed files with 407 additions and 340 deletions
@@ -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;
+35 -190
View File
@@ -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;
}
+37
View File
@@ -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 }) => (