refactor: Reorganize frontend by moving UI components, hooks, and utilities to new components, logic, features, and lib directories

This commit is contained in:
2025-12-04 23:05:35 +01:00
parent 128714a1e0
commit 8aa1872ea5
114 changed files with 199 additions and 199 deletions
@@ -1,83 +0,0 @@
import { useCallback, useEffect, useRef } from 'react';
import type { MutableRefObject } from 'react';
import { clearAuthToken, logoutSession, refreshSession, setAuthToken } from '../../lib/apiClient';
type AppStatus = string;
type AppDispatch = (action: { type: string; [key: string]: unknown }) => void;
type SetStatusMessage = (message: string, variant?: string) => void;
interface UseAuthManagerArgs {
token?: string | null;
appStatus: AppStatus;
appDispatch: AppDispatch;
setStatusMessage: SetStatusMessage;
}
interface UseAuthManagerResult {
tokenRef: MutableRefObject<string | null>;
refreshAccessToken: () => Promise<string>;
handleLogout: () => Promise<void>;
}
const useAuthManager = ({
token,
appStatus,
appDispatch,
setStatusMessage,
}: UseAuthManagerArgs): UseAuthManagerResult => {
const tokenRef = useRef<string | null>(token);
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 refreshSession();
if (data?.access_token) {
setAuthToken(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 as Error)?.message || null });
throw error;
}
}, [appDispatch]);
useEffect(() => {
tokenRef.current = token;
}, [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 handleLogout = useCallback(async () => {
try {
await logoutSession();
} catch (error) {
console.warn('[Auth] Failed to revoke refresh token during logout', error);
} finally {
clearAuthToken();
appDispatch({ type: 'LOGOUT' });
setStatusMessage('Logged out.', 'info');
}
}, [appDispatch, setStatusMessage]);
return { tokenRef, refreshAccessToken, handleLogout };
};
export default useAuthManager;
@@ -1,134 +0,0 @@
import { MutableRefObject, useCallback, useState } from 'react';
import type { Correspondent } from '../../types/documents';
import { listCorrespondents, createCorrespondent, updateCorrespondent, deleteCorrespondent } from '../../lib/apiClient';
interface UseCorrespondentsOptions {
notifyApiError: (error: unknown, fallback: string) => void;
setStatusMessage: (message: string, variant?: string) => void;
tenantIdRef: MutableRefObject<string | null>;
mapDocumentCaches?: (mapper: (doc: any) => any) => void;
}
const useCorrespondents = ({
notifyApiError,
setStatusMessage,
tenantIdRef,
mapDocumentCaches,
}: UseCorrespondentsOptions) => {
const [correspondents, setCorrespondents] = useState<Correspondent[]>([]);
const refreshCorrespondents = useCallback(async () => {
const requestTenantId = tenantIdRef.current;
try {
const data = await listCorrespondents();
if (tenantIdRef.current !== requestTenantId) {
return;
}
setCorrespondents(data || []);
} catch (error) {
if (tenantIdRef.current !== requestTenantId) {
return;
}
notifyApiError(error, 'Unable to load correspondents.');
}
}, [notifyApiError, tenantIdRef]);
const handleCorrespondentUpdate = useCallback(
async (correspondentId: string, changes: { name?: string }) => {
if (correspondentId == null) {
throw new Error('Missing correspondent identifier.');
}
const payload: Record<string, unknown> = {};
if (changes?.name != null) {
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 updateCorrespondent(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);
}
},
[notifyApiError, refreshCorrespondents, setStatusMessage],
);
const handleCorrespondentCreate = useCallback(
async ({ name }: { name?: string }) => {
const trimmed = name?.trim?.() || '';
if (!trimmed) {
throw new Error('Correspondent name is required.');
}
try {
const data = await createCorrespondent({ 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);
}
},
[notifyApiError, refreshCorrespondents, setStatusMessage],
);
const handleCorrespondentDelete = useCallback(
async (correspondentId: string) => {
if (correspondentId == null) {
throw new Error('Missing correspondent identifier.');
}
const stripFromDoc = (doc: any) => {
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 deleteCorrespondent(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);
}
},
[mapDocumentCaches, notifyApiError, refreshCorrespondents, setStatusMessage],
);
return {
correspondents,
refreshCorrespondents,
handleCorrespondentCreate,
handleCorrespondentUpdate,
handleCorrespondentDelete,
setCorrespondents,
};
};
export default useCorrespondents;
@@ -1,189 +0,0 @@
import { useCallback, useMemo } from 'react';
import type { Identifier } from '../../types/identifiers';
import { addDocumentCorrespondent, removeDocumentCorrespondent } from '../../lib/apiClient';
interface CorrespondentOption {
id?: string;
name?: string;
[key: string]: unknown;
}
interface UseDocumentCorrespondentActionsArgs {
correspondents: CorrespondentOption[];
handleCorrespondentCreate: (payload: { name: string }) => Promise<CorrespondentOption | null>;
notifyApiError: (error: unknown, fallback: string) => void;
setStatusMessage: (message: string, variant?: string) => void;
updateDocumentCaches?: (
id: Identifier,
updater: (doc: { correspondents?: CorrespondentOption[] } | null) => { correspondents?: CorrespondentOption[] } | null,
) => void;
}
const useDocumentCorrespondentActions = ({
correspondents,
handleCorrespondentCreate,
notifyApiError,
setStatusMessage,
updateDocumentCaches,
}: UseDocumentCorrespondentActionsArgs) => {
const correspondentLookupByName = useMemo(() => {
const map = new Map<string, CorrespondentOption>();
correspondents.forEach((correspondent) => {
if (correspondent?.name) {
map.set(correspondent.name.toLowerCase(), correspondent);
}
});
return map;
}, [correspondents]);
const handleDocumentCorrespondentAttach = useCallback(
async (
{
documentId,
correspondentId,
correspondent,
}: { documentId: Identifier; correspondentId: Identifier; correspondent?: CorrespondentOption | null },
{ notify = true }: { notify?: boolean } = {},
) => {
if (documentId == null || correspondentId == null) {
throw new Error('Missing document or correspondent.');
}
try {
await addDocumentCorrespondent(documentId, correspondentId);
if (updateDocumentCaches) {
const resolved = correspondent
|| correspondents.find((entry) => entry?.id === correspondentId)
|| null;
updateDocumentCaches(documentId, (doc) => {
if (!doc) {
return doc;
}
const current = Array.isArray(doc.correspondents) ? doc.correspondents : [];
if (current.some((entry) => entry?.id === correspondentId)) {
return doc;
}
const nextEntry = resolved?.name
? { id: resolved.id ?? correspondentId, name: resolved.name }
: { id: correspondentId };
return { ...doc, correspondents: [...current, nextEntry] };
});
}
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);
}
},
[correspondents, notifyApiError, setStatusMessage, updateDocumentCaches],
);
const handleCorrespondentRemove = useCallback(
async (
{ documentId, correspondentId }: { documentId: Identifier; correspondentId: Identifier },
{ notify = true }: { notify?: boolean } = {},
) => {
if (documentId == null || correspondentId == null) {
throw new Error('Missing document or correspondent.');
}
try {
await removeDocumentCorrespondent(documentId, correspondentId);
if (updateDocumentCaches) {
updateDocumentCaches(documentId, (doc) => {
if (!doc || !Array.isArray(doc.correspondents)) {
return doc;
}
const filtered = doc.correspondents.filter((entry) => entry?.id !== correspondentId);
return filtered.length === doc.correspondents.length ? doc : { ...doc, correspondents: filtered };
});
}
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, setStatusMessage, updateDocumentCaches],
);
const normalizeOption = (
option: CorrespondentOption | string | null,
): CorrespondentOption | null => {
if (!option) {
return null;
}
if (typeof option === 'string') {
const trimmed = option.trim();
if (trimmed) {
return { id: null, name: trimmed };
}
return null;
}
return option;
};
const handleCorrespondentAdd = useCallback(
async ({ document, name, input = null, option = null }: { document?: { id?: string }; name?: string; input?: HTMLInputElement | null; option?: CorrespondentOption | string | null }) => {
if (!document?.id) {
throw new Error('Missing document for correspondent assignment.');
}
const trimmed = name?.trim?.() || '';
if (!trimmed) {
setStatusMessage('Correspondent name is required.', 'error');
return;
}
let target = correspondentLookupByName.get(trimmed.toLowerCase()) || normalizeOption(option);
if (!target) {
try {
target = await handleCorrespondentCreate({ name: trimmed });
} catch {
return;
}
}
if (!target?.id) {
setStatusMessage('Unable to resolve correspondent.', 'error');
return;
}
try {
await handleDocumentCorrespondentAttach({
documentId: document.id,
correspondentId: target.id,
correspondent: target.name ? target : { ...target, name: trimmed },
});
if (input) {
input.value = '';
}
} catch (error) {
setStatusMessage('Failed to assign correspondent.', 'error');
console.error('[documents] assign correspondent failed', error);
}
},
[
correspondentLookupByName,
handleCorrespondentCreate,
handleDocumentCorrespondentAttach,
setStatusMessage,
],
);
return {
correspondentLookupByName,
handleDocumentCorrespondentAttach,
handleCorrespondentRemove,
handleCorrespondentAdd,
};
};
export default useDocumentCorrespondentActions;
@@ -1,413 +0,0 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import type { DragEvent } from 'react';
import { createDocumentEntryKey, createFolderEntryKey } from '../../app/entryKey';
import type { FolderId, Identifier } from '../../types/identifiers';
type FolderIdentifier = FolderId | 'root';
type FolderInput = FolderIdentifier | number;
import type { Document } from '../../types/documents';
type ApplySelectionFn = (
keys: string[],
options?: { anchor: string | null; interactedKeys?: string[] },
) => void;
type HandleEntrySelectionFn = (
key: string,
event: { preventDefault?: () => void },
) => void;
interface UseDocumentDragHandlersOptions {
selectedEntries: string[];
selectedDocumentIds: Identifier[];
selectedFolderIds: FolderInput[];
applySelection: ApplySelectionFn;
handleEntrySelection: HandleEntrySelectionFn;
documentLookup: Map<Identifier, Document>;
setDraggedDocumentIds: (ids: Identifier[] | []) => void;
setDraggedFolderId: (id: FolderIdentifier | null) => void;
documentsViewMode: string;
}
const useDocumentDragHandlers = ({
selectedEntries,
selectedDocumentIds,
selectedFolderIds,
applySelection,
handleEntrySelection,
documentLookup,
setDraggedDocumentIds,
setDraggedFolderId,
documentsViewMode: _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;
if (node && node.parentNode) {
node.parentNode.removeChild(node);
}
dragPreviewRef.current = null;
}, []);
useEffect(() => destroyDragPreview, [destroyDragPreview]);
const createDragPreview = useCallback(
({ documents = [], folders = [], prioritizeFolders = false }: { documents?: Document[]; folders?: FolderIdentifier[]; prioritizeFolders?: boolean } = {}) => {
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: Array<{ type: 'document' | 'folder'; payload: any }> = [];
let takeDocs = 0;
let takeFolders = 0;
if (docEntries.length > 0 && folderEntries.length > 0) {
if (prioritizeFolders) {
// Folders on top (added last)
takeDocs = Math.min(docEntries.length, maxVisible - 1);
takeFolders = Math.min(folderEntries.length, maxVisible - takeDocs);
} else {
// Docs on top (added last)
takeFolders = Math.min(folderEntries.length, maxVisible - 1);
takeDocs = Math.min(docEntries.length, maxVisible - takeFolders);
}
} else {
takeDocs = Math.min(docEntries.length, maxVisible);
takeFolders = Math.min(folderEntries.length, maxVisible - takeDocs);
}
if (prioritizeFolders) {
// Docs at bottom
docEntries.slice(0, takeDocs).forEach((doc) => {
visibleItems.push({ type: 'document', payload: doc });
});
// Folders at top
folderEntries.slice(0, takeFolders).forEach((folderId) => {
visibleItems.push({ type: 'folder', payload: folderId });
});
} else {
// Folders at bottom
folderEntries.slice(0, takeFolders).forEach((folderId) => {
visibleItems.push({ type: 'folder', payload: folderId });
});
// Docs at top
docEntries.slice(0, takeDocs).forEach((doc) => {
visibleItems.push({ type: 'document', payload: doc });
});
}
const wrapper = document.createElement('div');
wrapper.className = 'document-drag-preview';
wrapper.style.setProperty('--drag-preview-size', `${canvasSize}px`);
wrapper.style.width = `${canvasSize}px`;
wrapper.style.height = `${canvasSize}px`;
visibleItems.forEach((item, index) => {
const layer = document.createElement('div');
layer.className = 'document-drag-preview__item';
layer.style.setProperty('--index', String(index));
const rotationMagnitude = Math.random() * 8 + 2;
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-${doc.id}`)
: null;
const wrapperEl = rowEl instanceof HTMLElement
? rowEl.querySelector<HTMLElement>('.document-thumbnail-wrapper')
: null;
const thumbnailEl = rowEl instanceof HTMLElement
? rowEl.querySelector<HTMLImageElement>('.document-thumbnail')
: null;
const placeholderEl = rowEl instanceof HTMLElement
? rowEl.querySelector<HTMLElement>('.thumb-placeholder')
: null;
const aspectAttr = wrapperEl?.dataset?.thumbnailAspect;
const aspectRatio = aspectAttr ? parseFloat(aspectAttr) : null;
let thumbWidth = size;
let thumbHeight = size;
if (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__item--image');
layer.style.backgroundImage = `url("${thumbSrc}")`;
} else if (placeholderEl instanceof HTMLElement) {
const clone = placeholderEl.cloneNode(true) as HTMLElement;
clone.style.pointerEvents = 'none';
layer.appendChild(clone);
} else {
layer.textContent = doc?.title || 'Document';
}
} else {
const payload = item.payload;
const folderId = payload as FolderIdentifier;
const rowEl = folderId
? document.getElementById(`folder-${folderId}`)
: null;
const iconEl = rowEl instanceof HTMLElement
? rowEl.querySelector('.thumb-icon, .folder-card__icon')
: null;
layer.style.width = `${size}px`;
layer.style.height = `${size}px`;
layer.classList.add('document-drag-preview__item--folder');
let content: HTMLElement | SVGElement | null = null;
if (iconEl instanceof HTMLElement) {
const cloneSource = iconEl.classList.contains('folder-card__icon')
? iconEl.querySelector('svg') || iconEl
: iconEl;
const clone = cloneSource.cloneNode(true);
if (clone instanceof HTMLElement || clone instanceof SVGElement) {
content = clone as HTMLElement | SVGElement;
content.classList.add('document-drag-preview__folder-thumb');
const svg = content.nodeName.toLowerCase() === 'svg'
? content
: 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}`;
wrapper.appendChild(badge);
}
document.body.appendChild(wrapper);
dragPreviewRef.current = wrapper;
return wrapper;
},
[destroyDragPreview],
);
const handleDocumentDragStart = useCallback(
(event: DragEvent<HTMLElement>, documentOrId: Document | Identifier | null) => {
const documentId: Identifier | null = Object(documentOrId) === documentOrId
? (documentOrId as Document)?.id ?? null
: (documentOrId as Identifier | null);
if (!documentId) {
return;
}
const documentKey = createDocumentEntryKey(documentId);
if (!documentKey) {
return;
}
const isAlreadySelected = selectedDocumentIds.includes(documentId);
const selection: Identifier[] = isAlreadySelected
? [...selectedDocumentIds]
: [documentId];
// If the document is part of the selection, we also want to include any selected folders
const folderSelection: FolderIdentifier[] = isAlreadySelected
? normalizedFolderIds
: [];
if (!isAlreadySelected) {
applySelection([documentKey], {
anchor: documentKey,
interactedKeys: [documentKey],
});
}
const previewDocs = selection
.map((id) => documentLookup.get(id) || documentLookup.get(String(id)) || null)
.filter(Boolean);
const previewNode = createDragPreview({
documents: previewDocs,
folders: folderSelection,
prioritizeFolders: false,
});
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,
applySelection,
documentLookup,
createDragPreview,
setDraggedFolderId,
setDraggedDocumentIds,
normalizedFolderIds,
],
);
const handleDocumentDragEnd = useCallback(
(event: DragEvent<HTMLElement>) => {
setDraggedDocumentIds([]);
event.currentTarget.classList.remove('dragging');
destroyDragPreview();
setDraggedFolderId(null);
},
[destroyDragPreview, setDraggedFolderId, setDraggedDocumentIds],
);
const handleFolderDragStart = useCallback(
(event: DragEvent<HTMLElement>, folderId: FolderInput) => {
const normalizedFolderId: FolderIdentifier = folderId === 'root' ? 'root' : String(folderId);
if (normalizedFolderId === 'root') {
return;
}
event.stopPropagation();
const folderKey = createFolderEntryKey(normalizedFolderId);
const isAlreadySelected = folderKey ? selectedEntries.includes(folderKey) : false;
let effectiveFolderSelection: FolderIdentifier[] = normalizedFolderIds;
let effectiveDocumentSelection: Identifier[] = selectedDocumentIds;
if (!isAlreadySelected && folderKey) {
effectiveFolderSelection = [normalizedFolderId];
effectiveDocumentSelection = [];
handleEntrySelection(folderKey, { preventDefault: () => { } });
}
const uniqueFolders = effectiveFolderSelection.length
? Array.from(new Set(effectiveFolderSelection.filter(Boolean)))
: [normalizedFolderId];
setDraggedFolderId(normalizedFolderId);
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('[documents] Failed to populate folder drag payload', error);
}
const previewNode = createDragPreview({
documents: effectiveDocumentSelection
.map((id) => documentLookup.get(id) || documentLookup.get(String(id)) || null)
.filter(Boolean),
folders: uniqueFolders,
prioritizeFolders: true,
});
event.currentTarget.classList.add('dragging');
if (previewNode) {
const width = previewNode.offsetWidth || 96;
const height = previewNode.offsetHeight || 96;
event.dataTransfer.setDragImage(previewNode, width / 2, height / 2);
}
},
[
normalizedFolderIds,
selectedEntries,
selectedDocumentIds,
handleEntrySelection,
setDraggedFolderId,
setDraggedDocumentIds,
documentLookup,
createDragPreview,
],
);
const handleFolderDragEnd = useCallback(
(event?: DragEvent<HTMLElement>) => {
if (event?.currentTarget) {
event.currentTarget.classList.remove('dragging');
}
setDraggedFolderId(null);
setDraggedDocumentIds([]);
destroyDragPreview();
},
[setDraggedFolderId, setDraggedDocumentIds, destroyDragPreview],
);
return {
handleDocumentDragStart,
handleDocumentDragEnd,
handleFolderDragStart,
handleFolderDragEnd,
};
};
export default useDocumentDragHandlers;
@@ -1,764 +0,0 @@
import { useCallback } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { DEFAULT_FOLDER_NAME } from '../../app/workspaceUtils';
import { getEntryId, isDocumentEntry } from '../../app/entryKey';
import {
addDocumentTags,
createTag,
deleteDocumentTag,
deleteFolder,
moveDocumentsBulk,
moveDocumentToFolder,
queueDocumentReanalysis,
trashDocument,
updateDocument,
} from '../../lib/apiClient';
import type { DocumentId, FolderId as FolderIdentifier } from '../../types/identifiers';
import type { Document, MessageOptions } from '../../types/documents';
type FolderId = FolderIdentifier | 'root';
type NullableFolderId = FolderId | null;
type StatusLevel = 'success' | 'error' | 'info' | string;
type DocumentCacheMapper = (
doc: Document | null,
) => Document | null;
type MapDocumentCaches = (mapper: DocumentCacheMapper) => void;
type UpdateDocumentCaches = (
documentId: DocumentId,
updater: DocumentCacheMapper,
) => void;
type EnsureFolderData = (
folderId: FolderId,
options?: { force?: boolean; includeDocuments?: boolean; prefetchDepth?: number },
) => Promise<FolderContents>;
type ApplySelectedFolder = (folderId: FolderId, contents?: FolderContents | null) => void;
type RemoveDocumentsFromCaches = (documentIds: DocumentId[]) => void;
type CloseDocumentPreview = () => void;
type NotifyApiError = (error: unknown, fallbackMessage?: string) => void;
type SetStatusMessage = (message: string, level?: StatusLevel) => void;
interface Tag {
id: DocumentId;
label: string;
color?: string | null;
[key: string]: unknown;
}
interface FolderContents {
documents?: Document[];
subfolders?: Array<{ id?: FolderId;[key: string]: unknown }>;
[key: string]: unknown;
}
interface FolderNode {
id: FolderId;
parentId?: FolderId;
children: FolderId[];
hasChildren?: boolean;
[key: string]: unknown;
}
interface TagManager {
normalizeLabel: (label: string) => string;
buildPayload: (args: { label: string }) => Record<string, unknown>;
}
interface DocumentTagExtras {
option?: Tag | null;
input?: { value?: string } | null;
}
interface UseDocumentMutationsArgs {
token?: string | null;
documentLookup: Map<DocumentId, Document>;
folderLabelMap: Map<FolderId, string>;
ensureFolderData: EnsureFolderData;
selectedFolder: FolderId;
setSelectedFolder: Dispatch<SetStateAction<FolderId>>;
setDocuments: Dispatch<SetStateAction<Document[]>>;
setFolderContents: Dispatch<SetStateAction<Map<FolderId, FolderContents>>>;
setSearchResultIds: Dispatch<SetStateAction<DocumentId[] | null>>;
setSelectedEntries: Dispatch<SetStateAction<string[]>>;
setSelectionOrder: Dispatch<SetStateAction<string[]>>;
selectionOrderRef: MutableRefObject<string[] | null>;
selectionAnchorRef: MutableRefObject<string | null>;
setFocusedDocumentId: Dispatch<SetStateAction<DocumentId | null>>;
focusedDocumentId: DocumentId | null;
setFocusedEntryKey: Dispatch<SetStateAction<string | null>>;
focusedEntryKey: string | null;
notifyApiError: NotifyApiError;
setStatusMessage: SetStatusMessage;
mapDocumentCaches: MapDocumentCaches;
applySelectedFolder: ApplySelectedFolder;
folderNodes: Map<FolderId, FolderNode>;
setFolderNodes: Dispatch<SetStateAction<Map<FolderId, FolderNode>>>;
removeDocumentsFromCaches: RemoveDocumentsFromCaches;
closeDocumentPreview: CloseDocumentPreview;
previewDocumentId?: DocumentId | null;
refreshCurrentFolder: () => Promise<void>;
updateDocumentCaches: UpdateDocumentCaches;
tagLookupById: Map<DocumentId, Tag>;
tags: Tag[];
refreshTags: () => Promise<void>;
tagManager: TagManager;
extractDocumentFromResponse?: (payload: unknown) => Document | null;
ingestDocuments?: (docs: unknown[]) => { canonical: Document[]; changed: boolean };
}
interface UseDocumentMutationsResult {
moveDocumentsToFolder: (
documentIds: Array<DocumentId | Document>,
targetFolderId?: NullableFolderId,
) => Promise<void>;
handleThumbnailRegeneration: (documentId: DocumentId) => Promise<void>;
handleDocumentsDelete: (
documentIds: DocumentId[],
options?: MessageOptions,
) => Promise<boolean>;
handleDocumentTagAdd: (
document: Document,
label: string,
extras?: DocumentTagExtras | null,
) => Promise<void>;
handleDocumentTagAttach: (documentId: DocumentId, tagId: DocumentId) => Promise<boolean>;
handleDocumentTitleUpdate: (documentId: DocumentId, nextTitle: string) => Promise<boolean>;
handleDocumentIssuedUpdate: (
documentId: DocumentId,
nextIssuedDate: number | null,
) => Promise<boolean>;
handleTagRemove: (
documentId?: DocumentId,
tagId?: DocumentId,
) => Promise<boolean>;
handleFolderDelete: (folderId?: FolderId, options?: MessageOptions) => Promise<boolean>;
}
const normalizeDocumentId = (value: unknown): DocumentId | null => {
if (!value) return null;
if (value && typeof value === 'object' && 'id' in value && value.id != null) {
return value.id as DocumentId;
}
return value as DocumentId;
};
const useDocumentMutations = ({
token,
documentLookup,
folderLabelMap,
ensureFolderData,
selectedFolder,
setSelectedFolder,
setDocuments,
setFolderContents,
setSearchResultIds,
setSelectedEntries,
setSelectionOrder,
selectionOrderRef,
selectionAnchorRef,
setFocusedDocumentId,
focusedDocumentId,
setFocusedEntryKey,
focusedEntryKey,
notifyApiError,
setStatusMessage,
mapDocumentCaches,
applySelectedFolder,
folderNodes,
setFolderNodes,
removeDocumentsFromCaches,
closeDocumentPreview,
previewDocumentId,
refreshCurrentFolder,
updateDocumentCaches,
tagLookupById,
tags,
refreshTags,
tagManager,
extractDocumentFromResponse,
ingestDocuments,
}: UseDocumentMutationsArgs): UseDocumentMutationsResult => {
const moveDocumentsToFolder = useCallback(
async (documentIds: Array<DocumentId | Document>, targetFolderId?: NullableFolderId) => {
const uniqueIds = Array.from(
new Set((documentIds || []).map((value) => normalizeDocumentId(value)).filter(Boolean) as DocumentId[]),
);
if (!uniqueIds.length) return;
const uniqueIdSet = new Set(uniqueIds);
const target = targetFolderId === 'root' ? null : targetFolderId ?? null;
const targetLabel =
target === null ? DEFAULT_FOLDER_NAME : folderLabelMap.get(targetFolderId as FolderId) || 'target folder';
const movedDocs = uniqueIds
.map((id) => {
const doc = documentLookup.get(id) || null;
if (!doc) {
return null;
}
return {
id,
sourceFolderId: (doc.folder_id ?? null) as NullableFolderId,
document: doc,
};
})
.filter(Boolean) as Array<{ id: DocumentId; sourceFolderId: NullableFolderId; document: Document }>;
const updatedDocsMap = new Map<DocumentId, Document>();
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 = {
...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 pruneRow = (collection: string[]): string[] =>
collection.filter((key) => {
if (!isDocumentEntry(key)) {
return true;
}
const id = getEntryId(key);
return id ? !uniqueIdSet.has(id as DocumentId) : true;
});
try {
if (uniqueIds.length === 1) {
await moveDocumentToFolder(uniqueIds[0], target);
} else {
await moveDocumentsBulk(uniqueIds, 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 as DocumentId)) {
return doc;
}
const updated = updatedDocsMap.get(doc.id as DocumentId);
if (updated) {
return updated;
}
return { ...doc, folder_id: target };
});
} else {
mapDocumentCaches((doc) => {
if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) {
return doc;
}
return { ...doc, folder_id: target };
});
}
if (uniqueIdSet.size) {
setSearchResultIds((prev) => {
if (!Array.isArray(prev) || !prev.length) {
return prev;
}
const filtered = prev.filter((id) => !uniqueIdSet.has(id as DocumentId));
return filtered.length === prev.length ? prev : filtered;
});
setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id as DocumentId)));
setFolderContents((prev: Map<FolderId, FolderContents>) => {
if (!prev.size) {
return prev;
}
let changed = false;
const next = new Map<FolderId, FolderContents>(prev);
movedDocs.forEach(({ id, sourceFolderId }) => {
const sourceKey = (sourceFolderId || 'root') as FolderId;
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;
});
setSelectedEntries((prev) => pruneRow(prev));
setSelectionOrder((prev) => pruneRow(prev));
const nextSelectionOrder = pruneRow(selectionOrderRef.current || []);
selectionOrderRef.current = nextSelectionOrder;
if (
selectionAnchorRef.current &&
isDocumentEntry(selectionAnchorRef.current) &&
uniqueIdSet.has(getEntryId(selectionAnchorRef.current) as DocumentId)
) {
selectionAnchorRef.current = null;
}
if (focusedDocumentId && uniqueIdSet.has(focusedDocumentId)) {
setFocusedDocumentId(null);
}
if (
focusedEntryKey &&
isDocumentEntry(focusedEntryKey) &&
uniqueIdSet.has(getEntryId(focusedEntryKey) as DocumentId)
) {
setFocusedEntryKey(null);
}
}
if (targetFolderId && targetFolderId !== selectedFolder) {
await ensureFolderData(targetFolderId as FolderId, { force: true, prefetchDepth: 1 });
}
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to move documents.';
notifyApiError(error, message);
}
},
[
documentLookup,
folderLabelMap,
ensureFolderData,
selectedFolder,
setSearchResultIds,
setDocuments,
setFolderContents,
setSelectedEntries,
setSelectionOrder,
selectionOrderRef,
selectionAnchorRef,
setFocusedDocumentId,
focusedDocumentId,
setFocusedEntryKey,
focusedEntryKey,
notifyApiError,
setStatusMessage,
mapDocumentCaches,
],
);
const handleThumbnailRegeneration = useCallback(
async (documentId: DocumentId) => {
if (!token) {
setStatusMessage('Log in to manage assets.', 'error');
return;
}
try {
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);
}
},
[token, refreshCurrentFolder, notifyApiError, setStatusMessage],
);
const handleDocumentsDelete = useCallback(
async (documentIds: DocumentId[], { showMessage = true }: MessageOptions = {}) => {
if (!documentIds || documentIds.length === 0) {
return false;
}
if (!token) {
setStatusMessage('Log in to manage documents.', 'error');
return false;
}
try {
await Promise.all(documentIds.map((documentId) => trashDocument(documentId)));
removeDocumentsFromCaches(documentIds);
if (previewDocumentId && documentIds.includes(previewDocumentId)) {
closeDocumentPreview();
}
if (showMessage) {
const message = documentIds.length === 1 ? 'Document deleted.' : 'Documents deleted.';
setStatusMessage(message, 'success');
}
return true;
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to delete documents.';
notifyApiError(error, message);
return false;
}
},
[
token,
removeDocumentsFromCaches,
previewDocumentId,
closeDocumentPreview,
notifyApiError,
setStatusMessage,
],
);
const handleDocumentTitleUpdate = useCallback(
async (documentId: DocumentId, nextTitle: string) => {
const trimmed = nextTitle?.trim?.() || '';
if (!trimmed) {
setStatusMessage('Document title cannot be empty.', 'error');
return false;
}
try {
const data = await updateDocument(documentId, { title: trimmed });
const updatedDocument = extractDocumentFromResponse?.(data);
if (updatedDocument && ingestDocuments) {
ingestDocuments([updatedDocument]);
} else {
updateDocumentCaches(documentId, (doc) => {
if (updatedDocument) {
return { ...doc, ...updatedDocument };
}
return { ...doc, title: trimmed };
});
}
setStatusMessage('Document title updated.', 'success');
return true;
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to update document title.';
notifyApiError(error, message);
return false;
}
},
[
extractDocumentFromResponse,
ingestDocuments,
notifyApiError,
setStatusMessage,
updateDocumentCaches,
],
);
const handleDocumentIssuedUpdate = useCallback(
async (documentId: DocumentId, nextIssuedDate: number | null) => {
const payload = { issued_at: nextIssuedDate || null };
try {
const data = await updateDocument(documentId, payload);
const updatedDocument = extractDocumentFromResponse?.(data);
if (updatedDocument && ingestDocuments) {
ingestDocuments([updatedDocument]);
} else {
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 as Record<string, any>)?.response?.data?.error || 'Failed to update issued date.';
notifyApiError(error, message);
return false;
}
},
[
extractDocumentFromResponse,
ingestDocuments,
notifyApiError,
setStatusMessage,
updateDocumentCaches,
],
);
const attachTagToDocument = useCallback(
async ({
documentId,
tag,
}: {
documentId?: DocumentId;
tag?: Tag | null;
}) => {
if (!documentId || !tag?.id) {
return false;
}
const cachedTag: Tag = {
id: tag.id,
label: tag.label,
color: Object.prototype.hasOwnProperty.call(tag, 'color') ? tag.color ?? null : null,
};
try {
await addDocumentTags(documentId, [cachedTag.id]);
updateDocumentCaches(documentId, (doc) => {
if (!doc) {
return doc;
}
const currentTags = Array.isArray(doc.tags) ? doc.tags : [];
if (currentTags.some((entry) => entry?.id === cachedTag.id)) {
return doc;
}
return { ...doc, tags: [...currentTags, cachedTag] };
});
setStatusMessage('Tag assigned.', 'success');
return true;
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to assign tag.';
notifyApiError(error, message);
return false;
}
},
[notifyApiError, setStatusMessage, updateDocumentCaches],
);
const handleDocumentTagAdd = useCallback(
async (document: Document, label: string, extras: DocumentTagExtras | null = null) => {
const normalizedLabel = tagManager.normalizeLabel(label);
const optionCandidate = extras?.option ?? null;
const input = extras?.input ?? null;
let tag: Tag | null = 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 }) as { label: string; color?: string | null };
const data = await createTag(payload);
tag = data as Tag;
await refreshTags();
}
await attachTagToDocument({
documentId: document.id as DocumentId,
tag,
});
if (input && Object(input) === input && 'value' in (input as Record<string, unknown>)) {
(input as { value?: string }).value = '';
}
} catch (error) {
notifyApiError(error, 'Failed to assign tag.');
}
},
[tags, refreshTags, attachTagToDocument, notifyApiError, tagManager],
);
const handleDocumentTagAttach = useCallback(
async (documentId: DocumentId, tagId: DocumentId) => {
if (!documentId || !tagId) {
return false;
}
const resolveTagForCache = (): Tag | null => {
const lookupTag = tagLookupById.get(tagId);
if (!lookupTag || lookupTag.id == null) {
return null;
}
const labelText = `${lookupTag.label ?? ''} `.trim();
if (!labelText) {
return null;
}
return {
id: lookupTag.id,
label: labelText,
color: Object.prototype.hasOwnProperty.call(lookupTag, 'color') ? (lookupTag as Tag).color ?? null : null,
};
};
const resolvedTag = resolveTagForCache();
return attachTagToDocument({
documentId,
tag: resolvedTag,
});
},
[
attachTagToDocument,
tagLookupById,
],
);
const applyTagRemovalToCaches = useCallback(
(documentId?: DocumentId, tagId?: DocumentId) => {
if (!documentId || !tagId) {
return;
}
updateDocumentCaches(documentId, (doc) => {
if (!doc || !Array.isArray(doc.tags)) {
return doc;
}
const nextTags = doc.tags.filter((tagEntry) => tagEntry.id !== tagId);
if (nextTags.length === doc.tags.length) {
return doc;
}
return { ...doc, tags: nextTags };
});
},
[updateDocumentCaches],
);
const handleTagRemove = useCallback(
async (
documentId?: DocumentId,
tagId?: DocumentId,
) => {
if (!documentId || !tagId) {
return false;
}
try {
await deleteDocumentTag(documentId, tagId);
applyTagRemovalToCaches(documentId, tagId);
setStatusMessage('Tag removed.', 'success');
return true;
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to remove tag.';
notifyApiError(error, message);
return false;
}
},
[applyTagRemovalToCaches, notifyApiError, setStatusMessage],
);
const handleFolderDelete = useCallback(
async (folderId?: FolderId, { showMessage = true }: MessageOptions = {}) => {
if (!token) {
if (showMessage) {
setStatusMessage('Log in to manage folders.', 'error');
}
return false;
}
if (!folderId || folderId === 'root') {
if (showMessage) {
setStatusMessage('The root folder cannot be removed.', 'error');
}
return false;
}
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) {
if (showMessage) {
setStatusMessage('Folder must be empty before it can be deleted.', 'error');
}
return false;
}
await deleteFolder(folderId);
setFolderNodes((prev: Map<FolderId, FolderNode>) => {
const next = new Map<FolderId, FolderNode>(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: Map<FolderId, FolderContents>) => {
const next = new Map<FolderId, FolderContents>(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 });
}
if (showMessage) {
setStatusMessage('Folder deleted.', 'success');
}
return true;
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to delete folder.';
notifyApiError(error, message);
if (showMessage) {
setStatusMessage(message, 'error');
}
return false;
}
},
[
token,
ensureFolderData,
selectedFolder,
folderNodes,
setSelectedFolder,
applySelectedFolder,
setFolderNodes,
setFolderContents,
notifyApiError,
setStatusMessage,
],
);
return {
moveDocumentsToFolder,
handleThumbnailRegeneration,
handleDocumentsDelete,
handleDocumentTagAdd,
handleDocumentTagAttach,
handleDocumentTitleUpdate,
handleDocumentIssuedUpdate,
handleTagRemove,
handleFolderDelete,
};
};
export default useDocumentMutations;
@@ -1,286 +0,0 @@
import { useCallback } from 'react';
import type { Identifier } from '../../types/identifiers';
interface TagRecord {
id?: Identifier;
label: string;
[key: string]: unknown;
}
import { createTag, bulkTagDocuments, bulkReanalyzeDocuments } from '../../lib/apiClient';
interface TagManager {
buildPayload: (input: { label: string }) => Record<string, unknown>;
}
interface UseDocumentTaggingArgs {
tags: TagRecord[];
tagManager: TagManager;
refreshTags: () => Promise<void> | void;
resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[];
notifyApiError: (error: unknown, message: string) => void;
setStatusMessage: (message: string, variant?: string) => void;
updateDocumentCaches?: (id: Identifier, updater: (doc: TagRecord | null) => TagRecord | null) => void;
}
interface BulkTagOperationArgs {
labels: string[];
action: 'add' | 'remove';
documentIds?: Identifier[];
}
interface BulkTagOperationResult {
ok: boolean;
reason?: 'no-labels' | 'no-selection' | 'tag-missing' | 'no-tags' | 'request-failed';
label?: string;
tagCount?: number;
docsCount?: number;
}
const useDocumentTagging = ({
tags,
tagManager,
refreshTags,
resolveTargetDocumentIds,
notifyApiError,
setStatusMessage,
updateDocumentCaches,
}: UseDocumentTaggingArgs) => {
const bulkTagOperation = useCallback(
async ({ labels, action, documentIds }: BulkTagOperationArgs): Promise<BulkTagOperationResult> => {
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: Identifier[] = [];
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);
}
try {
if (action === 'add') {
const createdIds: Identifier[] = [];
const createdTags: TagRecord[] = [];
for (const label of normalized) {
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
if (!tag) {
const payload = tagManager.buildPayload({ label }) as { label: string; color?: string | null };
const response = await createTag(payload);
tag = response as TagRecord;
await refreshTags();
}
createdIds.push(tag.id);
createdTags.push(tag);
}
tagIds = Array.from(new Set(createdIds));
if (updateDocumentCaches) {
const tagById = new Map<Identifier, TagRecord>();
tags.forEach((tag) => {
if (tag?.id != null) {
tagById.set(tag.id, tag);
}
});
createdTags.forEach((tag) => {
if (tag?.id != null) {
tagById.set(tag.id, tag);
}
});
targetDocumentIds.forEach((docId) => {
tagIds.forEach((tagId) => {
const cachedTag = tagById.get(tagId);
if (!cachedTag) {
return;
}
updateDocumentCaches(docId, (doc) => {
if (!doc) {
return doc;
}
const currentTags = Array.isArray((doc as any).tags) ? (doc as any).tags : [];
if (currentTags.some((entry: any) => entry?.id === tagId)) {
return doc;
}
return {
...(doc as any),
tags: [...currentTags, { ...cachedTag }],
};
});
});
});
}
}
tagIds = Array.from(new Set(tagIds));
if (!tagIds.length) {
return { ok: false, reason: 'no-tags' };
}
await bulkTagDocuments({
document_ids: targetDocumentIds,
tag_ids: tagIds,
action,
});
if (updateDocumentCaches) {
targetDocumentIds.forEach((docId) => {
tagIds.forEach((tagId) => {
updateDocumentCaches(docId, (doc) => {
if (!doc || !Array.isArray((doc as any).tags)) {
return doc;
}
const currentTags = (doc as any).tags;
if (action === 'remove') {
const filtered = currentTags.filter((entry: any) => entry?.id !== tagId);
return filtered.length === currentTags.length ? doc : { ...(doc as any), tags: filtered };
}
return doc;
});
});
});
}
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' };
}
},
[
resolveTargetDocumentIds,
tags,
refreshTags,
notifyApiError,
tagManager,
updateDocumentCaches,
],
);
const handleBulkTagAddFromDetail = useCallback(
async ({ label, input, documentIds }: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
const trimmed = 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 }: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
const trimmed = 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: Identifier[] | null = null) => {
const targetIds = resolveTargetDocumentIds(documentIdsOverride);
if (!targetIds.length) {
setStatusMessage('Select documents before requesting re-analysis.', 'error');
return;
}
try {
const response = await bulkReanalyzeDocuments({
document_ids: targetIds,
force: true,
});
const payload = response;
const queued = payload?.queued != null
? Number(payload.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);
}
},
[resolveTargetDocumentIds, notifyApiError, setStatusMessage],
);
return {
bulkTagOperation,
handleBulkTagAddFromDetail,
handleBulkTagRemoveFromDetail,
handleBulkSelectionReanalyze,
};
};
export default useDocumentTagging;
@@ -1,534 +0,0 @@
import { useCallback, useRef, useState } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import useFileDrop from './useFileDrop';
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/workspaceUtils';
import { fetchDocument, uploadDocument, resolveFolderPath } from '../../lib/apiClient';
import type { Identifier } from '../../types/identifiers';
type FolderId = Identifier | 'root' | null;
type FileEntry = {
file: File;
segments: string[];
};
type UploadStatus = 'pending' | 'uploading' | 'success' | 'duplicate' | 'error';
type UploadQueueItem = {
id: string;
name: string;
size: number | null;
folderId: FolderId;
status: UploadStatus;
error: string | null;
code: number | null;
document: unknown;
conflictDocumentId: Identifier | null;
};
type StatusLevel = 'success' | 'info' | 'warning' | 'error' | string;
type NotifyApiError = (error: unknown, fallbackMessage?: string) => void;
type SetStatusMessage = (message: string, level?: StatusLevel) => void;
type DropOverlayState = {
active: boolean;
folderName: string;
};
type FileSystemEntryLike = FileSystemEntry;
type ExtendedDataTransferItem = DataTransferItem & {
webkitGetAsEntry?: () => FileSystemEntry | null;
};
interface FileSystemDirectoryReaderLike {
readEntries: (
successCallback: (entries: FileSystemEntryLike[]) => void,
errorCallback: (error: DOMException) => void,
) => void;
}
interface FileSystemFileEntryLike extends FileSystemEntry {
isFile: true;
isDirectory: false;
name: string;
file: (
successCallback: (file: File) => void,
errorCallback: (error: DOMException) => void,
) => void;
}
interface FileSystemDirectoryEntryLike extends FileSystemEntry {
isFile: false;
isDirectory: true;
name: string;
createReader: () => FileSystemDirectoryReaderLike;
}
const isFileEntry = (entry: FileSystemEntryLike): entry is FileSystemFileEntryLike => {
return entry.isFile && !entry.isDirectory;
};
const isDirectoryEntry = (entry: FileSystemEntryLike): entry is FileSystemDirectoryEntryLike => {
return entry.isDirectory && !entry.isFile && 'createReader' in entry;
};
const mapFilesToEntries = (filesInput?: FileList | File[] | null): FileEntry[] => {
if (!filesInput) {
return [];
}
const files = Array.isArray(filesInput) ? filesInput : Array.from(filesInput);
return files
.filter(Boolean)
.map((file) => {
const relativePath = (file as File & { webkitRelativePath?: string })?.webkitRelativePath ?? '';
const segments = relativePath
? relativePath
.split('/')
.slice(0, -1)
.filter(Boolean)
: [];
return { file, segments };
});
};
interface UseDocumentUploadsArgs {
token?: string | null;
selectedFolder?: FolderId;
currentFolderName?: string | null;
ensureFolderData: (folderId: FolderId, options?: { force?: boolean; prefetchDepth?: number }) => Promise<void>;
refreshCurrentFolder: () => Promise<void>;
shellRef: MutableRefObject<HTMLElement | null>;
notifyApiError?: NotifyApiError;
setStatusMessage?: SetStatusMessage;
}
interface UseDocumentUploadsResult {
dropOverlayState: DropOverlayState;
setDropOverlayState: Dispatch<SetStateAction<DropOverlayState>>;
dragCounterRef: MutableRefObject<number>;
handleFileDrop: (dataTransfer: DataTransfer, targetFolderId?: FolderId) => Promise<void>;
handleFileSelection: (files?: FileList | null, targetFolderId?: FolderId) => Promise<void>;
uploadFile: (file: File, targetFolderId: FolderId) => Promise<{
document: unknown;
duplicate: boolean;
statusCode: number | null;
conflictDocumentId: Identifier | null;
}>;
extractFilesFromDataTransfer: (dataTransfer: DataTransfer) => Promise<FileEntry[]>;
resetUploadsState: () => void;
uploadQueue: UploadQueueItem[];
clearUploadQueue: () => void;
}
const useDocumentUploads = ({
token,
selectedFolder,
currentFolderName,
ensureFolderData,
refreshCurrentFolder,
shellRef,
notifyApiError,
setStatusMessage,
}: UseDocumentUploadsArgs): UseDocumentUploadsResult => {
const [dropOverlayState, setDropOverlayState] = useState<DropOverlayState>({
active: false,
folderName: currentFolderName || DEFAULT_FOLDER_NAME,
});
const dragCounterRef = useRef(0);
const folderPathCacheRef = useRef<Map<string, FolderId>>(new Map());
const queueIdRef = useRef(0);
const [uploadQueue, setUploadQueue] = useState<UploadQueueItem[]>([]);
const uploadFile = useCallback(
async (file: File, targetFolderId: FolderId) => {
if (!file || file.size === 0) {
return { document: null, duplicate: false, statusCode: null, conflictDocumentId: null };
}
const formData = new FormData();
formData.append('file', file, file.name);
if (targetFolderId != null && targetFolderId !== 'root') {
formData.append('folder_id', String(targetFolderId));
}
try {
const { reused, document, status } = await uploadDocument(formData);
const duplicate = reused || status === 200;
return {
document: document ?? null,
duplicate,
statusCode: status ?? (duplicate ? 200 : 201),
conflictDocumentId: null,
};
} catch (error: any) {
if (error.response?.status === 409) {
const conflictId = error.response?.data?.details?.conflict_document_id ?? null;
let conflictDocument = null;
if (conflictId) {
try {
conflictDocument = await fetchDocument(conflictId);
} catch (fetchError) {
console.warn('[Uploads] failed to fetch conflict document', fetchError);
}
}
return {
document: conflictDocument,
duplicate: true,
statusCode: 409,
conflictDocumentId: conflictId,
};
}
const message = error.response?.data?.error || `Failed to upload ${file.name}.`;
notifyApiError?.(error, message);
setStatusMessage?.(message, 'error');
const wrapped = Object.assign(new Error(message), { response: error.response });
throw wrapped;
}
},
[notifyApiError, setStatusMessage],
);
const appendQueueItems = useCallback((entries: FileEntry[], targetFolderId?: FolderId) => {
const baseId = Date.now();
const items = entries.map(({ file }) => {
queueIdRef.current += 1;
return {
id: `upload-${baseId}-${queueIdRef.current}`,
name: file?.name || 'Unnamed file',
size: file?.size ?? null,
folderId: targetFolderId ?? selectedFolder ?? 'root',
status: 'pending' as UploadStatus,
error: null,
code: null,
document: null,
conflictDocumentId: null,
} satisfies UploadQueueItem;
});
if (items.length) {
setUploadQueue((current) => [...current, ...items]);
}
return items;
}, [selectedFolder]);
const updateQueueItem = useCallback((id: string, patch: Partial<UploadQueueItem>) => {
if (!id) {
return;
}
setUploadQueue((current) =>
current.map((item) => (item.id === id ? { ...item, ...patch } : item)),
);
}, []);
const ensureFolderPathOnServer = useCallback(
async (baseFolderId: FolderId, segments: string[]): Promise<FolderId> => {
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) ?? null;
}
const payload = {
parent_id: baseFolderId && baseFolderId !== 'root' ? baseFolderId : null,
segments: trimmedSegments,
};
const { folder } = await resolveFolderPath(payload);
const resolvedId = (folder?.id ?? null) as FolderId;
cache.set(cacheKey, resolvedId);
return resolvedId;
},
[],
);
const extractFilesFromDataTransfer = useCallback(async (dataTransfer: DataTransfer) => {
if (!dataTransfer) {
throw new Error('No drop payload found.');
}
const items = Array.from(dataTransfer.items || []) as ExtendedDataTransferItem[];
console.info('[Uploads] drop start', {
items: items.length,
files: (dataTransfer.files || []).length,
});
const results: FileEntry[] = [];
const seenKeys = new Set();
const pushFile = (file?: File | null, ancestors: string[] = []) => {
if (!file) return;
const segments = (ancestors || []).filter(Boolean);
const key = `${segments.join('/')}/${file.name}:${file.size}`;
if (seenKeys.has(key)) {
return;
}
seenKeys.add(key);
results.push({ file, segments });
};
const readAllEntries = async (reader: FileSystemDirectoryReaderLike) => {
const entries: FileSystemEntryLike[] = [];
let batch: FileSystemEntryLike[] = [];
do {
batch = await new Promise<FileSystemEntryLike[]>((resolve, reject) => reader.readEntries(resolve, reject));
if (batch.length) {
entries.push(...batch);
}
} while (batch.length);
return entries;
};
const walkEntry = async (entry: FileSystemEntryLike | null, ancestors: string[] = []) => {
if (!entry) return;
if (isFileEntry(entry)) {
const file = await new Promise<File>((resolve, reject) => {
try {
entry.file(resolve, reject);
} catch (error) {
console.warn('[Uploads] entry.file failed', error);
reject(error as Error);
}
});
pushFile(file, ancestors);
return;
}
if (isDirectoryEntry(entry)) {
const nextAncestors = entry.name ? [...ancestors, entry.name] : [...ancestors];
const reader = entry.createReader();
const entries = await readAllEntries(reader);
for (const child of entries) {
await walkEntry(child, nextAncestors);
}
}
};
await Promise.all(
items.map(async (item, index) => {
if (item.kind !== 'file') return;
const fileFromItem = item.getAsFile?.() ?? null;
if (fileFromItem) {
const relativePath = (fileFromItem as File & { webkitRelativePath?: string })?.webkitRelativePath ?? '';
const segments = relativePath
? relativePath
.split('/')
.slice(0, -1)
.filter(Boolean)
: [];
pushFile(fileFromItem, segments);
}
if ((item as ExtendedDataTransferItem).webkitGetAsEntry) {
try {
const entry = (item as ExtendedDataTransferItem).webkitGetAsEntry?.();
if (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;
const relativePath = (file as File & { webkitRelativePath?: string })?.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 uploadFileEntries = useCallback(
async (entries, targetFolderId) => {
if (!entries || !entries.length) {
console.warn('[Uploads] No files to upload.');
return;
}
const queueItems = appendQueueItems(entries, targetFolderId);
if (!token) {
queueItems.forEach((item) => {
const patch = {
status: 'error',
error: 'Please log in before uploading.',
code: null,
};
updateQueueItem(item.id, patch);
Object.assign(item, patch);
});
return;
}
try {
folderPathCacheRef.current.clear();
const baseFolderId =
targetFolderId && targetFolderId !== 'root' ? targetFolderId : null;
for (let index = 0; index < entries.length; index += 1) {
const { file, segments } = entries[index];
const queueItem = queueItems[index];
if (queueItem) {
const patch = { status: 'uploading', error: null, code: null };
updateQueueItem(queueItem.id, patch);
Object.assign(queueItem, patch);
}
const destinationId = segments.length
? await ensureFolderPathOnServer(baseFolderId, segments)
: baseFolderId;
const uploadTarget =
destinationId ??
(targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root');
try {
const { duplicate, statusCode, document, conflictDocumentId } = await uploadFile(
file,
uploadTarget,
);
if (queueItem) {
const patch = {
status: duplicate ? 'duplicate' : 'success',
code: statusCode ?? null,
document: document || queueItem.document,
conflictDocumentId: conflictDocumentId ?? queueItem.conflictDocumentId,
};
updateQueueItem(queueItem.id, patch);
Object.assign(queueItem, patch);
}
} catch (error: any) {
if (queueItem) {
const patch = {
status: 'error',
error: error.response?.data?.error || error.message || 'Upload failed.',
code: error.response?.status ?? null,
};
updateQueueItem(queueItem.id, patch);
Object.assign(queueItem, patch);
}
continue;
}
}
await refreshCurrentFolder();
if (
targetFolderId &&
targetFolderId !== 'root' &&
targetFolderId !== selectedFolder
) {
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
}
} catch (error: any) {
const message = error.message || 'Failed to upload files.';
queueItems.forEach((item) => {
if (item.status === 'success' || item.status === 'duplicate' || item.status === 'error') {
return;
}
const patch = {
status: 'error',
error: message,
code: error.response?.status ?? null,
};
updateQueueItem(item.id, patch);
Object.assign(item, patch);
});
console.error('[Uploads] batch failed', error);
}
},
[
token,
ensureFolderPathOnServer,
uploadFile,
refreshCurrentFolder,
selectedFolder,
ensureFolderData,
appendQueueItems,
updateQueueItem,
],
);
const handleFileDrop = useCallback(
async (dataTransfer: DataTransfer, targetFolderId?: FolderId) => {
let extracted: FileEntry[];
try {
extracted = await extractFilesFromDataTransfer(dataTransfer);
} catch (error) {
console.error('[Uploads] Failed to process dropped files.', error);
return;
}
await uploadFileEntries(extracted, targetFolderId);
},
[extractFilesFromDataTransfer, uploadFileEntries],
);
const handleFileSelection = useCallback(
async (files?: FileList | null, targetFolderId?: FolderId) => {
const entries = mapFilesToEntries(files);
await uploadFileEntries(entries, targetFolderId);
},
[uploadFileEntries],
);
useFileDrop({
shellRef,
token,
currentFolderName,
selectedFolder,
handleFileDrop,
hasFiles,
defaultFolderName: DEFAULT_FOLDER_NAME,
dragCounterRef,
setDropOverlayState,
});
const resetUploadsState = useCallback(() => {
dragCounterRef.current = 0;
setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME });
setUploadQueue([]);
}, []);
const clearUploadQueue = useCallback(() => {
setUploadQueue([]);
}, []);
return {
dropOverlayState,
setDropOverlayState,
dragCounterRef,
handleFileDrop,
handleFileSelection,
uploadFile,
extractFilesFromDataTransfer,
resetUploadsState,
uploadQueue,
clearUploadQueue,
} satisfies UseDocumentUploadsResult;
};
export default useDocumentUploads;
@@ -1,157 +0,0 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import DocumentsManager from '../../documents/DocumentsManager';
import type { DocumentId } from '../../types/identifiers';
import type { Document } from '../../types/documents';
interface FolderContentsEntry {
documents?: Document[];
[key: string]: unknown;
}
interface UseDocumentsOptions {
setFolderContents: Dispatch<SetStateAction<Map<string, FolderContentsEntry>>>;
fetchDocumentById?: (id: DocumentId) => Promise<Document | null>;
}
const useDocuments = ({
setFolderContents,
fetchDocumentById,
}: UseDocumentsOptions) => {
const managerRef = useRef(
new DocumentsManager<Document>(fetchDocumentById),
);
const [documents, setDocumentsState] = useState<Document[]>([]);
useEffect(() => {
managerRef.current.setFetcher(fetchDocumentById);
}, [fetchDocumentById]);
const setDocuments = useCallback(
(value: Document[] | ((prev: Document[]) => Document[])) => {
setDocumentsState((prev) => {
const resolved = typeof value === 'function' ? value(prev) : value;
if (!Array.isArray(resolved)) {
return resolved;
}
const { canonical } = managerRef.current.ingest(resolved);
return canonical;
});
},
[],
);
const mapDocumentCaches = useCallback(
(mapper: (doc: Document) => Document | undefined) => {
managerRef.current.map(mapper);
const lookupSnapshot = managerRef.current.getSnapshot();
setDocumentsState((prev) => {
if (!Array.isArray(prev) || prev.length === 0) {
return prev;
}
let changed = false;
const next = prev.map((doc) => {
const id = doc?.id;
if (id != null && lookupSnapshot.has(id as DocumentId)) {
const canonical = lookupSnapshot.get(id as DocumentId) as Document;
if (canonical !== doc) {
changed = true;
}
return canonical;
}
const updated = mapper(doc);
const nextDoc = updated === undefined ? doc : updated;
if (nextDoc !== doc) {
changed = true;
}
return nextDoc;
});
return changed ? next : 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 id = doc?.id;
if (id != null && lookupSnapshot.has(id as DocumentId)) {
const canonical = lookupSnapshot.get(id as DocumentId) as Document;
if (canonical !== doc) {
docsChanged = true;
}
return canonical;
}
const updated = mapper(doc);
const nextDoc = updated === undefined ? doc : updated;
if (nextDoc !== doc) {
docsChanged = true;
}
return nextDoc;
});
if (docsChanged) {
changed = true;
next.set(key, { ...contents, documents: updatedDocs });
} else {
next.set(key, contents);
}
});
return changed ? next : prev;
});
},
[setFolderContents],
);
const updateDocumentCaches = useCallback(
(documentId, updater) => {
if (!documentId) {
return;
}
mapDocumentCaches((doc) => {
if (!doc || doc.id !== documentId) {
return doc;
}
const updated = updater(doc);
return updated === undefined ? doc : updated;
});
},
[mapDocumentCaches],
);
const removeDocumentsFromLookup = useCallback(
(documentIds: Array<DocumentId>) => {
if (!Array.isArray(documentIds) || !documentIds.length) {
return;
}
managerRef.current.remove(documentIds);
},
[],
);
return {
documents,
setDocuments,
removeDocumentsFromLookup,
mapDocumentCaches,
updateDocumentCaches,
documentsManager: managerRef.current,
};
};
export default useDocuments;
File diff suppressed because it is too large Load Diff
-103
View File
@@ -1,103 +0,0 @@
import { MutableRefObject, useEffect } from 'react';
type FolderId = string | 'root' | null;
interface DropOverlayState {
active: boolean;
folderName: string | null;
}
interface UseFileDropOptions {
shellRef: MutableRefObject<HTMLElement | null>;
token?: string | null;
currentFolderName: string | null;
selectedFolder: FolderId;
handleFileDrop: (dataTransfer: DataTransfer, folderId: FolderId) => Promise<void>;
hasFiles: (event: DragEvent) => boolean;
defaultFolderName: string;
dragCounterRef: MutableRefObject<number>;
setDropOverlayState: (updater: ((prev: DropOverlayState) => DropOverlayState) | DropOverlayState) => void;
}
const useFileDrop = ({
shellRef,
token,
currentFolderName,
selectedFolder,
handleFileDrop,
hasFiles,
defaultFolderName,
dragCounterRef,
setDropOverlayState,
}: UseFileDropOptions) => {
useEffect(() => {
if (!token) {
setDropOverlayState((prev) => ({ ...prev, active: false }));
dragCounterRef.current = 0;
return undefined;
}
const handleDragEnter = (event: DragEvent) => {
if (!hasFiles(event)) return;
event.preventDefault();
dragCounterRef.current += 1;
setDropOverlayState({ active: true, folderName: currentFolderName });
};
const handleDragOver = (event: DragEvent) => {
if (!hasFiles(event)) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
};
const handleDragLeave = (event: DragEvent) => {
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: DragEvent) => {
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,
defaultFolderName,
selectedFolder,
hasFiles,
shellRef,
dragCounterRef,
setDropOverlayState,
]);
return null;
};
export default useFileDrop;
@@ -1,537 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getFolderTree, listFolderContents } from '../../lib/apiClient';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { createRootNode, DEFAULT_FOLDER_NAME } from '../../app/workspaceUtils';
import {
getEntryId,
isDocumentEntry,
isFolderEntry,
createDocumentEntryKey,
createFolderEntryKey,
} from '../../app/entryKey';
import type { FolderId as FolderIdentifier, Identifier } from '../../types/identifiers';
import type { Document } from '../../types/documents';
type FolderId = FolderIdentifier | 'root';
interface FolderSummary {
id?: FolderId;
name?: string;
parent_id?: FolderId | null;
parentId?: FolderId | null;
children?: FolderId[];
subfolders?: FolderSummary[];
has_children?: boolean;
hasChildren?: boolean;
[key: string]: unknown;
}
interface FolderContentsEntry {
folder?: FolderSummary | null;
documents?: Document[];
subfolders?: FolderSummary[];
__includesDocuments?: boolean;
__sortField?: string | null;
__sortDirection?: string | null;
[key: string]: unknown;
}
interface FolderTreeNode extends FolderSummary {
id: FolderId;
children: FolderId[];
expanded?: boolean;
loaded?: boolean;
hasChildren?: boolean;
}
interface SelectionHelpers {
focusedDocumentId: Identifier | null;
setFocusedDocumentId: Dispatch<SetStateAction<Identifier | null>>;
setSelectedEntries: Dispatch<SetStateAction<string[]>>;
setSelectionOrder: Dispatch<SetStateAction<string[]>>;
selectionOrderRef: MutableRefObject<string[] | null>;
selectionAnchorRef: MutableRefObject<string | null>;
}
interface UseFolderTreeOptions {
initialSelectedFolder?: FolderId;
tenantIdRef: MutableRefObject<Identifier | null>;
documentsSortFieldRef: MutableRefObject<string>;
documentsSortDirectionRef: MutableRefObject<string>;
selectionHelpers: SelectionHelpers;
setDocuments: Dispatch<SetStateAction<Document[]>>;
setFolderContents: Dispatch<SetStateAction<Map<FolderId, FolderContentsEntry>>>;
folderContentsRef: MutableRefObject<Map<FolderId, FolderContentsEntry>>;
}
interface FolderOption {
id: FolderId;
label: string;
}
const useFolderTree = ({
initialSelectedFolder = 'root',
tenantIdRef,
documentsSortFieldRef,
documentsSortDirectionRef,
selectionHelpers,
setDocuments,
setFolderContents,
folderContentsRef,
}: UseFolderTreeOptions) => {
const [folderNodes, setFolderNodes] = useState<Map<FolderId, FolderTreeNode>>(() => {
const rootNode = createRootNode() as FolderTreeNode;
return new Map([[rootNode.id, rootNode]]);
});
useEffect(() => {
const fetchTree = async () => {
try {
const data = await getFolderTree();
setFolderNodes((prev) => {
const next = new Map(prev);
const rootChildren: FolderId[] = [];
data.forEach((item) => {
const id = item.id as FolderId;
const parentId = (item.parent_id || 'root') as FolderId;
const children = (item.children || []).map((c) => c as FolderId);
next.set(id, {
id,
name: item.name,
parentId,
children,
expanded: false,
loaded: true,
hasChildren: children.length > 0,
});
if (parentId === 'root') {
rootChildren.push(id);
}
});
const root = next.get('root');
if (root) {
next.set('root', {
...(root as FolderTreeNode),
children: rootChildren,
hasChildren: rootChildren.length > 0,
loaded: true,
});
}
return next;
});
} catch (error) {
console.error('Failed to fetch folder tree', error);
}
};
fetchTree();
}, []);
const [selectedFolder, setSelectedFolder] = useState<FolderId>(initialSelectedFolder || 'root');
const [currentFolder, setCurrentFolder] = useState<FolderSummary | null>(null);
const [currentSubfolders, setCurrentSubfolders] = useState<FolderSummary[]>([]);
const {
focusedDocumentId,
setFocusedDocumentId,
setSelectedEntries,
setSelectionOrder,
selectionOrderRef,
selectionAnchorRef,
} = selectionHelpers;
const focusedDocumentIdRef = useRef(focusedDocumentId);
useEffect(() => {
focusedDocumentIdRef.current = focusedDocumentId;
}, [focusedDocumentId]);
const applySelectedFolder = useCallback(
(folderId: FolderId, contents?: FolderContentsEntry | null) => {
const subfolders = Array.isArray(contents?.subfolders) ? contents.subfolders : [];
const docs = Array.isArray(contents?.documents) ? contents.documents : [];
const folderInfo = contents?.folder ?? null;
setCurrentSubfolders(subfolders);
setDocuments(docs);
setCurrentFolder(folderInfo);
const availableDocKeys = docs
.map((doc) => createDocumentEntryKey(doc?.id as Identifier))
.filter(Boolean);
const availableDocKeySet = new Set(availableDocKeys);
const availableFolderKeys = new Set(
subfolders
.map((folder) => createFolderEntryKey(folder?.id as Identifier))
.filter(Boolean),
);
let nextDocKeys: string[] = [];
let mergedSelection: string[] = [];
setSelectedEntries((previous) => {
const previousFolderKeys = previous
.filter(isFolderEntry)
.filter((key) => availableFolderKeys.has(key));
const previousDocKeys = previous.filter(isDocumentEntry);
nextDocKeys = previousDocKeys.filter((key) => availableDocKeySet.has(key));
mergedSelection = [...previousFolderKeys, ...nextDocKeys];
return mergedSelection;
});
const nextFocus = (() => {
const currentFocusedId = focusedDocumentIdRef.current;
if (currentFocusedId) {
const currentFocusedKey = createDocumentEntryKey(currentFocusedId);
if (currentFocusedKey && availableDocKeySet.has(currentFocusedKey)) {
return currentFocusedId;
}
}
if (nextDocKeys.length) {
const lastDocKey = nextDocKeys[nextDocKeys.length - 1];
return getEntryId(lastDocKey) || null;
}
return null;
})();
setFocusedDocumentId(nextFocus);
const nextAnchor = mergedSelection.length ? mergedSelection[mergedSelection.length - 1] : null;
selectionAnchorRef.current = nextAnchor;
selectionOrderRef.current = mergedSelection;
setSelectionOrder(mergedSelection);
},
[
selectionAnchorRef,
selectionOrderRef,
setDocuments,
setFocusedDocumentId,
setSelectedEntries,
setSelectionOrder,
],
);
const expandFolderAncestors = useCallback((targetId: FolderId | null) => {
if (!targetId || targetId === 'root') {
return;
}
setFolderNodes((prev: Map<FolderId, FolderTreeNode>) => {
const next = new Map<FolderId, FolderTreeNode>(prev);
const node = next.get(targetId);
let currentId = node?.parentId ?? 'root';
let guard = 0;
while (currentId && guard < 32) {
guard += 1;
const currentNode = next.get(currentId);
if (!currentNode) break;
if (!currentNode.expanded) {
next.set(currentId, { ...currentNode, expanded: true });
}
currentId = currentNode.parentId ?? 'root';
}
return next;
});
}, []);
const ensureFolderData = useCallback(
async (
folderId: FolderId,
{
includeDocuments = true,
prefetchDepth = 0,
force = false,
sortField = documentsSortFieldRef.current,
sortDirection = documentsSortDirectionRef.current,
}: {
includeDocuments?: boolean;
prefetchDepth?: number;
force?: boolean;
sortField?: string;
sortDirection?: string;
} = {},
): Promise<FolderContentsEntry> => {
const requestTenantId = tenantIdRef.current;
const cached = folderContentsRef.current.get(folderId);
const cachedSortField = cached?.__sortField || documentsSortFieldRef.current;
const cachedSortDirection = cached?.__sortDirection || documentsSortDirectionRef.current;
const cachedSortMatches = cachedSortField === sortField && cachedSortDirection === sortDirection;
if (!force && cached) {
const includesDocuments = Boolean(cached.__includesDocuments);
if (!includeDocuments || (includesDocuments && cachedSortMatches)) {
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: Record<string, unknown> = {};
if (!includeDocuments) {
params.include_documents = false;
} else {
params.sort = sortField;
params.dir = sortDirection;
}
const data = await listFolderContents<FolderContentsEntry>(path, params);
const childFolders = Array.isArray(data.subfolders) ? data.subfolders : [];
const childIds = childFolders
.map((child) => (child?.id ?? null) as FolderId | null)
.filter((id): id is FolderId => Boolean(id));
const enriched = {
...data,
__includesDocuments: includeDocuments,
__sortField: includeDocuments ? sortField : cachedSortField,
__sortDirection: includeDocuments ? sortDirection : cachedSortDirection,
};
if (tenantIdRef.current !== requestTenantId) {
return enriched;
}
setFolderNodes((prev: Map<FolderId, FolderTreeNode>) => {
const next = new Map<FolderId, FolderTreeNode>(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 childId = (child?.id ?? null) as FolderId | null;
if (!childId) {
return;
}
const childNode = next.get(childId);
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;
}
const flag = [child?.has_children, child?.hasChildren, childNode?.hasChildren]
.find((value) => value != null);
return Boolean(flag);
})();
next.set(childId, {
id: childId,
name: child.name,
parentId: (child.parent_id ?? 'root') as FolderId,
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,
}),
),
);
}
setFolderContents((prev: Map<FolderId, FolderContentsEntry>) => {
if (tenantIdRef.current !== requestTenantId) {
return prev;
}
const next = new Map<FolderId, FolderContentsEntry>(prev);
if (includeDocuments) {
next.set(folderId, enriched);
} else {
const existingEntry = next.get(folderId);
if (existingEntry) {
next.set(folderId, {
...existingEntry,
...data,
documents: existingEntry.__includesDocuments
? existingEntry.documents
: data.documents,
__includesDocuments: existingEntry.__includesDocuments || false,
__sortField: existingEntry.__sortField ?? enriched.__sortField,
__sortDirection: existingEntry.__sortDirection ?? enriched.__sortDirection,
});
} else {
next.set(folderId, enriched);
}
}
return next;
});
return enriched;
},
[
documentsSortDirectionRef,
documentsSortFieldRef,
tenantIdRef,
setFolderContents,
folderContentsRef,
],
);
const ensureFolderAncestorsLoaded = useCallback(
async (targetId: FolderId | null) => {
if (!targetId || targetId === 'root') {
return;
}
let current = targetId;
let guard = 0;
while (current && current !== 'root' && guard < 32) {
guard += 1;
const node = folderNodes.get(current);
if (node?.loaded) {
current = node.parentId ?? 'root';
continue;
}
await ensureFolderData(current, { includeDocuments: false, prefetchDepth: 0 });
current = folderNodes.get(current)?.parentId ?? 'root';
}
},
[folderNodes, ensureFolderData],
);
const isInvalidFolderDrop = useCallback(
(sourceId: FolderId | null, targetId: FolderId | null) => {
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 resetFolderTreeState = useCallback(() => {
const rootNode = createRootNode() as FolderTreeNode;
setFolderNodes(new Map<FolderId, FolderTreeNode>([[rootNode.id, rootNode]]));
setFolderContents(new Map<FolderId, FolderContentsEntry>());
setSelectedFolder('root');
setCurrentFolder(null);
setCurrentSubfolders([]);
}, [setFolderContents]);
const currentFolderName = useMemo(() => {
if (selectedFolder === 'root' || !currentFolder) return DEFAULT_FOLDER_NAME;
return currentFolder.name;
}, [selectedFolder, currentFolder]);
const folderOptions: FolderOption[] = useMemo(() => {
const cache = new Map<FolderId, string>();
const computePath = (id: FolderId | null): string => {
if (cache.has(id as FolderId)) {
return cache.get(id as FolderId) as string;
}
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') as FolderId;
const parentPath = computePath(parentId);
const name = node.name || 'Folder';
const fullPath = parentId === 'root' ? name : `${parentPath}/${name}`;
cache.set(id, fullPath);
return fullPath;
};
const entries: FolderOption[] = [];
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<FolderId, string>();
folderOptions.forEach((option) => {
map.set(option.id, option.label);
});
return map;
}, [folderOptions]);
return {
folderNodes,
setFolderNodes,
selectedFolder,
setSelectedFolder,
currentFolder,
setCurrentFolder,
currentSubfolders,
setCurrentSubfolders,
currentFolderName,
folderOptions,
folderLabelMap,
applySelectedFolder,
ensureFolderData,
ensureFolderAncestorsLoaded,
expandFolderAncestors,
isInvalidFolderDrop,
resetFolderTreeState,
};
};
export default useFolderTree;
@@ -1,688 +0,0 @@
import { useCallback, useMemo } from 'react';
import type { DragEvent } from 'react';
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/workspaceUtils';
import {
createFolder,
deleteFolder,
moveFolder as moveFolderRequest,
renameFolder as renameFolderRequest,
} from '../../lib/apiClient';
import type { FolderId } from '../../types/identifiers';
import type { MessageOptions } from '../../types/documents';
type FolderKey = FolderId | 'root';
interface FolderNode {
id: FolderKey;
name?: string;
parentId?: FolderKey | null;
children: FolderId[];
expanded?: boolean;
loaded?: boolean;
hasChildren?: boolean;
}
interface FolderContentsState {
folder?: { id: FolderKey; name?: string };
subfolders?: any[];
documents?: any[];
[key: string]: unknown;
}
interface EnsureFolderOptions {
force?: boolean;
includeDocuments?: boolean;
prefetchDepth?: number;
}
interface LoadFolderOptions {
preserveSearch?: boolean;
}
interface SelectFolderOptions {
replace?: boolean;
immediate?: boolean;
}
interface FolderClickHandlers {
onToggle: (folderId: FolderKey) => Promise<void>;
onSelect: (folderId: FolderKey, options?: SelectFolderOptions) => Promise<void>;
onDrop: (event: DragEvent<HTMLElement>, folderId: FolderKey) => Promise<void>;
onDragOver: (event: DragEvent<HTMLElement>, folderId: FolderKey) => void;
onDragLeave: (event: DragEvent<HTMLElement>) => void;
}
interface UseFolderTreeActionsOptions {
token?: string | null;
folderNodes: Map<FolderKey, FolderNode>;
setFolderNodes: (updater: (prev: Map<FolderKey, FolderNode>) => Map<FolderKey, FolderNode>) => void;
selectedFolder: FolderKey;
setSelectedFolder: (folderId: FolderKey) => void;
ensureFolderData: (folderId: FolderKey, options?: EnsureFolderOptions) => Promise<any>;
ensureFolderAncestorsLoaded: (folderId: FolderKey) => Promise<void>;
expandFolderAncestors: (folderId: FolderKey, options?: { includeSelf?: boolean }) => void;
applySelectedFolder: (folderId: FolderKey, contents: any) => void;
notifyApiError: (error: unknown, message?: string) => void;
setStatusMessage: (message: string, level?: string) => void;
setFolderContents: (
updater: (prev: Map<FolderKey, FolderContentsState>) => Map<FolderKey, FolderContentsState>,
) => void;
setCurrentFolder: (updater: (prev: any) => any) => void;
setSearchResultIds: (value: any) => void;
isFilterActive: boolean;
navigate?: (path: string, options?: { replace?: boolean }) => void;
handleFileDrop: (dataTransfer: DataTransfer, folderId: FolderKey) => Promise<void> | void;
moveDocumentsToFolder: (docIds: FolderId[], folderId: FolderKey) => Promise<void>;
draggedDocumentIds: FolderId[];
draggedFolderId: FolderKey | null;
setDraggedDocumentIds: (ids: FolderId[]) => void;
setDraggedFolderId: (id: FolderKey | null) => void;
isInvalidFolderDrop: (sourceFolderId: FolderKey, targetFolderId: FolderKey) => boolean;
setCreatingFolder: (value: boolean) => void;
}
const useFolderTreeActions = ({
token,
folderNodes,
setFolderNodes,
selectedFolder,
setSelectedFolder,
ensureFolderData,
ensureFolderAncestorsLoaded,
expandFolderAncestors,
applySelectedFolder,
notifyApiError,
setStatusMessage,
setFolderContents,
setCurrentFolder,
setSearchResultIds,
isFilterActive,
navigate,
handleFileDrop,
moveDocumentsToFolder,
draggedDocumentIds,
draggedFolderId,
setDraggedDocumentIds,
setDraggedFolderId,
isInvalidFolderDrop,
setCreatingFolder,
}: UseFolderTreeActionsOptions) => {
const moveFolder = useCallback(
async (folderId: FolderKey, targetFolderId: FolderKey | null) => {
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 moveFolderRequest(folderId, parent_id);
setFolderNodes((prev) => {
const next = new Map(prev);
const currentNode = next.get(folderId);
if (!currentNode) {
return prev;
}
next.set(folderId, { ...currentNode, parentId: parent_id ?? null });
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]);
await Promise.all(
Array.from(refreshTargets).map((key) =>
ensureFolderData(key === 'root' ? 'root' : 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]);
await Promise.all(
Array.from(refreshTargets).map((key) =>
ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 }),
),
);
}
},
[
ensureFolderData,
folderNodes,
notifyApiError,
selectedFolder,
setFolderNodes,
setSelectedFolder,
setStatusMessage,
],
);
const loadFolder = useCallback(
async (folderId: FolderKey | null, { preserveSearch = false }: LoadFolderOptions = {}) => {
const targetId = folderId || 'root';
setSelectedFolder(targetId);
await ensureFolderAncestorsLoaded(targetId);
expandFolderAncestors(targetId);
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) {
setSearchResultIds(null);
}
} catch (error) {
notifyApiError(error, 'Failed to load folder contents.');
}
},
[
applySelectedFolder,
ensureFolderAncestorsLoaded,
ensureFolderData,
expandFolderAncestors,
notifyApiError,
setSearchResultIds,
setSelectedFolder,
],
);
const selectFolder = useCallback(
async (folderId: FolderKey | null, { replace = false, immediate = false }: SelectFolderOptions = {}) => {
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,
isFilterActive,
loadFolder,
navigate,
setSelectedFolder,
],
);
const handleFolderRename = useCallback(
async (folderId: FolderKey, nextName: string) => {
if (!token) {
setStatusMessage('Log in to rename folders.', 'error');
return false;
}
const trimmed = nextName?.trim?.() || '';
if (!trimmed) {
setStatusMessage('Folder name cannot be empty.', 'error');
return false;
}
try {
await renameFolderRequest(folderId, 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;
}
},
[
notifyApiError,
setCurrentFolder,
setFolderContents,
setFolderNodes,
setStatusMessage,
token,
],
);
const handleFolderCreate = useCallback(
async (name: string, parentId?: FolderKey | null) => {
if (!token) {
setStatusMessage('Log in to create folders.', 'error');
return false;
}
if (!name.trim()) {
setStatusMessage('Folder name cannot be empty.', 'error');
return false;
}
const targetParentId = parentId !== undefined
? (parentId === 'root' ? null : parentId)
: (selectedFolder === 'root' ? null : selectedFolder);
const payload = {
name: name.trim(),
parent_id: targetParentId,
};
setCreatingFolder(true);
let succeeded = false;
try {
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 = folderData.parent_id ?? payload.parent_id ?? 'root';
const parentNode = next.get(parentId);
if (parentNode) {
next.set(parentId, {
...parentNode,
children: parentNode.children.concat([folderData.id]),
loaded: true,
hasChildren: true,
});
}
next.set(folderData.id, {
id: folderData.id,
name: folderData.name ?? payload.name,
parentId: parentId,
children: folderData.children || [],
expanded: false,
loaded: false,
hasChildren: Array.isArray(folderData.children) ? folderData.children.length > 0 : false,
});
return next;
});
// Refresh the parent folder to ensure consistency
const refreshTarget = targetParentId || 'root';
await ensureFolderData(refreshTarget, { force: true, prefetchDepth: 1 });
await selectFolder(folderData.id, { immediate: true });
succeeded = true;
return true;
} catch (error) {
const message = error.response?.data?.error || 'Failed to create folder.';
notifyApiError(error, message);
return false;
} finally {
setCreatingFolder(false);
if (!succeeded) {
setStatusMessage('Folder creation failed.', 'error');
}
}
},
[
ensureFolderData,
notifyApiError,
selectFolder,
selectedFolder,
setCreatingFolder,
setFolderNodes,
setStatusMessage,
token,
],
);
const handleFolderDelete = useCallback(
async (folderId: FolderKey, { showMessage = true }: MessageOptions = {}) => {
if (!token) {
if (showMessage) {
setStatusMessage('Log in to manage folders.', 'error');
}
return false;
}
if (!folderId || folderId === 'root') {
if (showMessage) {
setStatusMessage('The root folder cannot be removed.', 'error');
}
return false;
}
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) {
if (showMessage) {
setStatusMessage('Folder must be empty before it can be deleted.', 'error');
}
return false;
}
await deleteFolder(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 });
}
if (showMessage) {
setStatusMessage('Folder deleted.', 'success');
}
return true;
} catch (error) {
const message = error.response?.data?.error || 'Failed to delete folder.';
notifyApiError(error, message);
if (showMessage) {
setStatusMessage(message, 'error');
}
return false;
}
},
[
token,
applySelectedFolder,
ensureFolderData,
folderNodes,
notifyApiError,
selectedFolder,
setFolderContents,
setFolderNodes,
setSelectedFolder,
setStatusMessage,
],
);
const folderClickHandlers: FolderClickHandlers = useMemo(
() => ({
onToggle: async (folderId: FolderKey) => {
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: DragEvent<HTMLElement>, folderId: FolderKey) => {
event.preventDefault();
event.stopPropagation();
event.currentTarget.classList.remove('is-drop-target');
let folderIds: FolderId[] = [];
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) {
await moveFolder(sourceId, folderId);
}
}
if (hasFiles(event)) {
await handleFileDrop(event.dataTransfer, folderId);
return;
}
let docIds: FolderId[] = [];
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: DragEvent<HTMLElement>, folderId: FolderKey) => {
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: DragEvent<HTMLElement>) => {
event.currentTarget.classList.remove('is-drop-target');
},
}),
[
draggedDocumentIds,
draggedFolderId,
ensureFolderData,
folderNodes,
handleFileDrop,
isInvalidFolderDrop,
moveDocumentsToFolder,
moveFolder,
notifyApiError,
selectFolder,
selectedFolder,
setDraggedDocumentIds,
setDraggedFolderId,
setFolderNodes,
setStatusMessage,
],
);
return {
loadFolder,
selectFolder,
handleFolderRename,
handleFolderCreate,
handleFolderDelete,
folderClickHandlers,
};
};
export default useFolderTreeActions;
-141
View File
@@ -1,141 +0,0 @@
import { MutableRefObject, useCallback, useState } from 'react';
import type { TagId, TenantId } from '../../types/identifiers';
import type { Tag } from '../../types/documents';
import { listTags, updateTag, createTag, deleteTag } from '../../lib/apiClient';
interface TagManagerInterface {
buildPayload: (input: { label?: string; color?: string | null }) => { label: string; color: string | null };
}
interface UseTagsOptions {
// apiClient removed
notifyApiError: (error: unknown, fallback: string) => void;
setStatusMessage: (message: string, variant?: string) => void;
tagManager: TagManagerInterface;
tenantIdRef: MutableRefObject<TenantId | null>;
setActiveTagFilters: (updater: (prev: Array<TagId>) => Array<TagId>) => void;
mapDocumentCaches?: (mapper: (doc: any) => any) => void;
}
const useTags = ({
// apiClient removed
notifyApiError,
setStatusMessage,
tagManager,
tenantIdRef,
setActiveTagFilters,
mapDocumentCaches,
}: UseTagsOptions) => {
const [tags, setTags] = useState<Tag[]>([]);
const refreshTags = useCallback(async () => {
const requestTenantId = tenantIdRef.current;
try {
const data = await listTags();
if (tenantIdRef.current !== requestTenantId) {
return;
}
setTags(data || []);
} catch (error) {
if (tenantIdRef.current !== requestTenantId) {
return;
}
notifyApiError(error, 'Unable to load tags.');
}
}, [notifyApiError, tenantIdRef]);
const handleTagUpdate = useCallback(
async (tagId: TagId, changes: { label?: string; color?: string | null }) => {
if (tagId == null) {
throw new Error('Missing tag identifier.');
}
const payload: Record<string, unknown> = {};
if (changes?.label != null) {
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 updateTag(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);
}
},
[notifyApiError, refreshTags, setStatusMessage],
);
const handleTagCreate = useCallback(
async ({ label, color }: { label?: string; color?: string | null } = {}) => {
const payload = tagManager.buildPayload({ label, color });
try {
await createTag(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);
}
},
[notifyApiError, refreshTags, setStatusMessage, tagManager],
);
const handleTagDelete = useCallback(
async (tagId: TagId) => {
if (tagId == null) {
throw new Error('Missing tag identifier.');
}
try {
await deleteTag(tagId);
setActiveTagFilters((prev) => prev.filter((id) => id !== tagId));
const stripTagFromDoc = (doc: any) => {
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);
}
},
[mapDocumentCaches, notifyApiError, refreshTags, setActiveTagFilters, setStatusMessage],
);
return {
tags,
refreshTags,
handleTagUpdate,
handleTagCreate,
handleTagDelete,
setTags,
};
};
export default useTags;
@@ -1,115 +0,0 @@
import { MutableRefObject, useCallback } from 'react';
import type { NavigateFunction } from 'react-router-dom';
import type { FolderId, TenantId } from '../../types/identifiers';
import { api, listTenants, switchTenant } from '../../lib/apiClient';
interface TenantOption {
id?: TenantId;
name?: string;
}
interface UseTenantManagerOptions {
appDispatch: (action: any) => void;
currentTenantId: TenantId | null;
resetWorkspaceState: () => void;
setStatusMessage: (message: string, variant?: string) => void;
notifyApiError: (error: unknown, message: string) => void;
refreshTags: () => Promise<void>;
refreshCorrespondents: () => Promise<void>;
loadFolder: (folderId: FolderId, options?: { preserveSearch?: boolean }) => Promise<void>;
handleDocumentsViewModeChange: (mode: string) => void;
navigate: NavigateFunction;
tokenRef?: MutableRefObject<string | null>;
tenantIdRef?: MutableRefObject<TenantId | null>;
}
const useTenantManager = ({
appDispatch,
currentTenantId,
resetWorkspaceState,
setStatusMessage,
notifyApiError,
refreshTags,
refreshCorrespondents,
loadFolder,
handleDocumentsViewModeChange,
navigate,
tokenRef,
tenantIdRef,
}: UseTenantManagerOptions) => {
const handleTenantSelect = useCallback(
async (tenantOption: TenantOption | null, { refreshOnly = false }: { refreshOnly?: boolean } = {}) => {
const requestedTenantId = tenantOption?.id ?? null;
if (!refreshOnly && (!requestedTenantId || requestedTenantId === currentTenantId)) {
return;
}
try {
if (refreshOnly) {
const data = await listTenants();
appDispatch({
type: 'SET_TENANTS',
tenants: data,
});
return;
}
const data = await switchTenant(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}`;
if (tokenRef) {
tokenRef.current = data.access_token;
}
if (tenantIdRef) {
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', { preserveSearch: false });
const tenantLabel = data?.tenant?.name || data?.tenant?.id || 'tenant';
setStatusMessage(`Switched to ${tenantLabel}.`, 'info');
} catch (error) {
notifyApiError(error, 'Failed to switch tenant.');
}
},
[
appDispatch,
currentTenantId,
handleDocumentsViewModeChange,
loadFolder,
navigate,
notifyApiError,
refreshCorrespondents,
refreshTags,
resetWorkspaceState,
setStatusMessage,
tenantIdRef,
tokenRef,
],
);
return { handleTenantSelect };
};
export default useTenantManager;
@@ -1,105 +0,0 @@
import React, { useEffect, useMemo } from 'react';
import { DEFAULT_FOLDER_NAME } from '../../app/workspaceUtils';
import type { FolderId as FolderIdentifier } from '../../types/identifiers';
type FolderId = FolderIdentifier | '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 = '…';
let parentId: FolderId | null = 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;
@@ -1,62 +0,0 @@
import { useEffect } from 'react';
import type { MutableRefObject } from 'react';
import type { Identifier } from '../../types/identifiers';
interface UseWorkspaceSelectionSyncArgs {
showingSearchResults: boolean;
searchQuery: string;
setSelectedEntries: (entries: Array<string>) => void;
setSelectionOrder: (order: Array<string>) => void;
selectionOrderRef: MutableRefObject<Array<string>>;
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;
@@ -1,134 +0,0 @@
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';
import type { Identifier } from '../../types/identifiers';
interface UseWorkspaceTaxonomiesArgs {
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 = ({
notifyApiError,
setStatusMessage,
tagManager,
tenantIdRef,
currentTenantId,
setActiveTagFilters,
mapDocumentCaches,
updateDocumentCaches,
token,
}: UseWorkspaceTaxonomiesArgs) => {
const {
tags,
refreshTags,
handleTagCreate,
handleTagUpdate,
handleTagDelete,
setTags,
} = useTags({
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({
notifyApiError,
setStatusMessage,
tenantIdRef,
mapDocumentCaches,
});
const {
correspondentLookupByName,
handleDocumentCorrespondentAttach,
handleCorrespondentRemove,
handleCorrespondentAdd,
} = useDocumentCorrespondentActions({
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;