diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx index 43b3556..2b28eff 100644 --- a/frontend/src/app/AppLayout.jsx +++ b/frontend/src/app/AppLayout.jsx @@ -27,6 +27,10 @@ import { isTagTransferEvent } from '../documents/tagTransfer'; const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early const DEFAULT_FOLDER_NAME = 'Documents'; +const DEFAULT_SORT_FIELD = 'title'; +const DEFAULT_SORT_DIRECTION = 'asc'; +const SORT_FIELD_VALUES = ['title', 'issued_at', 'created_at', 'updated_at']; +const TAG_FILTER_UNTAGGED = '__UNTAGGED__'; const ROW_KEY_SEPARATOR = ':'; const DOCUMENT_ROW_PREFIX = 'document'; @@ -204,6 +208,46 @@ const AppLayout = () => { return 'list'; } }); + const [documentsSortField, setDocumentsSortField] = useState(() => { + if (typeof window === 'undefined') { + return DEFAULT_SORT_FIELD; + } + try { + const stored = window.sessionStorage.getItem('papercrate_sort_field'); + return SORT_FIELD_VALUES.includes(stored) ? stored : DEFAULT_SORT_FIELD; + } catch (error) { + console.warn('[sort] failed to read stored sort field', error); + return DEFAULT_SORT_FIELD; + } + }); + const [documentsSortDirection, setDocumentsSortDirection] = useState(() => { + if (typeof window === 'undefined') { + return DEFAULT_SORT_DIRECTION; + } + try { + const stored = window.sessionStorage.getItem('papercrate_sort_direction'); + return stored === 'desc' || stored === 'asc' ? stored : DEFAULT_SORT_DIRECTION; + } catch (error) { + console.warn('[sort] failed to read stored sort direction', error); + return DEFAULT_SORT_DIRECTION; + } + }); + const documentsSortFieldRef = useRef(DEFAULT_SORT_FIELD); + const documentsSortDirectionRef = useRef(DEFAULT_SORT_DIRECTION); + const [searchIncludeDescendants, setSearchIncludeDescendants] = useState(() => { + if (typeof window === 'undefined') { + return true; + } + try { + const stored = window.sessionStorage.getItem('papercrate_include_descendants'); + if (stored === 'true') return true; + if (stored === 'false') return false; + } catch (error) { + console.warn('[search] failed to read include_descendants preference', error); + } + return true; + }); + const sortRefreshReadyRef = useRef(false); const [deskHelpOpen, setDeskHelpOpen] = useState(false); const lastNonDeskViewRef = useRef(documentsViewMode === 'desk' ? 'list' : documentsViewMode); @@ -218,6 +262,42 @@ const AppLayout = () => { setDeskHelpOpen(false); } }, [documentsViewMode, deskHelpOpen]); + useEffect(() => { + documentsSortFieldRef.current = documentsSortField; + if (typeof window !== 'undefined') { + try { + window.sessionStorage.setItem('papercrate_sort_field', documentsSortField); + } catch (error) { + console.warn('[sort] failed to persist sort field', error); + } + } + }, [documentsSortField, documentsSortFieldRef]); + + useEffect(() => { + documentsSortDirectionRef.current = documentsSortDirection; + if (typeof window !== 'undefined') { + try { + window.sessionStorage.setItem('papercrate_sort_direction', documentsSortDirection); + } catch (error) { + console.warn('[sort] failed to persist sort direction', error); + } + } + }, [documentsSortDirection, documentsSortDirectionRef]); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + try { + window.sessionStorage.setItem( + 'papercrate_include_descendants', + searchIncludeDescendants ? 'true' : 'false', + ); + } catch (error) { + console.warn('[search] failed to persist include_descendants preference', error); + } + }, [searchIncludeDescendants]); + const tokenRef = useRef(token); const refreshPromiseRef = useRef(null); const breadcrumbFetchRef = useRef(new Set()); @@ -281,11 +361,16 @@ const AppLayout = () => { ); const toggleTagFilter = useCallback((tagId) => { if (!tagId) return; - setActiveTagFilters((previous) => - previous.includes(tagId) - ? previous.filter((id) => id !== tagId) - : previous.concat([tagId]), - ); + setActiveTagFilters((previous) => { + if (tagId === TAG_FILTER_UNTAGGED) { + return previous.includes(TAG_FILTER_UNTAGGED) ? [] : [TAG_FILTER_UNTAGGED]; + } + const sanitized = previous.filter((id) => id !== TAG_FILTER_UNTAGGED); + if (sanitized.includes(tagId)) { + return sanitized.filter((id) => id !== tagId); + } + return sanitized.concat([tagId]); + }); }, []); const toggleCorrespondentFilter = useCallback((correspondentId) => { @@ -297,6 +382,19 @@ const AppLayout = () => { }); }, []); + const handleDocumentsSortFieldChange = useCallback((field) => { + const nextField = SORT_FIELD_VALUES.includes(field) ? field : DEFAULT_SORT_FIELD; + setDocumentsSortField((previous) => (previous === nextField ? previous : nextField)); + }, []); + + const handleDocumentsSortDirectionToggle = useCallback(() => { + setDocumentsSortDirection((previous) => (previous === 'asc' ? 'desc' : 'asc')); + }, []); + + const toggleSearchIncludeDescendants = useCallback(() => { + setSearchIncludeDescendants((previous) => !previous); + }, []); + const initialRefreshAttemptedRef = useRef(Boolean(token)); useEffect(() => { @@ -312,6 +410,7 @@ const AppLayout = () => { setActiveTagFilters([]); setActiveCorrespondentFilters([]); setSearchLoading(false); + setSearchIncludeDescendants(true); }, []); const handleSearchChange = useCallback((value) => { @@ -982,13 +1081,32 @@ const AppLayout = () => { const ensureFolderData = useCallback( async ( folderId, - { force = false, includeDocuments = true, prefetchDepth = 0 } = {}, + { + force = false, + includeDocuments = true, + prefetchDepth = 0, + sortField: overrideSortField, + sortDirection: overrideSortDirection, + } = {}, ) => { const requestTenantId = tenantIdRef.current; const cached = folderContents.get(folderId); + const cachedSortField = cached?.__sortField ?? DEFAULT_SORT_FIELD; + const cachedSortDirection = cached?.__sortDirection ?? DEFAULT_SORT_DIRECTION; + const sortField = includeDocuments + ? overrideSortField || documentsSortFieldRef.current || DEFAULT_SORT_FIELD + : null; + const sortDirection = includeDocuments + ? overrideSortDirection || documentsSortDirectionRef.current || DEFAULT_SORT_DIRECTION + : null; + const cachedSortMatches = + !includeDocuments + || !cached + || (cachedSortField === sortField && cachedSortDirection === sortDirection); + if (!force && cached) { const includesDocuments = Boolean(cached.__includesDocuments); - if (!includeDocuments || includesDocuments) { + if (!includeDocuments || (includesDocuments && cachedSortMatches)) { if (prefetchDepth > 0) { const subfolders = Array.isArray(cached.subfolders) ? cached.subfolders : []; await Promise.allSettled( @@ -1006,18 +1124,28 @@ const AppLayout = () => { } const path = folderId === 'root' ? 'root' : folderId; - const params = includeDocuments - ? undefined - : { include_documents: false }; - const { data } = await api.get(`/folders/${path}/contents`, { - params, - }); + const params = {}; + if (!includeDocuments) { + params.include_documents = false; + } else { + params.sort = sortField; + params.dir = sortDirection; + } + const requestConfig = Object.keys(params).length ? { params } : {}; + const { data } = await api.get(`/folders/${path}/contents`, requestConfig); const hydrated = assetManager.hydrateFolderContents(data); const childFolders = Array.isArray(data.subfolders) ? data.subfolders : []; const childIds = childFolders.map((child) => child.id); + const enriched = { + ...hydrated, + __includesDocuments: includeDocuments, + __sortField: includeDocuments ? sortField : cachedSortField, + __sortDirection: includeDocuments ? sortDirection : cachedSortDirection, + }; + if (tenantIdRef.current !== requestTenantId) { - return { ...hydrated, __includesDocuments: includeDocuments }; + return enriched; } setFolderNodes((prev) => { @@ -1089,11 +1217,6 @@ const AppLayout = () => { ); } - const enriched = { - ...hydrated, - __includesDocuments: includeDocuments, - }; - if (includeDocuments) { setFolderContents((prev) => { if (tenantIdRef.current !== requestTenantId) { @@ -1118,6 +1241,8 @@ const AppLayout = () => { ? existingEntry.documents : hydrated.documents, __includesDocuments: existingEntry.__includesDocuments || false, + __sortField: existingEntry.__sortField ?? enriched.__sortField, + __sortDirection: existingEntry.__sortDirection ?? enriched.__sortDirection, }); } else { next.set(folderId, enriched); @@ -1128,7 +1253,7 @@ const AppLayout = () => { return enriched; }, - [assetManager, folderContents], + [assetManager, documentsSortDirectionRef, documentsSortFieldRef, folderContents], ); const isInvalidFolderDrop = useCallback( @@ -1461,6 +1586,22 @@ const AppLayout = () => { } }, [selectedFolder, ensureFolderData, applySelectedFolder, notifyApiError]); + useEffect(() => { + if (!sortRefreshReadyRef.current) { + sortRefreshReadyRef.current = true; + return; + } + if (!isFilterActive && token) { + refreshCurrentFolder(); + } + }, [ + documentsSortField, + documentsSortDirection, + isFilterActive, + refreshCurrentFolder, + token, + ]); + const handleDocumentCorrespondentAttach = useCallback( async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => { if (!documentId || !correspondentId) { @@ -3719,7 +3860,16 @@ const AppLayout = () => { params.query = trimmedQuery; } if (activeTagFilters.length) { - params.tags = activeTagFilters.join(','); + const onlyUntagged = activeTagFilters.length === 1 + && activeTagFilters[0] === TAG_FILTER_UNTAGGED; + if (onlyUntagged) { + params.tags = 'none'; + } else { + const tagIds = activeTagFilters.filter((id) => id !== TAG_FILTER_UNTAGGED); + if (tagIds.length) { + params.tags = tagIds.join(','); + } + } } if (activeCorrespondentFilters.length) { params.correspondents = activeCorrespondentFilters.join(','); @@ -3728,6 +3878,15 @@ const AppLayout = () => { if (folderIdentifier) { params.folder_id = folderIdentifier; } + if (!searchIncludeDescendants) { + params.include_descendants = false; + } + if (documentsSortField) { + params.sort = documentsSortField; + } + if (documentsSortDirection) { + params.dir = documentsSortDirection; + } const { data } = await api.get('/documents', { params }); if (cancelled) return; @@ -3803,6 +3962,9 @@ const AppLayout = () => { searchQuery, activeTagFilters, activeCorrespondentFilters, + searchIncludeDescendants, + documentsSortField, + documentsSortDirection, selectedFolder, notifyApiError, assetManager, @@ -4561,6 +4723,12 @@ const AppLayout = () => { onCorrespondentClick: toggleCorrespondentFilter, onDocumentTagDrop: handleDocumentTagDrop, viewMode: documentsViewMode, + sortField: documentsSortField, + sortDirection: documentsSortDirection, + onSortFieldChange: handleDocumentsSortFieldChange, + onSortDirectionToggle: handleDocumentsSortDirectionToggle, + searchIncludeDescendants, + onToggleSearchIncludeDescendants: toggleSearchIncludeDescendants, onViewModeChange: handleDocumentsViewModeChange, onClearSelection: clearDocumentSelection, onDeleteSelection: handleDeleteSelection, @@ -4586,6 +4754,8 @@ const AppLayout = () => { currentSubfolders, documents, documentsViewMode, + documentsSortField, + documentsSortDirection, draggedDocumentIds, draggedFolderId, focusedRowKey, @@ -4595,6 +4765,8 @@ const AppLayout = () => { handleDocumentTagDrop, handleDocumentTitleUpdate, handleDocumentsViewModeChange, + handleDocumentsSortFieldChange, + handleDocumentsSortDirectionToggle, handleFolderDragEnd, handleFolderDragStart, handleFolderRename, @@ -4607,6 +4779,7 @@ const AppLayout = () => { refreshCurrentFolder, searchLoading, searchResults, + searchIncludeDescendants, selectFolder, selectedDocumentIds, selectedEntries, @@ -4627,6 +4800,7 @@ const AppLayout = () => { handleBulkSelectionReanalyze, folderOptions, moveDocumentsToFolder, + toggleSearchIncludeDescendants, ], ); @@ -4647,6 +4821,7 @@ const AppLayout = () => { onCreateFolder: handlePromptCreateFolder, creatingFolder, tags, + untaggedFilterId: TAG_FILTER_UNTAGGED, activeTagIds: activeTagFilters, onToggleTagFilter: toggleTagFilter, onCreateTag: (label) => handleTagCreate({ label }), diff --git a/frontend/src/documents/DocumentsPanel.jsx b/frontend/src/documents/DocumentsPanel.jsx index dc3965c..9e1d759 100644 --- a/frontend/src/documents/DocumentsPanel.jsx +++ b/frontend/src/documents/DocumentsPanel.jsx @@ -6,7 +6,12 @@ import { RefreshIcon, MinusVerticalIcon, InfoIcon, + FoldersIcon, + FoldersOffIcon, + SortAscendingLettersIcon, + SortDescendingLettersIcon, } from '../ui/icons'; +import QuickAddMenu from '../ui/QuickAddMenu'; import BreadcrumbTrail from '../ui/BreadcrumbTrail'; import createWorkspaceSurfaceConfig from './workspaceHeader'; import DetailPanel from '../detail/DetailPanel'; @@ -25,6 +30,19 @@ const EntryType = { document: 'document', }; +const SORT_OPTIONS = [ + { value: 'title', label: 'Title' }, + { value: 'issued_at', label: 'Issued date' }, + { value: 'created_at', label: 'Added' }, + { value: 'updated_at', label: 'Updated date' }, +]; + +const SORT_LABEL_LOOKUP = SORT_OPTIONS.reduce((accumulator, option) => { + const next = accumulator; + next[option.value] = option.label; + return next; +}, {}); + const DocumentsPanel = ({ currentFolderName, breadcrumbs, @@ -545,7 +563,6 @@ const DocumentsPanel = ({ const showDefaultEmptyState = !showingSearchResults && !isFilterActive && entries.length === 0; const showListSearchEmptyState = showingSearchResults && !hasDocumentEntries && !isGridView && !isSearchLoading; const showGridSearchEmptyState = isGridView && showingSearchResults && !hasDocumentEntries && !isSearchLoading; - const showSearchHint = showingSearchResults && rows.length > 0; const breadcrumbEntries = useMemo(() => (Array.isArray(breadcrumbs) ? breadcrumbs.filter(Boolean) : []), [breadcrumbs]); const trailEntries = useMemo(() => { if (!breadcrumbEntries.length) { @@ -724,11 +741,6 @@ const DocumentsPanel = ({ /> )} - {showSearchHint && ( -