5111 lines
152 KiB
React
5111 lines
152 KiB
React
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
Navigate,
|
|
Outlet,
|
|
matchPath,
|
|
useLocation,
|
|
useMatch,
|
|
useNavigate,
|
|
} from 'react-router-dom';
|
|
import AssetManager, {
|
|
getAssetFromVersion,
|
|
resolveDocumentAssetUrl,
|
|
createAssetView,
|
|
} from '../asset_manager';
|
|
import useApiError from '../hooks/useApiError';
|
|
import TagManager from '../tag_manager';
|
|
import usePasskeys from '../settings/usePasskeys';
|
|
import { AppShellContext } from '../appShellContext';
|
|
import DropOverlay from './DropOverlay';
|
|
import { useManagementModals } from './useManagementModals';
|
|
import { api, useAppDispatch, useAppState } from './appState';
|
|
import { useDetailPanel } from './useDetailPanel';
|
|
import { useDocumentSelection } from './useDocumentSelection';
|
|
|
|
const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early
|
|
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
|
|
|
const DEFAULT_FOLDER_NAME = 'Documents';
|
|
|
|
const ROW_KEY_SEPARATOR = ':';
|
|
const DOCUMENT_ROW_PREFIX = 'document';
|
|
const FOLDER_ROW_PREFIX = 'folder';
|
|
|
|
const resolveApiPath = (path = '') => path;
|
|
|
|
const makeRowKey = (type, id) =>
|
|
id ? `${type}${ROW_KEY_SEPARATOR}${id}` : `${type}${ROW_KEY_SEPARATOR}`;
|
|
|
|
const getRowType = (key) => (typeof key === 'string' ? key.split(ROW_KEY_SEPARATOR, 1)[0] : '');
|
|
|
|
const getRowId = (key) => {
|
|
if (typeof key !== 'string') return '';
|
|
const separatorIndex = key.indexOf(ROW_KEY_SEPARATOR);
|
|
if (separatorIndex === -1) return key;
|
|
return key.slice(separatorIndex + 1);
|
|
};
|
|
|
|
const isDocumentRowKey = (key) => getRowType(key) === DOCUMENT_ROW_PREFIX;
|
|
const isFolderRowKey = (key) => getRowType(key) === FOLDER_ROW_PREFIX;
|
|
|
|
const resolveDocumentRowKey = (documentId) =>
|
|
documentId ? makeRowKey(DOCUMENT_ROW_PREFIX, documentId) : null;
|
|
|
|
const resolveFolderRowKey = (folderId) =>
|
|
folderId ? makeRowKey(FOLDER_ROW_PREFIX, folderId) : null;
|
|
|
|
const hasFiles = (event) =>
|
|
Array.from(event.dataTransfer?.types || []).includes('Files');
|
|
|
|
const isAssetEquivalent = (lhs, rhs) => {
|
|
if (!lhs || !rhs) return false;
|
|
const lhsView = createAssetView(lhs);
|
|
const rhsView = createAssetView(rhs);
|
|
const lhsPrimaryMetadata = lhsView.getPrimaryMetadata() || lhs?.metadata;
|
|
const rhsPrimaryMetadata = rhsView.getPrimaryMetadata() || rhs?.metadata;
|
|
const lhsCardinality = lhsView.getCardinality() || lhs?.cardinality || null;
|
|
const rhsCardinality = rhsView.getCardinality() || rhs?.cardinality || null;
|
|
const lhsObjects = lhsView.getObjects();
|
|
const rhsObjects = rhsView.getObjects();
|
|
const objectsComparable =
|
|
lhsObjects.length === rhsObjects.length
|
|
&& lhsObjects.every((entry, index) => {
|
|
const other = rhsObjects[index];
|
|
if (!other) return false;
|
|
if (entry.ordinal !== other.ordinal) return false;
|
|
if (entry.url && other.url && entry.url === other.url) {
|
|
return true;
|
|
}
|
|
if (!entry.url && !other.url) {
|
|
return JSON.stringify(entry.metadata || null) === JSON.stringify(other.metadata || null);
|
|
}
|
|
return entry.url === other.url;
|
|
});
|
|
return (
|
|
lhs.id === rhs.id
|
|
&& lhs.url === rhs.url
|
|
&& lhsPrimaryMetadata?.width === rhsPrimaryMetadata?.width
|
|
&& lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height
|
|
&& lhs.mime_type === rhs.mime_type
|
|
&& lhs.asset_type === rhs.asset_type
|
|
&& lhs.updated_at === rhs.updated_at
|
|
&& lhsCardinality === rhsCardinality
|
|
&& objectsComparable
|
|
);
|
|
};
|
|
|
|
const mergeAssetIntoGroup = (group, assetData) => {
|
|
if (!assetData || !assetData.asset_type) {
|
|
if (Array.isArray(group)) {
|
|
return group;
|
|
}
|
|
return group || {};
|
|
}
|
|
|
|
if (Array.isArray(group) || !group) {
|
|
const list = Array.isArray(group) ? group : [];
|
|
const index = list.findIndex((item) => item?.id === assetData.id);
|
|
if (index >= 0) {
|
|
const existing = list[index];
|
|
if (isAssetEquivalent(existing, assetData)) {
|
|
return list;
|
|
}
|
|
const next = list.slice();
|
|
next[index] = { ...existing, ...assetData };
|
|
return next;
|
|
}
|
|
return list.concat({ ...assetData });
|
|
}
|
|
|
|
const key = assetData.asset_type;
|
|
const previous = group?.[key];
|
|
if (previous && isAssetEquivalent(previous, assetData)) {
|
|
return group;
|
|
}
|
|
|
|
const next = { ...(group || {}) };
|
|
next[key] = { ...(previous || {}), ...assetData };
|
|
return next;
|
|
};
|
|
|
|
const mergeAssetIntoDocument = (doc, assetData) => {
|
|
if (!doc) return doc;
|
|
const existingGroup = doc.current_version?.assets || null;
|
|
const nextGroup = mergeAssetIntoGroup(existingGroup, assetData);
|
|
if (nextGroup === existingGroup) {
|
|
return doc;
|
|
}
|
|
const updatedCurrentVersion = doc.current_version
|
|
? { ...doc.current_version, assets: nextGroup }
|
|
: { assets: nextGroup };
|
|
return { ...doc, current_version: updatedCurrentVersion };
|
|
};
|
|
|
|
const createRootNode = () => ({
|
|
id: 'root',
|
|
name: DEFAULT_FOLDER_NAME,
|
|
parentId: null,
|
|
children: [],
|
|
expanded: true,
|
|
loaded: false,
|
|
hasChildren: false,
|
|
});
|
|
|
|
const AppLayout = () => {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const appState = useAppState();
|
|
const appDispatch = useAppDispatch();
|
|
const folderMatch = matchPath('/documents/folder/:folderId', location.pathname);
|
|
const docMatch = matchPath('/documents/:documentId', location.pathname);
|
|
const routeFolderId = folderMatch?.params?.folderId || null;
|
|
const routeDocumentId = docMatch?.params?.documentId || null;
|
|
const previewDocumentId = routeDocumentId;
|
|
const { status: appStatus, token, tenant, tenants: tenantOptions = [] } = appState;
|
|
const tenantName = tenant?.name || tenant?.slug || null;
|
|
const currentTenantId = tenant?.id || null;
|
|
const [status, setStatus] = useState(null);
|
|
const setStatusMessage = useCallback((message, variant = 'info') => {
|
|
setStatus(message ? { message, variant } : null);
|
|
}, []);
|
|
const handleApiReport = useCallback(
|
|
({ message, variant }) => setStatusMessage(message, variant),
|
|
[setStatusMessage],
|
|
);
|
|
const reportApiError = useApiError({
|
|
onReport: handleApiReport,
|
|
});
|
|
const notifyApiError = useCallback(
|
|
(error, fallbackMessage, variant = 'error') =>
|
|
reportApiError(error, { message: fallbackMessage, variant }),
|
|
[reportApiError],
|
|
);
|
|
const [loading, setLoading] = useState(false);
|
|
const [creatingFolder, setCreatingFolder] = useState(false);
|
|
const [folderNodes, setFolderNodes] = useState(() => {
|
|
const rootNode = createRootNode();
|
|
return new Map([[rootNode.id, rootNode]]);
|
|
});
|
|
const [folderContents, setFolderContents] = useState(() => new Map());
|
|
const [selectedFolder, setSelectedFolder] = useState(routeFolderId || 'root');
|
|
const [currentFolder, setCurrentFolder] = useState(null);
|
|
const [currentSubfolders, setCurrentSubfolders] = useState([]);
|
|
const [documents, setDocuments] = useState([]);
|
|
const [documentsViewMode, setDocumentsViewMode] = useState(() => {
|
|
if (typeof window === 'undefined') {
|
|
return 'list';
|
|
}
|
|
const stored = window.localStorage.getItem('papercrate_view_mode');
|
|
return stored === 'grid' || stored === 'desk' ? stored : 'list';
|
|
});
|
|
const lastNonDeskViewRef = useRef(documentsViewMode === 'desk' ? 'list' : documentsViewMode);
|
|
|
|
useEffect(() => {
|
|
if (documentsViewMode !== 'desk') {
|
|
lastNonDeskViewRef.current = documentsViewMode;
|
|
}
|
|
}, [documentsViewMode]);
|
|
const initialRowSelection = [];
|
|
const tokenRef = useRef(token);
|
|
const refreshPromiseRef = useRef(null);
|
|
const breadcrumbFetchRef = useRef(new Set());
|
|
const tagRemovalCursorActiveRef = useRef(false);
|
|
const tenantIdRef = useRef(currentTenantId);
|
|
const detailPanelControlRef = useRef({ open: () => {}, close: () => {} });
|
|
const setTagRemovalCursor = useCallback((active) => {
|
|
if (typeof document === 'undefined') {
|
|
return;
|
|
}
|
|
if (tagRemovalCursorActiveRef.current === active) {
|
|
return;
|
|
}
|
|
const body = document.body;
|
|
if (!body) {
|
|
return;
|
|
}
|
|
tagRemovalCursorActiveRef.current = active;
|
|
if (active) {
|
|
body.classList.add('desk-cursor-remove');
|
|
} else {
|
|
body.classList.remove('desk-cursor-remove');
|
|
}
|
|
}, []);
|
|
const refreshAccessToken = useCallback(async () => {
|
|
console.log('[Auth] Attempting to refresh access token…');
|
|
appDispatch({ type: 'TOKEN_REFRESH_START' });
|
|
try {
|
|
const { data } = await api.post('/auth/refresh');
|
|
if (data?.access_token) {
|
|
appDispatch({
|
|
type: 'TOKEN_REFRESH_SUCCESS',
|
|
token: data.access_token,
|
|
tenant: data.tenant || null,
|
|
});
|
|
console.log('[Auth] Access token refreshed at', new Date().toISOString());
|
|
return data.access_token;
|
|
}
|
|
throw new Error('Missing access token in refresh response');
|
|
} catch (error) {
|
|
console.warn('[Auth] Failed to refresh access token', error);
|
|
appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: error?.message || null });
|
|
throw error;
|
|
}
|
|
}, [appDispatch]);
|
|
const [searchResults, setSearchResults] = useState(null);
|
|
const [previewEntries, setPreviewEntries] = useState(() => new Map());
|
|
const previewInflightRef = useRef(new Map());
|
|
const previewReturnPathRef = useRef(null);
|
|
const [tags, setTags] = useState([]);
|
|
const [correspondents, setCorrespondents] = useState([]);
|
|
const [webdavTokens, setWebdavTokens] = useState([]);
|
|
const [webdavTokensLoading, setWebdavTokensLoading] = useState(false);
|
|
const [creatingWebdavToken, setCreatingWebdavToken] = useState(false);
|
|
const [deletingWebdavTokenId, setDeletingWebdavTokenId] = useState(null);
|
|
const [regeneratingWebdavTokenId, setRegeneratingWebdavTokenId] = useState(null);
|
|
const [webdavTokenSecret, setWebdavTokenSecret] = useState(null);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [activeTagFilters, setActiveTagFilters] = useState([]);
|
|
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]);
|
|
const [searchLoading, setSearchLoading] = useState(false);
|
|
const documentsRouteMatch = useMatch('/documents');
|
|
const documentsFolderRouteMatch = useMatch('/documents/folder/:folderId');
|
|
const documentsDetailRouteMatch = useMatch('/documents/:documentId');
|
|
const isDocumentsRoute = Boolean(
|
|
documentsRouteMatch || documentsFolderRouteMatch || documentsDetailRouteMatch,
|
|
);
|
|
const toggleTagFilter = useCallback((tagId) => {
|
|
if (!tagId) return;
|
|
setActiveTagFilters((previous) =>
|
|
previous.includes(tagId)
|
|
? previous.filter((id) => id !== tagId)
|
|
: previous.concat([tagId]),
|
|
);
|
|
}, []);
|
|
|
|
const toggleCorrespondentFilter = useCallback((correspondentId) => {
|
|
setActiveCorrespondentFilters((previous) => {
|
|
if (!correspondentId) {
|
|
return [];
|
|
}
|
|
return previous.includes(correspondentId) ? [] : [correspondentId];
|
|
});
|
|
}, []);
|
|
|
|
const initialRefreshAttemptedRef = useRef(Boolean(token));
|
|
|
|
useEffect(() => {
|
|
if (!token && !initialRefreshAttemptedRef.current && appStatus === 'logged-out') {
|
|
initialRefreshAttemptedRef.current = true;
|
|
console.log('[Auth] Attempting refresh at startup');
|
|
refreshAccessToken().catch(() => {});
|
|
}
|
|
}, [token, appStatus, refreshAccessToken]);
|
|
|
|
const clearFilters = useCallback(() => {
|
|
setSearchQuery('');
|
|
setActiveTagFilters([]);
|
|
setActiveCorrespondentFilters([]);
|
|
setSearchLoading(false);
|
|
}, []);
|
|
|
|
const handleSearchChange = useCallback((value) => {
|
|
setSearchQuery(value);
|
|
}, []);
|
|
|
|
const handleSearchSubmit = useCallback(() => {
|
|
if (!navigate) return;
|
|
const targetFolder = selectedFolder && selectedFolder !== 'root' ? selectedFolder : 'root';
|
|
const targetPath = targetFolder === 'root' ? '/documents' : `/documents/folder/${targetFolder}`;
|
|
if (!isDocumentsRoute || location.pathname !== targetPath) {
|
|
navigate(targetPath, { replace: false });
|
|
}
|
|
}, [navigate, selectedFolder, isDocumentsRoute, location.pathname]);
|
|
const [draggedDocumentIds, setDraggedDocumentIds] = useState([]);
|
|
const [draggedFolderId, setDraggedFolderId] = useState(null);
|
|
const [dropOverlayState, setDropOverlayState] = useState({
|
|
active: false,
|
|
folderName: DEFAULT_FOLDER_NAME,
|
|
});
|
|
const [activePreviewId, setActivePreviewId] = useState(routeDocumentId || null);
|
|
const shellRef = useRef(null);
|
|
const assetManagerRef = useRef(null);
|
|
if (!assetManagerRef.current) {
|
|
assetManagerRef.current = new AssetManager({ api, assetPresignTtlMs: ASSET_PRESIGN_TTL_MS });
|
|
}
|
|
const assetManager = assetManagerRef.current;
|
|
|
|
const extractDocumentFromResponse = useCallback(
|
|
(payload) => {
|
|
if (!payload) {
|
|
return null;
|
|
}
|
|
const hydratedDetail = assetManager.hydrateDetail(payload);
|
|
return hydratedDetail?.document || payload.document || payload;
|
|
},
|
|
[assetManager],
|
|
);
|
|
|
|
const tagManagerRef = useRef(null);
|
|
if (!tagManagerRef.current) {
|
|
tagManagerRef.current = new TagManager();
|
|
}
|
|
const tagManager = tagManagerRef.current;
|
|
|
|
const {
|
|
selectedRowKeys,
|
|
setSelectedRowKeys,
|
|
selectionOrder,
|
|
setSelectionOrder,
|
|
selectionOrderRef,
|
|
selectionAnchorRef,
|
|
selectionInitializedRef,
|
|
focusedDocumentId,
|
|
setFocusedDocumentId,
|
|
focusedRowKey,
|
|
setFocusedRowKey,
|
|
applySelection,
|
|
clearSelection: clearSelectionInternal,
|
|
handleRowSelection: handleRowSelectionInternal,
|
|
promoteSelectionOrder: promoteSelectionOrderInternal,
|
|
configureSelectionEnvironment,
|
|
} = useDocumentSelection({
|
|
resolveDocumentRowKey,
|
|
resolveFolderRowKey,
|
|
isDocumentRowKey,
|
|
isFolderRowKey,
|
|
getRowId,
|
|
initialSelection: initialRowSelection,
|
|
});
|
|
|
|
const getDocumentAsset = useCallback((doc, type) => {
|
|
if (!doc || !type) return null;
|
|
return getAssetFromVersion(doc.current_version || null, type);
|
|
}, []);
|
|
|
|
const bootstrapInitializedRef = useRef(false);
|
|
const dragCounterRef = useRef(0);
|
|
const detailFolderFetchRef = useRef(new Set());
|
|
|
|
const selectedDocumentIds = useMemo(
|
|
() =>
|
|
selectedRowKeys
|
|
.filter(isDocumentRowKey)
|
|
.map((key) => getRowId(key))
|
|
.filter(Boolean),
|
|
[selectedRowKeys],
|
|
);
|
|
|
|
const selectionCount = selectedDocumentIds.length;
|
|
|
|
const selectedFolderIds = useMemo(
|
|
() =>
|
|
selectedRowKeys
|
|
.filter(isFolderRowKey)
|
|
.map((key) => getRowId(key))
|
|
.filter(Boolean),
|
|
[selectedRowKeys],
|
|
);
|
|
|
|
|
|
const resetWorkspaceState = useCallback(() => {
|
|
const rootNode = createRootNode();
|
|
setFolderNodes(new Map([[rootNode.id, rootNode]]));
|
|
setFolderContents(new Map());
|
|
setSelectedFolder('root');
|
|
setCurrentFolder(null);
|
|
setCurrentSubfolders([]);
|
|
setDocuments([]);
|
|
setSelectedRowKeys([]);
|
|
setSelectionOrder([]);
|
|
selectionOrderRef.current = [];
|
|
setFocusedDocumentId(null);
|
|
selectionAnchorRef.current = null;
|
|
setDraggedDocumentIds([]);
|
|
setDraggedFolderId(null);
|
|
setSearchResults(null);
|
|
setTags([]);
|
|
setCorrespondents([]);
|
|
setWebdavTokens([]);
|
|
setWebdavTokensLoading(false);
|
|
setCreatingWebdavToken(false);
|
|
setDeletingWebdavTokenId(null);
|
|
setWebdavTokenSecret(null);
|
|
setSearchQuery('');
|
|
setActiveTagFilters([]);
|
|
setActiveCorrespondentFilters([]);
|
|
setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME });
|
|
setActivePreviewId(null);
|
|
detailPanelControlRef.current.close();
|
|
assetManager.reset();
|
|
setPreviewEntries(() => new Map());
|
|
previewInflightRef.current = new Map();
|
|
dragCounterRef.current = 0;
|
|
breadcrumbFetchRef.current = new Set();
|
|
detailFolderFetchRef.current = new Set();
|
|
bootstrapInitializedRef.current = false;
|
|
selectionInitializedRef.current = false;
|
|
tenantIdRef.current = null;
|
|
}, [
|
|
assetManager,
|
|
selectionAnchorRef,
|
|
selectionInitializedRef,
|
|
selectionOrderRef,
|
|
setFocusedDocumentId,
|
|
setSelectedRowKeys,
|
|
setSelectionOrder,
|
|
]);
|
|
|
|
const tagLookupById = useMemo(() => {
|
|
const map = new Map();
|
|
tags.forEach((tag) => {
|
|
if (tag?.id) {
|
|
map.set(tag.id, tag);
|
|
}
|
|
});
|
|
return map;
|
|
}, [tags]);
|
|
|
|
const correspondentLookupByName = useMemo(() => {
|
|
const map = new Map();
|
|
correspondents.forEach((correspondent) => {
|
|
if (correspondent?.name) {
|
|
map.set(correspondent.name.toLowerCase(), correspondent);
|
|
}
|
|
});
|
|
return map;
|
|
}, [correspondents]);
|
|
useEffect(() => {
|
|
if (appStatus === 'logged-out' || appStatus === 'selecting-tenant') {
|
|
resetWorkspaceState();
|
|
}
|
|
}, [appStatus, resetWorkspaceState]);
|
|
|
|
useEffect(() => {
|
|
tokenRef.current = token;
|
|
}, [token]);
|
|
|
|
useEffect(() => {
|
|
tenantIdRef.current = currentTenantId;
|
|
}, [currentTenantId]);
|
|
|
|
useEffect(() => {
|
|
const requestInterceptor = api.interceptors.request.use((config) => {
|
|
const currentToken = tokenRef.current;
|
|
if (currentToken) {
|
|
config.headers = config.headers || {};
|
|
if (!config.headers.Authorization) {
|
|
config.headers.Authorization = `Bearer ${currentToken}`;
|
|
}
|
|
}
|
|
return config;
|
|
});
|
|
|
|
const responseInterceptor = api.interceptors.response.use(
|
|
(response) => response,
|
|
async (error) => {
|
|
const { response, config } = error;
|
|
if (!response || !config) {
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
const status = response.status;
|
|
const url = typeof config.url === 'string' ? config.url : '';
|
|
const isAuthRoute = url.includes('/auth/login') || url.includes('/auth/refresh');
|
|
|
|
if (status === 401 && !config._retry && !isAuthRoute) {
|
|
console.warn('[Auth] 401 received for', url, '- attempting token refresh');
|
|
|
|
if (!refreshPromiseRef.current) {
|
|
refreshPromiseRef.current = (async () => {
|
|
try {
|
|
return await refreshAccessToken();
|
|
} finally {
|
|
refreshPromiseRef.current = null;
|
|
}
|
|
})();
|
|
}
|
|
|
|
try {
|
|
const newToken = await refreshPromiseRef.current;
|
|
if (!newToken) {
|
|
throw new Error('No token returned from refresh');
|
|
}
|
|
config._retry = true;
|
|
config.headers = config.headers || {};
|
|
config.headers.Authorization = `Bearer ${newToken}`;
|
|
console.log('[Auth] Retrying original request', url);
|
|
try {
|
|
return await api(config);
|
|
} catch (retryError) {
|
|
if (retryError?.response?.status === 401) {
|
|
notifyApiError(retryError, 'Session expired. Please log in again.');
|
|
}
|
|
throw retryError;
|
|
}
|
|
} catch (refreshError) {
|
|
console.warn('[Auth] Refresh failed, clearing session');
|
|
notifyApiError(refreshError, 'Session expired. Please log in again.');
|
|
return Promise.reject(refreshError);
|
|
}
|
|
}
|
|
|
|
return Promise.reject(error);
|
|
},
|
|
);
|
|
|
|
return () => {
|
|
api.interceptors.request.eject(requestInterceptor);
|
|
api.interceptors.response.eject(responseInterceptor);
|
|
};
|
|
}, [notifyApiError, refreshAccessToken]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedDocumentIds.length) {
|
|
return;
|
|
}
|
|
if (!selectedDocumentIds.includes(activePreviewId)) {
|
|
setActivePreviewId(selectedDocumentIds[selectedDocumentIds.length - 1]);
|
|
}
|
|
selectionInitializedRef.current = true;
|
|
}, [selectedDocumentIds, activePreviewId, selectionInitializedRef]);
|
|
|
|
const currentFolderName = useMemo(() => {
|
|
if (selectedFolder === 'root' || !currentFolder) return DEFAULT_FOLDER_NAME;
|
|
return currentFolder.name;
|
|
}, [selectedFolder, currentFolder]);
|
|
|
|
const isFilterActive = useMemo(
|
|
() =>
|
|
searchQuery.trim().length > 0 ||
|
|
activeTagFilters.length > 0 ||
|
|
activeCorrespondentFilters.length > 0,
|
|
[searchQuery, activeTagFilters, activeCorrespondentFilters],
|
|
);
|
|
|
|
const applySelectedFolder = useCallback(
|
|
(folderId, contents) => {
|
|
const subfolders = contents?.subfolders ?? [];
|
|
const docs = assetManager.hydrateDocuments(contents?.documents ?? []);
|
|
const folderInfo = contents?.folder ?? null;
|
|
|
|
setCurrentSubfolders(subfolders);
|
|
setDocuments(docs);
|
|
setCurrentFolder(folderInfo);
|
|
|
|
const availableDocKeys = docs
|
|
.map((doc) => resolveDocumentRowKey(doc.id))
|
|
.filter(Boolean);
|
|
const availableDocKeySet = new Set(availableDocKeys);
|
|
const availableFolderKeys = new Set(
|
|
subfolders
|
|
.map((folder) => resolveFolderRowKey(folder.id))
|
|
.filter(Boolean),
|
|
);
|
|
|
|
let nextDocKeys = [];
|
|
let mergedSelection = [];
|
|
|
|
setSelectedRowKeys((previous) => {
|
|
const previousFolderKeys = previous
|
|
.filter(isFolderRowKey)
|
|
.filter((key) => availableFolderKeys.has(key));
|
|
const previousDocKeys = previous.filter(isDocumentRowKey);
|
|
|
|
if (selectionInitializedRef.current) {
|
|
nextDocKeys = previousDocKeys.filter((key) => availableDocKeySet.has(key));
|
|
} else {
|
|
nextDocKeys = previousDocKeys.filter((key) => availableDocKeySet.has(key));
|
|
}
|
|
|
|
mergedSelection = [...previousFolderKeys, ...nextDocKeys];
|
|
return mergedSelection;
|
|
});
|
|
|
|
const nextFocus = (() => {
|
|
const currentFocusedKey = resolveDocumentRowKey(focusedDocumentId);
|
|
if (currentFocusedKey && availableDocKeySet.has(currentFocusedKey)) {
|
|
return focusedDocumentId;
|
|
}
|
|
if (nextDocKeys.length) {
|
|
const lastDocKey = nextDocKeys[nextDocKeys.length - 1];
|
|
return getRowId(lastDocKey) || null;
|
|
}
|
|
return null;
|
|
})();
|
|
|
|
setFocusedDocumentId(nextFocus);
|
|
selectionAnchorRef.current = nextDocKeys.length
|
|
? nextDocKeys[nextDocKeys.length - 1]
|
|
: null;
|
|
selectionOrderRef.current = mergedSelection;
|
|
setSelectionOrder(mergedSelection);
|
|
|
|
return nextFocus;
|
|
},
|
|
[
|
|
assetManager,
|
|
focusedDocumentId,
|
|
selectionAnchorRef,
|
|
selectionInitializedRef,
|
|
selectionOrderRef,
|
|
setFocusedDocumentId,
|
|
setSelectedRowKeys,
|
|
setSelectionOrder,
|
|
],
|
|
);
|
|
|
|
const showingSearchResults = searchResults !== null;
|
|
|
|
const visibleDocuments = useMemo(
|
|
() => (showingSearchResults ? searchResults : documents),
|
|
[showingSearchResults, searchResults, documents],
|
|
);
|
|
|
|
const visibleDocumentIds = useMemo(
|
|
() => visibleDocuments.map((doc) => doc.id),
|
|
[visibleDocuments],
|
|
);
|
|
|
|
const visibleDocumentKeys = useMemo(
|
|
() => visibleDocumentIds.map((id) => resolveDocumentRowKey(id)).filter(Boolean),
|
|
[visibleDocumentIds],
|
|
);
|
|
|
|
const visibleFolderKeys = useMemo(
|
|
() =>
|
|
showingSearchResults
|
|
? []
|
|
: currentSubfolders
|
|
.map((folder) => resolveFolderRowKey(folder.id))
|
|
.filter(Boolean),
|
|
[showingSearchResults, currentSubfolders],
|
|
);
|
|
|
|
const visibleRowKeys = useMemo(
|
|
() => [...visibleFolderKeys, ...visibleDocumentKeys],
|
|
[visibleFolderKeys, visibleDocumentKeys],
|
|
);
|
|
|
|
const visibleRowKeySet = useMemo(
|
|
() => new Set(visibleRowKeys),
|
|
[visibleRowKeys],
|
|
);
|
|
|
|
const documentLookup = useMemo(() => {
|
|
const map = new Map();
|
|
const push = (items) => {
|
|
(items || []).forEach((doc) => {
|
|
if (doc?.id) {
|
|
map.set(doc.id, doc);
|
|
}
|
|
});
|
|
};
|
|
|
|
push(documents);
|
|
if (Array.isArray(searchResults)) {
|
|
push(searchResults);
|
|
}
|
|
return map;
|
|
}, [documents, searchResults]);
|
|
|
|
const mapDocumentCaches = useCallback(
|
|
(mapper) => {
|
|
if (typeof mapper !== 'function') {
|
|
return;
|
|
}
|
|
|
|
const applyToList = (list) => {
|
|
let changed = false;
|
|
const next = list.map((doc) => {
|
|
const updated = mapper(doc);
|
|
if (updated === undefined || updated === doc) {
|
|
return doc;
|
|
}
|
|
changed = true;
|
|
return updated;
|
|
});
|
|
return changed ? next : list;
|
|
};
|
|
|
|
setDocuments((prev) => applyToList(prev));
|
|
setSearchResults((prev) => {
|
|
if (!Array.isArray(prev)) {
|
|
return prev;
|
|
}
|
|
return applyToList(prev);
|
|
});
|
|
setFolderContents((prev) => {
|
|
if (!prev.size) {
|
|
return prev;
|
|
}
|
|
let changed = false;
|
|
const next = new Map();
|
|
prev.forEach((contents, key) => {
|
|
const docs = Array.isArray(contents?.documents) ? contents.documents : null;
|
|
if (!docs || docs.length === 0) {
|
|
next.set(key, contents);
|
|
return;
|
|
}
|
|
let docsChanged = false;
|
|
const updatedDocs = docs.map((doc) => {
|
|
const updated = mapper(doc);
|
|
if (updated === undefined || updated === doc) {
|
|
return doc;
|
|
}
|
|
docsChanged = true;
|
|
return updated;
|
|
});
|
|
if (docsChanged) {
|
|
changed = true;
|
|
next.set(key, { ...contents, documents: updatedDocs });
|
|
} else {
|
|
next.set(key, contents);
|
|
}
|
|
});
|
|
return changed ? next : prev;
|
|
});
|
|
},
|
|
[setDocuments, setSearchResults, setFolderContents],
|
|
);
|
|
|
|
const updateDocumentCaches = useCallback(
|
|
(documentId, updater) => {
|
|
if (!documentId || typeof updater !== 'function') {
|
|
return;
|
|
}
|
|
|
|
mapDocumentCaches((doc) => {
|
|
if (!doc || doc.id !== documentId) {
|
|
return doc;
|
|
}
|
|
const updated = updater(doc);
|
|
return updated === undefined ? doc : updated;
|
|
});
|
|
},
|
|
[mapDocumentCaches],
|
|
);
|
|
|
|
const removeDocumentFromCaches = useCallback(
|
|
(documentId) => {
|
|
if (!documentId) {
|
|
return;
|
|
}
|
|
|
|
const removeFromList = (list) => {
|
|
const next = list.filter((doc) => doc.id !== documentId);
|
|
return next.length === list.length ? list : next;
|
|
};
|
|
|
|
setDocuments((prev) => removeFromList(prev));
|
|
setSearchResults((prev) => (Array.isArray(prev) ? removeFromList(prev) : prev));
|
|
setFolderContents((prev) => {
|
|
if (!prev.size) {
|
|
return prev;
|
|
}
|
|
let changed = false;
|
|
const next = new Map();
|
|
prev.forEach((contents, key) => {
|
|
const docs = Array.isArray(contents?.documents) ? contents.documents : null;
|
|
if (!docs || docs.length === 0) {
|
|
next.set(key, contents);
|
|
return;
|
|
}
|
|
const filteredDocs = docs.filter((doc) => doc.id !== documentId);
|
|
if (filteredDocs.length !== docs.length) {
|
|
changed = true;
|
|
next.set(key, { ...contents, documents: filteredDocs });
|
|
} else {
|
|
next.set(key, contents);
|
|
}
|
|
});
|
|
return changed ? next : prev;
|
|
});
|
|
},
|
|
[setDocuments, setSearchResults, setFolderContents],
|
|
);
|
|
|
|
|
|
const folderOptions = useMemo(() => {
|
|
const cache = new Map();
|
|
const computePath = (id) => {
|
|
if (cache.has(id)) {
|
|
return cache.get(id);
|
|
}
|
|
if (!id || id === 'root') {
|
|
cache.set('root', DEFAULT_FOLDER_NAME);
|
|
return DEFAULT_FOLDER_NAME;
|
|
}
|
|
const node = folderNodes.get(id);
|
|
if (!node) {
|
|
return 'Folder';
|
|
}
|
|
const parentId = node.parentId || 'root';
|
|
const parentPath = computePath(parentId);
|
|
const name = node.name || 'Folder';
|
|
const fullPath = parentId === 'root' ? name : `${parentPath}/${name}`;
|
|
cache.set(id, fullPath);
|
|
return fullPath;
|
|
};
|
|
|
|
const entries = [];
|
|
folderNodes.forEach((node, id) => {
|
|
if (!node) return;
|
|
entries.push({ id, label: computePath(id) });
|
|
});
|
|
|
|
entries.sort((a, b) => {
|
|
if (a.id === 'root') return -1;
|
|
if (b.id === 'root') return 1;
|
|
return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' });
|
|
});
|
|
|
|
return entries;
|
|
}, [folderNodes]);
|
|
|
|
const folderLabelMap = useMemo(() => {
|
|
const map = new Map();
|
|
folderOptions.forEach((option) => {
|
|
map.set(option.id, option.label);
|
|
});
|
|
return map;
|
|
}, [folderOptions]);
|
|
|
|
const navigableRows = useMemo(() => {
|
|
const entries = [];
|
|
if (!showingSearchResults) {
|
|
currentSubfolders.forEach((folder) => {
|
|
const key = resolveFolderRowKey(folder.id);
|
|
if (key) {
|
|
entries.push({ key, type: 'folder', id: folder.id });
|
|
}
|
|
});
|
|
}
|
|
visibleDocuments.forEach((doc) => {
|
|
const key = resolveDocumentRowKey(doc.id);
|
|
if (key) {
|
|
entries.push({ key, type: 'document', id: doc.id });
|
|
}
|
|
});
|
|
return entries;
|
|
}, [showingSearchResults, currentSubfolders, visibleDocuments]);
|
|
|
|
const navigableRowKeys = useMemo(
|
|
() => navigableRows.map((entry) => entry.key),
|
|
[navigableRows],
|
|
);
|
|
|
|
useEffect(() => {
|
|
configureSelectionEnvironment({
|
|
visibleRowKeySet,
|
|
navigableRowKeys,
|
|
});
|
|
}, [configureSelectionEnvironment, visibleRowKeySet, navigableRowKeys]);
|
|
|
|
const handleRowSelection = useCallback(
|
|
(rowKey, event) => {
|
|
handleRowSelectionInternal(rowKey, event);
|
|
},
|
|
[handleRowSelectionInternal],
|
|
);
|
|
|
|
const promoteSelectionOrder = useCallback(
|
|
(docId) => {
|
|
if (!docId) return;
|
|
promoteSelectionOrderInternal(docId);
|
|
const rowKey = resolveDocumentRowKey(docId);
|
|
if (rowKey) {
|
|
selectionAnchorRef.current = rowKey;
|
|
}
|
|
setFocusedDocumentId(docId);
|
|
setActivePreviewId(docId);
|
|
},
|
|
[
|
|
promoteSelectionOrderInternal,
|
|
selectionAnchorRef,
|
|
setFocusedDocumentId,
|
|
setActivePreviewId,
|
|
],
|
|
);
|
|
|
|
const handleDocumentRowClick = useCallback(
|
|
(documentId, event) => {
|
|
const rowKey = resolveDocumentRowKey(documentId);
|
|
if (!rowKey) return;
|
|
const hadSelection = selectionCount > 0;
|
|
handleRowSelection(rowKey, event);
|
|
if (!hadSelection) {
|
|
detailPanelControlRef.current.open();
|
|
}
|
|
},
|
|
[handleRowSelection, selectionCount],
|
|
);
|
|
|
|
const clearDocumentSelection = useCallback(() => {
|
|
clearSelectionInternal();
|
|
}, [clearSelectionInternal]);
|
|
|
|
const handleFolderRowClick = useCallback(
|
|
(folderId, event) => {
|
|
const rowKey = resolveFolderRowKey(folderId);
|
|
if (!rowKey) return;
|
|
handleRowSelection(rowKey, event);
|
|
},
|
|
[handleRowSelection],
|
|
);
|
|
|
|
const prevFocusedDocIdRef = useRef(focusedDocumentId);
|
|
useEffect(() => {
|
|
const previous = prevFocusedDocIdRef.current;
|
|
if (previous === focusedDocumentId) {
|
|
return;
|
|
}
|
|
prevFocusedDocIdRef.current = focusedDocumentId;
|
|
if (focusedDocumentId) {
|
|
setFocusedRowKey(resolveDocumentRowKey(focusedDocumentId));
|
|
} else {
|
|
setFocusedRowKey((current) => (isFolderRowKey(current) ? current : null));
|
|
}
|
|
}, [focusedDocumentId, setFocusedRowKey]);
|
|
|
|
useEffect(() => {
|
|
if (!focusedRowKey) {
|
|
return;
|
|
}
|
|
if (navigableRowKeys.includes(focusedRowKey)) {
|
|
return;
|
|
}
|
|
const docKey = focusedDocumentId ? resolveDocumentRowKey(focusedDocumentId) : null;
|
|
if (docKey && navigableRowKeys.includes(docKey)) {
|
|
setFocusedRowKey(docKey);
|
|
return;
|
|
}
|
|
if (navigableRowKeys.length) {
|
|
setFocusedRowKey(navigableRowKeys[0]);
|
|
} else {
|
|
setFocusedRowKey(null);
|
|
}
|
|
}, [focusedRowKey, navigableRowKeys, focusedDocumentId, setFocusedRowKey]);
|
|
|
|
|
|
const ensureFolderData = useCallback(
|
|
async (
|
|
folderId,
|
|
{ force = false, includeDocuments = true, prefetchDepth = 0 } = {},
|
|
) => {
|
|
const requestTenantId = tenantIdRef.current;
|
|
const cached = folderContents.get(folderId);
|
|
if (!force && cached) {
|
|
const includesDocuments = Boolean(cached.__includesDocuments);
|
|
if (!includeDocuments || includesDocuments) {
|
|
if (prefetchDepth > 0) {
|
|
const subfolders = Array.isArray(cached.subfolders) ? cached.subfolders : [];
|
|
await Promise.allSettled(
|
|
subfolders.map((entry) =>
|
|
ensureFolderData(entry.id, {
|
|
includeDocuments: false,
|
|
prefetchDepth: prefetchDepth - 1,
|
|
force: false,
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
return cached;
|
|
}
|
|
}
|
|
|
|
const path = folderId === 'root' ? 'root' : folderId;
|
|
const params = includeDocuments
|
|
? undefined
|
|
: { include_documents: false };
|
|
const { data } = await api.get(`/folders/${path}/contents`, {
|
|
params,
|
|
});
|
|
const hydrated = assetManager.hydrateFolderContents(data);
|
|
const childFolders = Array.isArray(data.subfolders) ? data.subfolders : [];
|
|
const childIds = childFolders.map((child) => child.id);
|
|
|
|
if (tenantIdRef.current !== requestTenantId) {
|
|
return { ...hydrated, __includesDocuments: includeDocuments };
|
|
}
|
|
|
|
setFolderNodes((prev) => {
|
|
const next = new Map(prev);
|
|
const existingNode = next.get(folderId) || {
|
|
id: folderId,
|
|
name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || 'Folder',
|
|
parentId: data.folder?.parent_id || 'root',
|
|
children: [],
|
|
expanded: folderId === 'root',
|
|
loaded: false,
|
|
hasChildren: false,
|
|
};
|
|
|
|
next.set(folderId, {
|
|
...existingNode,
|
|
name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || existingNode.name,
|
|
parentId: data.folder?.parent_id ?? existingNode.parentId ?? 'root',
|
|
children: childIds,
|
|
expanded: folderId === 'root' ? true : existingNode.expanded,
|
|
loaded: true,
|
|
hasChildren: childIds.length > 0,
|
|
});
|
|
|
|
childFolders.forEach((child) => {
|
|
const childNode = next.get(child.id);
|
|
const previousChildren = Array.isArray(childNode?.children) ? childNode.children : [];
|
|
const childHasChildren = (() => {
|
|
if (childNode?.loaded) {
|
|
return previousChildren.length > 0;
|
|
}
|
|
if (Array.isArray(child?.subfolders)) {
|
|
return child.subfolders.length > 0;
|
|
}
|
|
if (typeof child?.has_children === 'boolean') {
|
|
return child.has_children;
|
|
}
|
|
if (typeof child?.hasChildren === 'boolean') {
|
|
return child.hasChildren;
|
|
}
|
|
if (typeof childNode?.hasChildren === 'boolean') {
|
|
return childNode.hasChildren;
|
|
}
|
|
return false;
|
|
})();
|
|
next.set(child.id, {
|
|
id: child.id,
|
|
name: child.name,
|
|
parentId: child.parent_id ?? 'root',
|
|
children: previousChildren,
|
|
expanded: childNode?.expanded ?? false,
|
|
loaded: childNode?.loaded ?? false,
|
|
hasChildren: childHasChildren,
|
|
});
|
|
});
|
|
|
|
return next;
|
|
});
|
|
|
|
if (prefetchDepth > 0 && childIds.length > 0 && tenantIdRef.current === requestTenantId) {
|
|
await Promise.allSettled(
|
|
childIds.map((childId) =>
|
|
ensureFolderData(childId, {
|
|
includeDocuments: false,
|
|
force: false,
|
|
prefetchDepth: prefetchDepth - 1,
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
const enriched = {
|
|
...hydrated,
|
|
__includesDocuments: includeDocuments,
|
|
};
|
|
|
|
if (includeDocuments) {
|
|
setFolderContents((prev) => {
|
|
if (tenantIdRef.current !== requestTenantId) {
|
|
return prev;
|
|
}
|
|
const next = new Map(prev);
|
|
next.set(folderId, enriched);
|
|
return next;
|
|
});
|
|
} else {
|
|
setFolderContents((prev) => {
|
|
if (tenantIdRef.current !== requestTenantId) {
|
|
return prev;
|
|
}
|
|
const next = new Map(prev);
|
|
const existingEntry = next.get(folderId);
|
|
if (existingEntry) {
|
|
next.set(folderId, {
|
|
...existingEntry,
|
|
...hydrated,
|
|
documents: existingEntry.__includesDocuments
|
|
? existingEntry.documents
|
|
: hydrated.documents,
|
|
__includesDocuments: existingEntry.__includesDocuments || false,
|
|
});
|
|
} else {
|
|
next.set(folderId, enriched);
|
|
}
|
|
return next;
|
|
});
|
|
}
|
|
|
|
return enriched;
|
|
},
|
|
[assetManager, folderContents],
|
|
);
|
|
|
|
const isInvalidFolderDrop = useCallback(
|
|
(sourceId, targetId) => {
|
|
if (!sourceId) return false;
|
|
if (!targetId || targetId === 'root') {
|
|
return false;
|
|
}
|
|
if (sourceId === targetId) {
|
|
return true;
|
|
}
|
|
|
|
let current = targetId;
|
|
const visited = new Set();
|
|
while (current && current !== 'root' && !visited.has(current)) {
|
|
visited.add(current);
|
|
if (current === sourceId) {
|
|
return true;
|
|
}
|
|
const node = folderNodes.get(current);
|
|
if (!node) break;
|
|
current = node.parentId ?? 'root';
|
|
}
|
|
return false;
|
|
},
|
|
[folderNodes],
|
|
);
|
|
|
|
const moveFolder = useCallback(
|
|
async (folderId, targetFolderId) => {
|
|
const node = folderNodes.get(folderId);
|
|
if (!node) {
|
|
setStatusMessage('Folder metadata unavailable. Try refreshing.', 'error');
|
|
return;
|
|
}
|
|
|
|
const previousParentKey = node.parentId ?? 'root';
|
|
const targetKey = targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root';
|
|
|
|
if (previousParentKey === targetKey) {
|
|
return;
|
|
}
|
|
|
|
const parent_id = targetKey === 'root' ? null : targetKey;
|
|
|
|
try {
|
|
await api.patch(`/folders/${folderId}`, { parent_id });
|
|
|
|
setFolderNodes((prev) => {
|
|
const next = new Map(prev);
|
|
const currentNode = next.get(folderId);
|
|
if (!currentNode) {
|
|
return prev;
|
|
}
|
|
|
|
const updatedNode = { ...currentNode, parentId: parent_id ?? null };
|
|
next.set(folderId, updatedNode);
|
|
|
|
const previousParent = next.get(previousParentKey);
|
|
if (previousParent) {
|
|
const remainingChildren = (previousParent.children || []).filter(
|
|
(childId) => childId !== folderId,
|
|
);
|
|
next.set(previousParentKey, {
|
|
...previousParent,
|
|
children: remainingChildren,
|
|
hasChildren: remainingChildren.length > 0,
|
|
});
|
|
}
|
|
|
|
if (!next.has(targetKey)) {
|
|
next.set(targetKey, {
|
|
id: targetKey,
|
|
name: targetKey === 'root' ? DEFAULT_FOLDER_NAME : 'Folder',
|
|
parentId: targetKey === 'root' ? null : null,
|
|
children: [],
|
|
expanded: targetKey === 'root',
|
|
loaded: false,
|
|
hasChildren: false,
|
|
});
|
|
}
|
|
|
|
const targetNode = next.get(targetKey);
|
|
if (targetNode && !targetNode.children.includes(folderId)) {
|
|
next.set(targetKey, {
|
|
...targetNode,
|
|
children: [...targetNode.children, folderId],
|
|
hasChildren: true,
|
|
});
|
|
}
|
|
|
|
return next;
|
|
});
|
|
|
|
const refreshTargets = new Set([previousParentKey, targetKey]);
|
|
for (const key of refreshTargets) {
|
|
if (key === 'root') {
|
|
await ensureFolderData('root', { force: true, prefetchDepth: 1 });
|
|
} else {
|
|
await ensureFolderData(key, { force: true, prefetchDepth: 1 });
|
|
}
|
|
}
|
|
|
|
if (selectedFolder === folderId) {
|
|
await ensureFolderData(folderId, { force: true, prefetchDepth: 1 });
|
|
setSelectedFolder(folderId);
|
|
}
|
|
|
|
setStatusMessage('Folder moved.', 'success');
|
|
} catch (error) {
|
|
const message = error.response?.data?.error || 'Failed to move folder.';
|
|
notifyApiError(error, message);
|
|
|
|
const refreshTargets = new Set([previousParentKey, targetKey]);
|
|
for (const key of refreshTargets) {
|
|
if (key === 'root') {
|
|
await ensureFolderData('root', { force: true, prefetchDepth: 1 });
|
|
} else {
|
|
await ensureFolderData(key, { force: true, prefetchDepth: 1 });
|
|
}
|
|
}
|
|
}
|
|
},
|
|
[
|
|
folderNodes,
|
|
ensureFolderData,
|
|
selectedFolder,
|
|
setSelectedFolder,
|
|
setFolderNodes,
|
|
notifyApiError,
|
|
setStatusMessage,
|
|
],
|
|
);
|
|
|
|
const refreshTags = useCallback(async () => {
|
|
const requestTenantId = tenantIdRef.current;
|
|
try {
|
|
const { data } = await api.get('/tags');
|
|
if (tenantIdRef.current !== requestTenantId) {
|
|
return;
|
|
}
|
|
setTags(data || []);
|
|
} catch (error) {
|
|
if (tenantIdRef.current !== requestTenantId) {
|
|
return;
|
|
}
|
|
notifyApiError(error, 'Unable to load tags.');
|
|
}
|
|
}, [notifyApiError]);
|
|
|
|
const refreshCorrespondents = useCallback(async () => {
|
|
const requestTenantId = tenantIdRef.current;
|
|
try {
|
|
const { data } = await api.get('/correspondents');
|
|
if (tenantIdRef.current !== requestTenantId) {
|
|
return;
|
|
}
|
|
setCorrespondents(data || []);
|
|
} catch (error) {
|
|
if (tenantIdRef.current !== requestTenantId) {
|
|
return;
|
|
}
|
|
notifyApiError(error, 'Unable to load correspondents.');
|
|
}
|
|
}, [notifyApiError]);
|
|
|
|
const refreshWebdavTokens = useCallback(async () => {
|
|
if (!token) {
|
|
return;
|
|
}
|
|
setWebdavTokensLoading(true);
|
|
try {
|
|
const { data } = await api.get('/profile/webdav-tokens');
|
|
setWebdavTokens(Array.isArray(data) ? data : []);
|
|
} catch (error) {
|
|
notifyApiError(error, 'Failed to load WebDAV tokens.');
|
|
} finally {
|
|
setWebdavTokensLoading(false);
|
|
}
|
|
}, [notifyApiError, token]);
|
|
|
|
const createWebdavToken = useCallback(
|
|
async ({ label, expires_at } = {}) => {
|
|
if (creatingWebdavToken) {
|
|
return false;
|
|
}
|
|
setCreatingWebdavToken(true);
|
|
try {
|
|
const payload = {};
|
|
if (label) {
|
|
payload.label = label;
|
|
}
|
|
if (expires_at) {
|
|
payload.expires_at = expires_at;
|
|
}
|
|
const { data } = await api.post('/profile/webdav-tokens', payload);
|
|
if (data?.token_info) {
|
|
setWebdavTokens((previous) => {
|
|
const filtered = previous.filter((entry) => entry.id !== data.token_info.id);
|
|
return [data.token_info, ...filtered];
|
|
});
|
|
} else {
|
|
await refreshWebdavTokens();
|
|
}
|
|
if (data?.token) {
|
|
setWebdavTokenSecret(data.token);
|
|
}
|
|
setStatusMessage('WebDAV token created.', 'success');
|
|
return data;
|
|
} catch (error) {
|
|
notifyApiError(error, 'Failed to create WebDAV token.');
|
|
return false;
|
|
} finally {
|
|
setCreatingWebdavToken(false);
|
|
}
|
|
},
|
|
[creatingWebdavToken, notifyApiError, refreshWebdavTokens, setStatusMessage],
|
|
);
|
|
|
|
const deleteWebdavToken = useCallback(
|
|
async (tokenId) => {
|
|
if (!tokenId) {
|
|
return false;
|
|
}
|
|
setDeletingWebdavTokenId(tokenId);
|
|
try {
|
|
await api.delete(`/profile/webdav-tokens/${tokenId}`);
|
|
await refreshWebdavTokens();
|
|
setStatusMessage('WebDAV token revoked.', 'success');
|
|
return true;
|
|
} catch (error) {
|
|
notifyApiError(error, 'Failed to revoke WebDAV token.');
|
|
return false;
|
|
} finally {
|
|
setDeletingWebdavTokenId(null);
|
|
}
|
|
},
|
|
[refreshWebdavTokens, notifyApiError, setStatusMessage],
|
|
);
|
|
|
|
const regenerateWebdavToken = useCallback(
|
|
async (tokenId) => {
|
|
if (!tokenId) {
|
|
return false;
|
|
}
|
|
setRegeneratingWebdavTokenId(tokenId);
|
|
try {
|
|
const { data } = await api.post(`/profile/webdav-tokens/${tokenId}/regenerate`);
|
|
if (data?.token_info) {
|
|
setWebdavTokens((previous) => {
|
|
let found = false;
|
|
const next = previous.map((entry) => {
|
|
if (entry.id === data.token_info.id) {
|
|
found = true;
|
|
return data.token_info;
|
|
}
|
|
return entry;
|
|
});
|
|
if (!found) {
|
|
return [data.token_info, ...previous];
|
|
}
|
|
return next;
|
|
});
|
|
} else {
|
|
await refreshWebdavTokens();
|
|
}
|
|
if (data?.token) {
|
|
setWebdavTokenSecret(data.token);
|
|
}
|
|
setStatusMessage('WebDAV token regenerated.', 'success');
|
|
return true;
|
|
} catch (error) {
|
|
notifyApiError(error, 'Failed to regenerate WebDAV token.');
|
|
return false;
|
|
} finally {
|
|
setRegeneratingWebdavTokenId(null);
|
|
}
|
|
},
|
|
[notifyApiError, refreshWebdavTokens, setStatusMessage],
|
|
);
|
|
|
|
const dismissCreatedWebdavToken = useCallback(() => {
|
|
setWebdavTokenSecret(null);
|
|
}, []);
|
|
|
|
const {
|
|
passkeys,
|
|
passkeysSupported,
|
|
passkeysLoading,
|
|
registeringPasskey,
|
|
revokingPasskeyId,
|
|
refreshPasskeys,
|
|
registerPasskey,
|
|
revokePasskey,
|
|
} = usePasskeys({
|
|
api,
|
|
notifyApiError,
|
|
setStatusMessage,
|
|
token,
|
|
});
|
|
|
|
const handleTagUpdate = useCallback(
|
|
async (tagId, changes) => {
|
|
if (!tagId) {
|
|
throw new Error('Missing tag identifier.');
|
|
}
|
|
|
|
const payload = {};
|
|
if (typeof changes.label === 'string') {
|
|
payload.label = changes.label;
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(changes, 'color')) {
|
|
payload.color = changes.color;
|
|
}
|
|
|
|
if (Object.keys(payload).length === 0) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
await api.patch(`/tags/${tagId}`, payload);
|
|
await refreshTags();
|
|
setStatusMessage('Tag updated.', 'success');
|
|
return true;
|
|
} catch (error) {
|
|
const message = error.response?.data?.error || 'Failed to update tag.';
|
|
notifyApiError(error, message);
|
|
throw new Error(message);
|
|
}
|
|
},
|
|
[refreshTags, notifyApiError, setStatusMessage],
|
|
);
|
|
|
|
const handleTagCreate = useCallback(
|
|
async ({ label, color } = {}) => {
|
|
const payload = tagManager.buildPayload({ label, color });
|
|
try {
|
|
await api.post('/tags', payload);
|
|
await refreshTags();
|
|
setStatusMessage('Tag created.', 'success');
|
|
} catch (error) {
|
|
const message = error.response?.data?.error || 'Failed to create tag.';
|
|
notifyApiError(error, message);
|
|
throw new Error(message);
|
|
}
|
|
},
|
|
[refreshTags, notifyApiError, setStatusMessage, tagManager],
|
|
);
|
|
|
|
const handleCorrespondentUpdate = useCallback(
|
|
async (correspondentId, changes) => {
|
|
if (!correspondentId) {
|
|
throw new Error('Missing correspondent identifier.');
|
|
}
|
|
|
|
const payload = {};
|
|
if (typeof changes.name === 'string') {
|
|
const trimmed = changes.name.trim();
|
|
if (!trimmed) {
|
|
throw new Error('Correspondent name cannot be empty.');
|
|
}
|
|
payload.name = trimmed;
|
|
}
|
|
|
|
if (Object.keys(payload).length === 0) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
await api.patch(`/correspondents/${correspondentId}`, payload);
|
|
await refreshCorrespondents();
|
|
setStatusMessage('Correspondent updated.', 'success');
|
|
return true;
|
|
} catch (error) {
|
|
const message = error.response?.data?.error || 'Failed to update correspondent.';
|
|
notifyApiError(error, message);
|
|
throw new Error(message);
|
|
}
|
|
},
|
|
[refreshCorrespondents, notifyApiError, setStatusMessage],
|
|
);
|
|
|
|
const handleCorrespondentCreate = useCallback(
|
|
async ({ name }) => {
|
|
const trimmed = typeof name === 'string' ? name.trim() : '';
|
|
if (!trimmed) {
|
|
throw new Error('Correspondent name is required.');
|
|
}
|
|
try {
|
|
const { data } = await api.post('/correspondents', { name: trimmed });
|
|
await refreshCorrespondents();
|
|
setStatusMessage('Correspondent created.', 'success');
|
|
return data;
|
|
} catch (error) {
|
|
const message = error.response?.data?.error || 'Failed to create correspondent.';
|
|
notifyApiError(error, message);
|
|
throw new Error(message);
|
|
}
|
|
},
|
|
[refreshCorrespondents, notifyApiError, setStatusMessage],
|
|
);
|
|
|
|
const handleCorrespondentDelete = useCallback(
|
|
async (correspondentId) => {
|
|
if (!correspondentId) {
|
|
throw new Error('Missing correspondent identifier.');
|
|
}
|
|
|
|
const stripFromDoc = (doc) => {
|
|
if (!doc || !Array.isArray(doc.correspondents)) {
|
|
return doc;
|
|
}
|
|
const next = doc.correspondents.filter((entry) => entry.id !== correspondentId);
|
|
if (next.length === doc.correspondents.length) {
|
|
return doc;
|
|
}
|
|
return { ...doc, correspondents: next };
|
|
};
|
|
|
|
try {
|
|
await api.delete(`/correspondents/${correspondentId}`);
|
|
await refreshCorrespondents();
|
|
|
|
mapDocumentCaches(stripFromDoc);
|
|
|
|
setStatusMessage('Correspondent deleted.', 'success');
|
|
return true;
|
|
} catch (error) {
|
|
const message = error.response?.data?.error || 'Failed to delete correspondent.';
|
|
notifyApiError(error, message);
|
|
throw new Error(message);
|
|
}
|
|
},
|
|
[refreshCorrespondents, notifyApiError, setStatusMessage, mapDocumentCaches],
|
|
);
|
|
|
|
const refreshCurrentFolder = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const contents = await ensureFolderData(selectedFolder, {
|
|
force: true,
|
|
prefetchDepth: 1,
|
|
});
|
|
applySelectedFolder(selectedFolder, contents);
|
|
} catch (error) {
|
|
notifyApiError(error, 'Failed to refresh folder.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [selectedFolder, ensureFolderData, applySelectedFolder, notifyApiError]);
|
|
|
|
const handleDocumentCorrespondentAttach = useCallback(
|
|
async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => {
|
|
if (!documentId || !correspondentId) {
|
|
throw new Error('Missing document or correspondent.');
|
|
}
|
|
try {
|
|
await api.post(`/documents/${documentId}/correspondents`, {
|
|
assignments: [{ correspondent_id: correspondentId }],
|
|
replace: false,
|
|
});
|
|
if (refresh) {
|
|
await refreshCurrentFolder();
|
|
}
|
|
if (notify) {
|
|
setStatusMessage('Correspondent assigned.', 'success');
|
|
}
|
|
return true;
|
|
} catch (error) {
|
|
const message = error.response?.data?.error || 'Failed to assign correspondent.';
|
|
notifyApiError(error, message);
|
|
throw new Error(message);
|
|
}
|
|
},
|
|
[notifyApiError, refreshCurrentFolder, setStatusMessage],
|
|
);
|
|
|
|
const handleCorrespondentRemove = useCallback(
|
|
async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => {
|
|
if (!documentId || !correspondentId) {
|
|
throw new Error('Missing document or correspondent.');
|
|
}
|
|
try {
|
|
await api.delete(`/documents/${documentId}/correspondents/${correspondentId}`);
|
|
if (refresh) {
|
|
await refreshCurrentFolder();
|
|
}
|
|
if (notify) {
|
|
setStatusMessage('Correspondent removed.', 'success');
|
|
}
|
|
return true;
|
|
} catch (error) {
|
|
const message = error.response?.data?.error || 'Failed to remove correspondent.';
|
|
notifyApiError(error, message);
|
|
throw new Error(message);
|
|
}
|
|
},
|
|
[notifyApiError, refreshCurrentFolder, setStatusMessage],
|
|
);
|
|
|
|
const handleCorrespondentAdd = useCallback(
|
|
async ({ document, name, input = null, option = null }) => {
|
|
if (!document?.id) {
|
|
throw new Error('Missing document for correspondent assignment.');
|
|
}
|
|
const trimmed = typeof name === 'string' ? name.trim() : '';
|
|
if (!trimmed) {
|
|
setStatusMessage('Correspondent name is required.', 'error');
|
|
return;
|
|
}
|
|
|
|
let target = null;
|
|
if (option && option.id) {
|
|
target = correspondentLookupByName.get(trimmed.toLowerCase()) || option;
|
|
} else {
|
|
target = correspondentLookupByName.get(trimmed.toLowerCase()) || null;
|
|
}
|
|
if (!target) {
|
|
try {
|
|
target = await handleCorrespondentCreate({ name: trimmed });
|
|
} catch (error) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!target?.id) {
|
|
setStatusMessage('Unable to resolve correspondent.', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await handleDocumentCorrespondentAttach({
|
|
documentId: document.id,
|
|
correspondentId: target.id,
|
|
});
|
|
if (input) {
|
|
input.value = '';
|
|
}
|
|
} catch (error) {
|
|
setStatusMessage('Failed to assign correspondent.', 'error');
|
|
console.error('[documents] assign correspondent failed', error);
|
|
}
|
|
},
|
|
[
|
|
handleCorrespondentCreate,
|
|
handleDocumentCorrespondentAttach,
|
|
correspondentLookupByName,
|
|
setStatusMessage,
|
|
],
|
|
);
|
|
|
|
const resolveTargetDocumentIds = useCallback(
|
|
(candidateIds) => {
|
|
const normalized = Array.isArray(candidateIds)
|
|
? candidateIds.filter(Boolean)
|
|
: [];
|
|
if (normalized.length) {
|
|
return Array.from(new Set(normalized));
|
|
}
|
|
return selectedDocumentIds;
|
|
},
|
|
[selectedDocumentIds],
|
|
);
|
|
|
|
const handleTagDelete = useCallback(
|
|
async (tagId) => {
|
|
if (!tagId) {
|
|
throw new Error('Missing tag identifier.');
|
|
}
|
|
|
|
try {
|
|
await api.delete(`/tags/${tagId}`);
|
|
setActiveTagFilters((prev) => prev.filter((id) => id !== tagId));
|
|
|
|
const stripTagFromDoc = (doc) => {
|
|
if (!doc || !Array.isArray(doc.tags)) {
|
|
return doc;
|
|
}
|
|
const nextTags = doc.tags.filter((tag) => tag.id !== tagId);
|
|
if (nextTags.length === doc.tags.length) {
|
|
return doc;
|
|
}
|
|
return { ...doc, tags: nextTags };
|
|
};
|
|
|
|
mapDocumentCaches(stripTagFromDoc);
|
|
|
|
await refreshTags();
|
|
setStatusMessage('Tag deleted.', 'success');
|
|
return true;
|
|
} catch (error) {
|
|
const message = error.response?.data?.error || 'Failed to delete tag.';
|
|
notifyApiError(error, message);
|
|
throw new Error(message);
|
|
}
|
|
},
|
|
[
|
|
refreshTags,
|
|
notifyApiError,
|
|
setStatusMessage,
|
|
mapDocumentCaches,
|
|
setActiveTagFilters,
|
|
],
|
|
);
|
|
|
|
const expandFolderAncestors = useCallback(
|
|
(targetId) => {
|
|
if (!targetId || targetId === 'root') {
|
|
setFolderNodes((prev) => {
|
|
if (prev.get('root')?.expanded) {
|
|
return prev;
|
|
}
|
|
const next = new Map(prev);
|
|
const rootNode = next.get('root');
|
|
if (rootNode) {
|
|
next.set('root', { ...rootNode, expanded: true });
|
|
}
|
|
return next;
|
|
});
|
|
return;
|
|
}
|
|
|
|
setFolderNodes((prev) => {
|
|
const next = new Map(prev);
|
|
let currentId = targetId;
|
|
let guard = 0;
|
|
while (currentId && !next.has(currentId) && guard < 32) {
|
|
guard += 1;
|
|
const node = prev.get(currentId);
|
|
if (!node) {
|
|
break;
|
|
}
|
|
currentId = node.parentId ?? 'root';
|
|
}
|
|
|
|
currentId = targetId;
|
|
guard = 0;
|
|
while (currentId && guard < 32) {
|
|
guard += 1;
|
|
const node = next.get(currentId);
|
|
if (!node) {
|
|
break;
|
|
}
|
|
if (!node.expanded && currentId !== targetId) {
|
|
next.set(currentId, { ...node, expanded: true });
|
|
}
|
|
currentId = node.parentId ?? 'root';
|
|
if (!currentId || currentId === 'root') {
|
|
const rootNode = next.get('root');
|
|
if (rootNode && !rootNode.expanded) {
|
|
next.set('root', { ...rootNode, expanded: true });
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
},
|
|
[]);
|
|
|
|
const ensureFolderAncestorsLoaded = useCallback(
|
|
async (targetId) => {
|
|
if (!targetId || targetId === 'root') {
|
|
return;
|
|
}
|
|
|
|
const fetchAncestor = async (folderId, guard = 0) => {
|
|
if (!folderId || folderId === 'root' || guard > 32) {
|
|
return;
|
|
}
|
|
|
|
const existing = folderNodes.get(folderId);
|
|
if (existing?.loaded) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const contents = await ensureFolderData(folderId, {
|
|
includeDocuments: false,
|
|
force: false,
|
|
prefetchDepth: 0,
|
|
});
|
|
const parentId = contents?.folder?.parent_id ?? 'root';
|
|
if (parentId && parentId !== 'root') {
|
|
await fetchAncestor(parentId, guard + 1);
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to ensure ancestor folder for navigation', folderId, error);
|
|
}
|
|
};
|
|
|
|
await fetchAncestor(targetId, 0);
|
|
},
|
|
[folderNodes, ensureFolderData],
|
|
);
|
|
|
|
const loadFolder = useCallback(
|
|
async (folderId, { showLoading = true, preserveSearch = false } = {}) => {
|
|
const targetId = folderId || 'root';
|
|
setSelectedFolder(targetId);
|
|
await ensureFolderAncestorsLoaded(targetId);
|
|
expandFolderAncestors(targetId);
|
|
if (showLoading) setLoading(true);
|
|
try {
|
|
const contents = await ensureFolderData(targetId, { force: true, prefetchDepth: 1 });
|
|
if (targetId !== 'root') {
|
|
try {
|
|
await ensureFolderData('root', {
|
|
force: false,
|
|
includeDocuments: false,
|
|
prefetchDepth: 1,
|
|
});
|
|
} catch (error) {
|
|
console.warn('Failed to refresh root folder tree', error);
|
|
}
|
|
}
|
|
applySelectedFolder(targetId, contents);
|
|
if (!preserveSearch) {
|
|
setSearchResults(null);
|
|
}
|
|
} catch (error) {
|
|
notifyApiError(error, 'Failed to load folder contents.');
|
|
} finally {
|
|
if (showLoading) setLoading(false);
|
|
}
|
|
},
|
|
[
|
|
ensureFolderData,
|
|
applySelectedFolder,
|
|
notifyApiError,
|
|
ensureFolderAncestorsLoaded,
|
|
expandFolderAncestors,
|
|
],
|
|
);
|
|
|
|
const selectFolder = useCallback(
|
|
async (folderId, { replace = false, immediate = false } = {}) => {
|
|
const targetId = folderId && folderId !== 'root' ? folderId : 'root';
|
|
|
|
await ensureFolderAncestorsLoaded(targetId);
|
|
expandFolderAncestors(targetId);
|
|
|
|
if (!navigate || immediate) {
|
|
await loadFolder(targetId, { preserveSearch: isFilterActive });
|
|
setSelectedFolder(targetId);
|
|
return;
|
|
}
|
|
|
|
const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
|
|
navigate(path, { replace });
|
|
},
|
|
[
|
|
ensureFolderAncestorsLoaded,
|
|
expandFolderAncestors,
|
|
navigate,
|
|
loadFolder,
|
|
isFilterActive,
|
|
setSelectedFolder,
|
|
],
|
|
);
|
|
|
|
const initializeAfterLogin = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
await Promise.all([refreshTags(), refreshCorrespondents()]);
|
|
const initialFolder = routeFolderId && routeFolderId !== 'root' ? routeFolderId : 'root';
|
|
await loadFolder(initialFolder, { showLoading: false });
|
|
} catch (error) {
|
|
notifyApiError(error, 'Failed to initialize data.');
|
|
throw error;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [refreshTags, refreshCorrespondents, routeFolderId, loadFolder, notifyApiError]);
|
|
|
|
useEffect(() => {
|
|
if (!token) {
|
|
return;
|
|
}
|
|
if (appStatus !== 'ready' && appStatus !== 'bootstrapping') {
|
|
return;
|
|
}
|
|
|
|
const targetParam = routeFolderId ?? 'root';
|
|
|
|
if (targetParam === 'root' && routeDocumentId) {
|
|
return;
|
|
}
|
|
|
|
const hasData = folderContents.has(targetParam);
|
|
if (targetParam !== selectedFolder || !hasData) {
|
|
selectFolder(targetParam, { immediate: true });
|
|
}
|
|
}, [
|
|
token,
|
|
appStatus,
|
|
routeFolderId,
|
|
routeDocumentId,
|
|
selectedFolder,
|
|
folderContents,
|
|
isFilterActive,
|
|
selectFolder,
|
|
]);
|
|
|
|
const handleBulkCorrespondentAdd = useCallback(
|
|
async ({ name, input, documentIds }) => {
|
|
const trimmed = typeof name === 'string' ? name.trim() : '';
|
|
if (!trimmed) {
|
|
setStatusMessage('Correspondent name is required.', 'error');
|
|
return;
|
|
}
|
|
const targets = resolveTargetDocumentIds(documentIds);
|
|
if (!targets.length) {
|
|
setStatusMessage('Select documents before assigning correspondents.', 'error');
|
|
return;
|
|
}
|
|
|
|
let target = correspondentLookupByName.get(trimmed.toLowerCase()) || null;
|
|
if (!target) {
|
|
try {
|
|
target = await handleCorrespondentCreate({ name: trimmed });
|
|
} catch (error) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!target?.id) {
|
|
setStatusMessage('Unable to resolve correspondent.', 'error');
|
|
return;
|
|
}
|
|
|
|
const response = await api.post('/documents/bulk/correspondents', {
|
|
document_ids: targets,
|
|
assignments: [
|
|
{
|
|
correspondent_id: target.id,
|
|
},
|
|
],
|
|
action: 'add',
|
|
});
|
|
|
|
const { assigned = 0, removed = 0 } = response.data || {};
|
|
|
|
await refreshCurrentFolder();
|
|
const assignedSuffix = assigned === 1 ? '' : 's';
|
|
if (removed > 0) {
|
|
const removedSuffix = removed === 1 ? '' : 's';
|
|
setStatusMessage(
|
|
`Correspondent assigned (${assigned}) and replaced ${removed} link${removedSuffix}.`,
|
|
'success',
|
|
);
|
|
} else {
|
|
setStatusMessage(
|
|
`Correspondent assigned to ${assigned} document${assignedSuffix}.`,
|
|
'success',
|
|
);
|
|
}
|
|
|
|
if (input) {
|
|
input.value = '';
|
|
}
|
|
},
|
|
[
|
|
correspondentLookupByName,
|
|
handleCorrespondentCreate,
|
|
refreshCurrentFolder,
|
|
resolveTargetDocumentIds,
|
|
setStatusMessage,
|
|
],
|
|
);
|
|
|
|
const handleBulkCorrespondentRemove = useCallback(
|
|
async ({ assignments = [], documentIds }) => {
|
|
if (!assignments.length) {
|
|
setStatusMessage('Select a correspondent to remove.', 'error');
|
|
return;
|
|
}
|
|
|
|
const targets = resolveTargetDocumentIds(documentIds);
|
|
|
|
if (!targets.length) {
|
|
setStatusMessage('Select documents before removing correspondents.', 'error');
|
|
return;
|
|
}
|
|
|
|
const normalizedAssignments = assignments.map((entry) => ({
|
|
correspondent_id: entry.correspondent_id,
|
|
}));
|
|
|
|
const response = await api.post('/documents/bulk/correspondents', {
|
|
document_ids: targets,
|
|
assignments: normalizedAssignments,
|
|
action: 'remove',
|
|
});
|
|
|
|
const { assigned = 0, removed = 0 } = response.data || {};
|
|
await refreshCurrentFolder();
|
|
|
|
if (removed > 0) {
|
|
const removedSuffix = removed === 1 ? '' : 's';
|
|
setStatusMessage(
|
|
`Correspondent removed from ${removed} link${removedSuffix}.`,
|
|
'success',
|
|
);
|
|
} else if (assigned > 0) {
|
|
const assignedSuffix = assigned === 1 ? '' : 's';
|
|
setStatusMessage(`Correspondent updated ${assigned} link${assignedSuffix}.`, 'info');
|
|
} else {
|
|
setStatusMessage('No correspondents changed.', 'info');
|
|
}
|
|
},
|
|
[refreshCurrentFolder, resolveTargetDocumentIds, setStatusMessage],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (appStatus !== 'authenticated') {
|
|
return;
|
|
}
|
|
if (bootstrapInitializedRef.current) {
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
const bootstrap = async () => {
|
|
bootstrapInitializedRef.current = true;
|
|
appDispatch({ type: 'BOOTSTRAP_START' });
|
|
try {
|
|
await initializeAfterLogin();
|
|
if (!cancelled) {
|
|
appDispatch({ type: 'BOOTSTRAP_SUCCESS' });
|
|
}
|
|
} catch (error) {
|
|
if (!cancelled) {
|
|
appDispatch({
|
|
type: 'BOOTSTRAP_FAILURE',
|
|
error: error?.message || 'Failed to initialize data.',
|
|
});
|
|
bootstrapInitializedRef.current = false;
|
|
}
|
|
}
|
|
};
|
|
|
|
bootstrap();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [appStatus, appDispatch, initializeAfterLogin]);
|
|
|
|
const bulkTagOperation = useCallback(
|
|
async ({ labels, action, documentIds }) => {
|
|
const normalized = labels.map((label) => label.trim()).filter((label) => label.length > 0);
|
|
if (!normalized.length) {
|
|
return { ok: false, reason: 'no-labels' };
|
|
}
|
|
const targetDocumentIds = resolveTargetDocumentIds(documentIds);
|
|
if (!targetDocumentIds.length) {
|
|
return { ok: false, reason: 'no-selection' };
|
|
}
|
|
|
|
let tagIds = [];
|
|
|
|
if (action === 'remove') {
|
|
const missing = normalized.find(
|
|
(label) => !tags.some((tag) => tag.label.toLowerCase() === label.toLowerCase()),
|
|
);
|
|
if (missing) {
|
|
return { ok: false, reason: 'tag-missing', label: missing };
|
|
}
|
|
|
|
tagIds = normalized.map((label) => {
|
|
const tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase());
|
|
return tag?.id;
|
|
}).filter(Boolean);
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
if (action === 'add') {
|
|
const createdIds = [];
|
|
for (const label of normalized) {
|
|
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
|
|
if (!tag) {
|
|
const payload = tagManager.buildPayload({ label });
|
|
const { data } = await api.post('/tags', payload);
|
|
tag = data;
|
|
await refreshTags();
|
|
}
|
|
createdIds.push(tag.id);
|
|
}
|
|
tagIds = Array.from(new Set(createdIds));
|
|
}
|
|
|
|
tagIds = Array.from(new Set(tagIds));
|
|
|
|
if (!tagIds.length) {
|
|
return { ok: false, reason: 'no-tags' };
|
|
}
|
|
|
|
await api.post('/documents/bulk/tags', {
|
|
document_ids: targetDocumentIds,
|
|
tag_ids: tagIds,
|
|
action,
|
|
});
|
|
|
|
await refreshCurrentFolder();
|
|
|
|
return {
|
|
ok: true,
|
|
tagCount: tagIds.length,
|
|
docsCount: targetDocumentIds.length,
|
|
};
|
|
} catch (error) {
|
|
const message =
|
|
error.response?.data?.error ||
|
|
(action === 'add' ? 'Failed to assign tags.' : 'Failed to remove tags.');
|
|
notifyApiError(error, message);
|
|
return { ok: false, reason: 'request-failed' };
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[
|
|
resolveTargetDocumentIds,
|
|
tags,
|
|
refreshTags,
|
|
refreshCurrentFolder,
|
|
notifyApiError,
|
|
setLoading,
|
|
tagManager,
|
|
],
|
|
);
|
|
|
|
const handleBulkTagAddFromDetail = useCallback(
|
|
async ({ label, input, documentIds }) => {
|
|
const trimmed = typeof label === 'string' ? label.trim() : '';
|
|
if (!trimmed) {
|
|
setStatusMessage('Enter a tag label.', 'error');
|
|
return;
|
|
}
|
|
const targetIds = resolveTargetDocumentIds(documentIds);
|
|
if (!targetIds.length) {
|
|
setStatusMessage('Select documents before assigning tags.', 'error');
|
|
return;
|
|
}
|
|
const result = await bulkTagOperation({
|
|
labels: [trimmed],
|
|
action: 'add',
|
|
documentIds: targetIds,
|
|
});
|
|
if (result?.ok) {
|
|
const { tagCount, docsCount } = result;
|
|
setStatusMessage(
|
|
`Assigned ${tagCount} tag${tagCount === 1 ? '' : 's'} to ${docsCount} document${
|
|
docsCount === 1 ? '' : 's'
|
|
}.`,
|
|
'success',
|
|
);
|
|
if (input) {
|
|
input.value = '';
|
|
}
|
|
}
|
|
},
|
|
[bulkTagOperation, resolveTargetDocumentIds, setStatusMessage],
|
|
);
|
|
|
|
const handleBulkTagRemoveFromDetail = useCallback(
|
|
async ({ label, input, documentIds }) => {
|
|
const trimmed = typeof label === 'string' ? label.trim() : '';
|
|
if (!trimmed) {
|
|
setStatusMessage('Enter a tag label to remove.', 'error');
|
|
return;
|
|
}
|
|
const targetIds = resolveTargetDocumentIds(documentIds);
|
|
if (!targetIds.length) {
|
|
setStatusMessage('Select documents before removing tags.', 'error');
|
|
return;
|
|
}
|
|
const result = await bulkTagOperation({
|
|
labels: [trimmed],
|
|
action: 'remove',
|
|
documentIds: targetIds,
|
|
});
|
|
if (result?.ok) {
|
|
const { docsCount } = result;
|
|
setStatusMessage(
|
|
`Removed tags from ${docsCount} document${docsCount === 1 ? '' : 's'}.`,
|
|
'success',
|
|
);
|
|
if (input) {
|
|
input.value = '';
|
|
}
|
|
} else if (result?.reason === 'tag-missing') {
|
|
setStatusMessage(`Tag “${result.label}” not found.`, 'error');
|
|
}
|
|
},
|
|
[bulkTagOperation, resolveTargetDocumentIds, setStatusMessage],
|
|
);
|
|
const handleBulkSelectionReanalyze = useCallback(
|
|
async (documentIdsOverride = null) => {
|
|
const targetIds = resolveTargetDocumentIds(documentIdsOverride);
|
|
if (!targetIds.length) {
|
|
setStatusMessage('Select documents before requesting re-analysis.', 'error');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const { data } = await api.post('/documents/bulk/reanalyze', {
|
|
document_ids: targetIds,
|
|
force: true,
|
|
});
|
|
const queued = data?.queued ?? targetIds.length;
|
|
setStatusMessage(
|
|
`Queued re-analysis for ${queued} document${queued === 1 ? '' : 's'}.`,
|
|
'success',
|
|
);
|
|
} catch (error) {
|
|
const message =
|
|
error.response?.data?.error || 'Failed to queue document re-analysis.';
|
|
notifyApiError(error, message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[resolveTargetDocumentIds, notifyApiError, setStatusMessage],
|
|
);
|
|
|
|
const uploadFile = useCallback(
|
|
async (file, targetFolderId) => {
|
|
if (!file || file.size === 0) {
|
|
setStatusMessage('Skipped empty file.', 'error');
|
|
return null;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', file, file.name);
|
|
if (targetFolderId && targetFolderId !== 'root') {
|
|
formData.append('folder_id', targetFolderId);
|
|
}
|
|
|
|
try {
|
|
const { data, status } = await api.post('/documents', formData);
|
|
const duplicate = data?.reused || status === 200;
|
|
setStatusMessage(
|
|
duplicate
|
|
? `${file.name} already exists; reused existing document.`
|
|
: `Uploaded ${file.name}`,
|
|
duplicate ? 'info' : 'success',
|
|
);
|
|
return data;
|
|
} catch (error) {
|
|
const message = error.response?.data?.error || `Failed to upload ${file.name}.`;
|
|
notifyApiError(error, message);
|
|
throw error;
|
|
}
|
|
},
|
|
[notifyApiError, setStatusMessage],
|
|
);
|
|
|
|
const folderPathCacheRef = useRef(new Map());
|
|
|
|
const ensureFolderPathOnServer = useCallback(
|
|
async (baseFolderId, segments) => {
|
|
const trimmedSegments = segments.map((segment) => segment.trim()).filter(Boolean);
|
|
if (trimmedSegments.length === 0) {
|
|
return baseFolderId ?? null;
|
|
}
|
|
|
|
const cacheKey = `${baseFolderId ?? 'ROOT'}:${trimmedSegments.join('/')}`;
|
|
const cache = folderPathCacheRef.current;
|
|
if (cache.has(cacheKey)) {
|
|
return cache.get(cacheKey);
|
|
}
|
|
|
|
const payload = {
|
|
parent_id: baseFolderId && baseFolderId !== 'root' ? baseFolderId : null,
|
|
segments: trimmedSegments,
|
|
};
|
|
|
|
const { data } = await api.post('/folders/path', payload);
|
|
const folderId = data.folder.id;
|
|
cache.set(cacheKey, folderId);
|
|
return folderId;
|
|
},
|
|
[],
|
|
);
|
|
|
|
const ensureAssetUrl = useCallback(
|
|
async (documentId, asset, { force = false, start = null, limit = null } = {}) => {
|
|
if (!documentId || !asset?.id) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const entry = await assetManager.ensureAsset(documentId, asset, {
|
|
force,
|
|
start,
|
|
limit,
|
|
});
|
|
|
|
if (!entry) {
|
|
return null;
|
|
}
|
|
|
|
setDocuments((prev) =>
|
|
prev.map((doc) => (doc.id === documentId ? mergeAssetIntoDocument(doc, entry) : doc)),
|
|
);
|
|
|
|
setSearchResults((prev) =>
|
|
Array.isArray(prev)
|
|
? prev.map((doc) => (doc.id === documentId ? mergeAssetIntoDocument(doc, entry) : doc))
|
|
: prev,
|
|
);
|
|
|
|
return entry;
|
|
} catch (error) {
|
|
notifyApiError(error, 'Unable to refresh document asset.');
|
|
throw error;
|
|
}
|
|
},
|
|
[assetManager, setDocuments, setSearchResults, notifyApiError],
|
|
);
|
|
|
|
const dragPreviewRef = useRef(null);
|
|
|
|
const destroyDragPreview = useCallback(() => {
|
|
const node = dragPreviewRef.current;
|
|
if (node && node.parentNode) {
|
|
node.parentNode.removeChild(node);
|
|
}
|
|
dragPreviewRef.current = null;
|
|
}, []);
|
|
|
|
useEffect(() => destroyDragPreview, [destroyDragPreview]);
|
|
|
|
const createDragPreview = useCallback(
|
|
({ documents = [], folders = [] } = {}) => {
|
|
destroyDragPreview();
|
|
|
|
const docEntries = (documents || []).filter(Boolean);
|
|
const folderEntries = (folders || []).filter(Boolean);
|
|
const totalCount = docEntries.length + folderEntries.length;
|
|
if (!totalCount) {
|
|
return null;
|
|
}
|
|
|
|
const maxVisible = 4;
|
|
const size = 64;
|
|
const canvasSize = Math.round(size * 1.6);
|
|
|
|
const visibleItems = [];
|
|
docEntries.slice(0, maxVisible).forEach((doc) => {
|
|
visibleItems.push({ type: 'document', payload: doc });
|
|
});
|
|
|
|
if (visibleItems.length < maxVisible) {
|
|
folderEntries
|
|
.slice(0, maxVisible - visibleItems.length)
|
|
.forEach((folderId) => visibleItems.push({ type: 'folder', payload: folderId }));
|
|
}
|
|
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'document-drag-preview';
|
|
wrapper.style.setProperty('--drag-preview-size', `${canvasSize}px`);
|
|
|
|
visibleItems.forEach((item, index) => {
|
|
const layer = document.createElement('div');
|
|
layer.className = 'document-drag-preview__thumb';
|
|
const rotationMagnitude = Math.random() * 8 + 2; // 2..10 degrees
|
|
const rotation = (index % 2 === 0 ? 1 : -1) * rotationMagnitude;
|
|
layer.style.setProperty('--rotation-deg', `${rotation}deg`);
|
|
|
|
if (item.type === 'document') {
|
|
const doc = item.payload;
|
|
const rowEl = doc?.id
|
|
? document.getElementById(`document-row-${doc.id}`) ||
|
|
document.getElementById(`document-card-${doc.id}`)
|
|
: null;
|
|
const thumbnailEl = rowEl?.querySelector('.document-thumbnail');
|
|
const placeholderEl = rowEl?.querySelector('.thumb-placeholder');
|
|
const wrapperEl = rowEl?.querySelector('.document-thumbnail-wrapper');
|
|
const aspectAttr = wrapperEl?.dataset?.thumbnailAspect;
|
|
const aspectRatio = aspectAttr ? parseFloat(aspectAttr) : null;
|
|
|
|
let thumbWidth = size;
|
|
let thumbHeight = size;
|
|
if (Number.isFinite(aspectRatio) && aspectRatio > 0) {
|
|
if (aspectRatio >= 1) {
|
|
thumbWidth = size;
|
|
thumbHeight = Math.max(size / aspectRatio, size * 0.5);
|
|
} else {
|
|
thumbHeight = size;
|
|
thumbWidth = Math.max(size * aspectRatio, size * 0.5);
|
|
}
|
|
}
|
|
layer.style.width = `${Math.round(thumbWidth)}px`;
|
|
layer.style.height = `${Math.round(thumbHeight)}px`;
|
|
|
|
const thumbSrc = thumbnailEl?.currentSrc || thumbnailEl?.src || null;
|
|
|
|
if (thumbSrc) {
|
|
layer.classList.add('document-drag-preview__thumb--image');
|
|
layer.style.backgroundImage = `url("${thumbSrc}")`;
|
|
} else if (placeholderEl instanceof HTMLElement) {
|
|
const content = placeholderEl.cloneNode(true);
|
|
content.style.pointerEvents = 'none';
|
|
layer.appendChild(content);
|
|
} else {
|
|
layer.textContent = 'DOC';
|
|
}
|
|
} else {
|
|
const folderId = item.payload;
|
|
const rowEl = folderId ? document.getElementById(`folder-row-${folderId}`) : null;
|
|
const iconEl = rowEl?.querySelector('.thumb-icon');
|
|
|
|
let content = null;
|
|
if (iconEl instanceof HTMLElement) {
|
|
content = iconEl.cloneNode(true);
|
|
content.classList.add('document-drag-preview__folder-thumb');
|
|
const svg = content.querySelector('svg');
|
|
if (svg) {
|
|
svg.setAttribute('width', '48');
|
|
svg.setAttribute('height', '48');
|
|
}
|
|
}
|
|
|
|
if (!content) {
|
|
content = document.createElement('div');
|
|
content.className = 'document-drag-preview__folder-placeholder';
|
|
content.textContent = 'Folder';
|
|
}
|
|
|
|
layer.appendChild(content);
|
|
}
|
|
|
|
wrapper.appendChild(layer);
|
|
});
|
|
|
|
if (totalCount > 1) {
|
|
const badge = document.createElement('div');
|
|
badge.className = 'document-drag-preview__count';
|
|
badge.textContent =
|
|
totalCount > maxVisible ? `+${totalCount - maxVisible}` : `${totalCount}`;
|
|
wrapper.appendChild(badge);
|
|
}
|
|
|
|
document.body.appendChild(wrapper);
|
|
dragPreviewRef.current = wrapper;
|
|
return wrapper;
|
|
},
|
|
[destroyDragPreview],
|
|
);
|
|
|
|
const handleDocumentDragStart = useCallback(
|
|
(event, documentOrId) => {
|
|
const documentId = typeof documentOrId === 'string' ? documentOrId : documentOrId?.id;
|
|
if (!documentId) {
|
|
return;
|
|
}
|
|
|
|
const documentKey = resolveDocumentRowKey(documentId);
|
|
if (!documentKey) {
|
|
return;
|
|
}
|
|
|
|
const isGridView = documentsViewMode === 'grid';
|
|
const isAlreadySelected = selectedDocumentIds.includes(documentId);
|
|
const selection = isAlreadySelected
|
|
? [...selectedDocumentIds]
|
|
: isGridView
|
|
? [...selectedDocumentIds, documentId]
|
|
: [documentId];
|
|
const folderSelection = selectedFolderIds.length ? [...selectedFolderIds] : [];
|
|
|
|
if (!isAlreadySelected && !isGridView) {
|
|
applySelection([documentKey], {
|
|
anchor: documentKey,
|
|
interactedKeys: [documentKey],
|
|
});
|
|
}
|
|
|
|
const previewDocs = selection.map((id) => documentLookup.get(id) || null).filter(Boolean);
|
|
const previewNode = createDragPreview({
|
|
documents: previewDocs,
|
|
folders: folderSelection,
|
|
});
|
|
|
|
setDraggedDocumentIds(selection);
|
|
if (folderSelection.length) {
|
|
setDraggedFolderId(folderSelection[0] || null);
|
|
}
|
|
event.dataTransfer.effectAllowed = 'move';
|
|
try {
|
|
event.dataTransfer.setData(
|
|
'application/x-papercrate-doc-list',
|
|
JSON.stringify(selection),
|
|
);
|
|
if (folderSelection.length) {
|
|
event.dataTransfer.setData(
|
|
'application/x-papercrate-folder-list',
|
|
JSON.stringify(folderSelection),
|
|
);
|
|
if (folderSelection.length === 1) {
|
|
event.dataTransfer.setData('application/x-papercrate-folder', folderSelection[0]);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('[documents] Failed to populate drag payload', error);
|
|
}
|
|
if (previewNode) {
|
|
const width = previewNode.offsetWidth || 96;
|
|
const height = previewNode.offsetHeight || 96;
|
|
event.dataTransfer.setDragImage(previewNode, width / 2, height / 2);
|
|
}
|
|
event.currentTarget.classList.add('dragging');
|
|
},
|
|
[
|
|
selectedDocumentIds,
|
|
selectedFolderIds,
|
|
applySelection,
|
|
documentLookup,
|
|
createDragPreview,
|
|
setDraggedFolderId,
|
|
documentsViewMode,
|
|
],
|
|
);
|
|
|
|
const handleDocumentDragEnd = useCallback(
|
|
(event) => {
|
|
setDraggedDocumentIds([]);
|
|
event.currentTarget.classList.remove('dragging');
|
|
destroyDragPreview();
|
|
setDraggedFolderId(null);
|
|
},
|
|
[destroyDragPreview, setDraggedFolderId],
|
|
);
|
|
|
|
const handleFolderDragStart = useCallback(
|
|
(event, folderId) => {
|
|
if (folderId === 'root') {
|
|
return;
|
|
}
|
|
event.stopPropagation();
|
|
const folderKey = resolveFolderRowKey(folderId);
|
|
const isAlreadySelected = folderKey ? selectedRowKeys.includes(folderKey) : false;
|
|
|
|
let effectiveFolderSelection = selectedFolderIds;
|
|
let effectiveDocumentSelection = selectedDocumentIds;
|
|
|
|
if (!isAlreadySelected && folderKey) {
|
|
effectiveFolderSelection = [folderId];
|
|
effectiveDocumentSelection = [];
|
|
handleRowSelection(folderKey, { preventDefault: () => {} });
|
|
}
|
|
|
|
const uniqueFolders = effectiveFolderSelection.length
|
|
? Array.from(new Set(effectiveFolderSelection.filter(Boolean)))
|
|
: [folderId];
|
|
|
|
setDraggedFolderId(folderId);
|
|
if (effectiveDocumentSelection.length) {
|
|
setDraggedDocumentIds(effectiveDocumentSelection);
|
|
}
|
|
|
|
event.dataTransfer.effectAllowed = 'move';
|
|
try {
|
|
event.dataTransfer.setData(
|
|
'application/x-papercrate-folder-list',
|
|
JSON.stringify(uniqueFolders),
|
|
);
|
|
if (uniqueFolders.length === 1) {
|
|
event.dataTransfer.setData('application/x-papercrate-folder', uniqueFolders[0]);
|
|
}
|
|
if (effectiveDocumentSelection.length) {
|
|
event.dataTransfer.setData(
|
|
'application/x-papercrate-doc-list',
|
|
JSON.stringify(effectiveDocumentSelection),
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.warn('[folders] Failed to set drag payload', error);
|
|
}
|
|
|
|
const previewDocs = effectiveDocumentSelection
|
|
.map((id) => documentLookup.get(id) || null)
|
|
.filter(Boolean);
|
|
const previewNode = createDragPreview({
|
|
documents: previewDocs,
|
|
folders: uniqueFolders,
|
|
});
|
|
if (previewNode) {
|
|
const width = previewNode.offsetWidth || 96;
|
|
const height = previewNode.offsetHeight || 96;
|
|
event.dataTransfer.setDragImage(previewNode, width / 2, height / 2);
|
|
}
|
|
event.currentTarget?.classList.add('dragging');
|
|
},
|
|
[
|
|
selectedRowKeys,
|
|
selectedFolderIds,
|
|
selectedDocumentIds,
|
|
setDraggedFolderId,
|
|
setDraggedDocumentIds,
|
|
handleRowSelection,
|
|
documentLookup,
|
|
createDragPreview,
|
|
],
|
|
);
|
|
|
|
const handleFolderDragEnd = useCallback(
|
|
(event) => {
|
|
if (event?.currentTarget) {
|
|
event.currentTarget.classList.remove('dragging');
|
|
}
|
|
setDraggedFolderId(null);
|
|
setDraggedDocumentIds([]);
|
|
destroyDragPreview();
|
|
},
|
|
[setDraggedFolderId, setDraggedDocumentIds, destroyDragPreview],
|
|
);
|
|
|
|
const ensurePreviewUrl = useCallback(
|
|
async (documentId, { force = false } = {}) => {
|
|
if (!documentId) return null;
|
|
|
|
const existing = previewEntries.get(documentId) || null;
|
|
const now = Date.now();
|
|
const expiresAt = typeof existing?.expiresAt === 'number' ? existing.expiresAt : null;
|
|
if (!force && existing && (!expiresAt || expiresAt > now)) {
|
|
return existing;
|
|
}
|
|
|
|
if (!force && previewInflightRef.current.has(documentId)) {
|
|
return previewInflightRef.current.get(documentId);
|
|
}
|
|
|
|
const request = (async () => {
|
|
try {
|
|
const docResponse = await api.get(`/documents/${documentId}`);
|
|
const downloadPath = docResponse.data?.document?.current_version?.download_path;
|
|
if (!downloadPath || !resolveApiPath) {
|
|
throw new Error('Document missing download path');
|
|
}
|
|
|
|
const href = resolveApiPath(downloadPath);
|
|
const entry = {
|
|
url: href,
|
|
contentType: docResponse.data?.document?.current_version?.version?.content_type || null,
|
|
filename: docResponse.data?.document?.filename || 'document',
|
|
expiresAt: Date.now() + 5 * 60 * 1000,
|
|
};
|
|
setPreviewEntries((prev) => {
|
|
const next = new Map(prev);
|
|
next.set(documentId, entry);
|
|
return next;
|
|
});
|
|
return entry;
|
|
} catch (error) {
|
|
notifyApiError(error, 'Unable to fetch document preview.');
|
|
throw error;
|
|
} finally {
|
|
previewInflightRef.current.delete(documentId);
|
|
}
|
|
})();
|
|
|
|
previewInflightRef.current.set(documentId, request);
|
|
return request;
|
|
},
|
|
[previewEntries, notifyApiError],
|
|
);
|
|
|
|
const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => {
|
|
if (!dataTransfer) {
|
|
throw new Error('No drop payload found.');
|
|
}
|
|
|
|
const items = Array.from(dataTransfer.items || []);
|
|
console.info('[Uploads] drop start', { items: items.length, files: (dataTransfer.files || []).length });
|
|
|
|
const results = [];
|
|
const seenKeys = new Set();
|
|
|
|
const pushFile = (file, ancestors = []) => {
|
|
if (!file) return;
|
|
const segments = (ancestors || []).filter(Boolean);
|
|
const key = `${segments.join('/')}/${file.name}:${file.size}`;
|
|
if (seenKeys.has(key)) {
|
|
// skipped duplicate
|
|
return;
|
|
}
|
|
seenKeys.add(key);
|
|
results.push({ file, segments });
|
|
// queued file
|
|
};
|
|
|
|
const readAllEntries = async (reader) => {
|
|
const entries = [];
|
|
let batch = [];
|
|
do {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
|
|
if (batch.length) {
|
|
entries.push(...batch);
|
|
}
|
|
} while (batch.length);
|
|
return entries;
|
|
};
|
|
|
|
const walkEntry = async (entry, ancestors = []) => {
|
|
if (!entry) return;
|
|
if (entry.isFile) {
|
|
const file = await new Promise((resolve, reject) => {
|
|
try {
|
|
entry.file(resolve, reject);
|
|
} catch (error) {
|
|
console.warn('[Uploads] entry.file failed', error);
|
|
reject(error);
|
|
}
|
|
});
|
|
pushFile(file, ancestors);
|
|
return;
|
|
}
|
|
if (entry.isDirectory) {
|
|
const nextAncestors = entry.name ? [...ancestors, entry.name] : [...ancestors];
|
|
const reader = entry.createReader();
|
|
const entries = await readAllEntries(reader);
|
|
for (const child of entries) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await walkEntry(child, nextAncestors);
|
|
}
|
|
}
|
|
};
|
|
|
|
await Promise.all(
|
|
items.map(async (item, index) => {
|
|
if (item.kind !== 'file') return;
|
|
|
|
const fileFromItem = typeof item.getAsFile === 'function' ? item.getAsFile() : null;
|
|
if (fileFromItem) {
|
|
const relativePath =
|
|
typeof fileFromItem.webkitRelativePath === 'string' ? fileFromItem.webkitRelativePath : '';
|
|
const segments = relativePath
|
|
? relativePath
|
|
.split('/')
|
|
.slice(0, -1)
|
|
.filter(Boolean)
|
|
: [];
|
|
pushFile(fileFromItem, segments);
|
|
}
|
|
|
|
if (typeof item.webkitGetAsEntry === 'function') {
|
|
try {
|
|
const entry = item.webkitGetAsEntry();
|
|
if (entry) {
|
|
// processing entry
|
|
await walkEntry(entry, []);
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
console.warn('[Uploads] webkitGetAsEntry failed', error);
|
|
}
|
|
}
|
|
|
|
if (!fileFromItem) {
|
|
console.info('[Uploads] item missing file handle', index);
|
|
}
|
|
}),
|
|
);
|
|
|
|
Array.from(dataTransfer.files || []).forEach((file) => {
|
|
if (!file) return;
|
|
// FileList entry suppressed
|
|
const relativePath =
|
|
typeof file.webkitRelativePath === 'string' ? file.webkitRelativePath : '';
|
|
const segments = relativePath
|
|
? relativePath
|
|
.split('/')
|
|
.slice(0, -1)
|
|
.filter(Boolean)
|
|
: [];
|
|
pushFile(file, segments);
|
|
});
|
|
|
|
if (!results.length) {
|
|
throw new Error('No files detected in drop payload.');
|
|
}
|
|
|
|
console.info('[Uploads] prepared files', results.length);
|
|
|
|
return results;
|
|
}, []);
|
|
|
|
|
|
const handleFileDrop = useCallback(
|
|
async (dataTransfer, targetFolderId) => {
|
|
if (!token) {
|
|
setStatusMessage('Please log in before uploading.', 'error');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
|
|
try {
|
|
folderPathCacheRef.current.clear();
|
|
|
|
let extracted;
|
|
try {
|
|
extracted = await extractFilesFromDataTransfer(dataTransfer);
|
|
} catch (error) {
|
|
const message = error.message || 'Failed to process dropped files.';
|
|
notifyApiError(error, message);
|
|
return;
|
|
}
|
|
|
|
if (!extracted.length) {
|
|
setStatusMessage('No files to upload.', 'info');
|
|
return;
|
|
}
|
|
const baseFolderId =
|
|
targetFolderId && targetFolderId !== 'root' ? targetFolderId : null;
|
|
|
|
for (const { file, segments } of extracted) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const destinationId = segments.length
|
|
? await ensureFolderPathOnServer(baseFolderId, segments)
|
|
: baseFolderId;
|
|
|
|
const uploadTarget =
|
|
destinationId ??
|
|
(targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root');
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await uploadFile(file, uploadTarget);
|
|
}
|
|
|
|
await refreshCurrentFolder();
|
|
|
|
if (
|
|
targetFolderId &&
|
|
targetFolderId !== 'root' &&
|
|
targetFolderId !== selectedFolder
|
|
) {
|
|
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
|
|
}
|
|
} catch (error) {
|
|
notifyApiError(error, 'Failed to upload files.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[
|
|
token,
|
|
extractFilesFromDataTransfer,
|
|
ensureFolderPathOnServer,
|
|
uploadFile,
|
|
refreshCurrentFolder,
|
|
selectedFolder,
|
|
ensureFolderData,
|
|
notifyApiError,
|
|
setStatusMessage,
|
|
],
|
|
);
|
|
|
|
const normalizeDocumentId = (value) => {
|
|
if (!value) return null;
|
|
if (typeof value === 'object' && value.id) {
|
|
return value.id;
|
|
}
|
|
return value;
|
|
};
|
|
|
|
const moveDocumentsToFolder = useCallback(
|
|
async (documentIds, targetFolderId) => {
|
|
const uniqueIds = Array.from(
|
|
new Set((documentIds || []).map((value) => normalizeDocumentId(value)).filter(Boolean)),
|
|
);
|
|
if (!uniqueIds.length) return;
|
|
|
|
const uniqueIdSet = new Set(uniqueIds);
|
|
const target = targetFolderId === 'root' ? null : targetFolderId;
|
|
const targetLabel =
|
|
target === null
|
|
? DEFAULT_FOLDER_NAME
|
|
: folderLabelMap.get(targetFolderId) || 'target folder';
|
|
|
|
const movedDocs = uniqueIds
|
|
.map((id) => {
|
|
const doc = documentLookup.get(id);
|
|
if (!doc) {
|
|
return null;
|
|
}
|
|
return {
|
|
id,
|
|
sourceFolderId: doc.folder_id ?? null,
|
|
document: doc,
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
|
|
const updatedDocsMap = new Map();
|
|
const resolveTargetName = () => {
|
|
if (!targetLabel) {
|
|
return null;
|
|
}
|
|
const segments = String(targetLabel).split('/');
|
|
return segments[segments.length - 1] || targetLabel;
|
|
};
|
|
const targetName = resolveTargetName();
|
|
|
|
movedDocs.forEach(({ id, document }) => {
|
|
if (!document) {
|
|
return;
|
|
}
|
|
const updated = {
|
|
...document,
|
|
folder_id: target,
|
|
};
|
|
if (targetLabel) {
|
|
updated.folder_path = targetLabel;
|
|
if (targetName) {
|
|
updated.folder_name = targetName;
|
|
}
|
|
} else if (target === null) {
|
|
updated.folder_path = DEFAULT_FOLDER_NAME;
|
|
updated.folder_name = DEFAULT_FOLDER_NAME;
|
|
}
|
|
updatedDocsMap.set(id, updated);
|
|
});
|
|
|
|
const pruneRowCollection = (collection) =>
|
|
collection.filter((key) => {
|
|
if (!isDocumentRowKey(key)) {
|
|
return true;
|
|
}
|
|
const id = getRowId(key);
|
|
return id ? !uniqueIdSet.has(id) : true;
|
|
});
|
|
|
|
setLoading(true);
|
|
try {
|
|
if (uniqueIds.length === 1) {
|
|
await api.patch(`/documents/${uniqueIds[0]}/folder`, { folder_id: target });
|
|
} else {
|
|
await api.post('/documents/bulk/move', {
|
|
document_ids: uniqueIds,
|
|
folder_id: target,
|
|
});
|
|
}
|
|
|
|
const count = uniqueIds.length;
|
|
const suffix = count === 1 ? '' : 's';
|
|
setStatusMessage(`Moved ${count} document${suffix} to ${targetLabel}.`, 'success');
|
|
|
|
if (updatedDocsMap.size) {
|
|
mapDocumentCaches((doc) => {
|
|
if (!doc || !uniqueIdSet.has(doc.id)) {
|
|
return doc;
|
|
}
|
|
const updated = updatedDocsMap.get(doc.id);
|
|
if (updated) {
|
|
return updated;
|
|
}
|
|
return { ...doc, folder_id: target };
|
|
});
|
|
} else {
|
|
mapDocumentCaches((doc) => {
|
|
if (!doc || !uniqueIdSet.has(doc.id)) {
|
|
return doc;
|
|
}
|
|
return { ...doc, folder_id: target };
|
|
});
|
|
}
|
|
|
|
if (uniqueIdSet.size) {
|
|
setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id)));
|
|
setFolderContents((prev) => {
|
|
if (!prev.size) {
|
|
return prev;
|
|
}
|
|
let changed = false;
|
|
const next = new Map(prev);
|
|
movedDocs.forEach(({ id, sourceFolderId }) => {
|
|
const sourceKey = sourceFolderId || 'root';
|
|
const entry = next.get(sourceKey);
|
|
if (!entry?.documents?.length) {
|
|
return;
|
|
}
|
|
const filteredDocs = entry.documents.filter((doc) => doc.id !== id);
|
|
if (filteredDocs.length !== entry.documents.length) {
|
|
changed = true;
|
|
next.set(sourceKey, { ...entry, documents: filteredDocs });
|
|
}
|
|
});
|
|
return changed ? next : prev;
|
|
});
|
|
|
|
setSelectedRowKeys((prev) => pruneRowCollection(prev));
|
|
setSelectionOrder((prev) => pruneRowCollection(prev));
|
|
selectionOrderRef.current = pruneRowCollection(selectionOrderRef.current);
|
|
if (
|
|
selectionAnchorRef.current &&
|
|
isDocumentRowKey(selectionAnchorRef.current) &&
|
|
uniqueIdSet.has(getRowId(selectionAnchorRef.current))
|
|
) {
|
|
selectionAnchorRef.current = null;
|
|
}
|
|
if (focusedDocumentId && uniqueIdSet.has(focusedDocumentId)) {
|
|
setFocusedDocumentId(null);
|
|
}
|
|
if (
|
|
focusedRowKey &&
|
|
isDocumentRowKey(focusedRowKey) &&
|
|
uniqueIdSet.has(getRowId(focusedRowKey))
|
|
) {
|
|
setFocusedRowKey(null);
|
|
}
|
|
}
|
|
|
|
if (targetFolderId && targetFolderId !== selectedFolder) {
|
|
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
|
|
}
|
|
} catch (error) {
|
|
const message = error.response?.data?.error || 'Failed to move documents.';
|
|
notifyApiError(error, message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[
|
|
documentLookup,
|
|
ensureFolderData,
|
|
folderLabelMap,
|
|
focusedDocumentId,
|
|
mapDocumentCaches,
|
|
notifyApiError,
|
|
selectedFolder,
|
|
setDocuments,
|
|
setFolderContents,
|
|
setFocusedDocumentId,
|
|
focusedRowKey,
|
|
setFocusedRowKey,
|
|
setSelectionOrder,
|
|
setSelectedRowKeys,
|
|
setStatusMessage,
|
|
selectionAnchorRef,
|
|
selectionOrderRef,
|
|
],
|
|
);
|
|
|
|
const handleThumbnailRegeneration = useCallback(
|
|
async (documentId) => {
|
|
if (!token) {
|
|
setStatusMessage('Log in to manage assets.', 'error');
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
try {
|
|
await api.post(`/documents/${documentId}/assets`, null, {
|
|
params: { force: true },
|
|
});
|
|
setStatusMessage('Document re-analysis queued.', 'info');
|
|
await refreshCurrentFolder();
|
|
} catch (error) {
|
|
const message =
|
|
error.response?.data?.error || 'Failed to request thumbnail generation.';
|
|
notifyApiError(error, message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[token, refreshCurrentFolder, notifyApiError, setStatusMessage],
|
|
);
|
|
|
|
const ensurePreviewData = useCallback(
|
|
async (documentId) => {
|
|
if (!documentId) return null;
|
|
|
|
const findInCache = () => {
|
|
const pool = searchResults ?? documents;
|
|
return pool.find((item) => item.id === documentId) || null;
|
|
};
|
|
|
|
let doc = findInCache();
|
|
|
|
if (!doc) {
|
|
const { data } = await api.get(`/documents/${documentId}`);
|
|
const hydratedDetail = assetManager.hydrateDetail(data);
|
|
const fetched = hydratedDetail?.document || data.document || data;
|
|
doc = fetched ? assetManager.hydrateDocument(fetched) : null;
|
|
if (!doc) {
|
|
throw new Error('Document metadata unavailable.');
|
|
}
|
|
|
|
setDocuments((prev) => {
|
|
if (prev.some((item) => item.id === doc.id)) {
|
|
return prev;
|
|
}
|
|
return [doc, ...prev];
|
|
});
|
|
}
|
|
|
|
if (!previewReturnPathRef.current) {
|
|
const fallbackFolderId = doc?.folder_id || 'root';
|
|
previewReturnPathRef.current =
|
|
fallbackFolderId === 'root' ? '/documents' : `/documents/folder/${fallbackFolderId}`;
|
|
}
|
|
|
|
await ensurePreviewUrl(documentId, { force: false });
|
|
setActivePreviewId(documentId);
|
|
return doc;
|
|
},
|
|
[
|
|
searchResults,
|
|
documents,
|
|
assetManager,
|
|
setDocuments,
|
|
ensurePreviewUrl,
|
|
setActivePreviewId,
|
|
],
|
|
);
|
|
|
|
const openDocumentPreview = useCallback(
|
|
(documentId, { replace = false } = {}) => {
|
|
if (!documentId) return;
|
|
detailPanelControlRef.current.close();
|
|
previewReturnPathRef.current = `${location.pathname}${location.search}`;
|
|
navigate(`/documents/${documentId}`, { replace });
|
|
},
|
|
[navigate, location.pathname, location.search],
|
|
);
|
|
|
|
const closeDocumentPreview = useCallback(
|
|
(folderId = null) => {
|
|
const fallbackPath = previewReturnPathRef.current;
|
|
previewReturnPathRef.current = null;
|
|
|
|
if (fallbackPath) {
|
|
navigate(fallbackPath, { replace: false });
|
|
return;
|
|
}
|
|
|
|
const targetId = folderId || selectedFolder || 'root';
|
|
const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
|
|
navigate(path, { replace: false });
|
|
},
|
|
[navigate, selectedFolder],
|
|
);
|
|
|
|
const handleDocumentListFocus = useCallback(() => {
|
|
if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) {
|
|
return;
|
|
}
|
|
|
|
let resolvedKey = null;
|
|
for (let index = selectedRowKeys.length - 1; index >= 0; index -= 1) {
|
|
const candidate = selectedRowKeys[index];
|
|
if (navigableRowKeys.includes(candidate)) {
|
|
resolvedKey = candidate;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!resolvedKey && navigableRows.length) {
|
|
resolvedKey = navigableRows[0].key;
|
|
}
|
|
|
|
if (!resolvedKey) {
|
|
return;
|
|
}
|
|
|
|
setFocusedRowKey(resolvedKey);
|
|
|
|
if (!selectedRowKeys.includes(resolvedKey)) {
|
|
applySelection([resolvedKey], { anchor: resolvedKey, interactedKeys: [resolvedKey] });
|
|
}
|
|
}, [
|
|
focusedRowKey,
|
|
navigableRowKeys,
|
|
selectedRowKeys,
|
|
navigableRows,
|
|
applySelection,
|
|
setFocusedRowKey,
|
|
]);
|
|
|
|
const handleDocumentListKeyDown = useCallback(
|
|
(event) => {
|
|
const { key, shiftKey } = event;
|
|
const triggers = ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', ' ', 'Space', 'Spacebar'];
|
|
if (!triggers.includes(key)) {
|
|
return;
|
|
}
|
|
|
|
if (!navigableRows.length) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
let activeKey =
|
|
focusedRowKey && navigableRowKeys.includes(focusedRowKey)
|
|
? focusedRowKey
|
|
: null;
|
|
|
|
if (!activeKey) {
|
|
for (let index = selectedRowKeys.length - 1; index >= 0; index -= 1) {
|
|
const candidate = selectedRowKeys[index];
|
|
if (navigableRowKeys.includes(candidate)) {
|
|
activeKey = candidate;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!activeKey) {
|
|
activeKey = navigableRowKeys[0];
|
|
setFocusedRowKey(activeKey);
|
|
}
|
|
|
|
let currentIndex = navigableRowKeys.indexOf(activeKey);
|
|
|
|
if (key === 'Enter' || key === ' ' || key === 'Space' || key === 'Spacebar') {
|
|
const row = currentIndex === -1 ? navigableRows[0] : navigableRows[currentIndex];
|
|
if (!row) {
|
|
return;
|
|
}
|
|
handleRowSelection(row.key, event);
|
|
if (row.type === 'folder') {
|
|
selectFolder(row.id);
|
|
} else if (row.type === 'document') {
|
|
if (selectionCount === 0) {
|
|
detailPanelControlRef.current.open();
|
|
}
|
|
openDocumentPreview(row.id);
|
|
}
|
|
return;
|
|
}
|
|
|
|
let nextIndex = currentIndex;
|
|
|
|
if (key === 'ArrowDown') {
|
|
nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, navigableRows.length - 1);
|
|
} else if (key === 'ArrowUp') {
|
|
nextIndex = currentIndex === -1 ? navigableRows.length - 1 : Math.max(currentIndex - 1, 0);
|
|
} else if (key === 'Home') {
|
|
nextIndex = 0;
|
|
} else if (key === 'End') {
|
|
nextIndex = navigableRows.length - 1;
|
|
}
|
|
|
|
if (nextIndex === -1 || nextIndex >= navigableRows.length) {
|
|
return;
|
|
}
|
|
|
|
if (nextIndex === currentIndex && key !== 'Home' && key !== 'End') {
|
|
return;
|
|
}
|
|
|
|
const targetRow = navigableRows[nextIndex];
|
|
if (!targetRow) {
|
|
return;
|
|
}
|
|
|
|
setFocusedRowKey(targetRow.key);
|
|
|
|
const hadSelection = selectionCount > 0;
|
|
handleRowSelection(targetRow.key, {
|
|
shiftKey,
|
|
preventDefault: () => {},
|
|
});
|
|
if (targetRow.type === 'document' && !hadSelection) {
|
|
detailPanelControlRef.current.open();
|
|
}
|
|
},
|
|
[
|
|
navigableRows,
|
|
navigableRowKeys,
|
|
focusedRowKey,
|
|
selectedRowKeys,
|
|
handleRowSelection,
|
|
selectFolder,
|
|
openDocumentPreview,
|
|
selectionCount,
|
|
setFocusedRowKey,
|
|
],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!previewDocumentId) return;
|
|
const handleKeyDown = (event) => {
|
|
if (event.key === 'Escape') {
|
|
closeDocumentPreview();
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [previewDocumentId, closeDocumentPreview]);
|
|
|
|
useEffect(() => {
|
|
if (!previewDocumentId) {
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
|
|
ensurePreviewData(previewDocumentId).catch((error) => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
notifyApiError(error, 'Failed to open document preview.');
|
|
closeDocumentPreview();
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [previewDocumentId, ensurePreviewData, notifyApiError, closeDocumentPreview]);
|
|
|
|
const handleDocumentTitleUpdate = useCallback(
|
|
async (documentId, nextTitle) => {
|
|
const trimmed = nextTitle.trim();
|
|
if (!trimmed) {
|
|
setStatusMessage('Document title cannot be empty.', 'error');
|
|
return false;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const { data } = await api.patch(`/documents/${documentId}`, { title: trimmed });
|
|
const updatedDocument = extractDocumentFromResponse(data);
|
|
|
|
updateDocumentCaches(documentId, (doc) => {
|
|
if (updatedDocument) {
|
|
return { ...doc, ...updatedDocument };
|
|
}
|
|
return { ...doc, title: trimmed };
|
|
});
|
|
|
|
setStatusMessage('Document title updated.', 'success');
|
|
return true;
|
|
} catch (error) {
|
|
const message = error.response?.data?.error || 'Failed to update document title.';
|
|
notifyApiError(error, message);
|
|
return false;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[notifyApiError, setStatusMessage, updateDocumentCaches, extractDocumentFromResponse],
|
|
);
|
|
|
|
const handleDocumentIssuedUpdate = useCallback(
|
|
async (documentId, nextIssuedDate) => {
|
|
setLoading(true);
|
|
const payload = { issued_at: nextIssuedDate || null };
|
|
try {
|
|
const { data } = await api.patch(`/documents/${documentId}`, payload);
|
|
const updatedDocument = extractDocumentFromResponse(data);
|
|
|
|
updateDocumentCaches(documentId, (doc) => {
|
|
if (updatedDocument) {
|
|
return { ...doc, ...updatedDocument };
|
|
}
|
|
return { ...doc, issued_at: payload.issued_at };
|
|
});
|
|
|
|
const message = payload.issued_at ? 'Issued date updated.' : 'Issued date cleared.';
|
|
setStatusMessage(message, 'success');
|
|
return true;
|
|
} catch (error) {
|
|
const message = error.response?.data?.error || 'Failed to update issued date.';
|
|
notifyApiError(error, message);
|
|
return false;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[extractDocumentFromResponse, notifyApiError, setStatusMessage, updateDocumentCaches],
|
|
);
|
|
|
|
const applyTagRemovalToCaches = useCallback(
|
|
(documentId, tagId) => {
|
|
if (!documentId || !tagId) {
|
|
return;
|
|
}
|
|
|
|
updateDocumentCaches(documentId, (doc) => {
|
|
if (!Array.isArray(doc.tags)) {
|
|
return doc;
|
|
}
|
|
const nextTags = doc.tags.filter((tag) => tag.id !== tagId);
|
|
if (nextTags.length === doc.tags.length) {
|
|
return doc;
|
|
}
|
|
return { ...doc, tags: nextTags };
|
|
});
|
|
},
|
|
[updateDocumentCaches],
|
|
);
|
|
|
|
const handleTagRemove = useCallback(
|
|
async (documentId, tagId, { refreshTagList = true, showMessage = true } = {}) => {
|
|
if (!documentId || !tagId) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
await api.delete(`/documents/${documentId}/tags/${tagId}`);
|
|
applyTagRemovalToCaches(documentId, tagId);
|
|
if (refreshTagList) {
|
|
await refreshTags();
|
|
}
|
|
if (showMessage) {
|
|
setStatusMessage('Tag removed.', 'success');
|
|
}
|
|
return true;
|
|
} catch (error) {
|
|
const message = error.response?.data?.error || 'Failed to remove tag.';
|
|
notifyApiError(error, message);
|
|
return false;
|
|
}
|
|
},
|
|
[refreshTags, notifyApiError, setStatusMessage, applyTagRemovalToCaches],
|
|
);
|
|
|
|
const handleTagAdd = useCallback(
|
|
async (document, label, extras = null) => {
|
|
const normalizedLabel = tagManager.normalizeLabel(label);
|
|
const optionCandidate =
|
|
extras && typeof extras === 'object' && 'option' in extras ? extras.option : null;
|
|
const input =
|
|
extras && typeof extras === 'object' && 'input' in extras ? extras.input : null;
|
|
|
|
let tag = null;
|
|
if (optionCandidate && optionCandidate.id) {
|
|
tag = tags.find((item) => item.id === optionCandidate.id) || optionCandidate;
|
|
}
|
|
if (!tag) {
|
|
tag =
|
|
tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null;
|
|
}
|
|
try {
|
|
if (!tag) {
|
|
const payload = tagManager.buildPayload({ label: normalizedLabel });
|
|
const { data } = await api.post('/tags', payload);
|
|
tag = data;
|
|
await refreshTags();
|
|
}
|
|
await api.post(`/documents/${document.id}/tags`, { tag_ids: [tag.id] });
|
|
setStatusMessage('Tag assigned.', 'success');
|
|
if (input && typeof input === 'object') {
|
|
input.value = '';
|
|
}
|
|
await refreshCurrentFolder();
|
|
} catch (error) {
|
|
notifyApiError(error, 'Failed to assign tag.');
|
|
}
|
|
},
|
|
[tags, refreshTags, refreshCurrentFolder, notifyApiError, setStatusMessage, tagManager],
|
|
);
|
|
|
|
const handleDocumentTagAttach = useCallback(
|
|
async ({ documentId, tagId, tag: tagData = null }) => {
|
|
if (!documentId || !tagId) {
|
|
return false;
|
|
}
|
|
|
|
const resolveTagForCache = () => {
|
|
const lookupTag = tagLookupById.get(tagId);
|
|
const source = lookupTag || tagData;
|
|
if (!source) {
|
|
return { id: tagId, label: 'Tag', color: null };
|
|
}
|
|
return {
|
|
id: source.id ?? tagId,
|
|
label: source.label || source.name || 'Tag',
|
|
color: Object.prototype.hasOwnProperty.call(source, 'color')
|
|
? source.color
|
|
: null,
|
|
};
|
|
};
|
|
|
|
try {
|
|
await api.post(`/documents/${documentId}/tags`, { tag_ids: [tagId] });
|
|
updateDocumentCaches(documentId, (doc) => {
|
|
if (!doc) {
|
|
return doc;
|
|
}
|
|
const currentTags = Array.isArray(doc.tags) ? doc.tags : [];
|
|
if (currentTags.some((existing) => existing?.id === tagId)) {
|
|
return doc;
|
|
}
|
|
return { ...doc, tags: [...currentTags, resolveTagForCache()] };
|
|
});
|
|
setStatusMessage('Tag assigned.', 'success');
|
|
await refreshCurrentFolder();
|
|
return true;
|
|
} catch (error) {
|
|
const message = error.response?.data?.error || 'Failed to assign tag.';
|
|
notifyApiError(error, message);
|
|
return false;
|
|
}
|
|
},
|
|
[
|
|
refreshCurrentFolder,
|
|
notifyApiError,
|
|
setStatusMessage,
|
|
updateDocumentCaches,
|
|
tagLookupById,
|
|
],
|
|
);
|
|
|
|
const handleDocumentTagDrop = useCallback(
|
|
async (documentId, tag) => {
|
|
if (!documentId || !tag?.id) {
|
|
return;
|
|
}
|
|
|
|
if (tag.sourceDocId && tag.sourceDocId === documentId) {
|
|
return;
|
|
}
|
|
|
|
const attached = await handleDocumentTagAttach({ documentId, tagId: tag.id, tag });
|
|
if (!attached) {
|
|
return;
|
|
}
|
|
|
|
if (tag.sourceDocId && tag.sourceDocId !== documentId) {
|
|
await handleTagRemove(tag.sourceDocId, tag.id, {
|
|
refreshTagList: false,
|
|
showMessage: false,
|
|
});
|
|
}
|
|
},
|
|
[handleDocumentTagAttach, handleTagRemove],
|
|
);
|
|
|
|
const handleFolderDelete = useCallback(
|
|
async (folderId) => {
|
|
if (!token) {
|
|
setStatusMessage('Log in to manage folders.', 'error');
|
|
return;
|
|
}
|
|
if (folderId === 'root') {
|
|
setStatusMessage('The root folder cannot be removed.', 'error');
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
try {
|
|
const contents = await ensureFolderData(folderId, {
|
|
force: true,
|
|
prefetchDepth: 1,
|
|
});
|
|
const hasChildren = (contents.subfolders || []).length > 0;
|
|
const hasDocs = (contents.documents || []).length > 0;
|
|
if (hasChildren || hasDocs) {
|
|
setStatusMessage('Folder must be empty before it can be deleted.', 'error');
|
|
return;
|
|
}
|
|
await api.delete(`/folders/${folderId}`);
|
|
setFolderNodes((prev) => {
|
|
const next = new Map(prev);
|
|
const node = next.get(folderId);
|
|
next.delete(folderId);
|
|
if (node) {
|
|
const parentId = node.parentId || 'root';
|
|
const parentNode = next.get(parentId);
|
|
if (parentNode) {
|
|
const remaining = parentNode.children.filter((id) => id !== folderId);
|
|
next.set(parentId, {
|
|
...parentNode,
|
|
children: remaining,
|
|
hasChildren: remaining.length > 0,
|
|
});
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
setFolderContents((prev) => {
|
|
const next = new Map(prev);
|
|
next.delete(folderId);
|
|
return next;
|
|
});
|
|
if (selectedFolder === folderId) {
|
|
const node = folderNodes.get(folderId);
|
|
const parentId = node?.parentId || 'root';
|
|
setSelectedFolder(parentId);
|
|
const parentContents = await ensureFolderData(parentId, {
|
|
force: true,
|
|
prefetchDepth: 1,
|
|
});
|
|
applySelectedFolder(parentId, parentContents);
|
|
} else if (selectedFolder !== 'root') {
|
|
await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 });
|
|
}
|
|
setStatusMessage('Folder deleted.', 'success');
|
|
} catch (error) {
|
|
const message = error.response?.data?.error || 'Failed to delete folder.';
|
|
notifyApiError(error, message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[
|
|
token,
|
|
ensureFolderData,
|
|
selectedFolder,
|
|
folderNodes,
|
|
applySelectedFolder,
|
|
notifyApiError,
|
|
setStatusMessage,
|
|
],
|
|
);
|
|
|
|
const handleFolderRename = useCallback(
|
|
async (folderId, nextName) => {
|
|
if (!token) {
|
|
setStatusMessage('Log in to rename folders.', 'error');
|
|
return false;
|
|
}
|
|
if (!folderId || folderId === 'root') {
|
|
setStatusMessage('The root folder cannot be renamed.', 'error');
|
|
return false;
|
|
}
|
|
const trimmed = typeof nextName === 'string' ? nextName.trim() : '';
|
|
if (!trimmed) {
|
|
setStatusMessage('Folder name cannot be empty.', 'error');
|
|
return false;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
await api.patch(`/folders/${folderId}`, { name: trimmed });
|
|
|
|
setFolderNodes((prev) => {
|
|
const next = new Map(prev);
|
|
const node = next.get(folderId);
|
|
if (node) {
|
|
next.set(folderId, { ...node, name: trimmed });
|
|
}
|
|
return next;
|
|
});
|
|
|
|
setFolderContents((prev) => {
|
|
if (!prev.has(folderId)) {
|
|
return prev;
|
|
}
|
|
const next = new Map(prev);
|
|
const existing = next.get(folderId) || {};
|
|
const folderInfo = existing.folder
|
|
? { ...existing.folder, name: trimmed }
|
|
: { id: folderId, name: trimmed };
|
|
next.set(folderId, { ...existing, folder: folderInfo });
|
|
return next;
|
|
});
|
|
|
|
setCurrentFolder((prev) => (prev?.id === folderId ? { ...prev, name: trimmed } : prev));
|
|
|
|
setStatusMessage('Folder renamed.', 'success');
|
|
return true;
|
|
} catch (error) {
|
|
const message = error.response?.data?.error || 'Failed to rename folder.';
|
|
notifyApiError(error, message);
|
|
return false;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[token, notifyApiError, setStatusMessage],
|
|
);
|
|
|
|
const handleFolderCreate = useCallback(
|
|
async (name) => {
|
|
if (!token) {
|
|
setStatusMessage('Log in to create folders.', 'error');
|
|
return false;
|
|
}
|
|
if (!name.trim()) {
|
|
setStatusMessage('Folder name cannot be empty.', 'error');
|
|
return false;
|
|
}
|
|
const payload = {
|
|
name: name.trim(),
|
|
parent_id: selectedFolder === 'root' ? null : selectedFolder,
|
|
};
|
|
setLoading(true);
|
|
let succeeded = false;
|
|
try {
|
|
const { data } = await api.post('/folders', payload);
|
|
setStatusMessage('Folder created.', 'success');
|
|
setFolderNodes((prev) => {
|
|
const next = new Map(prev);
|
|
const parentId = payload.parent_id || 'root';
|
|
const parentNode = next.get(parentId);
|
|
if (parentNode) {
|
|
next.set(parentId, {
|
|
...parentNode,
|
|
children: parentNode.children.concat([data.folder.id]),
|
|
loaded: true,
|
|
hasChildren: true,
|
|
});
|
|
}
|
|
next.set(data.folder.id, {
|
|
id: data.folder.id,
|
|
name: data.folder.name,
|
|
parentId: parentId,
|
|
children: [],
|
|
expanded: false,
|
|
loaded: false,
|
|
hasChildren: false,
|
|
});
|
|
return next;
|
|
});
|
|
await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 });
|
|
await selectFolder(data.folder.id, { immediate: true });
|
|
succeeded = true;
|
|
} catch (error) {
|
|
const message = error.response?.data?.error || 'Failed to create folder.';
|
|
notifyApiError(error, message);
|
|
succeeded = false;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
return succeeded;
|
|
},
|
|
[token, selectedFolder, ensureFolderData, notifyApiError, setStatusMessage, selectFolder],
|
|
);
|
|
|
|
const handlePromptCreateFolder = useCallback(async () => {
|
|
if (creatingFolder) {
|
|
return;
|
|
}
|
|
const input = window.prompt('New folder name');
|
|
if (!input) {
|
|
return;
|
|
}
|
|
const trimmed = input.trim();
|
|
if (!trimmed) {
|
|
setStatusMessage('Folder name cannot be empty.', 'error');
|
|
return;
|
|
}
|
|
setCreatingFolder(true);
|
|
try {
|
|
const success = await handleFolderCreate(trimmed);
|
|
if (!success) {
|
|
setStatusMessage('Unable to create folder. Check the status message for details.', 'error');
|
|
}
|
|
} finally {
|
|
setCreatingFolder(false);
|
|
}
|
|
}, [creatingFolder, handleFolderCreate, setStatusMessage]);
|
|
|
|
const { managementModals, openTagsModal, openCorrespondentsModal } = useManagementModals({
|
|
locationPathname: location.pathname,
|
|
tags,
|
|
refreshTags,
|
|
onTagCreate: handleTagCreate,
|
|
onTagUpdate: handleTagUpdate,
|
|
onTagDelete: handleTagDelete,
|
|
correspondents,
|
|
refreshCorrespondents,
|
|
onCorrespondentCreate: handleCorrespondentCreate,
|
|
onCorrespondentUpdate: handleCorrespondentUpdate,
|
|
onCorrespondentDelete: handleCorrespondentDelete,
|
|
setStatusMessage,
|
|
});
|
|
|
|
const openSettings = useCallback(() => {
|
|
navigate('/settings');
|
|
}, [navigate]);
|
|
|
|
useEffect(() => {
|
|
if (!token) return undefined;
|
|
|
|
if (!isFilterActive) {
|
|
setSearchResults(null);
|
|
setSearchLoading(false);
|
|
return undefined;
|
|
}
|
|
|
|
let cancelled = false;
|
|
let started = false;
|
|
setSearchLoading(true);
|
|
|
|
const debounce = setTimeout(async () => {
|
|
started = true;
|
|
setLoading(true);
|
|
try {
|
|
const params = {};
|
|
const trimmedQuery = searchQuery.trim();
|
|
if (trimmedQuery.length) {
|
|
params.query = trimmedQuery;
|
|
}
|
|
if (activeTagFilters.length) {
|
|
params.tags = activeTagFilters.join(',');
|
|
}
|
|
if (activeCorrespondentFilters.length) {
|
|
params.correspondents = activeCorrespondentFilters.join(',');
|
|
}
|
|
const folderIdentifier = selectedFolder === 'root' ? null : selectedFolder;
|
|
if (folderIdentifier) {
|
|
params.folder_id = folderIdentifier;
|
|
}
|
|
const { data } = await api.get('/documents', { params });
|
|
if (cancelled) return;
|
|
|
|
const results = assetManager.hydrateDocuments(data || []);
|
|
setSearchResults(results);
|
|
|
|
if (!results.length) {
|
|
setSearchLoading(false);
|
|
setSelectedRowKeys([]);
|
|
setFocusedDocumentId(null);
|
|
selectionOrderRef.current = [];
|
|
setSelectionOrder([]);
|
|
selectionAnchorRef.current = null;
|
|
return;
|
|
}
|
|
|
|
const resultKeys = results
|
|
.map((doc) => resolveDocumentRowKey(doc.id))
|
|
.filter(Boolean);
|
|
|
|
let targetKey = null;
|
|
let nextSelectionKeys = [];
|
|
|
|
setSelectedRowKeys((previous) => {
|
|
const previousDocKeys = previous.filter(isDocumentRowKey);
|
|
const filtered = previousDocKeys.filter((key) => resultKeys.includes(key));
|
|
if (filtered.length) {
|
|
targetKey = filtered[filtered.length - 1];
|
|
nextSelectionKeys = filtered;
|
|
return filtered;
|
|
}
|
|
targetKey = null;
|
|
nextSelectionKeys = [];
|
|
return [];
|
|
});
|
|
|
|
selectionOrderRef.current = nextSelectionKeys;
|
|
setSelectionOrder(nextSelectionKeys);
|
|
|
|
setFocusedDocumentId((previous) => {
|
|
if (previous && resultKeys.includes(resolveDocumentRowKey(previous))) {
|
|
return previous;
|
|
}
|
|
return null;
|
|
});
|
|
|
|
selectionAnchorRef.current = targetKey;
|
|
|
|
// rely on hydrated search results; assets refresh on demand
|
|
} catch (error) {
|
|
if (cancelled) return;
|
|
notifyApiError(error, 'Search failed. Please try again.');
|
|
setSearchResults(null);
|
|
} finally {
|
|
if (!cancelled && started) {
|
|
setLoading(false);
|
|
setSearchLoading(false);
|
|
}
|
|
}
|
|
}, 300);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
clearTimeout(debounce);
|
|
if (started) {
|
|
setLoading(false);
|
|
setSearchLoading(false);
|
|
}
|
|
};
|
|
}, [
|
|
token,
|
|
isFilterActive,
|
|
searchQuery,
|
|
activeTagFilters,
|
|
activeCorrespondentFilters,
|
|
selectedFolder,
|
|
notifyApiError,
|
|
assetManager,
|
|
selectionOrderRef,
|
|
selectionAnchorRef,
|
|
setSelectedRowKeys,
|
|
setSelectionOrder,
|
|
setFocusedDocumentId,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!token) {
|
|
setDropOverlayState((prev) => ({ ...prev, active: false }));
|
|
dragCounterRef.current = 0;
|
|
return undefined;
|
|
}
|
|
|
|
const handleDragEnter = (event) => {
|
|
if (!hasFiles(event)) return;
|
|
event.preventDefault();
|
|
dragCounterRef.current += 1;
|
|
setDropOverlayState({ active: true, folderName: currentFolderName });
|
|
};
|
|
|
|
const handleDragOver = (event) => {
|
|
if (!hasFiles(event)) return;
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = 'copy';
|
|
};
|
|
|
|
const handleDragLeave = (event) => {
|
|
if (!hasFiles(event)) return;
|
|
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
|
|
if (dragCounterRef.current === 0) {
|
|
setDropOverlayState((prev) => ({ ...prev, active: false }));
|
|
}
|
|
};
|
|
|
|
const handleDrop = async (event) => {
|
|
if (!hasFiles(event)) return;
|
|
event.preventDefault();
|
|
dragCounterRef.current = 0;
|
|
setDropOverlayState((prev) => ({ ...prev, active: false }));
|
|
await handleFileDrop(event.dataTransfer, selectedFolder);
|
|
};
|
|
|
|
const dropTarget = shellRef.current;
|
|
if (!dropTarget) {
|
|
return undefined;
|
|
}
|
|
|
|
dropTarget.addEventListener('dragenter', handleDragEnter);
|
|
dropTarget.addEventListener('dragover', handleDragOver);
|
|
dropTarget.addEventListener('dragleave', handleDragLeave);
|
|
dropTarget.addEventListener('drop', handleDrop);
|
|
|
|
return () => {
|
|
dropTarget.removeEventListener('dragenter', handleDragEnter);
|
|
dropTarget.removeEventListener('dragover', handleDragOver);
|
|
dropTarget.removeEventListener('dragleave', handleDragLeave);
|
|
dropTarget.removeEventListener('drop', handleDrop);
|
|
dragCounterRef.current = 0;
|
|
setDropOverlayState((prev) => ({ ...prev, active: false }));
|
|
};
|
|
}, [token, handleFileDrop, currentFolderName, selectedFolder]);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
setTagRemovalCursor(false);
|
|
},
|
|
[setTagRemovalCursor],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const host = shellRef.current;
|
|
if (!host) {
|
|
return undefined;
|
|
}
|
|
|
|
const isTagTransfer = (event) => {
|
|
const types = event?.dataTransfer?.types;
|
|
if (!types) {
|
|
return false;
|
|
}
|
|
if (typeof types.includes === 'function') {
|
|
return TAG_MIME_TYPES.some((type) => types.includes(type));
|
|
}
|
|
return TAG_MIME_TYPES.some((type) => Array.from(types).includes(type));
|
|
};
|
|
|
|
const isDocumentDropTarget = (target) =>
|
|
target instanceof Element ? Boolean(target.closest('[data-doc-id]')) : false;
|
|
|
|
const handleTagDragOver = (event) => {
|
|
if (!isTagTransfer(event)) {
|
|
return;
|
|
}
|
|
if (isDocumentDropTarget(event.target)) {
|
|
setTagRemovalCursor(false);
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = 'move';
|
|
setTagRemovalCursor(true);
|
|
};
|
|
|
|
const handleTagDragLeave = (event) => {
|
|
if (!isTagTransfer(event)) {
|
|
return;
|
|
}
|
|
const related = event.relatedTarget;
|
|
if (related instanceof Element && host.contains(related)) {
|
|
if (isDocumentDropTarget(related)) {
|
|
setTagRemovalCursor(false);
|
|
}
|
|
return;
|
|
}
|
|
setTagRemovalCursor(false);
|
|
};
|
|
|
|
const handleTagDrop = async (event) => {
|
|
if (!isTagTransfer(event)) {
|
|
return;
|
|
}
|
|
setTagRemovalCursor(false);
|
|
if (isDocumentDropTarget(event.target) || event.defaultPrevented) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const raw =
|
|
event.dataTransfer.getData('application/x-papercrate-tag') ||
|
|
event.dataTransfer.getData('text/papercrate-tag');
|
|
if (!raw) {
|
|
return;
|
|
}
|
|
try {
|
|
const payload = JSON.parse(raw);
|
|
if (payload?.sourceDocId && payload?.id) {
|
|
await handleTagRemove(payload.sourceDocId, payload.id, {
|
|
refreshTagList: false,
|
|
showMessage: true,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to remove tag from drop target', error);
|
|
}
|
|
};
|
|
|
|
const handleTagDragEnd = () => {
|
|
setTagRemovalCursor(false);
|
|
};
|
|
|
|
host.addEventListener('dragover', handleTagDragOver, true);
|
|
host.addEventListener('dragleave', handleTagDragLeave, true);
|
|
host.addEventListener('drop', handleTagDrop, true);
|
|
window.addEventListener('dragend', handleTagDragEnd, true);
|
|
|
|
return () => {
|
|
host.removeEventListener('dragover', handleTagDragOver, true);
|
|
host.removeEventListener('dragleave', handleTagDragLeave, true);
|
|
host.removeEventListener('drop', handleTagDrop, true);
|
|
window.removeEventListener('dragend', handleTagDragEnd, true);
|
|
setTagRemovalCursor(false);
|
|
};
|
|
}, [handleTagRemove, setTagRemovalCursor]);
|
|
|
|
const handleLogout = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
await api.post('/auth/logout');
|
|
} catch (error) {
|
|
console.warn('[Auth] Failed to revoke refresh token during logout', error);
|
|
} finally {
|
|
setLoading(false);
|
|
appDispatch({ type: 'LOGOUT' });
|
|
setStatusMessage('Logged out.', 'info');
|
|
}
|
|
}, [appDispatch, setStatusMessage]);
|
|
|
|
const folderClickHandlers = useMemo(
|
|
() => ({
|
|
onToggle: async (folderId) => {
|
|
const node = folderNodes.get(folderId);
|
|
const nextExpanded = !(node?.expanded ?? false);
|
|
if (nextExpanded) {
|
|
try {
|
|
await ensureFolderData(folderId, {
|
|
includeDocuments: false,
|
|
prefetchDepth: 1,
|
|
});
|
|
} catch (error) {
|
|
notifyApiError(error, 'Failed to load folder.');
|
|
}
|
|
} else if (node && !node.loaded) {
|
|
try {
|
|
await ensureFolderData(folderId, {
|
|
includeDocuments: false,
|
|
prefetchDepth: 1,
|
|
});
|
|
} catch (error) {
|
|
notifyApiError(error, 'Failed to load folder.');
|
|
}
|
|
}
|
|
setFolderNodes((prev) => {
|
|
const next = new Map(prev);
|
|
const current = next.get(folderId);
|
|
if (!current) return prev;
|
|
next.set(folderId, { ...current, expanded: nextExpanded });
|
|
return next;
|
|
});
|
|
},
|
|
onSelect: selectFolder,
|
|
onDrop: async (event, folderId) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
event.currentTarget.classList.remove('is-drop-target');
|
|
|
|
let folderIds = [];
|
|
try {
|
|
const rawFolderList = event.dataTransfer.getData('application/x-papercrate-folder-list');
|
|
if (rawFolderList) {
|
|
const parsed = JSON.parse(rawFolderList);
|
|
if (Array.isArray(parsed)) {
|
|
folderIds = parsed.filter(Boolean);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('[folders] Failed to parse folder list drag payload', error);
|
|
}
|
|
|
|
if (!folderIds.length) {
|
|
let folderSourceId = draggedFolderId;
|
|
if (!folderSourceId) {
|
|
try {
|
|
if (event.dataTransfer.types?.includes('application/x-papercrate-folder')) {
|
|
folderSourceId = event.dataTransfer.getData('application/x-papercrate-folder');
|
|
}
|
|
} catch (error) {
|
|
console.warn('[folders] Failed to read folder id from drag payload', error);
|
|
}
|
|
}
|
|
|
|
if (folderSourceId) {
|
|
folderIds = [folderSourceId];
|
|
}
|
|
}
|
|
|
|
folderIds = Array.from(new Set(folderIds.filter(Boolean)));
|
|
|
|
if (folderIds.length) {
|
|
setDraggedFolderId(null);
|
|
const invalidMove = folderIds.some((sourceId) => isInvalidFolderDrop(sourceId, folderId));
|
|
if (invalidMove) {
|
|
setStatusMessage(
|
|
'Cannot move a folder into itself or one of its descendants.',
|
|
'error',
|
|
);
|
|
return;
|
|
}
|
|
|
|
for (const sourceId of folderIds) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await moveFolder(sourceId, folderId);
|
|
}
|
|
}
|
|
|
|
if (hasFiles(event)) {
|
|
await handleFileDrop(event.dataTransfer, folderId);
|
|
return;
|
|
}
|
|
|
|
let docIds = [];
|
|
try {
|
|
const raw = event.dataTransfer.getData('application/x-papercrate-doc-list');
|
|
if (raw) {
|
|
const parsed = JSON.parse(raw);
|
|
if (Array.isArray(parsed)) {
|
|
docIds = parsed.filter(Boolean);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('[documents] Failed to parse document list drag payload', error);
|
|
}
|
|
|
|
if (!docIds.length) {
|
|
try {
|
|
const single = event.dataTransfer.getData('application/x-papercrate-doc');
|
|
if (single) {
|
|
docIds = [single];
|
|
}
|
|
} catch (error) {
|
|
console.warn('[documents] Failed to read single document drag payload', error);
|
|
}
|
|
}
|
|
|
|
if (!docIds.length && draggedDocumentIds.length) {
|
|
docIds = draggedDocumentIds;
|
|
}
|
|
|
|
docIds = Array.from(new Set(docIds));
|
|
|
|
if (!docIds.length || folderId === selectedFolder) {
|
|
return;
|
|
}
|
|
|
|
setDraggedDocumentIds([]);
|
|
await moveDocumentsToFolder(docIds, folderId);
|
|
},
|
|
onDragOver: (event, folderId) => {
|
|
const folderDragActive = Boolean(draggedFolderId);
|
|
if (folderDragActive && isInvalidFolderDrop(draggedFolderId, folderId)) {
|
|
return;
|
|
}
|
|
|
|
if (hasFiles(event)) {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = 'copy';
|
|
event.currentTarget.classList.add('is-drop-target');
|
|
return;
|
|
}
|
|
|
|
if (draggedDocumentIds.length || folderDragActive) {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = 'move';
|
|
event.currentTarget.classList.add('is-drop-target');
|
|
}
|
|
},
|
|
onDragLeave: (event) => {
|
|
event.currentTarget.classList.remove('is-drop-target');
|
|
},
|
|
}),
|
|
[
|
|
draggedDocumentIds,
|
|
draggedFolderId,
|
|
ensureFolderData,
|
|
folderNodes,
|
|
handleFileDrop,
|
|
isInvalidFolderDrop,
|
|
moveDocumentsToFolder,
|
|
moveFolder,
|
|
notifyApiError,
|
|
selectFolder,
|
|
selectedFolder,
|
|
setDraggedDocumentIds,
|
|
setDraggedFolderId,
|
|
setFolderNodes,
|
|
setStatusMessage,
|
|
],
|
|
);
|
|
|
|
const selectedDocument = useMemo(() => {
|
|
if (!focusedDocumentId) {
|
|
return null;
|
|
}
|
|
const list = searchResults ?? documents;
|
|
return list.find((doc) => doc.id === focusedDocumentId) || null;
|
|
}, [searchResults, documents, focusedDocumentId]);
|
|
|
|
const handleDocumentDelete = useCallback(
|
|
async (documentId) => {
|
|
if (!documentId) return;
|
|
if (!token) {
|
|
setStatusMessage('Log in to manage documents.', 'error');
|
|
return;
|
|
}
|
|
|
|
const doc = documentLookup.get(documentId) || null;
|
|
const label = doc?.title;
|
|
|
|
const confirmed = window.confirm(`Move "${label}" to trash? You can restore it from trash later.`);
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
await api.delete(`/documents/${documentId}`);
|
|
|
|
removeDocumentFromCaches(documentId);
|
|
|
|
setPreviewEntries((prev) => {
|
|
if (!prev.has(documentId)) {
|
|
return prev;
|
|
}
|
|
const next = new Map(prev);
|
|
next.delete(documentId);
|
|
return next;
|
|
});
|
|
previewInflightRef.current.delete(documentId);
|
|
|
|
if (selectedDocumentIds.includes(documentId)) {
|
|
const remainingRowKeys = selectedDocumentIds
|
|
.filter((id) => id !== documentId)
|
|
.map((id) => resolveDocumentRowKey(id))
|
|
.filter(Boolean);
|
|
const removedKey = resolveDocumentRowKey(documentId);
|
|
applySelection(remainingRowKeys, {
|
|
anchor: null,
|
|
interactedKeys: removedKey ? [removedKey] : [],
|
|
});
|
|
}
|
|
|
|
if (previewDocumentId === documentId) {
|
|
closeDocumentPreview();
|
|
}
|
|
|
|
setStatusMessage('Document deleted.', 'success');
|
|
} catch (error) {
|
|
const message = error.response?.data?.error || 'Failed to delete document.';
|
|
notifyApiError(error, message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[
|
|
token,
|
|
documentLookup,
|
|
setStatusMessage,
|
|
removeDocumentFromCaches,
|
|
setPreviewEntries,
|
|
previewInflightRef,
|
|
previewDocumentId,
|
|
closeDocumentPreview,
|
|
selectedDocumentIds,
|
|
applySelection,
|
|
notifyApiError,
|
|
],
|
|
);
|
|
|
|
const orderedSelectedDocuments = useMemo(() => {
|
|
const ordered = [];
|
|
const seen = new Set();
|
|
const pushDoc = (doc) => {
|
|
if (doc?.id && !seen.has(doc.id)) {
|
|
ordered.push(doc);
|
|
seen.add(doc.id);
|
|
}
|
|
};
|
|
|
|
selectionOrder.forEach((key) => {
|
|
if (!isDocumentRowKey(key)) {
|
|
return;
|
|
}
|
|
const docId = getRowId(key);
|
|
const doc = documentLookup.get(docId) || null;
|
|
pushDoc(doc);
|
|
});
|
|
|
|
selectedDocumentIds.forEach((id) => {
|
|
if (seen.has(id)) return;
|
|
const doc = documentLookup.get(id) || null;
|
|
pushDoc(doc);
|
|
});
|
|
|
|
return ordered;
|
|
}, [selectionOrder, documentLookup, selectedDocumentIds]);
|
|
|
|
const {
|
|
detailPanelOpen,
|
|
detailPanelSelectedDocuments,
|
|
openDetailPanel,
|
|
closeDetailPanel,
|
|
} = useDetailPanel({
|
|
selectedDocumentIds,
|
|
documentLookup,
|
|
orderedSelectedDocuments,
|
|
selectionOrder,
|
|
documentsViewMode,
|
|
getRowId,
|
|
isDocumentRowKey,
|
|
});
|
|
|
|
detailPanelControlRef.current = {
|
|
open: openDetailPanel,
|
|
close: closeDetailPanel,
|
|
};
|
|
|
|
const { breadcrumbs, missingBreadcrumbAncestors } = useMemo(() => {
|
|
const chain = [];
|
|
const seen = new Set();
|
|
const pending = new Set();
|
|
let currentId = selectedFolder || 'root';
|
|
let guard = 0;
|
|
|
|
while (currentId && !seen.has(currentId) && guard < 32) {
|
|
guard += 1;
|
|
seen.add(currentId);
|
|
|
|
if (currentId === 'root') {
|
|
chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
|
|
currentId = null;
|
|
break;
|
|
}
|
|
|
|
const node = folderNodes.get(currentId);
|
|
if (node) {
|
|
chain.push({ id: currentId, name: node.name || 'Folder' });
|
|
currentId = node.parentId ?? 'root';
|
|
continue;
|
|
}
|
|
|
|
let fallbackName = '…';
|
|
let parentId = null;
|
|
|
|
if (currentFolder && currentFolder.id === currentId) {
|
|
fallbackName = currentFolder.name;
|
|
parentId = currentFolder.parent_id ?? 'root';
|
|
}
|
|
|
|
chain.push({ id: currentId, name: fallbackName });
|
|
pending.add(currentId);
|
|
currentId = parentId;
|
|
}
|
|
|
|
if (!chain.some((crumb) => crumb.id === 'root')) {
|
|
chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
|
|
}
|
|
|
|
const ordered = [];
|
|
const seenOrdered = new Set();
|
|
chain
|
|
.slice()
|
|
.reverse()
|
|
.forEach((crumb) => {
|
|
if (!seenOrdered.has(crumb.id)) {
|
|
seenOrdered.add(crumb.id);
|
|
ordered.push(crumb);
|
|
}
|
|
});
|
|
|
|
return { breadcrumbs: ordered, missingBreadcrumbAncestors: Array.from(pending) };
|
|
}, [selectedFolder, folderNodes, currentFolder]);
|
|
|
|
useEffect(() => {
|
|
if (!missingBreadcrumbAncestors.length) {
|
|
return;
|
|
}
|
|
|
|
missingBreadcrumbAncestors.forEach((folderId) => {
|
|
if (!folderId || folderId === 'root') {
|
|
return;
|
|
}
|
|
if (breadcrumbFetchRef.current.has(folderId)) {
|
|
return;
|
|
}
|
|
|
|
breadcrumbFetchRef.current.add(folderId);
|
|
ensureFolderData(folderId, { force: false })
|
|
.catch((error) => {
|
|
console.warn('Failed to preload breadcrumb ancestor', folderId, error);
|
|
})
|
|
.finally(() => {
|
|
breadcrumbFetchRef.current.delete(folderId);
|
|
});
|
|
});
|
|
}, [missingBreadcrumbAncestors, ensureFolderData]);
|
|
|
|
useEffect(() => {
|
|
if (!orderedSelectedDocuments.length) {
|
|
return;
|
|
}
|
|
|
|
const visited = new Set();
|
|
|
|
orderedSelectedDocuments.forEach((doc) => {
|
|
const folderId = doc?.folder_id;
|
|
if (!folderId) {
|
|
return;
|
|
}
|
|
let currentId = folderId;
|
|
let guard = 0;
|
|
while (currentId && currentId !== 'root' && guard < 32) {
|
|
guard += 1;
|
|
if (visited.has(currentId)) {
|
|
break;
|
|
}
|
|
visited.add(currentId);
|
|
const node = folderNodes.get(currentId);
|
|
if (!node) {
|
|
if (!detailFolderFetchRef.current.has(currentId)) {
|
|
detailFolderFetchRef.current.add(currentId);
|
|
ensureFolderData(currentId, { force: false, includeDocuments: false })
|
|
.catch((error) => {
|
|
console.warn('Failed to preload folder metadata for detail path', currentId, error);
|
|
})
|
|
.finally(() => {
|
|
detailFolderFetchRef.current.delete(currentId);
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
const parentId = node.parentId ?? 'root';
|
|
if (!parentId || parentId === 'root') {
|
|
break;
|
|
}
|
|
currentId = parentId;
|
|
}
|
|
});
|
|
}, [orderedSelectedDocuments, folderNodes, ensureFolderData]);
|
|
|
|
const resolveFolderPath = useCallback(
|
|
(folderId) => {
|
|
if (!folderId || folderId === 'root') {
|
|
return [];
|
|
}
|
|
|
|
const segments = [];
|
|
const visited = new Set();
|
|
let currentId = folderId;
|
|
let guard = 0;
|
|
|
|
while (currentId && guard < 32 && !visited.has(currentId)) {
|
|
guard += 1;
|
|
visited.add(currentId);
|
|
|
|
if (currentId === 'root') {
|
|
break;
|
|
}
|
|
|
|
const node = folderNodes.get(currentId);
|
|
if (!node) {
|
|
segments.push({ id: currentId, name: '…' });
|
|
break;
|
|
}
|
|
|
|
segments.push({ id: node.id, name: node.name || 'Folder' });
|
|
|
|
const parentId = node.parentId ?? 'root';
|
|
if (!parentId || parentId === 'root') {
|
|
segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
|
|
break;
|
|
}
|
|
|
|
currentId = parentId;
|
|
}
|
|
|
|
if (!segments.some((segment) => segment.id === 'root')) {
|
|
segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
|
|
}
|
|
|
|
return segments.reverse();
|
|
},
|
|
[folderNodes],
|
|
);
|
|
|
|
const selectedPreviewEntry = useMemo(() => {
|
|
if (!selectedDocument) {
|
|
return null;
|
|
}
|
|
return previewEntries.get(selectedDocument.id) || null;
|
|
}, [selectedDocument, previewEntries]);
|
|
|
|
const previewWorkspaceEntry = useMemo(() => {
|
|
if (!previewDocumentId) {
|
|
return null;
|
|
}
|
|
return previewEntries.get(previewDocumentId) || null;
|
|
}, [previewDocumentId, previewEntries]);
|
|
|
|
const previewWorkspaceDocument = useMemo(() => {
|
|
if (!previewDocumentId) return null;
|
|
const pool = searchResults ?? documents;
|
|
return pool.find((doc) => doc.id === previewDocumentId) || null;
|
|
}, [previewDocumentId, searchResults, documents]);
|
|
|
|
const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument);
|
|
|
|
const resolveThumbnailUrlForDoc = useCallback(
|
|
(doc) =>
|
|
resolveDocumentAssetUrl(doc, 'thumbnail', {
|
|
ensureAssetUrl,
|
|
getAsset: getDocumentAsset,
|
|
}),
|
|
[ensureAssetUrl, getDocumentAsset],
|
|
);
|
|
|
|
const handleDocumentsViewModeChange = useCallback((mode) => {
|
|
const next = mode === 'grid' ? 'grid' : mode === 'desk' ? 'desk' : 'list';
|
|
setDocumentsViewMode((previous) => {
|
|
if (next !== previous && typeof window !== 'undefined') {
|
|
window.localStorage.setItem('papercrate_view_mode', next);
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const handleDeskExit = useCallback(() => {
|
|
const fallback = lastNonDeskViewRef.current && lastNonDeskViewRef.current !== 'desk'
|
|
? lastNonDeskViewRef.current
|
|
: 'list';
|
|
handleDocumentsViewModeChange(fallback);
|
|
}, [handleDocumentsViewModeChange]);
|
|
|
|
const handleTenantSelect = useCallback(
|
|
async (tenantOption, { refreshOnly = false } = {}) => {
|
|
const requestedTenantId = tenantOption?.id ?? null;
|
|
if (!refreshOnly && (!requestedTenantId || requestedTenantId === currentTenantId)) {
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
if (!refreshOnly) {
|
|
setStatusMessage('Switching tenant…', 'info');
|
|
}
|
|
|
|
if (refreshOnly) {
|
|
const { data } = await api.get('/auth/tenants');
|
|
appDispatch({
|
|
type: 'SET_TENANTS',
|
|
tenants: Array.isArray(data?.tenants) ? data.tenants : [],
|
|
});
|
|
return;
|
|
}
|
|
|
|
const { data } = await api.post('/auth/select-tenant', { tenant_id: requestedTenantId });
|
|
if (!data?.access_token) {
|
|
throw new Error('Missing access token in tenant switch response.');
|
|
}
|
|
|
|
appDispatch({ type: 'LOGOUT' });
|
|
resetWorkspaceState();
|
|
|
|
appDispatch({
|
|
type: 'LOGIN_SUCCESS',
|
|
token: data.access_token,
|
|
tenant: data.tenant || null,
|
|
});
|
|
|
|
api.defaults.headers.common.Authorization = `Bearer ${data.access_token}`;
|
|
tokenRef.current = data.access_token;
|
|
tenantIdRef.current = data?.tenant?.id ?? null;
|
|
|
|
if (Array.isArray(data?.tenants)) {
|
|
appDispatch({ type: 'SET_TENANTS', tenants: data.tenants });
|
|
}
|
|
|
|
handleDocumentsViewModeChange('list');
|
|
navigate('/documents', { replace: true });
|
|
|
|
await Promise.all([refreshTags(), refreshCorrespondents()]);
|
|
await loadFolder('root', { showLoading: false, preserveSearch: false });
|
|
|
|
const tenantLabel = data?.tenant?.name || data?.tenant?.id || 'tenant';
|
|
setStatusMessage(`Switched to ${tenantLabel}.`, 'info');
|
|
} catch (error) {
|
|
notifyApiError(error, 'Failed to switch tenant.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[
|
|
currentTenantId,
|
|
appDispatch,
|
|
notifyApiError,
|
|
setStatusMessage,
|
|
resetWorkspaceState,
|
|
handleDocumentsViewModeChange,
|
|
navigate,
|
|
refreshTags,
|
|
refreshCorrespondents,
|
|
loadFolder,
|
|
],
|
|
);
|
|
|
|
const documentsTableProps = useMemo(
|
|
() => ({
|
|
currentFolderName,
|
|
breadcrumbs,
|
|
onRefresh: refreshCurrentFolder,
|
|
subfolders: currentSubfolders,
|
|
documents,
|
|
searchResults,
|
|
isFilterActive,
|
|
onFolderSelect: selectFolder,
|
|
onFolderDrop: folderClickHandlers.onDrop,
|
|
onFolderDragOver: folderClickHandlers.onDragOver,
|
|
onFolderDragLeave: folderClickHandlers.onDragLeave,
|
|
onFolderDragStart: handleFolderDragStart,
|
|
onFolderDragEnd: handleFolderDragEnd,
|
|
draggedFolderId,
|
|
onFolderDelete: handleFolderDelete,
|
|
onFolderRowClick: handleFolderRowClick,
|
|
onFolderRename: handleFolderRename,
|
|
onDocumentRowClick: handleDocumentRowClick,
|
|
onDocumentOpen: openDocumentPreview,
|
|
onDocumentDelete: handleDocumentDelete,
|
|
onDocumentRename: handleDocumentTitleUpdate,
|
|
selectedDocumentIds,
|
|
selectedFolderIds,
|
|
focusedDocumentId,
|
|
focusedRowKey,
|
|
draggingDocumentIds: draggedDocumentIds,
|
|
onDocumentDragStart: handleDocumentDragStart,
|
|
onDocumentDragEnd: handleDocumentDragEnd,
|
|
isSearchLoading: searchLoading,
|
|
tagLookupById,
|
|
activeCorrespondentIds: activeCorrespondentFilters,
|
|
onDocumentListFocus: handleDocumentListFocus,
|
|
onDocumentListKeyDown: handleDocumentListKeyDown,
|
|
onFocusedRowChange: setFocusedRowKey,
|
|
ensureAssetUrl,
|
|
getDocumentAsset,
|
|
getDownloadHref: (doc) =>
|
|
doc?.current_version?.download_path
|
|
? resolveApiPath(doc.current_version.download_path)
|
|
: null,
|
|
onTagClick: toggleTagFilter,
|
|
onCorrespondentClick: toggleCorrespondentFilter,
|
|
onDocumentTagDrop: handleDocumentTagDrop,
|
|
viewMode: documentsViewMode,
|
|
onViewModeChange: handleDocumentsViewModeChange,
|
|
onClearSelection: clearDocumentSelection,
|
|
}),
|
|
[
|
|
activeCorrespondentFilters,
|
|
breadcrumbs,
|
|
clearDocumentSelection,
|
|
currentFolderName,
|
|
currentSubfolders,
|
|
documents,
|
|
documentsViewMode,
|
|
draggedDocumentIds,
|
|
draggedFolderId,
|
|
focusedDocumentId,
|
|
focusedRowKey,
|
|
folderClickHandlers,
|
|
handleDocumentDelete,
|
|
handleDocumentDragEnd,
|
|
handleDocumentDragStart,
|
|
handleDocumentListFocus,
|
|
handleDocumentListKeyDown,
|
|
handleDocumentRowClick,
|
|
handleDocumentTagDrop,
|
|
handleDocumentTitleUpdate,
|
|
handleDocumentsViewModeChange,
|
|
handleFolderDelete,
|
|
handleFolderDragEnd,
|
|
handleFolderDragStart,
|
|
handleFolderRename,
|
|
handleFolderRowClick,
|
|
isFilterActive,
|
|
openDocumentPreview,
|
|
refreshCurrentFolder,
|
|
searchLoading,
|
|
searchResults,
|
|
selectFolder,
|
|
selectedDocumentIds,
|
|
selectedFolderIds,
|
|
setFocusedRowKey,
|
|
tagLookupById,
|
|
toggleCorrespondentFilter,
|
|
toggleTagFilter,
|
|
ensureAssetUrl,
|
|
getDocumentAsset,
|
|
],
|
|
);
|
|
|
|
const sidebarProps = useMemo(
|
|
() => ({
|
|
folderNodes,
|
|
onToggle: folderClickHandlers.onToggle,
|
|
onSelect: folderClickHandlers.onSelect,
|
|
onDrop: folderClickHandlers.onDrop,
|
|
onDragOver: folderClickHandlers.onDragOver,
|
|
onDragLeave: folderClickHandlers.onDragLeave,
|
|
onDeleteFolder: handleFolderDelete,
|
|
onRenameFolder: handleFolderRename,
|
|
selectedFolder,
|
|
onFolderDragStart: handleFolderDragStart,
|
|
onFolderDragEnd: handleFolderDragEnd,
|
|
draggedFolderId,
|
|
onCreateFolder: handlePromptCreateFolder,
|
|
creatingFolder,
|
|
tags,
|
|
activeTagIds: activeTagFilters,
|
|
onToggleTagFilter: toggleTagFilter,
|
|
onCreateTag: (label) => handleTagCreate({ label }),
|
|
correspondents,
|
|
activeCorrespondentIds: activeCorrespondentFilters,
|
|
onToggleCorrespondentFilter: toggleCorrespondentFilter,
|
|
onCreateCorrespondent: (name) => handleCorrespondentCreate({ name }),
|
|
appStatus,
|
|
loading,
|
|
previewActive,
|
|
searchQuery,
|
|
onSearchChange: handleSearchChange,
|
|
onSearchSubmit: handleSearchSubmit,
|
|
onSearchClear: clearFilters,
|
|
isFilterActive,
|
|
onLogout: handleLogout,
|
|
status,
|
|
tenantName,
|
|
tenants: tenantOptions,
|
|
activeTenantId: currentTenantId,
|
|
onSelectTenant: handleTenantSelect,
|
|
onOpenSettings: openSettings,
|
|
}),
|
|
[
|
|
activeCorrespondentFilters,
|
|
activeTagFilters,
|
|
appStatus,
|
|
clearFilters,
|
|
correspondents,
|
|
currentTenantId,
|
|
folderClickHandlers,
|
|
folderNodes,
|
|
handleFolderDelete,
|
|
handleFolderDragEnd,
|
|
handleFolderDragStart,
|
|
handleFolderRename,
|
|
handleLogout,
|
|
handleSearchChange,
|
|
handleSearchSubmit,
|
|
handleTenantSelect,
|
|
loading,
|
|
openSettings,
|
|
previewActive,
|
|
searchQuery,
|
|
draggedFolderId,
|
|
isFilterActive,
|
|
selectedFolder,
|
|
status,
|
|
tags,
|
|
tenantOptions,
|
|
tenantName,
|
|
toggleCorrespondentFilter,
|
|
toggleTagFilter,
|
|
handleTagCreate,
|
|
handleCorrespondentCreate,
|
|
handlePromptCreateFolder,
|
|
creatingFolder,
|
|
],
|
|
);
|
|
|
|
const handleDetailPanelClose = useCallback(() => {
|
|
closeDetailPanel();
|
|
}, [closeDetailPanel]);
|
|
|
|
const detailPanelProps = useMemo(
|
|
() => ({
|
|
selectedDocuments: detailPanelSelectedDocuments,
|
|
tags,
|
|
tagLookupById,
|
|
onTagAdd: handleTagAdd,
|
|
onTagRemove: handleTagRemove,
|
|
onRegenerateThumbnails: handleThumbnailRegeneration,
|
|
previewEntry: selectedPreviewEntry,
|
|
onOpenPreview: openDocumentPreview,
|
|
onBulkTagAdd: handleBulkTagAddFromDetail,
|
|
onBulkTagRemove: handleBulkTagRemoveFromDetail,
|
|
onBulkReanalyze: handleBulkSelectionReanalyze,
|
|
onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
|
|
onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
|
|
onPromoteSelection: promoteSelectionOrder,
|
|
activePreviewId,
|
|
onUpdateTitle: handleDocumentTitleUpdate,
|
|
onUpdateIssued: handleDocumentIssuedUpdate,
|
|
ensureAssetUrl,
|
|
getDocumentAsset,
|
|
ensurePreviewData,
|
|
correspondents,
|
|
onCorrespondentAdd: handleCorrespondentAdd,
|
|
onCorrespondentRemove: handleCorrespondentRemove,
|
|
resolveApiPath,
|
|
onFolderNavigate: selectFolder,
|
|
onClose: handleDetailPanelClose,
|
|
resolveFolderPath,
|
|
}),
|
|
[
|
|
activePreviewId,
|
|
correspondents,
|
|
detailPanelSelectedDocuments,
|
|
ensureAssetUrl,
|
|
ensurePreviewData,
|
|
getDocumentAsset,
|
|
handleBulkCorrespondentAdd,
|
|
handleBulkCorrespondentRemove,
|
|
handleBulkSelectionReanalyze,
|
|
handleBulkTagAddFromDetail,
|
|
handleBulkTagRemoveFromDetail,
|
|
handleCorrespondentAdd,
|
|
handleCorrespondentRemove,
|
|
handleDetailPanelClose,
|
|
handleDocumentTitleUpdate,
|
|
handleDocumentIssuedUpdate,
|
|
handleTagAdd,
|
|
handleTagRemove,
|
|
handleThumbnailRegeneration,
|
|
openDocumentPreview,
|
|
promoteSelectionOrder,
|
|
resolveFolderPath,
|
|
selectFolder,
|
|
selectedPreviewEntry,
|
|
tags,
|
|
tagLookupById,
|
|
],
|
|
);
|
|
|
|
const deskWorkspaceProps = useMemo(
|
|
() => ({
|
|
documents,
|
|
searchResults,
|
|
breadcrumbs,
|
|
currentFolderName,
|
|
viewMode: documentsViewMode,
|
|
onViewModeChange: handleDocumentsViewModeChange,
|
|
onExit: handleDeskExit,
|
|
onRefresh: refreshCurrentFolder,
|
|
onDocumentOpen: openDocumentPreview,
|
|
resolveThumbnailUrl: resolveThumbnailUrlForDoc,
|
|
onAssignTagToDocument: handleDocumentTagAttach,
|
|
onRemoveTagFromDocument: handleTagRemove,
|
|
ensureAssetUrl,
|
|
getDocumentAsset,
|
|
activeTagIds: activeTagFilters,
|
|
}),
|
|
[
|
|
documents,
|
|
searchResults,
|
|
breadcrumbs,
|
|
currentFolderName,
|
|
documentsViewMode,
|
|
handleDocumentsViewModeChange,
|
|
handleDeskExit,
|
|
refreshCurrentFolder,
|
|
openDocumentPreview,
|
|
resolveThumbnailUrlForDoc,
|
|
handleDocumentTagAttach,
|
|
handleTagRemove,
|
|
ensureAssetUrl,
|
|
getDocumentAsset,
|
|
activeTagFilters,
|
|
],
|
|
);
|
|
|
|
const contextValue = useMemo(
|
|
() => ({
|
|
token,
|
|
appStatus,
|
|
status,
|
|
setStatusMessage,
|
|
dropOverlayState,
|
|
handleLogout,
|
|
sidebarProps,
|
|
tags,
|
|
refreshTags,
|
|
handleTagUpdate,
|
|
handleTagDelete,
|
|
handleDocumentTagAttach,
|
|
correspondents,
|
|
refreshCorrespondents,
|
|
handleCorrespondentUpdate,
|
|
handleCorrespondentCreate,
|
|
handleCorrespondentDelete,
|
|
handleDocumentCorrespondentAttach,
|
|
handleCorrespondentRemove,
|
|
handleCorrespondentAdd,
|
|
webdavTokens,
|
|
webdavTokensLoading,
|
|
creatingWebdavToken,
|
|
deletingWebdavTokenId,
|
|
regeneratingWebdavTokenId,
|
|
refreshWebdavTokens,
|
|
createWebdavToken,
|
|
deleteWebdavToken,
|
|
regenerateWebdavToken,
|
|
webdavTokenSecret,
|
|
dismissCreatedWebdavToken,
|
|
passkeys,
|
|
passkeysSupported,
|
|
passkeysLoading,
|
|
registeringPasskey,
|
|
revokingPasskeyId,
|
|
refreshPasskeys,
|
|
registerPasskey,
|
|
revokePasskey,
|
|
previewActive,
|
|
previewWorkspaceDocument,
|
|
previewWorkspaceEntry,
|
|
previewDocumentId,
|
|
closeDocumentPreview,
|
|
handleThumbnailRegeneration,
|
|
documentsTableProps,
|
|
detailPanelProps,
|
|
documentsViewMode,
|
|
deskWorkspaceProps,
|
|
ensurePreviewData,
|
|
ensureAssetUrl,
|
|
resolveApiPath,
|
|
getDocumentAsset,
|
|
notifyApiError,
|
|
openTagsModal,
|
|
openCorrespondentsModal,
|
|
openSettings,
|
|
detailPanelOpen,
|
|
openDetailPanel,
|
|
}),
|
|
[
|
|
token,
|
|
appStatus,
|
|
status,
|
|
setStatusMessage,
|
|
dropOverlayState,
|
|
handleLogout,
|
|
sidebarProps,
|
|
tags,
|
|
refreshTags,
|
|
handleTagUpdate,
|
|
handleTagDelete,
|
|
handleDocumentTagAttach,
|
|
correspondents,
|
|
refreshCorrespondents,
|
|
handleCorrespondentUpdate,
|
|
handleCorrespondentCreate,
|
|
handleCorrespondentDelete,
|
|
handleDocumentCorrespondentAttach,
|
|
handleCorrespondentRemove,
|
|
handleCorrespondentAdd,
|
|
webdavTokens,
|
|
webdavTokensLoading,
|
|
creatingWebdavToken,
|
|
deletingWebdavTokenId,
|
|
regeneratingWebdavTokenId,
|
|
refreshWebdavTokens,
|
|
createWebdavToken,
|
|
deleteWebdavToken,
|
|
regenerateWebdavToken,
|
|
webdavTokenSecret,
|
|
dismissCreatedWebdavToken,
|
|
passkeys,
|
|
passkeysSupported,
|
|
passkeysLoading,
|
|
registeringPasskey,
|
|
revokingPasskeyId,
|
|
refreshPasskeys,
|
|
registerPasskey,
|
|
revokePasskey,
|
|
previewActive,
|
|
previewWorkspaceDocument,
|
|
previewWorkspaceEntry,
|
|
previewDocumentId,
|
|
closeDocumentPreview,
|
|
handleThumbnailRegeneration,
|
|
documentsTableProps,
|
|
detailPanelProps,
|
|
documentsViewMode,
|
|
deskWorkspaceProps,
|
|
ensurePreviewData,
|
|
ensureAssetUrl,
|
|
getDocumentAsset,
|
|
notifyApiError,
|
|
openTagsModal,
|
|
openCorrespondentsModal,
|
|
openSettings,
|
|
detailPanelOpen,
|
|
openDetailPanel,
|
|
],
|
|
);
|
|
|
|
if (['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) {
|
|
const shouldRememberLastLocation = appStatus !== 'logged-out';
|
|
return (
|
|
<Navigate
|
|
to="/account/login"
|
|
replace
|
|
state={
|
|
shouldRememberLastLocation
|
|
? { from: location.pathname + location.search }
|
|
: undefined
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AppShellContext.Provider value={contextValue}>
|
|
<div className="app-shell" ref={shellRef}>
|
|
<DropOverlay
|
|
active={dropOverlayState.active}
|
|
folderName={dropOverlayState.folderName}
|
|
/>
|
|
<Outlet />
|
|
{managementModals}
|
|
</div>
|
|
</AppShellContext.Provider>
|
|
);
|
|
};
|
|
|
|
|
|
export default AppLayout;
|