Merge remote-tracking branch 'ui/ui' into dev

This commit is contained in:
2025-11-24 20:53:41 +01:00
68 changed files with 1904 additions and 1296 deletions
+7 -110
View File
@@ -1,30 +1,18 @@
import { useCallback, useEffect, useRef } from 'react';
import type { MutableRefObject } from 'react';
import type { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
import { AxiosHeaders } from 'axios';
import { clearAuthToken, logoutSession, refreshSession, setAuthToken } from '../../lib/apiClient';
type AppStatus = string;
type AppDispatch = (action: { type: string; [key: string]: unknown }) => void;
type NotifyApiError = (error: unknown, fallbackMessage: string, variant?: string) => void;
type SetStatusMessage = (message: string, variant?: string) => void;
type SetLoading = (state: boolean) => void;
interface RetryableAxiosRequestConfig extends InternalAxiosRequestConfig {
_retry?: boolean;
}
interface UseAuthManagerArgs {
apiClient: AxiosInstance;
token?: string | null;
appStatus: AppStatus;
appDispatch: AppDispatch;
notifyApiError: NotifyApiError;
setStatusMessage: SetStatusMessage;
setLoading: SetLoading;
}
interface UseAuthManagerResult {
@@ -33,40 +21,22 @@ interface UseAuthManagerResult {
handleLogout: () => Promise<void>;
}
const ensureAxiosHeaders = (
headers?: InternalAxiosRequestConfig['headers'],
): AxiosHeaders => {
if (headers instanceof AxiosHeaders) {
return headers;
}
return AxiosHeaders.from(headers || {});
};
const setHeaderAuthorization = (config: InternalAxiosRequestConfig, token: string): void => {
const headers = ensureAxiosHeaders(config.headers);
headers.set('Authorization', `Bearer ${token}`);
config.headers = headers;
};
const useAuthManager = ({
apiClient,
token,
appStatus,
appDispatch,
notifyApiError,
setStatusMessage,
setLoading,
}: UseAuthManagerArgs): UseAuthManagerResult => {
const tokenRef = useRef<string | null>(token);
const refreshPromiseRef = useRef<Promise<string> | null>(null);
const initialRefreshAttemptedRef = useRef(Boolean(token));
const refreshAccessToken = useCallback(async (): Promise<string> => {
console.log('[Auth] Attempting to refresh access token…');
appDispatch({ type: 'TOKEN_REFRESH_START' });
try {
const { data } = await apiClient.post<{ access_token?: string; tenant?: unknown }>('/auth/refresh');
const data = await refreshSession();
if (data?.access_token) {
setAuthToken(data.access_token);
appDispatch({
type: 'TOKEN_REFRESH_SUCCESS',
token: data.access_token,
@@ -81,7 +51,7 @@ const useAuthManager = ({
appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: (error as Error)?.message || null });
throw error;
}
}, [apiClient, appDispatch]);
}, [appDispatch]);
useEffect(() => {
tokenRef.current = token;
@@ -95,90 +65,17 @@ const useAuthManager = ({
}
}, [token, appStatus, refreshAccessToken]);
useEffect(() => {
const requestInterceptor = apiClient.interceptors.request.use((config) => {
const currentToken = tokenRef.current;
if (currentToken) {
const headers = ensureAxiosHeaders(config.headers);
if (!headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${currentToken}`);
}
config.headers = headers;
}
return config;
});
const responseInterceptor = apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const axiosError = error as AxiosError & { config?: RetryableAxiosRequestConfig };
const { response, config } = axiosError;
if (!response || !config) {
return Promise.reject(error);
}
const status = response.status;
const 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;
setHeaderAuthorization(config, newToken);
console.log('[Auth] Retrying original request', url);
try {
return await apiClient(config);
} catch (retryError) {
if ((retryError as AxiosError)?.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 () => {
apiClient.interceptors.request.eject(requestInterceptor);
apiClient.interceptors.response.eject(responseInterceptor);
};
}, [apiClient, notifyApiError, refreshAccessToken]);
const handleLogout = useCallback(async () => {
try {
setLoading(true);
await apiClient.post('/auth/logout');
await logoutSession();
} catch (error) {
console.warn('[Auth] Failed to revoke refresh token during logout', error);
} finally {
setLoading(false);
clearAuthToken();
appDispatch({ type: 'LOGOUT' });
setStatusMessage('Logged out.', 'info');
}
}, [apiClient, appDispatch, setLoading, setStatusMessage]);
}, [appDispatch, setStatusMessage]);
return { tokenRef, refreshAccessToken, handleLogout };
};
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import type { DragEvent } from 'react';
import { isPlainObject, isFunctionValue } from '../../utils/typeGuards';
type Identifier = string | number;
type FolderIdentifier = Identifier | 'root';
type FolderIdentifier = string | 'root';
type FolderInput = FolderIdentifier | number;
interface DocumentLike {
id?: Identifier | null;
@@ -24,7 +24,7 @@ type HandleEntrySelectionFn = (
interface UseDocumentDragHandlersOptions {
selectedEntries: string[];
selectedDocumentIds: Identifier[];
selectedFolderIds: FolderIdentifier[];
selectedFolderIds: FolderInput[];
applySelection: ApplySelectionFn;
handleEntrySelection: HandleEntrySelectionFn;
documentLookup: Map<Identifier, DocumentLike>;
@@ -49,6 +49,10 @@ const useDocumentDragHandlers = ({
documentsViewMode,
}: UseDocumentDragHandlersOptions) => {
const dragPreviewRef = useRef<HTMLDivElement | null>(null);
const normalizedFolderIds = useMemo(
() => selectedFolderIds.map((id) => (id === 'root' ? 'root' : String(id))) as FolderIdentifier[],
[selectedFolderIds],
);
const destroyDragPreview = useCallback(() => {
const node = dragPreviewRef.current;
@@ -61,7 +65,7 @@ const useDocumentDragHandlers = ({
useEffect(() => destroyDragPreview, [destroyDragPreview]);
const createDragPreview = useCallback(
({ documents = [], folders = [] }: { documents?: DocumentLike[]; folders?: Array<FolderIdentifier | Identifier> } = {}) => {
({ documents = [], folders = [] }: { documents?: DocumentLike[]; folders?: FolderIdentifier[] } = {}) => {
destroyDragPreview();
const docEntries = (documents || []).filter(Boolean);
@@ -145,17 +149,7 @@ const useDocumentDragHandlers = ({
}
} else {
const payload = item.payload;
const folderId = (() => {
if (isPlainObject(payload) && 'id' in payload) {
return (payload as { id?: FolderIdentifier }).id ?? null;
}
const maybeTrim = (payload as { trim?: () => string })?.trim;
if (isFunctionValue(maybeTrim)) {
const nextValue = maybeTrim.call(payload);
return nextValue || null;
}
return null;
})();
const folderId = payload as FolderIdentifier;
const rowEl = folderId
? (document.getElementById(`folder-row-${folderId}`)
|| document.getElementById(`folder-card-${folderId}`))
@@ -300,28 +294,29 @@ const useDocumentDragHandlers = ({
);
const handleFolderDragStart = useCallback(
(event: DragEvent<HTMLElement>, folderId: FolderIdentifier) => {
if (folderId === 'root') {
(event: DragEvent<HTMLElement>, folderId: FolderInput) => {
const normalizedFolderId: FolderIdentifier = folderId === 'root' ? 'root' : String(folderId);
if (normalizedFolderId === 'root') {
return;
}
event.stopPropagation();
const folderKey = resolveFolderRowKey(folderId);
const folderKey = resolveFolderRowKey(normalizedFolderId);
const isAlreadySelected = folderKey ? selectedEntries.includes(folderKey) : false;
let effectiveFolderSelection: FolderIdentifier[] = selectedFolderIds;
let effectiveFolderSelection: FolderIdentifier[] = normalizedFolderIds;
let effectiveDocumentSelection: Identifier[] = selectedDocumentIds;
if (!isAlreadySelected && folderKey) {
effectiveFolderSelection = [folderId];
effectiveFolderSelection = [normalizedFolderId];
effectiveDocumentSelection = [];
handleEntrySelection(folderKey, { preventDefault: () => {} });
}
const uniqueFolders = effectiveFolderSelection.length
? Array.from(new Set(effectiveFolderSelection.filter(Boolean)))
: [folderId];
: [normalizedFolderId];
setDraggedFolderId(folderId);
setDraggedFolderId(normalizedFolderId);
if (effectiveDocumentSelection.length) {
setDraggedDocumentIds(effectiveDocumentSelection);
}
@@ -360,7 +355,7 @@ const useDocumentDragHandlers = ({
}
},
[
selectedFolderIds,
normalizedFolderIds,
selectedEntries,
selectedDocumentIds,
handleEntrySelection,
@@ -2,6 +2,17 @@ import { useCallback } from 'react';
import { isPlainObject } from '../../utils/typeGuards';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { DEFAULT_FOLDER_NAME, getRowId, isDocumentRowKey } from '../../app/appLayoutUtils';
import {
addDocumentTags,
createTag,
deleteDocumentTag,
deleteFolder,
moveDocumentsBulk,
moveDocumentToFolder,
queueDocumentReanalysis,
trashDocument,
updateDocument,
} from '../../lib/apiClient';
type DocumentId = string | number;
type FolderId = DocumentId | 'root';
@@ -35,12 +46,6 @@ type NotifyApiError = (error: unknown, fallbackMessage?: string) => void;
type SetStatusMessage = (message: string, level?: StatusLevel) => void;
interface ApiClient {
post<T = unknown>(url: string, data?: unknown, config?: Record<string, unknown>): Promise<{ data: T }>;
patch<T = unknown>(url: string, data?: unknown, config?: Record<string, unknown>): Promise<{ data: T }>;
delete<T = unknown>(url: string, config?: Record<string, unknown>): Promise<{ data: T }>;
}
interface Tag {
id: DocumentId;
label: string;
@@ -85,7 +90,6 @@ interface DocumentTagExtras {
interface DeleteOptions {
showMessage?: boolean;
manageLoading?: boolean;
}
interface TagAttachArgs {
@@ -101,11 +105,9 @@ interface TagRemoveOptions {
interface FolderDeleteOptions {
showMessage?: boolean;
manageLoading?: boolean;
}
interface UseDocumentMutationsArgs {
api: ApiClient;
token?: string | null;
documentLookup: Map<DocumentId, DocumentLike>;
folderLabelMap: Map<FolderId, string>;
@@ -125,7 +127,6 @@ interface UseDocumentMutationsArgs {
focusedRowKey: string | null;
notifyApiError: NotifyApiError;
setStatusMessage: SetStatusMessage;
setLoading: (next: boolean) => void;
mapDocumentCaches: MapDocumentCaches;
applySelectedFolder: ApplySelectedFolder;
folderNodes: Map<FolderId, FolderNode>;
@@ -181,7 +182,6 @@ const normalizeDocumentId = (value: unknown): DocumentId | null => {
};
const useDocumentMutations = ({
api,
token,
documentLookup,
folderLabelMap,
@@ -201,7 +201,6 @@ const useDocumentMutations = ({
focusedRowKey,
notifyApiError,
setStatusMessage,
setLoading,
mapDocumentCaches,
applySelectedFolder,
folderNodes,
@@ -282,16 +281,11 @@ const useDocumentMutations = ({
const id = getRowId(key);
return id ? !uniqueIdSet.has(id as DocumentId) : true;
});
setLoading(true);
try {
if (uniqueIds.length === 1) {
await api.patch(`/documents/${uniqueIds[0]}/folder`, { folder_id: target });
await moveDocumentToFolder(uniqueIds[0], target);
} else {
await api.post('/documents/bulk/move', {
document_ids: uniqueIds,
folder_id: target,
});
await moveDocumentsBulk(uniqueIds, target);
}
const count = uniqueIds.length;
@@ -377,12 +371,9 @@ const useDocumentMutations = ({
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to move documents.';
notifyApiError(error, message);
} finally {
setLoading(false);
}
},
[
api,
documentLookup,
folderLabelMap,
ensureFolderData,
@@ -400,7 +391,6 @@ const useDocumentMutations = ({
focusedRowKey,
notifyApiError,
setStatusMessage,
setLoading,
mapDocumentCaches,
],
);
@@ -411,25 +401,20 @@ const useDocumentMutations = ({
setStatusMessage('Log in to manage assets.', 'error');
return;
}
setLoading(true);
try {
await api.post(`/documents/${documentId}/assets`, null, {
params: { force: true },
});
await queueDocumentReanalysis(documentId, { force: true });
setStatusMessage('Document re-analysis queued.', 'info');
await refreshCurrentFolder();
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to request thumbnail generation.';
notifyApiError(error, message);
} finally {
setLoading(false);
}
},
[api, token, refreshCurrentFolder, notifyApiError, setStatusMessage, setLoading],
[token, refreshCurrentFolder, notifyApiError, setStatusMessage],
);
const handleDocumentsDelete = useCallback(
async (documentIds: DocumentId[], { showMessage = true, manageLoading = true }: DeleteOptions = {}) => {
async (documentIds: DocumentId[], { showMessage = true }: DeleteOptions = {}) => {
if (!documentIds || documentIds.length === 0) {
return false;
}
@@ -439,12 +424,8 @@ const useDocumentMutations = ({
return false;
}
if (manageLoading) {
setLoading(true);
}
try {
await Promise.all(documentIds.map((documentId) => api.post(`/documents/${documentId}/trash`)));
await Promise.all(documentIds.map((documentId) => trashDocument(documentId)));
removeDocumentsFromCaches(documentIds);
@@ -461,14 +442,9 @@ const useDocumentMutations = ({
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to delete documents.';
notifyApiError(error, message);
return false;
} finally {
if (manageLoading) {
setLoading(false);
}
}
},
[
api,
token,
documentLookup,
removeDocumentsFromCaches,
@@ -476,7 +452,6 @@ const useDocumentMutations = ({
closeDocumentPreview,
notifyApiError,
setStatusMessage,
setLoading,
],
);
@@ -487,10 +462,8 @@ const useDocumentMutations = ({
setStatusMessage('Document title cannot be empty.', 'error');
return false;
}
setLoading(true);
try {
const { data } = await api.patch(`/documents/${documentId}`, { title: trimmed });
const data = await updateDocument(documentId, { title: trimmed });
const updatedDocument = extractDocumentFromResponse?.(data);
if (updatedDocument && ingestDocuments) {
@@ -510,27 +483,21 @@ const useDocumentMutations = ({
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to update document title.';
notifyApiError(error, message);
return false;
} finally {
setLoading(false);
}
},
[
api,
extractDocumentFromResponse,
ingestDocuments,
notifyApiError,
setLoading,
setStatusMessage,
updateDocumentCaches,
],
);
const handleDocumentIssuedUpdate = useCallback(
async (documentId: DocumentId, nextIssuedDate: number | null) => {
setLoading(true);
const payload = { issued_at: nextIssuedDate || null };
async (documentId: DocumentId, nextIssuedDate: number | null) => {const payload = { issued_at: nextIssuedDate || null };
try {
const { data } = await api.patch(`/documents/${documentId}`, payload);
const data = await updateDocument(documentId, payload);
const updatedDocument = extractDocumentFromResponse?.(data);
if (updatedDocument && ingestDocuments) {
@@ -551,16 +518,12 @@ const useDocumentMutations = ({
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to update issued date.';
notifyApiError(error, message);
return false;
} finally {
setLoading(false);
}
},
[
api,
extractDocumentFromResponse,
ingestDocuments,
notifyApiError,
setLoading,
setStatusMessage,
updateDocumentCaches,
],
@@ -585,7 +548,7 @@ const useDocumentMutations = ({
};
try {
await api.post(`/documents/${documentId}/tags`, { tag_ids: [cachedTag.id] });
await addDocumentTags(documentId, [cachedTag.id]);
updateDocumentCaches(documentId, (doc) => {
if (!doc) {
return doc;
@@ -604,7 +567,7 @@ const useDocumentMutations = ({
return false;
}
},
[api, notifyApiError, setStatusMessage, updateDocumentCaches],
[notifyApiError, setStatusMessage, updateDocumentCaches],
);
const handleDocumentTagAdd = useCallback(
@@ -622,8 +585,8 @@ const useDocumentMutations = ({
}
try {
if (!tag) {
const payload = tagManager.buildPayload({ label: normalizedLabel });
const { data } = await api.post('/tags', payload);
const payload = tagManager.buildPayload({ label: normalizedLabel }) as { label: string; color?: string | null };
const data = await createTag(payload);
tag = data as Tag;
await refreshTags();
}
@@ -638,7 +601,7 @@ const useDocumentMutations = ({
notifyApiError(error, 'Failed to assign tag.');
}
},
[api, tags, refreshTags, attachTagToDocument, notifyApiError, tagManager],
[tags, refreshTags, attachTagToDocument, notifyApiError, tagManager],
);
const handleDocumentTagAttach = useCallback(
@@ -707,7 +670,7 @@ const useDocumentMutations = ({
}
try {
await api.delete(`/documents/${documentId}/tags/${tagId}`);
await deleteDocumentTag(documentId, tagId);
applyTagRemovalToCaches(documentId, tagId);
if (refreshTagList) {
await refreshTags();
@@ -722,11 +685,11 @@ const useDocumentMutations = ({
return false;
}
},
[api, applyTagRemovalToCaches, notifyApiError, refreshTags, setStatusMessage],
[applyTagRemovalToCaches, notifyApiError, refreshTags, setStatusMessage],
);
const handleFolderDelete = useCallback(
async (folderId?: FolderId, { showMessage = true, manageLoading = true }: FolderDeleteOptions = {}) => {
async (folderId?: FolderId, { showMessage = true }: FolderDeleteOptions = {}) => {
if (!token) {
if (showMessage) {
setStatusMessage('Log in to manage folders.', 'error');
@@ -740,10 +703,6 @@ const useDocumentMutations = ({
return false;
}
if (manageLoading) {
setLoading(true);
}
try {
const contents = await ensureFolderData(folderId, {
force: true,
@@ -758,7 +717,7 @@ const useDocumentMutations = ({
return false;
}
await api.delete(`/folders/${folderId}`);
await deleteFolder(folderId);
setFolderNodes((prev: Map<FolderId, FolderNode>) => {
const next = new Map<FolderId, FolderNode>(prev);
@@ -809,14 +768,9 @@ const useDocumentMutations = ({
setStatusMessage(message, 'error');
}
return false;
} finally {
if (manageLoading) {
setLoading(false);
}
}
},
[
api,
token,
ensureFolderData,
selectedFolder,
@@ -827,7 +781,6 @@ const useDocumentMutations = ({
setFolderContents,
notifyApiError,
setStatusMessage,
setLoading,
],
);
@@ -24,7 +24,6 @@ interface UseDocumentTaggingArgs {
resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[];
notifyApiError: (error: unknown, message: string) => void;
setStatusMessage: (message: string, variant?: string) => void;
setLoading: (state: boolean) => void;
updateDocumentCaches?: (id: Identifier, updater: (doc: TagRecord | null) => TagRecord | null) => void;
}
@@ -50,7 +49,6 @@ const useDocumentTagging = ({
resolveTargetDocumentIds,
notifyApiError,
setStatusMessage,
setLoading,
updateDocumentCaches,
}: UseDocumentTaggingArgs) => {
const bulkTagOperation = useCallback(
@@ -80,7 +78,6 @@ const useDocumentTagging = ({
}).filter(Boolean);
}
setLoading(true);
try {
if (action === 'add') {
const createdIds: Identifier[] = [];
@@ -175,8 +172,6 @@ const useDocumentTagging = ({
(action === 'add' ? 'Failed to assign tags.' : 'Failed to remove tags.');
notifyApiError(error, message);
return { ok: false, reason: 'request-failed' };
} finally {
setLoading(false);
}
},
[
@@ -184,7 +179,6 @@ const useDocumentTagging = ({
tags,
refreshTags,
notifyApiError,
setLoading,
tagManager,
apiClient,
updateDocumentCaches,
@@ -265,7 +259,6 @@ const useDocumentTagging = ({
return;
}
setLoading(true);
try {
const response = await apiClient.post<{ queued?: number }>(
'/documents/bulk/reanalyze',
@@ -286,11 +279,9 @@ const useDocumentTagging = ({
const message =
error.response?.data?.error || 'Failed to queue document re-analysis.';
notifyApiError(error, message);
} finally {
setLoading(false);
}
},
[resolveTargetDocumentIds, notifyApiError, setStatusMessage, setLoading, apiClient],
[resolveTargetDocumentIds, notifyApiError, setStatusMessage, apiClient],
);
return {
@@ -2,6 +2,7 @@ import { useCallback, useRef, useState } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import useFileDrop from './useFileDrop';
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils';
import { fetchDocument } from '../../lib/apiClient';
type Identifier = string | number;
type FolderId = Identifier | 'root' | null;
@@ -101,7 +102,6 @@ interface UseDocumentUploadsArgs {
currentFolderName?: string | null;
ensureFolderData: (folderId: FolderId, options?: { force?: boolean; prefetchDepth?: number }) => Promise<void>;
refreshCurrentFolder: () => Promise<void>;
setLoading: (state: boolean) => void;
shellRef: MutableRefObject<HTMLElement | null>;
notifyApiError?: NotifyApiError;
setStatusMessage?: SetStatusMessage;
@@ -132,7 +132,6 @@ const useDocumentUploads = ({
currentFolderName,
ensureFolderData,
refreshCurrentFolder,
setLoading,
shellRef,
notifyApiError,
setStatusMessage,
@@ -174,8 +173,7 @@ const useDocumentUploads = ({
let conflictDocument = null;
if (conflictId) {
try {
const { data } = await apiClient.get(`/documents/${conflictId}`);
conflictDocument = (data as any)?.document ?? data ?? null;
conflictDocument = await fetchDocument(conflictId);
} catch (fetchError) {
console.warn('[Uploads] failed to fetch conflict document', fetchError);
}
@@ -395,8 +393,6 @@ const useDocumentUploads = ({
return;
}
setLoading(true);
try {
folderPathCacheRef.current.clear();
@@ -472,8 +468,6 @@ const useDocumentUploads = ({
Object.assign(item, patch);
});
console.error('[Uploads] batch failed', error);
} finally {
setLoading(false);
}
},
[
@@ -483,7 +477,6 @@ const useDocumentUploads = ({
refreshCurrentFolder,
selectedFolder,
ensureFolderData,
setLoading,
appendQueueItems,
updateQueueItem,
],
@@ -16,9 +16,10 @@ import {
import AssetManager, { getAssetFromVersion } from '../../asset_manager';
import useApiError from '../useApiError';
import TagManager from '../../tag_manager';
import usePasskeys from '../../settings/usePasskeys';
import { useManagementModals } from '../../app/useManagementModals';
import { api, useAppDispatch, useAppState } from '../../app/appState';
import { useAppDispatch, useAppState } from '../../app/appState';
import { fetchAsset } from '../../lib/apiClient';
import { useApi } from '../../app/ApiContext';
import useWorkspaceSelection from '../../app/useWorkspaceSelection';
import { useEntryPointer as useEntryPointerCore } from '../../documents/useEntryPointer';
import { isTagTransferEvent } from '../../documents/tagTransfer';
@@ -29,7 +30,6 @@ import useDocumentPreview from '../../app/useDocumentPreview';
import useSidebarProps from '../../sidebar/useSidebarProps';
import {
ASSET_PRESIGN_TTL_MS,
DEFAULT_FOLDER_NAME,
DEFAULT_SORT_DIRECTION,
DEFAULT_SORT_FIELD,
createRootNode,
@@ -44,18 +44,20 @@ import {
import useDocumentsSearch from '../../app/useDocumentsSearch';
import useDocumentsStore from './store/useDocumentsStore';
import useAuthManager from './useAuthManager';
import useTags from './useTags';
import useCorrespondents from './useCorrespondents';
import useTenantManager from './useTenantManager';
import useDocuments from './useDocuments';
import { fetchDocument } from '../../lib/apiClient';
import useFolderTree from './useFolderTree';
import useFolderTreeActions from './useFolderTreeActions';
import useDocumentTagging from './useDocumentTagging';
import useDocumentCorrespondentActions from './useDocumentCorrespondentActions';
import useDocumentUploads from './useDocumentUploads';
import useDocumentDragHandlers from './useDocumentDragHandlers';
import useDocumentMutations from './useDocumentMutations';
import useDetailWorkspace from '../../detail/useDetailWorkspace';
import useWorkspaceTaxonomies from './useWorkspaceTaxonomies';
import useWorkspaceBreadcrumbs from './useWorkspaceBreadcrumbs';
import useWorkspaceDeskProps from './useWorkspaceDeskProps';
import useWorkspaceSelectionSync from './useWorkspaceSelectionSync';
const EntryType = Object.freeze({
document: 'document',
@@ -152,6 +154,7 @@ const useDocumentsWorkspace = ({
tenant,
tenants: tenantOptionsRaw = [],
} = appState;
const { client: apiClient } = useApi();
const tenantRecord = (tenant ?? null) as TenantOption | null;
const tenantNameCandidate = tenantRecord?.name ?? tenantRecord?.slug ?? null;
@@ -175,16 +178,12 @@ const useDocumentsWorkspace = ({
reportApiError(error, { message: fallbackMessage, variant }),
[reportApiError],
);
const [loading, setLoading] = useState(false);
const [creatingFolder, setCreatingFolder] = useState(false);
const { tokenRef, handleLogout } = useAuthManager({
apiClient: api,
token,
appStatus,
appDispatch,
notifyApiError,
setStatusMessage,
setLoading,
});
const breadcrumbFetchRef = useRef(new Set());
@@ -219,7 +218,11 @@ const useDocumentsWorkspace = ({
const shellRef = useRef(null);
const assetManagerRef = useRef(null);
if (!assetManagerRef.current) {
assetManagerRef.current = new AssetManager({ api, assetPresignTtlMs: ASSET_PRESIGN_TTL_MS });
const fetcher = async (id: Identifier) => {
const asset = await fetchAsset(id);
return (asset as unknown) as any;
};
assetManagerRef.current = new AssetManager({ fetchAsset: fetcher, assetPresignTtlMs: ASSET_PRESIGN_TTL_MS });
}
const assetManager = assetManagerRef.current;
@@ -238,7 +241,7 @@ const useDocumentsWorkspace = ({
if (!documentId) {
return null;
}
const { data } = await api.get(`/documents/${documentId}`);
const data = await fetchDocument(documentId);
return extractDocumentFromResponse(data);
},
[extractDocumentFromResponse],
@@ -345,7 +348,7 @@ const useDocumentsWorkspace = ({
isInvalidFolderDrop,
} = useFolderTree({
initialSelectedFolder: routeFolderId || 'root',
apiClient: api,
apiClient,
tenantIdRef,
documentsSortFieldRef: activeSortFieldRef,
documentsSortDirectionRef: activeSortDirectionRef,
@@ -368,7 +371,7 @@ const useDocumentsWorkspace = ({
isFilterActive,
documentsFilterValue,
} = useDocumentsSearch({
api,
api: apiClient,
token,
selectedFolder,
navigate,
@@ -378,7 +381,6 @@ const useDocumentsWorkspace = ({
documentsSortField,
documentsSortDirection,
notifyApiError,
setLoading,
setSearchIncludeDescendants,
documentsManager,
});
@@ -449,8 +451,6 @@ const useDocumentsWorkspace = ({
routeDocumentId: previewDocumentId,
documentsManager,
selectedFolder,
api,
resolveApiPath,
notifyApiError,
navigate,
locationPathname: location.pathname,
@@ -478,17 +478,7 @@ const useDocumentsWorkspace = ({
const bootstrapInitializedRef = useRef(false);
const detailFolderFetchRef = useRef(new Set());
useEffect(() => {
if (!showingSearchResults) {
return;
}
setSelectedEntries([]);
setSelectionOrder([]);
selectionOrderRef.current = [];
selectionAnchorRef.current = null;
setFocusedDocumentId(null);
}, [
useWorkspaceSelectionSync({
showingSearchResults,
searchQuery,
setSelectedEntries,
@@ -496,7 +486,11 @@ const useDocumentsWorkspace = ({
selectionOrderRef,
selectionAnchorRef,
setFocusedDocumentId,
]);
selectedDocumentIds,
activePreviewId,
setActivePreviewId,
selectionInitializedRef,
});
const {
tags,
@@ -505,59 +499,17 @@ const useDocumentsWorkspace = ({
handleTagUpdate,
handleTagDelete,
setTags,
} = useTags({
apiClient: api,
notifyApiError,
setStatusMessage,
tagManager,
tenantIdRef,
setActiveTagFilters,
mapDocumentCaches,
});
useEffect(() => {
tenantIdRef.current = currentTenantId;
}, [currentTenantId]);
useEffect(() => {
if (!selectedDocumentIds.length) {
return;
}
if (!selectedDocumentIds.includes(activePreviewId)) {
setActivePreviewId(selectedDocumentIds[selectedDocumentIds.length - 1]);
}
selectionInitializedRef.current = true;
}, [selectedDocumentIds, activePreviewId, selectionInitializedRef]);
const tagLookupById = useMemo(() => {
const map = new Map();
tags.forEach((tag) => {
if (tag?.id) {
map.set(tag.id, tag);
}
});
return map;
}, [tags]);
const {
tagLookupById,
correspondents,
refreshCorrespondents,
handleCorrespondentCreate,
handleCorrespondentUpdate,
handleCorrespondentDelete,
setCorrespondents,
} = useCorrespondents({
apiClient: api,
notifyApiError,
setStatusMessage,
tenantIdRef,
mapDocumentCaches,
});
const {
correspondentLookupByName,
handleDocumentCorrespondentAttach,
handleCorrespondentRemove,
handleCorrespondentAdd,
passkeys,
passkeysSupported,
passkeysLoading,
@@ -566,10 +518,16 @@ const useDocumentsWorkspace = ({
refreshPasskeys,
registerPasskey,
revokePasskey,
} = usePasskeys({
api,
} = useWorkspaceTaxonomies({
apiClient,
notifyApiError,
setStatusMessage,
tagManager,
tenantIdRef,
currentTenantId,
setActiveTagFilters,
mapDocumentCaches,
updateDocumentCaches,
token,
});
@@ -587,33 +545,25 @@ const useDocumentsWorkspace = ({
);
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 contents = await ensureFolderData(selectedFolder, {
force: true,
prefetchDepth: 1,
});
applySelectedFolder(selectedFolder, contents);
}, [selectedFolder, ensureFolderData, applySelectedFolder]);
const {
handleBulkTagAddFromDetail,
handleBulkTagRemoveFromDetail,
handleBulkSelectionReanalyze,
} = useDocumentTagging({
apiClient: api,
apiClient,
tags,
tagManager,
refreshTags,
resolveTargetDocumentIds,
notifyApiError,
setStatusMessage,
setLoading,
updateDocumentCaches,
});
@@ -625,7 +575,7 @@ const useDocumentsWorkspace = ({
clearUploadQueue,
resetUploadsState,
} = useDocumentUploads({
apiClient: api,
apiClient,
token,
selectedFolder,
currentFolderName,
@@ -633,7 +583,6 @@ const useDocumentsWorkspace = ({
refreshCurrentFolder,
notifyApiError,
setStatusMessage,
setLoading,
shellRef,
});
@@ -656,20 +605,6 @@ const useDocumentsWorkspace = ({
documentsViewMode,
});
const {
correspondentLookupByName,
handleDocumentCorrespondentAttach,
handleCorrespondentRemove,
handleCorrespondentAdd,
} = useDocumentCorrespondentActions({
apiClient: api,
correspondents,
handleCorrespondentCreate,
notifyApiError,
setStatusMessage,
updateDocumentCaches,
});
useEffect(() => {
if (!activeSortRefreshReadyRef.current) {
activeSortRefreshReadyRef.current = true;
@@ -816,7 +751,6 @@ const useDocumentsWorkspace = ({
handleDocumentIssuedUpdate,
handleTagRemove,
} = useDocumentMutations({
api,
token,
documentLookup,
folderLabelMap,
@@ -836,7 +770,6 @@ const useDocumentsWorkspace = ({
focusedRowKey,
notifyApiError,
setStatusMessage,
setLoading,
mapDocumentCaches,
applySelectedFolder,
folderNodes,
@@ -862,7 +795,6 @@ const useDocumentsWorkspace = ({
handleFolderDelete,
folderClickHandlers,
} = useFolderTreeActions({
api,
token,
folderNodes,
setFolderNodes,
@@ -874,7 +806,6 @@ const useDocumentsWorkspace = ({
applySelectedFolder,
notifyApiError,
setStatusMessage,
setLoading,
setFolderContents,
setCurrentFolder,
setSearchResultIds,
@@ -913,18 +844,10 @@ const useDocumentsWorkspace = ({
isFolderRowKey,
});
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]);
await Promise.all([refreshTags(), refreshCorrespondents()]);
const initialFolder = routeFolderId && routeFolderId !== 'root' ? routeFolderId : 'root';
await loadFolder(initialFolder, {} );
}, [refreshTags, refreshCorrespondents, routeFolderId, loadFolder]);
useEffect(() => {
if (!token) {
@@ -997,7 +920,6 @@ const useDocumentsWorkspace = ({
handleBulkCorrespondentRemove,
handleDeleteSelection,
} = useBulkDocumentActions({
api,
resolveTargetDocumentIds,
correspondentLookupByName,
handleCorrespondentCreate,
@@ -1007,7 +929,6 @@ const useDocumentsWorkspace = ({
handleDocumentsDelete,
handleFolderDelete,
clearDocumentSelection,
setLoading,
updateDocumentCaches,
});
@@ -1233,7 +1154,6 @@ const useDocumentsWorkspace = ({
inspectDocument,
previewActive,
previewWorkspaceDocument,
documentLink,
resolveFolderPath,
} = useDetailWorkspace({
documents: viewDocuments,
@@ -1244,7 +1164,6 @@ const useDocumentsWorkspace = ({
ensureFolderData,
detailPanelControlRef,
detailFolderFetchRef,
documentLinks,
previewDocumentId,
activePreviewId,
openDocumentPreview: openDocumentPreviewForDetail,
@@ -1295,94 +1214,21 @@ const useDocumentsWorkspace = ({
},
});
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]);
const breadcrumbs = useWorkspaceBreadcrumbs({
selectedFolder,
folderNodes,
currentFolder,
breadcrumbFetchRef,
ensureFolderData,
});
const { handleTenantSelect } = useTenantManager({
apiClient: api,
apiClient,
appDispatch,
currentTenantId,
resetWorkspaceState,
setStatusMessage,
notifyApiError,
setLoading,
refreshTags,
refreshCorrespondents,
loadFolder,
@@ -1393,93 +1239,27 @@ const useDocumentsWorkspace = ({
});
const handleDeskDocumentStackSelect = useCallback(
(docIds: Array<Identifier | string>) => {
if (!Array.isArray(docIds) || docIds.length === 0) {
return;
}
const rowKeys = docIds
.map((id) => resolveDocumentRowKey(id as Identifier))
.filter((value): value is string => typeof value === 'string');
if (!rowKeys.length) {
return;
}
const nextKeys = [...selectedEntries];
rowKeys.forEach((key) => {
if (!nextKeys.includes(key)) {
nextKeys.push(key);
}
});
const anchor = (rowKeys[0]
|| selectionAnchorRef.current
|| nextKeys[nextKeys.length - 1]) as Identifier | string | null;
applySelection(nextKeys, {
anchor,
interactedKeys: rowKeys,
});
},
[applySelection, selectedEntries, selectionAnchorRef],
);
const deskViewId = useMemo(() => {
if (showingSearchResults) {
const trimmedQuery = searchQuery.trim();
const tagsKey = [...activeTagFilters].sort().join(',');
const correspondentsKey = [...activeCorrespondentFilters].sort().join(',');
return `search:${trimmedQuery}|tags:${tagsKey}|corr:${correspondentsKey}`;
}
const folderKey = selectedFolder && selectedFolder !== '' ? selectedFolder : 'root';
return `folder:${folderKey}`;
}, [
const deskWorkspaceProps = useWorkspaceDeskProps({
viewDocuments,
inspectDocumentForDesk,
handleEntryPointer: handleEntryPointerCore,
selectedEntries,
selectionAnchorRef,
applySelection,
resolveDocumentRowKey,
showingSearchResults,
searchQuery,
activeTagFilters,
activeCorrespondentFilters,
selectedFolder,
]);
const deskWorkspaceProps = useMemo(
() => ({
documents: viewDocuments,
onInspectDocument: inspectDocumentForDesk,
onEntryPointer: handleEntryPointerCore,
onDocumentStackSelect: handleDeskDocumentStackSelect,
onPromoteSelection: promoteSelectionOrder,
onAssignTagToDocument: handleDocumentTagDrop,
ensureAssetUrl,
getDocumentAsset,
activeTagIds: activeTagFilters,
selectedDocumentIds,
onClearSelection: clearDocumentSelection,
tenantId: currentTenantId,
viewId: deskViewId,
documentLinks,
ensureDownloadUrl,
}),
[
viewDocuments,
inspectDocumentForDesk,
handleEntryPointerCore,
handleDeskDocumentStackSelect,
promoteSelectionOrder,
handleDocumentTagDrop,
ensureAssetUrl,
getDocumentAsset,
activeTagFilters,
selectedDocumentIds,
clearDocumentSelection,
currentTenantId,
deskViewId,
documentLinks,
ensureDownloadUrl,
],
);
promoteSelectionOrder,
handleDocumentTagDrop,
ensureAssetUrl,
getDocumentAsset,
currentTenantId,
documentLinks,
ensureDownloadUrl,
});
const documentsPanelProps = useDocumentsPanelProps({
currentFolderName,
@@ -1513,7 +1293,7 @@ const useDocumentsWorkspace = ({
clearDocumentSelection,
handleDeleteSelection,
handleEntryPointerCore,
inspectDocument,
onDocumentActivate: inspectDocument,
tags,
correspondents,
documentLookup,
@@ -1554,7 +1334,6 @@ const useDocumentsWorkspace = ({
correspondents,
handleCorrespondentCreate,
appStatus,
loading,
previewActive,
handleLogout,
status,
@@ -1599,7 +1378,6 @@ const useDocumentsWorkspace = ({
revokePasskey,
previewActive,
previewWorkspaceDocument,
documentLink,
previewDocumentId,
closeDocumentPreview,
handleThumbnailRegeneration,
@@ -1650,7 +1428,6 @@ const useDocumentsWorkspace = ({
revokePasskey,
previewActive,
previewWorkspaceDocument,
documentLink,
previewDocumentId,
closeDocumentPreview,
handleThumbnailRegeneration,
@@ -1,6 +1,12 @@
import { useCallback, useMemo } from 'react';
import type { DragEvent } from 'react';
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils';
import {
createFolder,
deleteFolder,
moveFolder as moveFolderRequest,
renameFolder as renameFolderRequest,
} from '../../lib/apiClient';
type FolderId = string | number;
type FolderKey = FolderId | 'root';
@@ -22,12 +28,6 @@ interface FolderContentsState {
[key: string]: unknown;
}
interface ApiClient {
patch: (url: string, data?: unknown) => Promise<any>;
post: (url: string, data?: unknown) => Promise<{ data: any }>;
delete: (url: string) => Promise<any>;
}
interface EnsureFolderOptions {
force?: boolean;
includeDocuments?: boolean;
@@ -35,7 +35,6 @@ interface EnsureFolderOptions {
}
interface LoadFolderOptions {
showLoading?: boolean;
preserveSearch?: boolean;
}
@@ -53,7 +52,6 @@ interface FolderClickHandlers {
}
interface UseFolderTreeActionsOptions {
api: ApiClient;
token?: string | null;
folderNodes: Map<FolderKey, FolderNode>;
setFolderNodes: (updater: (prev: Map<FolderKey, FolderNode>) => Map<FolderKey, FolderNode>) => void;
@@ -65,7 +63,6 @@ interface UseFolderTreeActionsOptions {
applySelectedFolder: (folderId: FolderKey, contents: any) => void;
notifyApiError: (error: unknown, message?: string) => void;
setStatusMessage: (message: string, level?: string) => void;
setLoading: (value: boolean) => void;
setFolderContents: (
updater: (prev: Map<FolderKey, FolderContentsState>) => Map<FolderKey, FolderContentsState>,
) => void;
@@ -84,7 +81,6 @@ interface UseFolderTreeActionsOptions {
}
const useFolderTreeActions = ({
api,
token,
folderNodes,
setFolderNodes,
@@ -96,7 +92,6 @@ const useFolderTreeActions = ({
applySelectedFolder,
notifyApiError,
setStatusMessage,
setLoading,
setFolderContents,
setCurrentFolder,
setSearchResultIds,
@@ -129,7 +124,7 @@ const useFolderTreeActions = ({
const parent_id = targetKey === 'root' ? null : targetKey;
try {
await api.patch(`/folders/${folderId}`, { parent_id });
await moveFolderRequest(folderId, parent_id);
setFolderNodes((prev) => {
const next = new Map(prev);
@@ -202,7 +197,6 @@ const useFolderTreeActions = ({
}
},
[
api,
ensureFolderData,
folderNodes,
notifyApiError,
@@ -214,12 +208,11 @@ const useFolderTreeActions = ({
);
const loadFolder = useCallback(
async (folderId: FolderKey | null, { showLoading = true, preserveSearch = false }: LoadFolderOptions = {}) => {
async (folderId: FolderKey | null, { preserveSearch = false }: LoadFolderOptions = {}) => {
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') {
@@ -239,8 +232,6 @@ const useFolderTreeActions = ({
}
} catch (error) {
notifyApiError(error, 'Failed to load folder contents.');
} finally {
if (showLoading) setLoading(false);
}
},
[
@@ -249,7 +240,6 @@ const useFolderTreeActions = ({
ensureFolderData,
expandFolderAncestors,
notifyApiError,
setLoading,
setSearchResultIds,
setSelectedFolder,
],
@@ -292,10 +282,8 @@ const useFolderTreeActions = ({
setStatusMessage('Folder name cannot be empty.', 'error');
return false;
}
setLoading(true);
try {
await api.patch(`/folders/${folderId}`, { name: trimmed });
await renameFolderRequest(folderId, trimmed);
setFolderNodes((prev) => {
const next = new Map(prev);
@@ -326,17 +314,13 @@ const useFolderTreeActions = ({
const message = error.response?.data?.error || 'Failed to rename folder.';
notifyApiError(error, message);
return false;
} finally {
setLoading(false);
}
},
[
api,
notifyApiError,
setCurrentFolder,
setFolderContents,
setFolderNodes,
setLoading,
setStatusMessage,
token,
],
@@ -359,33 +343,37 @@ const useFolderTreeActions = ({
setCreatingFolder(true);
let succeeded = false;
try {
const { data } = await api.post('/folders', payload);
const data = await createFolder(payload);
const folderData = (data as { folder?: { id?: FolderKey; name?: string; parent_id?: FolderKey | null; children?: FolderKey[] } }).folder;
if (!folderData?.id) {
throw new Error('Folder creation failed.');
}
setStatusMessage('Folder created.', 'success');
setFolderNodes((prev) => {
const next = new Map(prev);
const parentId = payload.parent_id || 'root';
const parentId = folderData.parent_id ?? payload.parent_id ?? 'root';
const parentNode = next.get(parentId);
if (parentNode) {
next.set(parentId, {
...parentNode,
children: parentNode.children.concat([data.folder.id]),
children: parentNode.children.concat([folderData.id]),
loaded: true,
hasChildren: true,
});
}
next.set(data.folder.id, {
id: data.folder.id,
name: data.folder.name,
next.set(folderData.id, {
id: folderData.id,
name: folderData.name ?? payload.name,
parentId: parentId,
children: [],
children: folderData.children || [],
expanded: false,
loaded: false,
hasChildren: false,
hasChildren: Array.isArray(folderData.children) ? folderData.children.length > 0 : false,
});
return next;
});
await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 });
await selectFolder(data.folder.id, { immediate: true });
await selectFolder(folderData.id, { immediate: true });
succeeded = true;
return true;
} catch (error) {
@@ -400,7 +388,6 @@ const useFolderTreeActions = ({
}
},
[
api,
ensureFolderData,
notifyApiError,
selectFolder,
@@ -413,7 +400,7 @@ const useFolderTreeActions = ({
);
const handleFolderDelete = useCallback(
async (folderId: FolderKey, { showMessage = true, manageLoading = true }: { showMessage?: boolean; manageLoading?: boolean } = {}) => {
async (folderId: FolderKey, { showMessage = true }: { showMessage?: boolean } = {}) => {
if (!token) {
if (showMessage) {
setStatusMessage('Log in to manage folders.', 'error');
@@ -427,10 +414,6 @@ const useFolderTreeActions = ({
return false;
}
if (manageLoading) {
setLoading(true);
}
try {
const contents = await ensureFolderData(folderId, {
force: true,
@@ -445,7 +428,7 @@ const useFolderTreeActions = ({
return false;
}
await api.delete(`/folders/${folderId}`);
await deleteFolder(folderId);
setFolderNodes((prev) => {
const next = new Map(prev);
@@ -496,14 +479,9 @@ const useFolderTreeActions = ({
setStatusMessage(message, 'error');
}
return false;
} finally {
if (manageLoading) {
setLoading(false);
}
}
},
[
api,
token,
applySelectedFolder,
ensureFolderData,
@@ -512,7 +490,6 @@ const useFolderTreeActions = ({
selectedFolder,
setFolderContents,
setFolderNodes,
setLoading,
setSelectedFolder,
setStatusMessage,
],
@@ -19,10 +19,9 @@ interface UseTenantManagerOptions {
resetWorkspaceState: () => void;
setStatusMessage: (message: string, variant?: string) => void;
notifyApiError: (error: unknown, message: string) => void;
setLoading: (state: boolean) => void;
refreshTags: () => Promise<void>;
refreshCorrespondents: () => Promise<void>;
loadFolder: (folderId: string, options?: { showLoading?: boolean; preserveSearch?: boolean }) => Promise<void>;
loadFolder: (folderId: string, options?: { preserveSearch?: boolean }) => Promise<void>;
handleDocumentsViewModeChange: (mode: string) => void;
navigate: NavigateFunction;
tokenRef?: MutableRefObject<string | null>;
@@ -36,7 +35,6 @@ const useTenantManager = ({
resetWorkspaceState,
setStatusMessage,
notifyApiError,
setLoading,
refreshTags,
refreshCorrespondents,
loadFolder,
@@ -52,7 +50,6 @@ const useTenantManager = ({
return;
}
setLoading(true);
try {
if (!refreshOnly) {
setStatusMessage('Switching tenant…', 'info');
@@ -99,14 +96,12 @@ const useTenantManager = ({
navigate('/documents', { replace: true });
await Promise.all([refreshTags(), refreshCorrespondents()]);
await loadFolder('root', { showLoading: false, preserveSearch: false });
await loadFolder('root', { 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);
}
},
[
@@ -120,7 +115,6 @@ const useTenantManager = ({
refreshCorrespondents,
refreshTags,
resetWorkspaceState,
setLoading,
setStatusMessage,
tenantIdRef,
tokenRef,
@@ -0,0 +1,105 @@
import React, { useEffect, useMemo } from 'react';
import { DEFAULT_FOLDER_NAME } from '../../app/appLayoutUtils';
type Identifier = string | number;
type FolderId = Identifier | 'root';
interface UseWorkspaceBreadcrumbsArgs {
selectedFolder: FolderId | null;
folderNodes: Map<FolderId, { id?: FolderId; name?: string | null; parentId?: FolderId | null; parent_id?: FolderId | null }>;
currentFolder: { id?: FolderId; name?: string | null; parentId?: FolderId | null; parent_id?: FolderId | null } | null;
breadcrumbFetchRef: React.MutableRefObject<Set<FolderId>>;
ensureFolderData: (folderId: FolderId, options?: Record<string, unknown>) => Promise<unknown>;
}
const useWorkspaceBreadcrumbs = ({
selectedFolder,
folderNodes,
currentFolder,
breadcrumbFetchRef,
ensureFolderData,
}: UseWorkspaceBreadcrumbsArgs) => {
const { breadcrumbs, missingBreadcrumbAncestors } = useMemo(() => {
const chain: Array<{ id: FolderId; name?: string | null }> = [];
const seen = new Set<FolderId>();
const pending = new Set<FolderId>();
let currentId: FolderId | null = (selectedFolder || 'root') as FolderId;
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 as FolderId);
if (node) {
chain.push({ id: currentId, name: node.name || 'Folder' });
currentId = (node.parentId ?? node.parent_id ?? 'root') as FolderId;
continue;
}
let fallbackName: string | null | undefined = '…';
let parentId: FolderId | null | undefined = null;
if (currentFolder && currentFolder.id === currentId) {
fallbackName = currentFolder.name;
parentId = (currentFolder.parent_id ?? currentFolder.parentId ?? 'root') as FolderId;
}
chain.push({ id: currentId, name: fallbackName });
pending.add(currentId);
currentId = parentId as FolderId | null;
}
if (!chain.some((crumb) => crumb.id === 'root')) {
chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
}
const ordered: Array<{ id: FolderId; name?: string | null }> = [];
const seenOrdered = new Set<FolderId>();
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, breadcrumbFetchRef]);
return breadcrumbs;
};
export default useWorkspaceBreadcrumbs;
@@ -0,0 +1,136 @@
import { useCallback, useMemo } from 'react';
import type { MutableRefObject } from 'react';
type Identifier = string | number;
interface UseWorkspaceDeskPropsArgs {
viewDocuments: any[];
inspectDocumentForDesk: (doc: any) => void;
handleEntryPointer: (params: { rowKey?: string | null; id?: Identifier | null; type?: string; event?: any }) => void;
selectedEntries: Array<string | number>;
selectionAnchorRef: MutableRefObject<Identifier | string | null>;
applySelection: (rowKeys: Array<string | number>, options?: { anchor?: Identifier | string | null; interactedKeys?: Array<string | number> }) => void;
resolveDocumentRowKey: (id?: Identifier | null) => string | null;
showingSearchResults: boolean;
searchQuery: string;
activeTagFilters: Array<string | number>;
activeCorrespondentFilters: Array<string | number>;
selectedFolder: Identifier | 'root' | null;
promoteSelectionOrder: () => void;
handleDocumentTagDrop: (docId: Identifier, tagId: Identifier) => Promise<void> | void;
ensureAssetUrl: (docId: Identifier, asset: any, options?: Record<string, unknown>) => Promise<any> | null;
getDocumentAsset: (doc: any, type: string) => any;
currentTenantId: Identifier | null;
documentLinks: Map<Identifier, unknown> | null;
ensureDownloadUrl?: (docId: Identifier, options?: { force?: boolean }) => Promise<unknown>;
}
const useWorkspaceDeskProps = ({
viewDocuments,
inspectDocumentForDesk,
handleEntryPointer,
selectedEntries,
selectionAnchorRef,
applySelection,
resolveDocumentRowKey,
showingSearchResults,
searchQuery,
activeTagFilters,
activeCorrespondentFilters,
selectedFolder,
promoteSelectionOrder,
handleDocumentTagDrop,
ensureAssetUrl,
getDocumentAsset,
currentTenantId,
documentLinks,
ensureDownloadUrl,
}: UseWorkspaceDeskPropsArgs) => {
const handleDeskDocumentStackSelect = useCallback(
(docIds: Array<Identifier | string>) => {
if (!Array.isArray(docIds) || docIds.length === 0) {
return;
}
const rowKeys = docIds
.map((id) => resolveDocumentRowKey(id as Identifier))
.filter((value): value is string => typeof value === 'string');
if (!rowKeys.length) {
return;
}
const nextKeys = [...selectedEntries];
rowKeys.forEach((key) => {
if (!nextKeys.includes(key)) {
nextKeys.push(key);
}
});
const anchor = (rowKeys[0]
|| selectionAnchorRef.current
|| nextKeys[nextKeys.length - 1]) as Identifier | string | null;
applySelection(nextKeys, {
anchor,
interactedKeys: rowKeys,
});
},
[applySelection, resolveDocumentRowKey, selectedEntries, selectionAnchorRef],
);
const deskViewId = useMemo(() => {
if (showingSearchResults) {
const trimmedQuery = searchQuery.trim();
const tagsKey = [...activeTagFilters].sort().join(',');
const correspondentsKey = [...activeCorrespondentFilters].sort().join(',');
return `search:${trimmedQuery}|tags:${tagsKey}|corr:${correspondentsKey}`;
}
const folderKey = selectedFolder && selectedFolder !== '' ? selectedFolder : 'root';
return `folder:${folderKey}`;
}, [
showingSearchResults,
searchQuery,
activeTagFilters,
activeCorrespondentFilters,
selectedFolder,
]);
const deskWorkspaceProps = useMemo(
() => ({
entries: viewDocuments,
onDocumentActivate: inspectDocumentForDesk,
onDocumentClick: handleEntryPointer,
onDocumentStackSelect: handleDeskDocumentStackSelect,
onPromoteSelection: promoteSelectionOrder,
onDocumentTagDrop: handleDocumentTagDrop,
ensureAssetUrl,
getDocumentAsset,
activeTagFilters,
tenantId: currentTenantId,
viewId: deskViewId,
documentLinks,
ensureDownloadUrl,
}),
[
viewDocuments,
inspectDocumentForDesk,
handleEntryPointer,
handleDeskDocumentStackSelect,
promoteSelectionOrder,
handleDocumentTagDrop,
ensureAssetUrl,
getDocumentAsset,
activeTagFilters,
currentTenantId,
deskViewId,
documentLinks,
ensureDownloadUrl,
],
);
return deskWorkspaceProps;
};
export default useWorkspaceDeskProps;
@@ -0,0 +1,63 @@
import { useEffect } from 'react';
import type { MutableRefObject } from 'react';
type Identifier = string | number;
interface UseWorkspaceSelectionSyncArgs {
showingSearchResults: boolean;
searchQuery: string;
setSelectedEntries: (entries: Array<string | number>) => void;
setSelectionOrder: (order: Array<string | number>) => void;
selectionOrderRef: MutableRefObject<Array<string | number>>;
selectionAnchorRef: MutableRefObject<Identifier | string | null>;
setFocusedDocumentId: (id: Identifier | null) => void;
selectedDocumentIds: Identifier[];
activePreviewId: Identifier | null;
setActivePreviewId: (id: Identifier | null) => void;
selectionInitializedRef: MutableRefObject<boolean>;
}
const useWorkspaceSelectionSync = ({
showingSearchResults,
searchQuery,
setSelectedEntries,
setSelectionOrder,
selectionOrderRef,
selectionAnchorRef,
setFocusedDocumentId,
selectedDocumentIds,
activePreviewId,
setActivePreviewId,
selectionInitializedRef,
}: UseWorkspaceSelectionSyncArgs) => {
useEffect(() => {
if (!showingSearchResults) {
return;
}
setSelectedEntries([]);
setSelectionOrder([]);
selectionOrderRef.current = [];
selectionAnchorRef.current = null;
setFocusedDocumentId(null);
}, [
showingSearchResults,
searchQuery,
setSelectedEntries,
setSelectionOrder,
selectionOrderRef,
selectionAnchorRef,
setFocusedDocumentId,
]);
useEffect(() => {
if (!selectedDocumentIds.length) {
return;
}
if (!selectedDocumentIds.includes(activePreviewId as Identifier)) {
setActivePreviewId(selectedDocumentIds[selectedDocumentIds.length - 1]);
}
selectionInitializedRef.current = true;
}, [selectedDocumentIds, activePreviewId, selectionInitializedRef, setActivePreviewId]);
};
export default useWorkspaceSelectionSync;
@@ -0,0 +1,140 @@
import { useEffect, useMemo } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import usePasskeys from '../../settings/usePasskeys';
import TagManager from '../../tag_manager';
import useCorrespondents from './useCorrespondents';
import useDocumentCorrespondentActions from './useDocumentCorrespondentActions';
import useTags from './useTags';
type Identifier = string | number;
interface UseWorkspaceTaxonomiesArgs {
apiClient: any;
notifyApiError: (error: unknown, fallbackMessage?: string, variant?: string) => void;
setStatusMessage: (message: string, variant?: string) => void;
tagManager: TagManager;
tenantIdRef: MutableRefObject<Identifier | null>;
currentTenantId: Identifier | null;
setActiveTagFilters: Dispatch<SetStateAction<Identifier[]>>;
mapDocumentCaches: (mapper: (doc: any) => any | undefined) => void;
updateDocumentCaches: (id: Identifier, updater: (doc: any) => any) => void;
token: string;
}
const useWorkspaceTaxonomies = ({
apiClient,
notifyApiError,
setStatusMessage,
tagManager,
tenantIdRef,
currentTenantId,
setActiveTagFilters,
mapDocumentCaches,
updateDocumentCaches,
token,
}: UseWorkspaceTaxonomiesArgs) => {
const {
tags,
refreshTags,
handleTagCreate,
handleTagUpdate,
handleTagDelete,
setTags,
} = useTags({
apiClient,
notifyApiError,
setStatusMessage,
tagManager,
tenantIdRef,
setActiveTagFilters,
mapDocumentCaches,
});
useEffect(() => {
tenantIdRef.current = currentTenantId;
}, [currentTenantId, tenantIdRef]);
const tagLookupById = useMemo(() => {
const map = new Map();
tags.forEach((tag) => {
if (tag?.id) {
map.set(tag.id, tag);
}
});
return map;
}, [tags]);
const {
correspondents,
refreshCorrespondents,
handleCorrespondentCreate,
handleCorrespondentUpdate,
handleCorrespondentDelete,
setCorrespondents,
} = useCorrespondents({
apiClient,
notifyApiError,
setStatusMessage,
tenantIdRef,
mapDocumentCaches,
});
const {
correspondentLookupByName,
handleDocumentCorrespondentAttach,
handleCorrespondentRemove,
handleCorrespondentAdd,
} = useDocumentCorrespondentActions({
apiClient,
correspondents,
handleCorrespondentCreate,
notifyApiError,
setStatusMessage,
updateDocumentCaches,
});
const {
passkeys,
passkeysSupported,
passkeysLoading,
registeringPasskey,
revokingPasskeyId,
refreshPasskeys,
registerPasskey,
revokePasskey,
} = usePasskeys({
notifyApiError,
setStatusMessage,
token,
});
return {
tags,
refreshTags,
handleTagCreate,
handleTagUpdate,
handleTagDelete,
setTags,
tagLookupById,
correspondents,
refreshCorrespondents,
handleCorrespondentCreate,
handleCorrespondentUpdate,
handleCorrespondentDelete,
setCorrespondents,
correspondentLookupByName,
handleDocumentCorrespondentAttach,
handleCorrespondentRemove,
handleCorrespondentAdd,
passkeys,
passkeysSupported,
passkeysLoading,
registeringPasskey,
revokingPasskeyId,
refreshPasskeys,
registerPasskey,
revokePasskey,
};
};
export default useWorkspaceTaxonomies;