diff --git a/frontend/src/documents/SelectionFloatingActions.tsx b/frontend/src/documents/SelectionFloatingActions.tsx index eb7119e..c1279dc 100644 --- a/frontend/src/documents/SelectionFloatingActions.tsx +++ b/frontend/src/documents/SelectionFloatingActions.tsx @@ -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 | null; correspondents?: CorrespondentOption[] | null; - folderOptions?: SelectionAssignmentMenuItem[] | null; onBulkTagAdd?: (args: BulkTagMutationArgs) => Promise | void; onBulkTagRemove?: (args: BulkTagMutationArgs) => Promise | void; onBulkCorrespondentAdd?: (args: BulkCorrespondentAddArgs) => Promise | void; @@ -75,7 +68,7 @@ export interface SelectionFloatingActionsProps { onBulkReanalyze?: (documentIds: DocumentId[]) => Promise | void; onDeleteSelection?: () => void; onClearSelection?: () => void; - onMoveDocumentsToFolder?: (documentIds: DocumentId[], folderId: DocumentId) => Promise | void; + onMoveDocumentsToFolder?: (documentIds: DocumentId[], folderId: DocumentId | null) => Promise | 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 => 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 | null, @@ -261,7 +208,6 @@ const SelectionFloatingActions: React.FC = ({ tags = [], tagLookupById, correspondents = [], - folderOptions = [], onBulkTagAdd, onBulkTagRemove, onBulkCorrespondentAdd, @@ -271,7 +217,7 @@ const SelectionFloatingActions: React.FC = ({ 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 = ({ ), [documentLookup]); const tagLookupMap = tagLookupById instanceof Map ? tagLookupById : null; - const [remoteFolderOptions, setRemoteFolderOptions] = useState(null); - const folderTreeFetchRef = useRef | null>(null); + const [remoteFolderTree, setRemoteFolderTree] = useState(null); + const folderTreeFetchRef = useRef | null>(null); useEffect(() => { - setRemoteFolderOptions(null); + setRemoteFolderTree(null); folderTreeFetchRef.current = null; }, [tenantId, token]); - const requestFolderTree = useCallback(async (): Promise => { + const requestFolderTree = useCallback(async (): Promise => { 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 = ({ 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 = ({ folderTreeFetchRef.current = fetchPromise; return fetchPromise; - }, [remoteFolderOptions, token]); + }, [remoteFolderTree, token]); const handleMoveMenuOpen = useCallback(() => { requestFolderTree(); }, [requestFolderTree]); - const effectiveFolderOptions = useMemo(() => { - if (remoteFolderOptions !== null) { - return remoteFolderOptions; - } - return Array.isArray(folderOptions) ? folderOptions : []; - }, [remoteFolderOptions, folderOptions]); - const documentIdList = useMemo( () => normalizeDocumentList(selectedDocumentIds), [selectedDocumentIds], @@ -356,34 +294,6 @@ const SelectionFloatingActions: React.FC = ({ const selectedDocCount = selectedDocuments.length; - const moveAssignments = useMemo(() => { - if (!Array.isArray(effectiveFolderOptions)) { - return []; - } - return effectiveFolderOptions - .map((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 = ({ [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 ? ( - )} - 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; diff --git a/frontend/src/documents/SelectionFolderMenu.tsx b/frontend/src/documents/SelectionFolderMenu.tsx new file mode 100644 index 0000000..0f62a35 --- /dev/null +++ b/frontend/src/documents/SelectionFolderMenu.tsx @@ -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; + disabled?: boolean; + className?: string; + triggerContent?: React.ReactNode; + triggerClassName?: string; + placeholder?: string; + emptyMessage?: string; + onOpenMenu?: () => void; + positionStrategy?: 'absolute' | 'fixed'; + rootTitle?: string; +} + +const SelectionFolderMenu: React.FC = ({ + 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(null); + const inputRef = useRef(null); + const [query, setQuery] = useState(''); + const [currentFolderId, setCurrentFolderId] = useState(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; + 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(); + const pMap = new Map(); + + 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 ( +
+ + {isOpen ? ( +
+
+ {/* Search Bar */} +
+ setQuery(event.target.value)} + placeholder={placeholder} + aria-label={placeholder} + disabled={pending} + /> +
+ + {/* Navigation Header (only if not searching) */} + {!isSearching && ( +
+
+ {currentFolderId ? ( + + ) : null} + + {currentFolder ? currentFolder.name : rootTitle} + +
+ +
+ +
+
+ )} +
+ +
+ {displayedItems.length ? ( + displayedItems.map((item) => { + const hasChildren = item.children && item.children.length > 0; + return ( +
+ {/* Clickable area to navigate down */} + + + {/* Move Button for this specific folder */} +
+ +
+
+ ); + }) + ) : ( +
{emptyMessage}
+ )} +
+
+ ) : null} +
+ ); +}; + +export default SelectionFolderMenu; diff --git a/frontend/src/styles/detail/detail-panels.css b/frontend/src/styles/detail/detail-panels.css index 2a921a5..2da2124 100644 --- a/frontend/src/styles/detail/detail-panels.css +++ b/frontend/src/styles/detail/detail-panels.css @@ -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; +} \ No newline at end of file diff --git a/frontend/src/ui/icons.tsx b/frontend/src/ui/icons.tsx index b44567a..6654732 100644 --- a/frontend/src/ui/icons.tsx +++ b/frontend/src/ui/icons.tsx @@ -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 }) => ( + +); + +export const FolderMoveIcon: TablerIconComponent = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + + + + + + + + + + +); + 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 }) => (