typescript

This commit is contained in:
2025-11-13 01:07:55 +01:00
parent b812d748ea
commit ada089c05b
147 changed files with 7427 additions and 2534 deletions
@@ -1,13 +0,0 @@
import { useCallback, useState } from 'react';
export const useDocumentsStore = () => {
const [status, setStatus] = useState(null);
const setStatusMessage = useCallback((message, variant = 'info') => {
setStatus(message ? { message, variant } : null);
}, []);
return { status, setStatusMessage };
};
export default useDocumentsStore;
@@ -0,0 +1,20 @@
import { useCallback, useState } from 'react';
export type StatusVariant = 'info' | 'success' | 'error';
export interface StatusMessage {
message: string;
variant: StatusVariant;
}
export const useDocumentsStore = () => {
const [status, setStatus] = useState<StatusMessage | null>(null);
const setStatusMessage = useCallback((message?: string | null, variant: StatusVariant = 'info') => {
setStatus(message ? { message, variant } : null);
}, []);
return { status, setStatusMessage };
};
export default useDocumentsStore;
@@ -1,4 +1,52 @@
import { useCallback, useEffect, useRef } from 'react';
import type { MutableRefObject } from 'react';
import type { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
import { AxiosHeaders } from 'axios';
type AppStatus = string;
type AppDispatch = (action: { type: string; [key: string]: unknown }) => void;
type NotifyApiError = (error: unknown, fallbackMessage: string, variant?: string) => void;
type SetStatusMessage = (message: string, variant?: string) => void;
type SetLoading = (state: boolean) => void;
interface RetryableAxiosRequestConfig extends InternalAxiosRequestConfig {
_retry?: boolean;
}
interface UseAuthManagerArgs {
apiClient: AxiosInstance;
token?: string | null;
appStatus: AppStatus;
appDispatch: AppDispatch;
notifyApiError: NotifyApiError;
setStatusMessage: SetStatusMessage;
setLoading: SetLoading;
}
interface UseAuthManagerResult {
tokenRef: MutableRefObject<string | null | undefined>;
refreshAccessToken: () => Promise<string>;
handleLogout: () => Promise<void>;
}
const ensureAxiosHeaders = (
headers?: InternalAxiosRequestConfig['headers'],
): AxiosHeaders => {
if (headers instanceof AxiosHeaders) {
return headers;
}
return AxiosHeaders.from(headers || {});
};
const setHeaderAuthorization = (config: InternalAxiosRequestConfig, token: string): void => {
const headers = ensureAxiosHeaders(config.headers);
headers.set('Authorization', `Bearer ${token}`);
config.headers = headers;
};
const useAuthManager = ({
apiClient,
@@ -8,16 +56,16 @@ const useAuthManager = ({
notifyApiError,
setStatusMessage,
setLoading,
}) => {
const tokenRef = useRef(token);
const refreshPromiseRef = useRef(null);
}: UseAuthManagerArgs): UseAuthManagerResult => {
const tokenRef = useRef<string | null | undefined>(token);
const refreshPromiseRef = useRef<Promise<string> | null>(null);
const initialRefreshAttemptedRef = useRef(Boolean(token));
const refreshAccessToken = useCallback(async () => {
const refreshAccessToken = useCallback(async (): Promise<string> => {
console.log('[Auth] Attempting to refresh access token…');
appDispatch({ type: 'TOKEN_REFRESH_START' });
try {
const { data } = await apiClient.post('/auth/refresh');
const { data } = await apiClient.post<{ access_token?: string; tenant?: unknown }>('/auth/refresh');
if (data?.access_token) {
appDispatch({
type: 'TOKEN_REFRESH_SUCCESS',
@@ -30,7 +78,7 @@ const useAuthManager = ({
throw new Error('Missing access token in refresh response');
} catch (error) {
console.warn('[Auth] Failed to refresh access token', error);
appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: error?.message || null });
appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: (error as Error)?.message || null });
throw error;
}
}, [apiClient, appDispatch]);
@@ -51,10 +99,11 @@ const useAuthManager = ({
const requestInterceptor = apiClient.interceptors.request.use((config) => {
const currentToken = tokenRef.current;
if (currentToken) {
config.headers = config.headers || {};
if (!config.headers.Authorization) {
config.headers.Authorization = `Bearer ${currentToken}`;
const headers = ensureAxiosHeaders(config.headers);
if (!headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${currentToken}`);
}
config.headers = headers;
}
return config;
});
@@ -62,7 +111,8 @@ const useAuthManager = ({
const responseInterceptor = apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const { response, config } = error;
const axiosError = error as AxiosError & { config?: RetryableAxiosRequestConfig };
const { response, config } = axiosError;
if (!response || !config) {
return Promise.reject(error);
}
@@ -90,13 +140,12 @@ const useAuthManager = ({
throw new Error('No token returned from refresh');
}
config._retry = true;
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${newToken}`;
setHeaderAuthorization(config, newToken);
console.log('[Auth] Retrying original request', url);
try {
return await apiClient(config);
} catch (retryError) {
if (retryError?.response?.status === 401) {
if ((retryError as AxiosError)?.response?.status === 401) {
notifyApiError(retryError, 'Session expired. Please log in again.');
}
throw retryError;
@@ -1,4 +1,25 @@
import { useCallback, useState } from 'react';
import { MutableRefObject, useCallback, useState } from 'react';
type ApiClient = {
get: (path: string) => Promise<{ data: unknown }>;
post: (path: string, body: unknown) => Promise<{ data: unknown }>;
patch: (path: string, body: unknown) => Promise<{ data: unknown }>;
delete: (path: string) => Promise<{ data: unknown }>;
};
interface CorrespondentEntry {
id?: string | number;
name?: string;
[key: string]: unknown;
}
interface UseCorrespondentsOptions {
apiClient: ApiClient;
notifyApiError: (error: unknown, fallback: string) => void;
setStatusMessage: (message: string, variant?: string) => void;
tenantIdRef: MutableRefObject<string | number | null>;
mapDocumentCaches?: (mapper: (doc: any) => any) => void;
}
const useCorrespondents = ({
apiClient,
@@ -6,8 +27,8 @@ const useCorrespondents = ({
setStatusMessage,
tenantIdRef,
mapDocumentCaches,
}) => {
const [correspondents, setCorrespondents] = useState([]);
}: UseCorrespondentsOptions) => {
const [correspondents, setCorrespondents] = useState<CorrespondentEntry[]>([]);
const refreshCorrespondents = useCallback(async () => {
const requestTenantId = tenantIdRef.current;
@@ -26,12 +47,12 @@ const useCorrespondents = ({
}, [apiClient, notifyApiError, tenantIdRef]);
const handleCorrespondentUpdate = useCallback(
async (correspondentId, changes) => {
async (correspondentId: string | number | null | undefined, changes: { name?: string }) => {
if (!correspondentId) {
throw new Error('Missing correspondent identifier.');
}
const payload = {};
const payload: Record<string, unknown> = {};
if (typeof changes?.name?.trim === 'function') {
const trimmed = changes.name.trim();
if (!trimmed) {
@@ -59,7 +80,7 @@ const useCorrespondents = ({
);
const handleCorrespondentCreate = useCallback(
async ({ name }) => {
async ({ name }: { name?: string }) => {
const trimmed = name?.trim?.() || '';
if (!trimmed) {
throw new Error('Correspondent name is required.');
@@ -79,12 +100,12 @@ const useCorrespondents = ({
);
const handleCorrespondentDelete = useCallback(
async (correspondentId) => {
async (correspondentId: string | number | null | undefined) => {
if (!correspondentId) {
throw new Error('Missing correspondent identifier.');
}
const stripFromDoc = (doc) => {
const stripFromDoc = (doc: any) => {
if (!doc || !Array.isArray(doc.correspondents)) {
return doc;
}
@@ -1,5 +1,25 @@
import { useCallback, useMemo } from 'react';
type ApiClient = {
post: (path: string, body?: unknown) => Promise<{ data: unknown }>;
delete: (path: string) => Promise<{ data: unknown }>;
};
interface CorrespondentOption {
id?: string | number;
name?: string;
[key: string]: unknown;
}
interface UseDocumentCorrespondentActionsArgs {
apiClient: ApiClient;
correspondents: CorrespondentOption[];
handleCorrespondentCreate: (payload: { name: string }) => Promise<CorrespondentOption | null | undefined>;
refreshCurrentFolder: () => Promise<void>;
notifyApiError: (error: unknown, fallback: string) => void;
setStatusMessage: (message: string, variant?: string) => void;
}
const useDocumentCorrespondentActions = ({
apiClient,
correspondents,
@@ -7,9 +27,9 @@ const useDocumentCorrespondentActions = ({
refreshCurrentFolder,
notifyApiError,
setStatusMessage,
}) => {
}: UseDocumentCorrespondentActionsArgs) => {
const correspondentLookupByName = useMemo(() => {
const map = new Map();
const map = new Map<string, CorrespondentOption>();
correspondents.forEach((correspondent) => {
if (correspondent?.name) {
map.set(correspondent.name.toLowerCase(), correspondent);
@@ -19,7 +39,10 @@ const useDocumentCorrespondentActions = ({
}, [correspondents]);
const handleDocumentCorrespondentAttach = useCallback(
async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => {
async (
{ documentId, correspondentId }: { documentId?: string | number | null; correspondentId?: string | number | null },
{ notify = true, refresh = true }: { notify?: boolean; refresh?: boolean } = {},
) => {
if (!documentId || !correspondentId) {
throw new Error('Missing document or correspondent.');
}
@@ -45,7 +68,10 @@ const useDocumentCorrespondentActions = ({
);
const handleCorrespondentRemove = useCallback(
async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => {
async (
{ documentId, correspondentId }: { documentId?: string | number | null; correspondentId?: string | number | null },
{ notify = true, refresh = true }: { notify?: boolean; refresh?: boolean } = {},
) => {
if (!documentId || !correspondentId) {
throw new Error('Missing document or correspondent.');
}
@@ -68,7 +94,7 @@ const useDocumentCorrespondentActions = ({
);
const handleCorrespondentAdd = useCallback(
async ({ document, name, input = null, option = null }) => {
async ({ document, name, input = null, option = null }: { document?: { id?: string | number }; name?: string; input?: HTMLInputElement | null; option?: CorrespondentOption | string | null }) => {
if (!document?.id) {
throw new Error('Missing document for correspondent assignment.');
}
@@ -1,4 +1,38 @@
import { useCallback, useEffect, useRef } from 'react';
import type { DragEvent } from 'react';
type Identifier = string | number;
type FolderIdentifier = Identifier | 'root';
interface DocumentLike {
id?: Identifier | null;
title?: string;
[key: string]: unknown;
}
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: FolderIdentifier[];
applySelection: ApplySelectionFn;
handleEntrySelection: HandleEntrySelectionFn;
documentLookup: Map<Identifier, DocumentLike>;
setDraggedDocumentIds: (ids: Identifier[] | []) => void;
setDraggedFolderId: (id: FolderIdentifier | null) => void;
resolveDocumentRowKey: (id: Identifier) => string | null | undefined;
resolveFolderRowKey: (id: FolderIdentifier) => string | null | undefined;
documentsViewMode: string;
}
const useDocumentDragHandlers = ({
selectedEntries,
@@ -12,8 +46,8 @@ const useDocumentDragHandlers = ({
resolveDocumentRowKey,
resolveFolderRowKey,
documentsViewMode,
}) => {
const dragPreviewRef = useRef(null);
}: UseDocumentDragHandlersOptions) => {
const dragPreviewRef = useRef<HTMLDivElement | null>(null);
const destroyDragPreview = useCallback(() => {
const node = dragPreviewRef.current;
@@ -26,7 +60,7 @@ const useDocumentDragHandlers = ({
useEffect(() => destroyDragPreview, [destroyDragPreview]);
const createDragPreview = useCallback(
({ documents = [], folders = [] } = {}) => {
({ documents = [], folders = [] }: { documents?: DocumentLike[]; folders?: Array<FolderIdentifier | Identifier> } = {}) => {
destroyDragPreview();
const docEntries = (documents || []).filter(Boolean);
@@ -155,8 +189,11 @@ const useDocumentDragHandlers = ({
);
const handleDocumentDragStart = useCallback(
(event, documentOrId) => {
const documentId = documentOrId?.id ?? (typeof documentOrId?.trim === 'function' ? documentOrId : null);
(event: DragEvent<HTMLElement>, documentOrId: DocumentLike | Identifier | null | undefined) => {
const documentId =
(documentOrId as DocumentLike)?.id ?? (typeof documentOrId === 'string' || typeof documentOrId === 'number'
? documentOrId
: null);
if (!documentId) {
return;
}
@@ -168,12 +205,12 @@ const useDocumentDragHandlers = ({
const isGridView = documentsViewMode === 'grid';
const isAlreadySelected = selectedDocumentIds.includes(documentId);
const selection = isAlreadySelected
const selection: Identifier[] = isAlreadySelected
? [...selectedDocumentIds]
: isGridView
? [...selectedDocumentIds, documentId]
: [documentId];
const folderSelection = [];
const folderSelection: FolderIdentifier[] = [];
if (!isAlreadySelected && !isGridView) {
applySelection([documentKey], {
@@ -182,7 +219,9 @@ const useDocumentDragHandlers = ({
});
}
const previewDocs = selection.map((id) => documentLookup.get(id) || null).filter(Boolean);
const previewDocs = selection
.map((id) => documentLookup.get(id) || documentLookup.get(String(id)) || null)
.filter(Boolean);
const previewNode = createDragPreview({
documents: previewDocs,
folders: folderSelection,
@@ -230,7 +269,7 @@ const useDocumentDragHandlers = ({
);
const handleDocumentDragEnd = useCallback(
(event) => {
(event: DragEvent<HTMLElement>) => {
setDraggedDocumentIds([]);
event.currentTarget.classList.remove('dragging');
destroyDragPreview();
@@ -240,7 +279,7 @@ const useDocumentDragHandlers = ({
);
const handleFolderDragStart = useCallback(
(event, folderId) => {
(event: DragEvent<HTMLElement>, folderId: FolderIdentifier) => {
if (folderId === 'root') {
return;
}
@@ -248,8 +287,8 @@ const useDocumentDragHandlers = ({
const folderKey = resolveFolderRowKey(folderId);
const isAlreadySelected = folderKey ? selectedEntries.includes(folderKey) : false;
let effectiveFolderSelection = selectedFolderIds;
let effectiveDocumentSelection = selectedDocumentIds;
let effectiveFolderSelection: FolderIdentifier[] = selectedFolderIds;
let effectiveDocumentSelection: Identifier[] = selectedDocumentIds;
if (!isAlreadySelected && folderKey) {
effectiveFolderSelection = [folderId];
@@ -287,7 +326,7 @@ const useDocumentDragHandlers = ({
const previewNode = createDragPreview({
documents: effectiveDocumentSelection
.map((id) => documentLookup.get(id) || null)
.map((id) => documentLookup.get(id) || documentLookup.get(String(id)) || null)
.filter(Boolean),
folders: uniqueFolders,
});
@@ -313,7 +352,7 @@ const useDocumentDragHandlers = ({
);
const handleFolderDragEnd = useCallback(
(event) => {
(event?: DragEvent<HTMLElement>) => {
if (event?.currentTarget) {
event.currentTarget.classList.remove('dragging');
}
@@ -1,12 +1,182 @@
import { useCallback } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { DEFAULT_FOLDER_NAME, getRowId, isDocumentRowKey } from '../../app/appLayoutUtils';
const normalizeDocumentId = (value) => {
type DocumentId = string | number;
type FolderId = DocumentId | 'root';
type NullableFolderId = FolderId | null;
type StatusLevel = 'success' | 'error' | 'info' | string;
type DocumentCacheMapper = (
doc: DocumentLike | null | undefined,
) => DocumentLike | null | undefined;
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 ApiClient {
post<T = unknown>(url: string, data?: unknown, config?: Record<string, unknown>): Promise<{ data: T }>;
patch<T = unknown>(url: string, data?: unknown, config?: Record<string, unknown>): Promise<{ data: T }>;
delete<T = unknown>(url: string, config?: Record<string, unknown>): Promise<{ data: T }>;
}
interface Tag {
id: DocumentId;
label: string;
color?: string | null;
[key: string]: unknown;
}
interface DocumentLike {
id?: DocumentId;
folder_id?: NullableFolderId;
folder_path?: string | null;
folder_name?: string | null;
issued_at?: number | null;
title?: string;
tags?: Tag[];
[key: string]: unknown;
}
interface FolderContents {
documents?: DocumentLike[];
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 DeleteOptions {
showMessage?: boolean;
manageLoading?: boolean;
}
interface TagAttachArgs {
documentId?: DocumentId;
tagId?: DocumentId;
tag?: Tag | null;
}
interface TagRemoveOptions {
refreshTagList?: boolean;
showMessage?: boolean;
}
interface FolderDeleteOptions {
showMessage?: boolean;
manageLoading?: boolean;
}
interface UseDocumentMutationsArgs {
api: ApiClient;
token?: string | null;
documentLookup: Map<DocumentId, DocumentLike>;
folderLabelMap: Map<FolderId, string>;
ensureFolderData: EnsureFolderData;
selectedFolder: FolderId;
setSelectedFolder: Dispatch<SetStateAction<FolderId>>;
setDocuments: Dispatch<SetStateAction<DocumentLike[]>>;
setFolderContents: Dispatch<SetStateAction<Map<FolderId, FolderContents>>>;
setSearchResults: Dispatch<SetStateAction<DocumentLike[] | 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;
setFocusedRowKey: Dispatch<SetStateAction<string | null>>;
focusedRowKey: string | null;
notifyApiError: NotifyApiError;
setStatusMessage: SetStatusMessage;
setLoading: (next: boolean) => void;
mapDocumentCaches: MapDocumentCaches;
applySelectedFolder: ApplySelectedFolder;
folderNodes: Map<FolderId, FolderNode>;
setFolderNodes: Dispatch<SetStateAction<Map<FolderId, FolderNode>>>;
removeDocumentsFromCaches: RemoveDocumentsFromCaches;
closeDocumentPreview: CloseDocumentPreview;
previewDocumentId?: DocumentId | null;
refreshCurrentFolder: () => Promise<void>;
documentsViewMode?: string;
updateDocumentCaches: UpdateDocumentCaches;
tagLookupById: Map<DocumentId, Tag>;
tags: Tag[];
refreshTags: () => Promise<void>;
tagManager: TagManager;
extractDocumentFromResponse?: (payload: unknown) => DocumentLike | null | undefined;
}
interface UseDocumentMutationsResult {
moveDocumentsToFolder: (
documentIds: Array<DocumentId | DocumentLike>,
targetFolderId?: NullableFolderId,
) => Promise<void>;
handleThumbnailRegeneration: (documentId: DocumentId) => Promise<void>;
handleDocumentsDelete: (
documentIds: DocumentId[],
options?: DeleteOptions,
) => Promise<boolean>;
handleDocumentTagAdd: (
document: DocumentLike,
label: string,
extras?: DocumentTagExtras | null,
) => Promise<void>;
handleDocumentTagAttach: (args: TagAttachArgs) => Promise<boolean>;
handleDocumentTitleUpdate: (documentId: DocumentId, nextTitle: string) => Promise<boolean>;
handleDocumentIssuedUpdate: (
documentId: DocumentId,
nextIssuedDate: number | null,
) => Promise<boolean>;
handleTagRemove: (
documentId?: DocumentId,
tagId?: DocumentId,
options?: TagRemoveOptions,
) => Promise<boolean>;
handleFolderDelete: (folderId?: FolderId, options?: FolderDeleteOptions) => Promise<boolean>;
}
const normalizeDocumentId = (value: unknown): DocumentId | null => {
if (!value) return null;
if (typeof value === 'object' && value.id) {
return value.id;
if (typeof value === 'object' && value !== null && 'id' in value && value.id != null) {
return value.id as DocumentId;
}
return value;
return value as DocumentId;
};
const useDocumentMutations = ({
@@ -46,36 +216,34 @@ const useDocumentMutations = ({
refreshTags,
tagManager,
extractDocumentFromResponse,
}) => {
}: UseDocumentMutationsArgs): UseDocumentMutationsResult => {
const moveDocumentsToFolder = useCallback(
async (documentIds, targetFolderId) => {
async (documentIds: Array<DocumentId | DocumentLike>, targetFolderId?: NullableFolderId) => {
const uniqueIds = Array.from(
new Set((documentIds || []).map((value) => normalizeDocumentId(value)).filter(Boolean)),
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;
const target = targetFolderId === 'root' ? null : targetFolderId ?? null;
const targetLabel =
target === null
? DEFAULT_FOLDER_NAME
: folderLabelMap.get(targetFolderId) || 'target folder';
target === null ? DEFAULT_FOLDER_NAME : folderLabelMap.get(targetFolderId as FolderId) || 'target folder';
const movedDocs = uniqueIds
.map((id) => {
const doc = documentLookup.get(id);
const doc = documentLookup.get(id) || null;
if (!doc) {
return null;
}
return {
id,
sourceFolderId: doc.folder_id ?? null,
sourceFolderId: (doc.folder_id ?? null) as NullableFolderId,
document: doc,
};
})
.filter(Boolean);
.filter(Boolean) as Array<{ id: DocumentId; sourceFolderId: NullableFolderId; document: DocumentLike }>;
const updatedDocsMap = new Map();
const updatedDocsMap = new Map<DocumentId, DocumentLike>();
const resolveTargetName = () => {
if (!targetLabel) {
return null;
@@ -89,7 +257,7 @@ const useDocumentMutations = ({
if (!document) {
return;
}
const updated = {
const updated: DocumentLike = {
...document,
folder_id: target,
};
@@ -105,13 +273,13 @@ const useDocumentMutations = ({
updatedDocsMap.set(id, updated);
});
const pruneRow = (collection) =>
const pruneRow = (collection: string[]): string[] =>
collection.filter((key) => {
if (!isDocumentRowKey(key)) {
return true;
}
const id = getRowId(key);
return id ? !uniqueIdSet.has(id) : true;
return id ? !uniqueIdSet.has(id as DocumentId) : true;
});
setLoading(true);
@@ -131,10 +299,10 @@ const useDocumentMutations = ({
if (updatedDocsMap.size) {
mapDocumentCaches((doc) => {
if (!doc || !uniqueIdSet.has(doc.id)) {
if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) {
return doc;
}
const updated = updatedDocsMap.get(doc.id);
const updated = updatedDocsMap.get(doc.id as DocumentId);
if (updated) {
return updated;
}
@@ -142,7 +310,7 @@ const useDocumentMutations = ({
});
} else {
mapDocumentCaches((doc) => {
if (!doc || !uniqueIdSet.has(doc.id)) {
if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) {
return doc;
}
return { ...doc, folder_id: target };
@@ -154,10 +322,10 @@ const useDocumentMutations = ({
if (!Array.isArray(prev) || !prev.length) {
return prev;
}
const filtered = prev.filter((doc) => doc && !uniqueIdSet.has(doc.id));
const filtered = prev.filter((doc) => doc && !uniqueIdSet.has(doc.id as DocumentId));
return filtered.length === prev.length ? prev : filtered;
});
setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id)));
setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id as DocumentId)));
setFolderContents((prev) => {
if (!prev.size) {
return prev;
@@ -165,7 +333,7 @@ const useDocumentMutations = ({
let changed = false;
const next = new Map(prev);
movedDocs.forEach(({ id, sourceFolderId }) => {
const sourceKey = sourceFolderId || 'root';
const sourceKey = (sourceFolderId || 'root') as FolderId;
const entry = next.get(sourceKey);
if (!entry?.documents?.length) {
return;
@@ -179,13 +347,14 @@ const useDocumentMutations = ({
return changed ? next : prev;
});
setSelectedEntries((prev) => pruneRow(prev, uniqueIdSet));
setSelectionOrder((prev) => pruneRow(prev, uniqueIdSet));
selectionOrderRef.current = pruneRow(selectionOrderRef.current || [], uniqueIdSet);
setSelectedEntries((prev) => pruneRow(prev));
setSelectionOrder((prev) => pruneRow(prev));
const nextSelectionOrder = pruneRow(selectionOrderRef.current || []);
selectionOrderRef.current = nextSelectionOrder;
if (
selectionAnchorRef.current &&
isDocumentRowKey(selectionAnchorRef.current) &&
uniqueIdSet.has(getRowId(selectionAnchorRef.current))
uniqueIdSet.has(getRowId(selectionAnchorRef.current) as DocumentId)
) {
selectionAnchorRef.current = null;
}
@@ -195,17 +364,17 @@ const useDocumentMutations = ({
if (
focusedRowKey &&
isDocumentRowKey(focusedRowKey) &&
uniqueIdSet.has(getRowId(focusedRowKey))
uniqueIdSet.has(getRowId(focusedRowKey) as DocumentId)
) {
setFocusedRowKey(null);
}
}
if (targetFolderId && targetFolderId !== selectedFolder) {
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
await ensureFolderData(targetFolderId as FolderId, { force: true, prefetchDepth: 1 });
}
} catch (error) {
const message = error.response?.data?.error || 'Failed to move documents.';
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to move documents.';
notifyApiError(error, message);
} finally {
setLoading(false);
@@ -236,7 +405,7 @@ const useDocumentMutations = ({
);
const handleThumbnailRegeneration = useCallback(
async (documentId) => {
async (documentId: DocumentId) => {
if (!token) {
setStatusMessage('Log in to manage assets.', 'error');
return;
@@ -249,7 +418,7 @@ const useDocumentMutations = ({
setStatusMessage('Document re-analysis queued.', 'info');
await refreshCurrentFolder();
} catch (error) {
const message = error.response?.data?.error || 'Failed to request thumbnail generation.';
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to request thumbnail generation.';
notifyApiError(error, message);
} finally {
setLoading(false);
@@ -259,7 +428,7 @@ const useDocumentMutations = ({
);
const handleDocumentsDelete = useCallback(
async (documentIds, { showMessage = true, manageLoading = true } = {}) => {
async (documentIds: DocumentId[], { showMessage = true, manageLoading = true }: DeleteOptions = {}) => {
if (!documentIds || documentIds.length === 0) {
return false;
}
@@ -274,13 +443,11 @@ const useDocumentMutations = ({
}
try {
await Promise.all(
documentIds.map((documentId) => api.post(`/documents/${documentId}/trash`)),
);
await Promise.all(documentIds.map((documentId) => api.post(`/documents/${documentId}/trash`)));
removeDocumentsFromCaches(documentIds);
if (documentIds.includes(previewDocumentId)) {
if (previewDocumentId && documentIds.includes(previewDocumentId)) {
closeDocumentPreview();
}
@@ -290,7 +457,7 @@ const useDocumentMutations = ({
}
return true;
} catch (error) {
const message = error.response?.data?.error || 'Failed to delete documents.';
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to delete documents.';
notifyApiError(error, message);
return false;
} finally {
@@ -312,7 +479,7 @@ const useDocumentMutations = ({
);
const handleDocumentTitleUpdate = useCallback(
async (documentId, nextTitle) => {
async (documentId: DocumentId, nextTitle: string) => {
const trimmed = nextTitle?.trim?.() || '';
if (!trimmed) {
setStatusMessage('Document title cannot be empty.', 'error');
@@ -334,7 +501,7 @@ const useDocumentMutations = ({
setStatusMessage('Document title updated.', 'success');
return true;
} catch (error) {
const message = error.response?.data?.error || 'Failed to update document title.';
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to update document title.';
notifyApiError(error, message);
return false;
} finally {
@@ -345,7 +512,7 @@ const useDocumentMutations = ({
);
const handleDocumentIssuedUpdate = useCallback(
async (documentId, nextIssuedDate) => {
async (documentId: DocumentId, nextIssuedDate: number | null) => {
setLoading(true);
const payload = { issued_at: nextIssuedDate || null };
try {
@@ -363,7 +530,7 @@ const useDocumentMutations = ({
setStatusMessage(message, 'success');
return true;
} catch (error) {
const message = error.response?.data?.error || 'Failed to update issued date.';
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to update issued date.';
notifyApiError(error, message);
return false;
} finally {
@@ -374,26 +541,23 @@ const useDocumentMutations = ({
);
const handleDocumentTagAdd = useCallback(
async (document, label, extras = null) => {
async (document: DocumentLike, label: string, extras: DocumentTagExtras | null = null) => {
const normalizedLabel = tagManager.normalizeLabel(label);
const optionCandidate =
extras && typeof extras === 'object' && 'option' in extras ? extras.option : null;
const input =
extras && typeof extras === 'object' && 'input' in extras ? extras.input : null;
const optionCandidate = extras?.option ?? null;
const input = extras?.input ?? null;
let tag = 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;
tag = tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null;
}
try {
if (!tag) {
const payload = tagManager.buildPayload({ label: normalizedLabel });
const { data } = await api.post('/tags', payload);
tag = data;
tag = data as Tag;
await refreshTags();
}
await api.post(`/documents/${document.id}/tags`, { tag_ids: [tag.id] });
@@ -410,12 +574,12 @@ const useDocumentMutations = ({
);
const handleDocumentTagAttach = useCallback(
async ({ documentId, tagId, tag: tagData = null }) => {
async ({ documentId, tagId, tag: tagData = null }: TagAttachArgs) => {
if (!documentId || !tagId) {
return false;
}
const resolveTagForCache = () => {
const resolveTagForCache = (): Tag | null => {
const lookupTag = tagLookupById.get(tagId);
const source = lookupTag ?? tagData;
if (!source || source.id == null || typeof source.label?.trim !== 'function') {
@@ -424,7 +588,7 @@ const useDocumentMutations = ({
return {
id: source.id,
label: source.label,
color: Object.prototype.hasOwnProperty.call(source, 'color') ? source.color : null,
color: Object.prototype.hasOwnProperty.call(source, 'color') ? (source as Tag).color ?? null : null,
};
};
@@ -450,7 +614,7 @@ const useDocumentMutations = ({
}
return true;
} catch (error) {
const message = error.response?.data?.error || 'Failed to assign tag.';
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to assign tag.';
notifyApiError(error, message);
return false;
}
@@ -467,16 +631,16 @@ const useDocumentMutations = ({
);
const applyTagRemovalToCaches = useCallback(
(documentId, tagId) => {
(documentId?: DocumentId, tagId?: DocumentId) => {
if (!documentId || !tagId) {
return;
}
updateDocumentCaches(documentId, (doc) => {
if (!Array.isArray(doc.tags)) {
if (!doc || !Array.isArray(doc.tags)) {
return doc;
}
const nextTags = doc.tags.filter((tag) => tag.id !== tagId);
const nextTags = doc.tags.filter((tagEntry) => tagEntry.id !== tagId);
if (nextTags.length === doc.tags.length) {
return doc;
}
@@ -487,7 +651,11 @@ const useDocumentMutations = ({
);
const handleTagRemove = useCallback(
async (documentId, tagId, { refreshTagList = true, showMessage = true } = {}) => {
async (
documentId?: DocumentId,
tagId?: DocumentId,
{ refreshTagList = true, showMessage = true }: TagRemoveOptions = {},
) => {
if (!documentId || !tagId) {
return false;
}
@@ -503,7 +671,7 @@ const useDocumentMutations = ({
}
return true;
} catch (error) {
const message = error.response?.data?.error || 'Failed to remove tag.';
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to remove tag.';
notifyApiError(error, message);
return false;
}
@@ -512,7 +680,7 @@ const useDocumentMutations = ({
);
const handleFolderDelete = useCallback(
async (folderId, { showMessage = true, manageLoading = true } = {}) => {
async (folderId?: FolderId, { showMessage = true, manageLoading = true }: FolderDeleteOptions = {}) => {
if (!token) {
if (showMessage) {
setStatusMessage('Log in to manage folders.', 'error');
@@ -589,7 +757,7 @@ const useDocumentMutations = ({
}
return true;
} catch (error) {
const message = error.response?.data?.error || 'Failed to delete folder.';
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to delete folder.';
notifyApiError(error, message);
if (showMessage) {
setStatusMessage(message, 'error');
@@ -1,5 +1,47 @@
import { useCallback } from 'react';
type Identifier = string | number;
interface TagRecord {
id?: Identifier;
label: string;
[key: string]: unknown;
}
interface ApiClient {
post: <T = { data: unknown }>(path: string, payload: unknown) => Promise<{ data: T } | T>;
}
interface TagManager {
buildPayload: (input: { label: string }) => Record<string, unknown>;
}
interface UseDocumentTaggingArgs {
apiClient: ApiClient;
tags: TagRecord[];
tagManager: TagManager;
refreshTags: () => Promise<void> | void;
refreshCurrentFolder: () => Promise<void> | void;
resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[];
notifyApiError: (error: unknown, message: string) => void;
setStatusMessage: (message: string, variant?: string) => void;
setLoading: (state: boolean) => 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 = ({
apiClient,
tags,
@@ -10,9 +52,9 @@ const useDocumentTagging = ({
notifyApiError,
setStatusMessage,
setLoading,
}) => {
}: UseDocumentTaggingArgs) => {
const bulkTagOperation = useCallback(
async ({ labels, action, documentIds }) => {
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' };
@@ -22,7 +64,7 @@ const useDocumentTagging = ({
return { ok: false, reason: 'no-selection' };
}
let tagIds = [];
let tagIds: Identifier[] = [];
if (action === 'remove') {
const missing = normalized.find(
@@ -41,13 +83,13 @@ const useDocumentTagging = ({
setLoading(true);
try {
if (action === 'add') {
const createdIds = [];
const createdIds: Identifier[] = [];
for (const label of normalized) {
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
if (!tag) {
const payload = tagManager.buildPayload({ label });
const { data } = await apiClient.post('/tags', payload);
tag = data;
const response = await apiClient.post<{ id?: Identifier; label: string }>('/tags', payload);
tag = 'data' in response ? response.data : response;
await refreshTags();
}
createdIds.push(tag.id);
@@ -97,7 +139,7 @@ const useDocumentTagging = ({
);
const handleBulkTagAddFromDetail = useCallback(
async ({ label, input, documentIds }) => {
async ({ label, input, documentIds }: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
const trimmed = label?.trim?.() || '';
if (!trimmed) {
setStatusMessage('Enter a tag label.', 'error');
@@ -130,7 +172,7 @@ const useDocumentTagging = ({
);
const handleBulkTagRemoveFromDetail = useCallback(
async ({ label, input, documentIds }) => {
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');
@@ -163,7 +205,7 @@ const useDocumentTagging = ({
);
const handleBulkSelectionReanalyze = useCallback(
async (documentIdsOverride = null) => {
async (documentIdsOverride: Identifier[] | null = null) => {
const targetIds = resolveTargetDocumentIds(documentIdsOverride);
if (!targetIds.length) {
setStatusMessage('Select documents before requesting re-analysis.', 'error');
@@ -1,8 +1,76 @@
import { useCallback, useRef, useState } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import useFileDrop from './useFileDrop';
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils';
const mapFilesToEntries = (filesInput) => {
type Identifier = string | number;
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;
};
interface UploadResponse {
reused?: boolean;
document?: unknown;
}
interface ApiClient {
post<T = UploadResponse>(url: string, payload: unknown): Promise<{ data: T; status?: number }>;
get<T = { document?: unknown }>(url: string): Promise<{ data: T }>;
}
type DropOverlayState = {
active: boolean;
folderName: string;
};
type FileSystemEntryLike = FileSystemFileEntryLike | FileSystemDirectoryEntryLike;
type ExtendedDataTransferItem = DataTransferItem & {
webkitGetAsEntry?: () => FileSystemEntryLike | null;
};
interface FileSystemDirectoryReaderLike {
readEntries: (
successCallback: (entries: FileSystemEntryLike[]) => void,
errorCallback: (error: DOMException) => void,
) => void;
}
interface FileSystemFileEntryLike {
isFile: true;
isDirectory: false;
name: string;
file: (
successCallback: (file: File) => void,
errorCallback: (error: DOMException) => void,
) => void;
}
interface FileSystemDirectoryEntryLike {
isFile: false;
isDirectory: true;
name: string;
createReader: () => FileSystemDirectoryReaderLike;
}
const mapFilesToEntries = (filesInput?: FileList | File[] | null): FileEntry[] => {
if (!filesInput) {
return [];
}
@@ -10,7 +78,7 @@ const mapFilesToEntries = (filesInput) => {
return files
.filter(Boolean)
.map((file) => {
const relativePath = file?.webkitRelativePath ?? '';
const relativePath = (file as File & { webkitRelativePath?: string })?.webkitRelativePath ?? '';
const segments = relativePath
? relativePath
.split('/')
@@ -21,6 +89,35 @@ const mapFilesToEntries = (filesInput) => {
});
};
interface UseDocumentUploadsArgs {
apiClient: ApiClient;
token?: string | null;
selectedFolder?: FolderId;
currentFolderName?: string | null;
ensureFolderData: (folderId: FolderId, options?: { force?: boolean; prefetchDepth?: number }) => Promise<void>;
refreshCurrentFolder: () => Promise<void>;
setLoading: (state: boolean) => void;
shellRef: MutableRefObject<HTMLElement | null>;
}
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 = ({
apiClient,
token,
@@ -30,18 +127,18 @@ const useDocumentUploads = ({
refreshCurrentFolder,
setLoading,
shellRef,
}) => {
const [dropOverlayState, setDropOverlayState] = useState({
}: UseDocumentUploadsArgs): UseDocumentUploadsResult => {
const [dropOverlayState, setDropOverlayState] = useState<DropOverlayState>({
active: false,
folderName: DEFAULT_FOLDER_NAME,
folderName: currentFolderName || DEFAULT_FOLDER_NAME,
});
const dragCounterRef = useRef(0);
const folderPathCacheRef = useRef(new Map());
const folderPathCacheRef = useRef<Map<string, FolderId>>(new Map());
const queueIdRef = useRef(0);
const [uploadQueue, setUploadQueue] = useState([]);
const [uploadQueue, setUploadQueue] = useState<UploadQueueItem[]>([]);
const uploadFile = useCallback(
async (file, targetFolderId) => {
async (file: File, targetFolderId: FolderId) => {
if (!file || file.size === 0) {
return { document: null, duplicate: false, statusCode: null, conflictDocumentId: null };
}
@@ -62,14 +159,14 @@ const useDocumentUploads = ({
statusCode: status ?? (duplicate ? 200 : 201),
conflictDocumentId: null,
};
} catch (error) {
} catch (error: any) {
if (error.response?.status === 409) {
const conflictId = error.response?.data?.details?.conflict_document_id ?? null;
let conflictDocument = null;
if (conflictId) {
try {
const { data } = await apiClient.get(`/documents/${conflictId}`);
conflictDocument = data?.document ?? data ?? null;
conflictDocument = (data as any)?.document ?? data ?? null;
} catch (fetchError) {
console.warn('[Uploads] failed to fetch conflict document', fetchError);
}
@@ -89,7 +186,7 @@ const useDocumentUploads = ({
[apiClient],
);
const appendQueueItems = useCallback((entries, targetFolderId) => {
const appendQueueItems = useCallback((entries: FileEntry[], targetFolderId?: FolderId) => {
const baseId = Date.now();
const items = entries.map(({ file }) => {
queueIdRef.current += 1;
@@ -98,12 +195,12 @@ const useDocumentUploads = ({
name: file?.name || 'Unnamed file',
size: file?.size ?? null,
folderId: targetFolderId ?? selectedFolder ?? 'root',
status: 'pending',
status: 'pending' as UploadStatus,
error: null,
code: null,
document: null,
conflictDocumentId: null,
};
} satisfies UploadQueueItem;
});
if (items.length) {
setUploadQueue((current) => [...current, ...items]);
@@ -111,7 +208,7 @@ const useDocumentUploads = ({
return items;
}, [selectedFolder]);
const updateQueueItem = useCallback((id, patch) => {
const updateQueueItem = useCallback((id: string, patch: Partial<UploadQueueItem>) => {
if (!id) {
return;
}
@@ -121,7 +218,7 @@ const useDocumentUploads = ({
}, []);
const ensureFolderPathOnServer = useCallback(
async (baseFolderId, segments) => {
async (baseFolderId: FolderId, segments: string[]): Promise<FolderId> => {
const trimmedSegments = segments.map((segment) => segment.trim()).filter(Boolean);
if (trimmedSegments.length === 0) {
return baseFolderId ?? null;
@@ -130,7 +227,7 @@ const useDocumentUploads = ({
const cacheKey = `${baseFolderId ?? 'ROOT'}:${trimmedSegments.join('/')}`;
const cache = folderPathCacheRef.current;
if (cache.has(cacheKey)) {
return cache.get(cacheKey);
return cache.get(cacheKey) ?? null;
}
const payload = {
@@ -145,21 +242,21 @@ const useDocumentUploads = ({
[apiClient],
);
const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => {
const extractFilesFromDataTransfer = useCallback(async (dataTransfer: DataTransfer) => {
if (!dataTransfer) {
throw new Error('No drop payload found.');
}
const items = Array.from(dataTransfer.items || []);
const items = Array.from(dataTransfer.items || []) as ExtendedDataTransferItem[];
console.info('[Uploads] drop start', {
items: items.length,
files: (dataTransfer.files || []).length,
});
const results = [];
const results: FileEntry[] = [];
const seenKeys = new Set();
const pushFile = (file, ancestors = []) => {
const pushFile = (file?: File | null, ancestors: string[] = []) => {
if (!file) return;
const segments = (ancestors || []).filter(Boolean);
const key = `${segments.join('/')}/${file.name}:${file.size}`;
@@ -170,11 +267,11 @@ const useDocumentUploads = ({
results.push({ file, segments });
};
const readAllEntries = async (reader) => {
const entries = [];
let batch = [];
const readAllEntries = async (reader: FileSystemDirectoryReaderLike) => {
const entries: FileSystemEntryLike[] = [];
let batch: FileSystemEntryLike[] = [];
do {
batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
batch = await new Promise<FileSystemEntryLike[]>((resolve, reject) => reader.readEntries(resolve, reject));
if (batch.length) {
entries.push(...batch);
}
@@ -182,15 +279,15 @@ const useDocumentUploads = ({
return entries;
};
const walkEntry = async (entry, ancestors = []) => {
const walkEntry = async (entry: FileSystemEntryLike | null, ancestors: string[] = []) => {
if (!entry) return;
if (entry.isFile) {
const file = await new Promise((resolve, reject) => {
const file = await new Promise<File>((resolve, reject) => {
try {
entry.file(resolve, reject);
(entry as FileSystemFileEntryLike).file(resolve, reject);
} catch (error) {
console.warn('[Uploads] entry.file failed', error);
reject(error);
reject(error as Error);
}
});
pushFile(file, ancestors);
@@ -198,7 +295,7 @@ const useDocumentUploads = ({
}
if (entry.isDirectory) {
const nextAncestors = entry.name ? [...ancestors, entry.name] : [...ancestors];
const reader = entry.createReader();
const reader = (entry as FileSystemDirectoryEntryLike).createReader();
const entries = await readAllEntries(reader);
for (const child of entries) {
await walkEntry(child, nextAncestors);
@@ -212,7 +309,7 @@ const useDocumentUploads = ({
const fileFromItem = typeof item.getAsFile === 'function' ? item.getAsFile() : null;
if (fileFromItem) {
const relativePath = fileFromItem?.webkitRelativePath ?? '';
const relativePath = (fileFromItem as File & { webkitRelativePath?: string })?.webkitRelativePath ?? '';
const segments = relativePath
? relativePath
.split('/')
@@ -242,7 +339,7 @@ const useDocumentUploads = ({
Array.from(dataTransfer.files || []).forEach((file) => {
if (!file) return;
const relativePath = file?.webkitRelativePath ?? '';
const relativePath = (file as File & { webkitRelativePath?: string })?.webkitRelativePath ?? '';
const segments = relativePath
? relativePath
.split('/')
@@ -322,7 +419,7 @@ const useDocumentUploads = ({
updateQueueItem(queueItem.id, patch);
Object.assign(queueItem, patch);
}
} catch (error) {
} catch (error: any) {
if (queueItem) {
const patch = {
status: 'error',
@@ -345,7 +442,7 @@ const useDocumentUploads = ({
) {
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
}
} catch (error) {
} catch (error: any) {
const message = error.message || 'Failed to upload files.';
queueItems.forEach((item) => {
if (item.status === 'success' || item.status === 'duplicate' || item.status === 'error') {
@@ -378,8 +475,8 @@ const useDocumentUploads = ({
);
const handleFileDrop = useCallback(
async (dataTransfer, targetFolderId) => {
let extracted;
async (dataTransfer: DataTransfer, targetFolderId?: FolderId) => {
let extracted: FileEntry[];
try {
extracted = await extractFilesFromDataTransfer(dataTransfer);
} catch (error) {
@@ -393,7 +490,7 @@ const useDocumentUploads = ({
);
const handleFileSelection = useCallback(
async (files, targetFolderId) => {
async (files?: FileList | null, targetFolderId?: FolderId) => {
const entries = mapFilesToEntries(files);
await uploadFileEntries(entries, targetFolderId);
},
@@ -434,7 +531,7 @@ const useDocumentUploads = ({
resetUploadsState,
uploadQueue,
clearUploadQueue,
};
} satisfies UseDocumentUploadsResult;
};
export default useDocumentUploads;
@@ -1,17 +1,33 @@
import { useCallback, useState } from 'react';
import { Dispatch, SetStateAction, useCallback, useState } from 'react';
const useDocuments = ({ setSearchResults, setFolderContents }) => {
const [documents, setDocuments] = useState([]);
interface DocumentLike {
id?: string | number;
[key: string]: unknown;
}
interface FolderContentsEntry {
documents?: DocumentLike[];
[key: string]: unknown;
}
interface UseDocumentsOptions {
setSearchResults: Dispatch<SetStateAction<DocumentLike[] | null | undefined>>;
setFolderContents: Dispatch<SetStateAction<Map<string, FolderContentsEntry>>>;
}
const useDocuments = ({ setSearchResults, setFolderContents }: UseDocumentsOptions) => {
const [documents, setDocuments] = useState<DocumentLike[]>([]);
const mapDocumentCaches = useCallback(
(mapper) => {
(mapper: (doc: DocumentLike) => DocumentLike | undefined) => {
if (typeof mapper !== 'function') {
return;
}
const applyToList = (list) => {
const applyToList = (list?: DocumentLike[] | null) => {
let changed = false;
const next = list.map((doc) => {
const safeList = Array.isArray(list) ? list : [];
const next = safeList.map((doc) => {
const updated = mapper(doc);
if (updated === undefined || updated === doc) {
return doc;
@@ -19,7 +35,7 @@ const useDocuments = ({ setSearchResults, setFolderContents }) => {
changed = true;
return updated;
});
return changed ? next : list;
return changed ? next : safeList;
};
setDocuments((prev) => applyToList(prev));
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
matchPath,
useLocation,
@@ -57,6 +57,22 @@ const EntryType = Object.freeze({
const noop = () => {};
interface UseDocumentsWorkspaceOptions {
documentsViewMode?: string;
documentsSortField?: string;
documentsSortDirection?: string;
documentsSortFieldRef?: MutableRefObject<string>;
documentsSortDirectionRef?: MutableRefObject<string>;
onDocumentsViewModeChange?: (mode: string) => void;
onDocumentsSortFieldChange?: (field: string) => void;
onDocumentsSortDirectionToggle?: () => void;
searchIncludeDescendants?: boolean;
onToggleSearchIncludeDescendants?: () => void;
onSetSearchIncludeDescendants?: (value: boolean) => void;
sortRefreshReadyRef?: MutableRefObject<boolean>;
handleDeskExit?: () => void;
}
const useDocumentsWorkspace = ({
documentsViewMode = 'list',
documentsSortField = DEFAULT_SORT_FIELD,
@@ -71,7 +87,7 @@ const useDocumentsWorkspace = ({
onSetSearchIncludeDescendants,
sortRefreshReadyRef,
handleDeskExit,
} = {}) => {
}: UseDocumentsWorkspaceOptions = {}) => {
const handleDocumentsViewModeChange = onDocumentsViewModeChange || noop;
const handleDocumentsSortFieldChange = onDocumentsSortFieldChange || noop;
const handleDocumentsSortDirectionToggle = onDocumentsSortDirectionToggle || noop;
@@ -1,4 +1,21 @@
import { useEffect } from 'react';
import { MutableRefObject, useEffect } from 'react';
interface DropOverlayState {
active: boolean;
folderName: string | null;
}
interface UseFileDropOptions {
shellRef: MutableRefObject<HTMLElement | null>;
token?: string | null;
currentFolderName: string | null;
selectedFolder: string | null;
handleFileDrop: (dataTransfer: DataTransfer, folderId: string | null) => Promise<void>;
hasFiles: (event: DragEvent) => boolean;
defaultFolderName: string;
dragCounterRef: MutableRefObject<number>;
setDropOverlayState: (updater: ((prev: DropOverlayState) => DropOverlayState) | DropOverlayState) => void;
}
const useFileDrop = ({
shellRef,
@@ -10,7 +27,7 @@ const useFileDrop = ({
defaultFolderName,
dragCounterRef,
setDropOverlayState,
}) => {
}: UseFileDropOptions) => {
useEffect(() => {
if (!token) {
@@ -19,20 +36,20 @@ const useFileDrop = ({
return undefined;
}
const handleDragEnter = (event) => {
const handleDragEnter = (event: DragEvent) => {
if (!hasFiles(event)) return;
event.preventDefault();
dragCounterRef.current += 1;
setDropOverlayState({ active: true, folderName: currentFolderName });
};
const handleDragOver = (event) => {
const handleDragOver = (event: DragEvent) => {
if (!hasFiles(event)) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
};
const handleDragLeave = (event) => {
const handleDragLeave = (event: DragEvent) => {
if (!hasFiles(event)) return;
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
if (dragCounterRef.current === 0) {
@@ -40,7 +57,7 @@ const useFileDrop = ({
}
};
const handleDrop = async (event) => {
const handleDrop = async (event: DragEvent) => {
if (!hasFiles(event)) return;
event.preventDefault();
dragCounterRef.current = 0;
@@ -1,6 +1,88 @@
import { useCallback, useMemo } from 'react';
import type { DragEvent } from 'react';
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils';
type FolderId = string | number;
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 ApiClient {
patch: (url: string, data?: unknown) => Promise<any>;
post: (url: string, data?: unknown) => Promise<{ data: any }>;
delete: (url: string) => Promise<any>;
}
interface EnsureFolderOptions {
force?: boolean;
includeDocuments?: boolean;
prefetchDepth?: number;
}
interface LoadFolderOptions {
showLoading?: boolean;
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 {
api: ApiClient;
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) => void;
applySelectedFolder: (folderId: FolderKey, contents: any) => void;
notifyApiError: (error: unknown, message?: string) => void;
setStatusMessage: (message: string, level?: string) => void;
setLoading: (value: boolean) => void;
setFolderContents: (
updater: (prev: Map<FolderKey, FolderContentsState>) => Map<FolderKey, FolderContentsState>,
) => void;
setCurrentFolder: (updater: (prev: any) => any) => void;
setSearchResults: (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 = ({
api,
token,
@@ -28,9 +110,9 @@ const useFolderTreeActions = ({
setDraggedFolderId,
isInvalidFolderDrop,
setCreatingFolder,
}) => {
}: UseFolderTreeActionsOptions) => {
const moveFolder = useCallback(
async (folderId, targetFolderId) => {
async (folderId: FolderKey, targetFolderId: FolderKey | null | undefined) => {
const node = folderNodes.get(folderId);
if (!node) {
setStatusMessage('Folder metadata unavailable. Try refreshing.', 'error');
@@ -95,9 +177,11 @@ const useFolderTreeActions = ({
});
const refreshTargets = new Set([previousParentKey, targetKey]);
for (const key of refreshTargets) {
await ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 });
}
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 });
@@ -110,9 +194,11 @@ const useFolderTreeActions = ({
notifyApiError(error, message);
const refreshTargets = new Set([previousParentKey, targetKey]);
for (const key of refreshTargets) {
await ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 });
}
await Promise.all(
Array.from(refreshTargets).map((key) =>
ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 }),
),
);
}
},
[
@@ -128,7 +214,7 @@ const useFolderTreeActions = ({
);
const loadFolder = useCallback(
async (folderId, { showLoading = true, preserveSearch = false } = {}) => {
async (folderId: FolderKey | null | undefined, { showLoading = true, preserveSearch = false }: LoadFolderOptions = {}) => {
const targetId = folderId || 'root';
setSelectedFolder(targetId);
await ensureFolderAncestorsLoaded(targetId);
@@ -170,7 +256,7 @@ const useFolderTreeActions = ({
);
const selectFolder = useCallback(
async (folderId, { replace = false, immediate = false } = {}) => {
async (folderId: FolderKey | null | undefined, { replace = false, immediate = false }: SelectFolderOptions = {}) => {
const targetId = folderId && folderId !== 'root' ? folderId : 'root';
await ensureFolderAncestorsLoaded(targetId);
@@ -196,7 +282,7 @@ const useFolderTreeActions = ({
);
const handleFolderRename = useCallback(
async (folderId, nextName) => {
async (folderId: FolderKey, nextName: string) => {
if (!token) {
setStatusMessage('Log in to rename folders.', 'error');
return false;
@@ -257,7 +343,7 @@ const useFolderTreeActions = ({
);
const handleFolderCreate = useCallback(
async (name) => {
async (name: string) => {
if (!token) {
setStatusMessage('Log in to create folders.', 'error');
return false;
@@ -327,7 +413,7 @@ const useFolderTreeActions = ({
);
const handleFolderDelete = useCallback(
async (folderId, { showMessage = true, manageLoading = true } = {}) => {
async (folderId: FolderKey, { showMessage = true, manageLoading = true }: { showMessage?: boolean; manageLoading?: boolean } = {}) => {
if (!token) {
if (showMessage) {
setStatusMessage('Log in to manage folders.', 'error');
@@ -432,9 +518,9 @@ const useFolderTreeActions = ({
],
);
const folderClickHandlers = useMemo(
const folderClickHandlers: FolderClickHandlers = useMemo(
() => ({
onToggle: async (folderId) => {
onToggle: async (folderId: FolderKey) => {
const node = folderNodes.get(folderId);
const nextExpanded = !(node?.expanded ?? false);
if (nextExpanded) {
@@ -465,12 +551,12 @@ const useFolderTreeActions = ({
});
},
onSelect: selectFolder,
onDrop: async (event, folderId) => {
onDrop: async (event: DragEvent<HTMLElement>, folderId: FolderKey) => {
event.preventDefault();
event.stopPropagation();
event.currentTarget.classList.remove('is-drop-target');
let folderIds = [];
let folderIds: FolderId[] = [];
try {
const rawFolderList = event.dataTransfer.getData('application/x-papercrate-folder-list');
if (rawFolderList) {
@@ -523,7 +609,7 @@ const useFolderTreeActions = ({
return;
}
let docIds = [];
let docIds: FolderId[] = [];
try {
const raw = event.dataTransfer.getData('application/x-papercrate-doc-list');
if (raw) {
@@ -560,7 +646,7 @@ const useFolderTreeActions = ({
setDraggedDocumentIds([]);
await moveDocumentsToFolder(docIds, folderId);
},
onDragOver: (event, folderId) => {
onDragOver: (event: DragEvent<HTMLElement>, folderId: FolderKey) => {
const folderDragActive = Boolean(draggedFolderId);
if (folderDragActive && isInvalidFolderDrop(draggedFolderId, folderId)) {
return;
@@ -579,7 +665,7 @@ const useFolderTreeActions = ({
event.currentTarget.classList.add('is-drop-target');
}
},
onDragLeave: (event) => {
onDragLeave: (event: DragEvent<HTMLElement>) => {
event.currentTarget.classList.remove('is-drop-target');
},
}),
@@ -1,4 +1,32 @@
import { useCallback, useState } from 'react';
import { MutableRefObject, useCallback, useState } from 'react';
type ApiClient = {
get: (path: string) => Promise<{ data: unknown }>
post: (path: string, body: unknown) => Promise<{ data: unknown }>
patch: (path: string, body: unknown) => Promise<{ data: unknown }>
delete: (path: string) => Promise<{ data: unknown }>
};
interface TagManagerInterface {
buildPayload: (input: { label?: string; color?: string | null }) => { label: string; color: string | null };
}
interface TagEntry {
id?: string | number;
label?: string;
color?: string | null;
[key: string]: unknown;
}
interface UseTagsOptions {
apiClient: ApiClient;
notifyApiError: (error: unknown, fallback: string) => void;
setStatusMessage: (message: string, variant?: string) => void;
tagManager: TagManagerInterface;
tenantIdRef: MutableRefObject<string | number | null>;
setActiveTagFilters: (updater: (prev: Array<string | number>) => Array<string | number>) => void;
mapDocumentCaches?: (mapper: (doc: any) => any) => void;
}
const useTags = ({
apiClient,
@@ -8,8 +36,8 @@ const useTags = ({
tenantIdRef,
setActiveTagFilters,
mapDocumentCaches,
}) => {
const [tags, setTags] = useState([]);
}: UseTagsOptions) => {
const [tags, setTags] = useState<TagEntry[]>([]);
const refreshTags = useCallback(async () => {
const requestTenantId = tenantIdRef.current;
@@ -28,12 +56,12 @@ const useTags = ({
}, [apiClient, notifyApiError, tenantIdRef]);
const handleTagUpdate = useCallback(
async (tagId, changes) => {
async (tagId: string | number | null | undefined, changes: { label?: string; color?: string | null }) => {
if (!tagId) {
throw new Error('Missing tag identifier.');
}
const payload = {};
const payload: Record<string, unknown> = {};
if (typeof changes?.label?.trim === 'function') {
payload.label = changes.label;
}
@@ -60,7 +88,7 @@ const useTags = ({
);
const handleTagCreate = useCallback(
async ({ label, color } = {}) => {
async ({ label, color }: { label?: string; color?: string | null } = {}) => {
const payload = tagManager.buildPayload({ label, color });
try {
await apiClient.post('/tags', payload);
@@ -76,7 +104,7 @@ const useTags = ({
);
const handleTagDelete = useCallback(
async (tagId) => {
async (tagId: string | number | null | undefined) => {
if (!tagId) {
throw new Error('Missing tag identifier.');
}
@@ -85,7 +113,7 @@ const useTags = ({
await apiClient.delete(`/tags/${tagId}`);
setActiveTagFilters((prev) => prev.filter((id) => id !== tagId));
const stripTagFromDoc = (doc) => {
const stripTagFromDoc = (doc: any) => {
if (!doc || !Array.isArray(doc.tags)) {
return doc;
}
@@ -1,4 +1,33 @@
import { useCallback } from 'react';
import { MutableRefObject, useCallback } from 'react';
import type { NavigateFunction } from 'react-router-dom';
interface ApiClient {
get: (path: string) => Promise<{ data: unknown }>;
post: (path: string, body?: unknown) => Promise<{ data: any }>;
defaults: { headers: { common: Record<string, string> } };
}
interface TenantOption {
id?: string | number;
name?: string;
}
interface UseTenantManagerOptions {
apiClient: ApiClient;
appDispatch: (action: any) => void;
currentTenantId: string | number | null;
resetWorkspaceState: () => void;
setStatusMessage: (message: string, variant?: string) => void;
notifyApiError: (error: unknown, message: string) => void;
setLoading: (state: boolean) => void;
refreshTags: () => Promise<void>;
refreshCorrespondents: () => Promise<void>;
loadFolder: (folderId: string, options?: { showLoading?: boolean; preserveSearch?: boolean }) => Promise<void>;
handleDocumentsViewModeChange: (mode: string) => void;
navigate: NavigateFunction;
tokenRef?: MutableRefObject<string | null>;
tenantIdRef?: MutableRefObject<string | number | null>;
}
const useTenantManager = ({
apiClient,
@@ -15,9 +44,9 @@ const useTenantManager = ({
navigate,
tokenRef,
tenantIdRef,
}) => {
}: UseTenantManagerOptions) => {
const handleTenantSelect = useCallback(
async (tenantOption, { refreshOnly = false } = {}) => {
async (tenantOption: TenantOption | null, { refreshOnly = false }: { refreshOnly?: boolean } = {}) => {
const requestedTenantId = tenantOption?.id ?? null;
if (!refreshOnly && (!requestedTenantId || requestedTenantId === currentTenantId)) {
return;
-33
View File
@@ -1,33 +0,0 @@
import { useCallback } from 'react';
const noop = () => {};
const normalizeMessage = (error) => {
if (!error) return 'Something went wrong.';
if (typeof error?.trim === 'function') {
return error;
}
const { response, message } = error;
if (response?.data?.error) return response.data.error;
if (response?.data?.message) return response.data.message;
return message || 'Something went wrong.';
};
const useApiError = ({
logger = console,
onReport = noop,
} = {}) => {
return useCallback(
(error, { message, variant = 'error', retry = null } = {}) => {
const normalizedMessage = message || normalizeMessage(error);
if (logger && typeof logger.error === 'function') {
logger.error('[API]', normalizedMessage, error);
}
onReport({ message: normalizedMessage, variant, retry, error });
return normalizedMessage;
},
[logger, onReport],
);
};
export default useApiError;
+50
View File
@@ -0,0 +1,50 @@
import { useCallback } from 'react';
type ApiLogger = Pick<typeof console, 'error'>;
type ApiErrorVariant = 'error' | 'info' | 'success' | 'warning' | string;
interface ReportPayload {
message: string;
variant: ApiErrorVariant;
retry?: (() => void) | null;
error: unknown;
}
interface UseApiErrorOptions {
logger?: ApiLogger;
onReport?: (payload: ReportPayload) => void;
}
const noop = () => {};
const normalizeMessage = (error: unknown): string => {
if (!error) return 'Something went wrong.';
if (typeof (error as { trim?: () => string })?.trim === 'function') {
return (error as { trim: () => string }).trim();
}
const typed = error as { response?: { data?: { error?: string; message?: string } }; message?: string };
if (typed.response?.data?.error) return typed.response.data.error;
if (typed.response?.data?.message) return typed.response.data.message;
return typed.message || 'Something went wrong.';
};
const useApiError = ({
logger = console,
onReport = noop,
}: UseApiErrorOptions = {}) => {
return useCallback(
(
error: unknown,
{ message, variant = 'error', retry = null }: { message?: string; variant?: ApiErrorVariant; retry?: (() => void) | null } = {},
) => {
const normalizedMessage = message || normalizeMessage(error);
logger.error('[API]', normalizedMessage, error);
onReport({ message: normalizedMessage, variant, retry, error });
return normalizedMessage;
},
[logger, onReport],
);
};
export default useApiError;
-133
View File
@@ -1,133 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { createAssetView } from '../asset_manager';
const clampOrdinalValue = (value, cardinality, defaultOrdinal) => {
const raw = Number.isFinite(value) ? value : defaultOrdinal;
let next = Math.max(1, Math.floor(raw));
if (cardinality && cardinality > 0) {
next = Math.min(next, cardinality);
}
return next;
};
export const useAssetNavigator = ({
document,
assetType,
ensureAssetUrl,
getAsset,
prefetch = 2,
defaultOrdinal = 1,
}) => {
const documentId = document?.id || null;
const asset = useMemo(() => {
if (!document || typeof getAsset !== 'function') {
return null;
}
return getAsset(document, assetType);
}, [document, assetType, getAsset]);
const view = useMemo(() => createAssetView(asset), [asset]);
const cardinality = view.getCardinality();
const [ordinal, setOrdinalInternal] = useState(defaultOrdinal);
useEffect(() => {
setOrdinalInternal(defaultOrdinal);
}, [documentId, assetType, defaultOrdinal]);
const setOrdinal = useCallback(
(next) => {
setOrdinalInternal((prev) => {
const target = typeof next === 'function' ? next(prev) : next;
return clampOrdinalValue(target, cardinality, defaultOrdinal);
});
},
[cardinality, defaultOrdinal],
);
const goPrev = useCallback(() => setOrdinal((value) => value - 1), [setOrdinal]);
const goNext = useCallback(() => setOrdinal((value) => value + 1), [setOrdinal]);
const objects = view.getObjects();
const currentObject = view.getObject(ordinal);
const currentUrl = currentObject?.url || view.getPrimaryUrl();
const currentMetadata = currentObject?.metadata || view.getPrimaryMetadata() || null;
const canGoPrev = ordinal > 1;
const canGoNext = cardinality ? ordinal < cardinality : true;
const ordinalsNeedingLoad = useMemo(() => {
const missing = [];
if (!asset) {
return missing;
}
const maxOrdinal = cardinality && cardinality > 0
? Math.min(cardinality, ordinal + Math.max(1, prefetch) - 1)
: ordinal + Math.max(1, prefetch) - 1;
for (let ord = ordinal; ord <= maxOrdinal; ord += 1) {
const object = view.getObject(ord);
if (!object?.url) {
missing.push(ord);
}
}
return missing;
}, [asset, view, ordinal, prefetch, cardinality]);
const fetchStart = ordinalsNeedingLoad.length ? ordinalsNeedingLoad[0] : null;
const fetchEnd = ordinalsNeedingLoad.length ? ordinalsNeedingLoad[ordinalsNeedingLoad.length - 1] : null;
const fetchLimit = fetchStart && fetchEnd ? fetchEnd - fetchStart + 1 : null;
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!documentId || !asset || !ensureAssetUrl) {
setLoading(false);
return;
}
if (!fetchStart || !fetchLimit) {
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
ensureAssetUrl(documentId, asset, {
start: fetchStart,
limit: fetchLimit,
})
.catch(() => {})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [documentId, asset, ensureAssetUrl, fetchStart, fetchLimit]);
return {
document,
documentId,
asset,
assetType,
ordinal,
setOrdinal,
goPrev,
goNext,
canGoPrev,
canGoNext,
cardinality,
currentObject,
currentUrl,
currentMetadata,
objects,
isLoading: loading,
};
};
export default useAssetNavigator;
+208
View File
@@ -0,0 +1,208 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { createAssetView } from '../asset_manager';
type Identifier = string | number;
interface DocumentLike {
id?: Identifier;
[key: string]: unknown;
}
interface AssetObject {
ordinal?: number;
url?: string | null;
metadata?: Record<string, unknown> | null;
[key: string]: unknown;
}
interface AssetLike {
id?: Identifier;
cardinality?: number;
url?: string | null;
metadata?: Record<string, unknown> | null;
objects?: AssetObject[];
[key: string]: unknown;
}
type EnsureAssetUrl = (
documentId: Identifier,
asset: AssetLike,
options?: { start?: number; limit?: number; [key: string]: unknown },
) => Promise<unknown>;
type GetAsset = (document: DocumentLike, assetType: string) => AssetLike | null | undefined;
interface AssetViewLike {
getCardinality: () => number;
getObjects: () => AssetObject[];
getObject: (ordinal?: number) => AssetObject | null;
getPrimaryUrl: () => string | null;
getPrimaryMetadata: () => Record<string, unknown> | null;
}
interface UseAssetNavigatorOptions {
document: DocumentLike | null | undefined;
assetType: string;
ensureAssetUrl?: EnsureAssetUrl;
getAsset?: GetAsset;
prefetch?: number;
defaultOrdinal?: number;
}
type SetOrdinalArg = number | ((prev: number) => number);
interface AssetNavigatorReturn {
document: DocumentLike | null | undefined;
documentId: Identifier | null;
asset: AssetLike | null;
assetType: string;
ordinal: number;
setOrdinal: (next: SetOrdinalArg) => void;
goPrev: () => void;
goNext: () => void;
canGoPrev: boolean;
canGoNext: boolean;
cardinality: number;
currentObject: AssetObject | null;
currentUrl: string | null;
currentMetadata: AssetObject['metadata'];
objects: AssetObject[];
isLoading: boolean;
}
const clampOrdinalValue = (value: number, cardinality: number, defaultOrdinal: number) => {
const raw = Number.isFinite(value) ? value : defaultOrdinal;
let next = Math.max(1, Math.floor(raw));
if (cardinality && cardinality > 0) {
next = Math.min(next, cardinality);
}
return next;
};
export const useAssetNavigator = ({
document,
assetType,
ensureAssetUrl,
getAsset,
prefetch = 2,
defaultOrdinal = 1,
}: UseAssetNavigatorOptions): AssetNavigatorReturn => {
const documentId = (document?.id ?? null) as Identifier | null;
const asset = useMemo<AssetLike | null>(() => {
if (!document || typeof getAsset !== 'function') {
return null;
}
return getAsset(document, assetType) || null;
}, [document, assetType, getAsset]);
const view = useMemo<AssetViewLike>(
() => createAssetView(asset) as unknown as AssetViewLike,
[asset],
);
const cardinality = view.getCardinality();
const [ordinal, setOrdinalInternal] = useState(defaultOrdinal);
useEffect(() => {
setOrdinalInternal(defaultOrdinal);
}, [documentId, assetType, defaultOrdinal]);
const setOrdinal = useCallback(
(next: SetOrdinalArg) => {
setOrdinalInternal((prev) => {
const target = typeof next === 'function' ? next(prev) : next;
return clampOrdinalValue(target, cardinality, defaultOrdinal);
});
},
[cardinality, defaultOrdinal],
);
const goPrev = useCallback(() => setOrdinal((value) => value - 1), [setOrdinal]);
const goNext = useCallback(() => setOrdinal((value) => value + 1), [setOrdinal]);
const objects = view.getObjects();
const currentObject = view.getObject(ordinal);
const currentUrl = currentObject?.url ?? view.getPrimaryUrl() ?? null;
const currentMetadata = currentObject?.metadata ?? view.getPrimaryMetadata() ?? null;
const canGoPrev = ordinal > 1;
const canGoNext = cardinality ? ordinal < cardinality : true;
const ordinalsNeedingLoad = useMemo<number[]>(() => {
const missing: number[] = [];
if (!asset) {
return missing;
}
const safePrefetch = Math.max(1, prefetch);
const maxOrdinal = cardinality && cardinality > 0
? Math.min(cardinality, ordinal + safePrefetch - 1)
: ordinal + safePrefetch - 1;
for (let ord = ordinal; ord <= maxOrdinal; ord += 1) {
const object = view.getObject(ord);
if (!object?.url) {
missing.push(ord);
}
}
return missing;
}, [asset, view, ordinal, prefetch, cardinality]);
const fetchStart = ordinalsNeedingLoad.length ? ordinalsNeedingLoad[0] : null;
const fetchEnd = ordinalsNeedingLoad.length
? ordinalsNeedingLoad[ordinalsNeedingLoad.length - 1]
: null;
const fetchLimit = fetchStart !== null && fetchEnd !== null ? fetchEnd - fetchStart + 1 : null;
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!documentId || !asset || !ensureAssetUrl) {
setLoading(false);
return;
}
if (fetchStart === null || fetchLimit === null) {
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
ensureAssetUrl(documentId, asset, {
start: fetchStart,
limit: fetchLimit,
})
.catch(() => {})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [documentId, asset, ensureAssetUrl, fetchStart, fetchLimit]);
return {
document,
documentId,
asset,
assetType,
ordinal,
setOrdinal,
goPrev,
goNext,
canGoPrev,
canGoNext,
cardinality,
currentObject,
currentUrl,
currentMetadata,
objects,
isLoading: loading,
};
};
export default useAssetNavigator;