diff --git a/frontend/src/app/ApiContext.tsx b/frontend/src/app/ApiContext.tsx new file mode 100644 index 0000000..901ccc5 --- /dev/null +++ b/frontend/src/app/ApiContext.tsx @@ -0,0 +1,45 @@ +import React, { createContext, useContext, useEffect, useMemo } from 'react'; +import type { PropsWithChildren } from 'react'; +import { httpClient, setAuthToken, clearAuthToken } from '../lib/apiClient'; + +type HttpClient = typeof httpClient; + +interface ApiContextValue { + client: HttpClient; + setAuthToken: (token?: string | null) => void; + clearAuthToken: () => void; +} + +const ApiContext = createContext(null); + +export const ApiProvider: React.FC> = ({ + initialToken = null, + children, +}) => { + useEffect(() => { + if (initialToken) { + setAuthToken(initialToken); + } + }, [initialToken]); + + const value = useMemo( + () => ({ + client: httpClient, + setAuthToken, + clearAuthToken, + }), + [], + ); + + return {children}; +}; + +export const useApi = (): ApiContextValue => { + const ctx = useContext(ApiContext); + if (!ctx) { + throw new Error('useApi must be used within an ApiProvider'); + } + return ctx; +}; + +export const getHttpClient = (): HttpClient => httpClient; diff --git a/frontend/src/app/AppLayout.tsx b/frontend/src/app/AppLayout.tsx deleted file mode 100644 index c56721c..0000000 --- a/frontend/src/app/AppLayout.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react'; -import { Navigate, Outlet } from 'react-router-dom'; -import { AppShellContext } from '../appShellContext'; -import DropOverlay from './DropOverlay'; -import UploadQueueOverlay from './UploadQueueOverlay'; -import useDocumentsWorkspace from '../hooks/documents/useDocumentsWorkspace'; -import { useDocumentsPreferences } from './useDocumentsPreferences'; -import SettingsRoute from './SettingsRoute'; - -const AppLayout: React.FC = () => { - const documentsPreferences = useDocumentsPreferences(); - const { - appStatus, - location, - shellRef, - dropOverlayState, - managementModals, - contextValue, - settingsOpen, - closeSettings, - } = useDocumentsWorkspace({ - documentsViewMode: documentsPreferences.documentsViewMode, - documentsSortField: documentsPreferences.documentsSortField, - documentsSortDirection: documentsPreferences.documentsSortDirection, - documentsSortFieldRef: documentsPreferences.documentsSortFieldRef, - documentsSortDirectionRef: documentsPreferences.documentsSortDirectionRef, - onDocumentsViewModeChange: documentsPreferences.handleDocumentsViewModeChange, - onDocumentsSortFieldChange: documentsPreferences.handleDocumentsSortFieldChange, - onDocumentsSortDirectionToggle: documentsPreferences.handleDocumentsSortDirectionToggle, - searchIncludeDescendants: documentsPreferences.searchIncludeDescendants, - onSetSearchIncludeDescendants: documentsPreferences.setSearchIncludeDescendants, - sortRefreshReadyRef: documentsPreferences.sortRefreshReadyRef, - }); - - if (['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) { - const redirectTarget = `${location.pathname}${location.search}${location.hash || ''}`; - return ( - - ); - } - - return ( - -
- - - - {managementModals} - {settingsOpen ? ( - - ) : null} -
-
- ); -}; - -export default AppLayout; diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx deleted file mode 100644 index fb5488b..0000000 --- a/frontend/src/app/AppRouter.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import { Navigate, Route, Routes } from 'react-router-dom'; -import AppLayout from './AppLayout'; -import DocumentsRoute from './DocumentsRoute'; -import LoginRoute from './LoginRoute'; - -const AppRouter = () => ( - - } /> - }> - } /> - } /> - } /> - } /> - } /> - - -); - -export default AppRouter; diff --git a/frontend/src/app/DocumentsRoute.tsx b/frontend/src/app/DocumentsRoute.tsx index 4707435..bdcdfd6 100644 --- a/frontend/src/app/DocumentsRoute.tsx +++ b/frontend/src/app/DocumentsRoute.tsx @@ -131,25 +131,18 @@ const DocumentsRouteContent: React.FC = () => { }, []); const renderSurface = () => { - if (!surface) { - return ( -
- {!sidebarHidden ? : null} -
-
-
-
- ); - } + const layoutClass = `documents-main${sidebarHidden ? ' documents-main--sidebar-hidden' : ''}`; + const sidebarNode = !sidebarHidden ? : null; + const surfaceDetail = surface && (surface as { detail?: ReactNode }).detail ? (surface as { detail?: ReactNode }).detail : null; + const surfaceBody = surface ? surface.content : null; - const surfaceDetail = (surface as { detail?: ReactNode }).detail || null; return ( -
- {!sidebarHidden ? : null} +
+ {sidebarNode}
-
{surface.content}
- {surfaceDetail} + {surfaceBody}
+ {surfaceDetail}
); }; diff --git a/frontend/src/app/LoginRoute.tsx b/frontend/src/app/LoginRoute.tsx index df75310..1915506 100644 --- a/frontend/src/app/LoginRoute.tsx +++ b/frontend/src/app/LoginRoute.tsx @@ -1,3 +1,4 @@ +/* global PublicKeyCredentialCreationOptions, PublicKeyCredentialRequestOptions */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Navigate, useLocation } from 'react-router-dom'; import LoginView from '../login/LoginView'; @@ -9,7 +10,15 @@ import { serializeAuthenticationCredential, serializeRegistrationCredential, } from '../utils/webauthn'; -import { api, useAppDispatch, useAppState } from './appState'; +import { useAppDispatch, useAppState } from './appState'; +import { + finishPasskeyLogin, + finishSignup, + performLogin, + selectTenant, + startPasskeyLogin, + startSignup, +} from '../lib/apiClient'; type StatusVariant = 'info' | 'success' | 'error'; @@ -28,6 +37,11 @@ interface TenantSelectionState { tenants?: TenantOption[]; } +type AuthResponse = { + access_token?: string; + tenant?: TenantOption | null; + tenants?: TenantOption[]; +}; const LoginRoute: React.FC = () => { const appState = useAppState(); @@ -161,23 +175,19 @@ const LoginRoute: React.FC = () => { try { setSelectingTenantId(tenant.id); - const { data } = await api.post( - '/auth/select-tenant', + const data = await selectTenant( { tenant_id: tenant.id }, - { - headers: { - Authorization: `Bearer ${tenantSelection.selectionToken}`, - }, - }, - ); + tenantSelection.selectionToken, + ) as AuthResponse; - if (!data?.access_token) { + const accessToken = data?.access_token; + if (!accessToken) { throw new Error('Invalid tenant selection response.'); } appDispatch({ type: 'LOGIN_SUCCESS', - token: data.access_token, + token: accessToken, tenant: data.tenant || null, }); setStatusMessage('Login successful.', 'success'); @@ -211,9 +221,9 @@ const LoginRoute: React.FC = () => { setPasskeyLoading(true); appDispatch({ type: 'LOGIN_REQUEST' }); try { - const { data: startData } = await api.post('/auth/passkeys/login/start', { username }); - const challengeId = startData.challengeId; - const publicKeyOptions = startData.publicKey; + const startData = await startPasskeyLogin(username); + const challengeId = (startData as { challengeId?: string })?.challengeId; + const publicKeyOptions = (startData as { publicKey?: PublicKeyCredentialRequestOptions })?.publicKey; if (!challengeId || !publicKeyOptions) { throw new Error('Invalid passkey challenge response.'); @@ -239,13 +249,13 @@ const LoginRoute: React.FC = () => { credential: serialized, }; - const { data: finishData } = await api.post('/auth/passkeys/login/finish', finishPayload); + const finishData = await finishPasskeyLogin(finishPayload) as AuthResponse; - if (finishData?.access_token && Array.isArray(finishData?.tenants)) { + if (finishData?.access_token && Array.isArray(finishData.tenants)) { appDispatch({ type: 'TENANT_SELECTION_REQUIRED', selectionToken: finishData.access_token, - tenants: finishData.tenants, + tenants: finishData.tenants || [], }); setStatusMessage('Select a tenant to continue.', 'info'); return; @@ -322,16 +332,16 @@ const LoginRoute: React.FC = () => { payload.preferred_tenant_id = magicPreferredTenantId; } - const { data } = await api.post('/auth/login', payload); + const data = await performLogin(payload) as AuthResponse; if (cancelled) { return; } - if (data?.access_token && Array.isArray(data?.tenants)) { + if (data?.access_token && Array.isArray(data.tenants)) { appDispatch({ type: 'TENANT_SELECTION_REQUIRED', selectionToken: data.access_token, - tenants: data.tenants, + tenants: data.tenants || [], }); setStatusMessage('Select a tenant to continue.', 'info'); return; @@ -416,7 +426,10 @@ const LoginRoute: React.FC = () => { setSignupLoading(true); try { - const { data: startData } = await api.post('/auth/signup/start', { username }); + const startData = await startSignup(username) as { + signup_token?: string; + challenge?: { challengeId?: string; publicKey?: PublicKeyCredentialCreationOptions }; + }; const signupToken = startData.signup_token; const challengePayload = startData.challenge; const challengeId = challengePayload?.challengeId; @@ -446,13 +459,13 @@ const LoginRoute: React.FC = () => { credential: serialized, }; - const { data: finishData } = await api.post('/auth/signup/finish', finishPayload); + const finishData = await finishSignup(finishPayload) as AuthResponse; - if (finishData?.access_token && Array.isArray(finishData?.tenants)) { + if (finishData?.access_token && Array.isArray(finishData.tenants)) { appDispatch({ type: 'TENANT_SELECTION_REQUIRED', selectionToken: finishData.access_token, - tenants: finishData.tenants, + tenants: finishData.tenants || [], }); setStatusMessage('Select a tenant to continue.', 'info'); return; diff --git a/frontend/src/app/SettingsRoute.tsx b/frontend/src/app/SettingsRoute.tsx index 00a16ee..6e85bae 100644 --- a/frontend/src/app/SettingsRoute.tsx +++ b/frontend/src/app/SettingsRoute.tsx @@ -4,7 +4,6 @@ import { useAppShell } from '../appShellContext'; import useApiTokens from '../settings/useApiTokens'; import useCapabilitySets from '../settings/useCapabilitySets'; import useCapabilities from '../settings/useCapabilities'; -import { api } from './appState'; interface SettingsRouteProps { open?: boolean; @@ -38,7 +37,7 @@ const SettingsRoute: React.FC = ({ open = true, onClose }) = revoke: revokeToken, regenerate: regenerateToken, dismissSecret, - } = useApiTokens({ api, token, notifyApiError, setStatusMessage }); + } = useApiTokens({ token, notifyApiError, setStatusMessage }); const { capabilitySets, @@ -51,13 +50,13 @@ const SettingsRoute: React.FC = ({ open = true, onClose }) = createCapabilitySet, updateCapabilitySet, deleteCapabilitySet, - } = useCapabilitySets({ api, token, notifyApiError, setStatusMessage }); + } = useCapabilitySets({ token, notifyApiError, setStatusMessage }); const { capabilities, capabilitiesLoading, refreshCapabilities, - } = useCapabilities({ api, notifyApiError, token }); + } = useCapabilities({ notifyApiError, token }); useEffect(() => { refreshTokens(); diff --git a/frontend/src/app/appLayoutUtils.ts b/frontend/src/app/appLayoutUtils.ts index 784b107..eebdea1 100644 --- a/frontend/src/app/appLayoutUtils.ts +++ b/frontend/src/app/appLayoutUtils.ts @@ -1,4 +1,4 @@ -import { createAssetView, resolveAssetExpiresAt } from '../asset_manager'; +import { resolveAssetExpiresAt, resolveAssetUrl } from '../asset_manager'; export const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early export const DEFAULT_FOLDER_NAME = 'Documents'; @@ -41,15 +41,13 @@ export const hasFiles = (event) => const isAssetEquivalent = (lhs, rhs) => { if (!lhs || !rhs) return false; - const lhsView = createAssetView(lhs); - const rhsView = createAssetView(rhs); - const lhsPrimaryMetadata = lhsView.getPrimaryMetadata() || lhs?.metadata; - const rhsPrimaryMetadata = rhsView.getPrimaryMetadata() || rhs?.metadata; + const lhsPrimaryMetadata = lhs?.metadata; + const rhsPrimaryMetadata = rhs?.metadata; const lhsExpiresAt = resolveAssetExpiresAt(lhs); const rhsExpiresAt = resolveAssetExpiresAt(rhs); return ( lhs.id === rhs.id - && lhs.url === rhs.url + && resolveAssetUrl(lhs) === resolveAssetUrl(rhs) && lhsExpiresAt === rhsExpiresAt && lhsPrimaryMetadata?.width === rhsPrimaryMetadata?.width && lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height diff --git a/frontend/src/app/appState.tsx b/frontend/src/app/appState.tsx index e728128..0f3357d 100644 --- a/frontend/src/app/appState.tsx +++ b/frontend/src/app/appState.tsx @@ -1,5 +1,7 @@ import React, { useContext, useEffect, useMemo, useReducer } from 'react'; -import api from '../lib/api'; +import { clearAuthToken, setAuthToken, setAuthRefreshHandlers } from '../lib/apiClient'; +import { ApiProvider } from './ApiContext'; +import { listTenants } from '../lib/apiClient'; type Tenant = Record | null; @@ -60,7 +62,7 @@ if (storage) { } if (STORED_TOKEN) { - api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`; + setAuthToken(STORED_TOKEN); } const initialAppState: AppState = { @@ -187,10 +189,10 @@ const AppStateProvider: React.FC<{ children?: React.ReactNode }> = ({ children } useEffect(() => { const token = state.token ?? ''; if (token) { - api.defaults.headers.common.Authorization = `Bearer ${token}`; + setAuthToken(token); storage?.setItem('papercrate_token', token); } else { - delete api.defaults.headers.common.Authorization; + clearAuthToken(); storage?.removeItem('papercrate_token'); } }, [state.token]); @@ -207,6 +209,21 @@ const AppStateProvider: React.FC<{ children?: React.ReactNode }> = ({ children } } }, [state.tenant]); + useEffect(() => { + setAuthRefreshHandlers({ + onRefreshSuccess: (token, payload) => { + dispatch({ type: 'TOKEN_REFRESH_SUCCESS', token, tenant: payload?.tenant ?? null }); + }, + onRefreshFailure: (error) => { + dispatch({ type: 'TOKEN_REFRESH_FAILURE', error: (error as Error)?.message || null }); + }, + }); + + return () => { + setAuthRefreshHandlers({}); + }; + }, [dispatch]); + useEffect(() => { let abort = false; @@ -217,11 +234,11 @@ const AppStateProvider: React.FC<{ children?: React.ReactNode }> = ({ children } } try { - const { data } = await api.get('/tenants'); if (!abort) { + const tenants = await listTenants(); dispatch({ type: 'SET_TENANTS', - tenants: Array.isArray(data?.tenants) ? data.tenants : [], + tenants, }); } } catch (error) { @@ -241,11 +258,13 @@ const AppStateProvider: React.FC<{ children?: React.ReactNode }> = ({ children } const stateValue = useMemo(() => state, [state]); return ( - - - {children} - - + + + + {children} + + + ); }; @@ -265,4 +284,4 @@ const useAppDispatch = (): React.Dispatch => { return context; }; -export { api, AppStateProvider, useAppState, useAppDispatch }; +export { AppStateProvider, useAppState, useAppDispatch }; diff --git a/frontend/src/app/useDocumentPreview.ts b/frontend/src/app/useDocumentPreview.ts index aa09d21..1048bb4 100644 --- a/frontend/src/app/useDocumentPreview.ts +++ b/frontend/src/app/useDocumentPreview.ts @@ -4,6 +4,7 @@ import type { MutableRefObject, SetStateAction, } from 'react'; +import { fetchDocument } from '../lib/apiClient'; type DocumentId = string | number; type FolderId = DocumentId | 'root'; @@ -18,15 +19,11 @@ type DocumentLike = { type DocumentLink = { url?: string; - contentType?: string | null; + mimeType?: string | null; filename?: string | null; expiresAt?: number; }; -interface ApiClient { - get: (path: string) => Promise<{ data: T }>; -} - type NavigateHandler = (path: string, options?: { replace?: boolean }) => void; interface UseDocumentPreviewArgs { @@ -39,8 +36,6 @@ interface UseDocumentPreviewArgs { ingest: (docs: unknown[]) => { canonical: DocumentLike[]; changed: boolean }; }; selectedFolder?: FolderId | null; - api: ApiClient; - resolveApiPath?: (path: string) => string; notifyApiError: (error: unknown, message: string) => void; navigate: NavigateHandler; locationPathname: string; @@ -66,8 +61,6 @@ const useDocumentPreview = ({ routeDocumentId, documentsManager, selectedFolder, - api, - resolveApiPath, notifyApiError, navigate, locationPathname, @@ -109,9 +102,9 @@ const useDocumentPreview = ({ async (documentId: DocumentId, { force = false }: { force?: boolean } = {}): Promise => { if (!documentId) return null; - const existing = documentLinks.get(documentId) || null; + const existing = documentLinks.get(documentId); const now = Date.now(); - const expiresAt = Number.isFinite(existing?.expiresAt) ? Number(existing?.expiresAt) : null; + const expiresAt = existing?.expiresAt ?? null; if (!force && existing && (!expiresAt || expiresAt > now)) { return existing; } @@ -122,18 +115,18 @@ const useDocumentPreview = ({ const request: Promise = (async () => { try { - const docResponse = await api.get<{ document?: Record }>(`/documents/${documentId}`); - const downloadPath = docResponse.data?.document?.current_version?.download_path; - if (!downloadPath || !resolveApiPath) { - throw new Error('Document missing download path'); + const docResponse = await fetchDocument(documentId); + const download = docResponse?.current_version?.download || null; + const downloadUrl = download?.url; + if (!downloadUrl) { + throw new Error('Document missing download url'); } - const href = resolveApiPath(downloadPath); const entry: DocumentLink = { - url: href, - contentType: docResponse.data?.document?.current_version?.version?.content_type || null, - filename: docResponse.data?.document?.filename, - expiresAt: Date.now() + 5 * 60 * 1000, + url: downloadUrl, + mimeType: docResponse?.mime_type || null, + filename: docResponse?.filename, + expiresAt: download?.expires_at, }; setDocumentLinks((prev) => { const next = new Map(prev); @@ -152,7 +145,7 @@ const useDocumentPreview = ({ previewInflightRef.current.set(documentId, request); return request; }, - [documentLinks, api, resolveApiPath, notifyApiError], + [documentLinks, notifyApiError], ); const ensurePreviewData = useCallback( @@ -168,8 +161,7 @@ const useDocumentPreview = ({ } if (!doc) { - const { data } = await api.get(`/documents/${documentId}`); - const fetched = (data as { document?: DocumentLike })?.document || data; + const fetched = await fetchDocument(documentId); const { canonical } = documentsManager.ingest([fetched as unknown]); doc = (canonical[0] as DocumentLike | undefined) || null; if (!doc) { @@ -191,7 +183,6 @@ const useDocumentPreview = ({ documentsManager, ensureDownloadUrl, setActivePreviewId, - api, ], ); diff --git a/frontend/src/app/useDocumentsSearch.ts b/frontend/src/app/useDocumentsSearch.ts index 9329b99..e0db80b 100644 --- a/frontend/src/app/useDocumentsSearch.ts +++ b/frontend/src/app/useDocumentsSearch.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import type { Dispatch, SetStateAction } from 'react'; import { TAG_FILTER_UNTAGGED } from './appLayoutUtils'; +import { listDocuments } from '../lib/apiClient'; type Identifier = string | number; @@ -21,7 +22,6 @@ interface UseDocumentsSearchArgs { documentsSortField?: string; documentsSortDirection?: string; notifyApiError: (error: unknown, message: string) => void; - setLoading: (state: boolean) => void; setSearchIncludeDescendants: (value: boolean) => void; documentsManager: { ingest: (docs: unknown[]) => { canonical: DocumentLike[]; changed: boolean }; @@ -74,7 +74,6 @@ const useDocumentsSearch = ({ documentsSortField, documentsSortDirection, notifyApiError, - setLoading, setSearchIncludeDescendants, documentsManager, }: UseDocumentsSearchArgs): UseDocumentsSearchResult => { @@ -192,7 +191,6 @@ const useDocumentsSearch = ({ const debounce = setTimeout(async () => { started = true; - setLoading(true); try { const params: Record = {}; const trimmedQuery = searchQuery.trim(); @@ -227,7 +225,7 @@ const useDocumentsSearch = ({ if (documentsSortDirection) { params.dir = documentsSortDirection; } - const { data } = await api.get('/documents', { params }); + const data = await listDocuments(params); if (cancelled) return; const results = Array.isArray(data) ? data : []; @@ -247,7 +245,6 @@ const useDocumentsSearch = ({ setSearchResultIds(null); } finally { if (!cancelled && started) { - setLoading(false); setSearchLoading(false); } } @@ -257,7 +254,6 @@ const useDocumentsSearch = ({ cancelled = true; clearTimeout(debounce); if (started) { - setLoading(false); setSearchLoading(false); } }; @@ -273,7 +269,6 @@ const useDocumentsSearch = ({ documentsSortDirection, selectedFolder, notifyApiError, - setLoading, documentsManager, searchTrigger, ]); diff --git a/frontend/src/app/useWorkspaceSelection.ts b/frontend/src/app/useWorkspaceSelection.ts index c2b13a1..d80c354 100644 --- a/frontend/src/app/useWorkspaceSelection.ts +++ b/frontend/src/app/useWorkspaceSelection.ts @@ -14,7 +14,7 @@ interface WorkspaceSelectionOptions { isDocumentRowKey?: (key: RowKey | SelectionEntry) => boolean; isFolderRowKey?: (key: RowKey | SelectionEntry) => boolean; getRowId?: (key: RowKey | SelectionEntry) => string | number | null; - onInspectDocument?: (id: string | number) => void; + onDocumentActivate?: (id: string | number) => void; onInspectFolder?: (id: string | number) => void; } @@ -26,7 +26,7 @@ export const useWorkspaceSelection = ({ isDocumentRowKey = () => false, isFolderRowKey = () => false, getRowId = () => null, - onInspectDocument = identity, + onDocumentActivate = identity, onInspectFolder = identity, }: WorkspaceSelectionOptions = {}) => { const selection = useDocumentSelection({ @@ -88,9 +88,9 @@ export const useWorkspaceSelection = ({ const inspectDocument = useCallback( (documentId?: string | number | null) => { if (!documentId) return; - onInspectDocument(documentId); + onDocumentActivate(documentId); }, - [onInspectDocument], + [onDocumentActivate], ); const inspectFolder = useCallback( diff --git a/frontend/src/app/useWorkspaceSurface.tsx b/frontend/src/app/useWorkspaceSurface.tsx index 99ed49a..6b94b9c 100644 --- a/frontend/src/app/useWorkspaceSurface.tsx +++ b/frontend/src/app/useWorkspaceSurface.tsx @@ -4,6 +4,7 @@ import { SidebarExpandIcon } from '../ui/icons'; import DocumentsPanel from '../documents/panel/DocumentsPanel'; import DocumentViewerPanel from '../preview/DocumentViewerPanel'; import { usePanelManager } from './PanelManagerContext'; +import { FolderManagerProvider } from '../folders/FolderManagerContext'; type Identifier = string | number; @@ -91,8 +92,15 @@ export const useWorkspaceSurface = ({ const detail = detailPanelOpen && detailPanelProps ? (() => { - const { onClose, onOpenPreview, tags: tagOptions, ...restDetailProps } = detailPanelProps; - return ( + const { + onClose, + onOpenPreview, + tags: tagOptions, + folderNodes, + ensureFolderData, + ...restDetailProps + } = detailPanelProps; + const viewer = ( ); + if (folderNodes && ensureFolderData) { + return ( + + {viewer} + + ); + } + return ( + <>{viewer} + ); })() : null; @@ -140,35 +158,44 @@ export const useWorkspaceSurface = ({ onUpdateTitle, onUpdateIssued, resolveFolderPath, + folderNodes, + ensureFolderData, } = detailExtras; - return { - content: ( - - ), - detail: null, - }; + const viewer = ( + + ); + + const content = folderNodes && ensureFolderData + ? ( + + {viewer} + + ) + : viewer; + + return { content, detail: null }; }, [ showPreviewWorkspace, previewWorkspaceDocument, diff --git a/frontend/src/asset_manager.ts b/frontend/src/asset_manager.ts index deb6161..f2fbd00 100644 --- a/frontend/src/asset_manager.ts +++ b/frontend/src/asset_manager.ts @@ -1,5 +1,3 @@ -import type { AxiosInstance } from 'axios'; - export type Identifier = string | number; type Nullable = T | null; @@ -8,7 +6,7 @@ export interface AssetObject { ordinal?: number; url?: string | null; metadata?: Record | null; - expires_at?: number | null; + expires_at?: number; [key: string]: unknown; } @@ -16,10 +14,8 @@ export interface AssetLike { id?: Identifier; asset_type?: string; cardinality?: number | null; - url?: string | null; - expires_at?: number | null; + download?: { url: string; expires_at: number } | null; metadata?: Record | null; - expiresAt?: number | null; assets?: Record | AssetLike[] | null; objects?: AssetObject[] | null; [key: string]: unknown; @@ -37,19 +33,11 @@ export interface DocumentLike { [key: string]: unknown; } -export const resolveAssetExpiresAt = ( - asset?: { expiresAt?: number | null; expires_at?: number | null } | null, -): number | null => { - const camel = Number(asset?.expiresAt); - if (Number.isFinite(camel)) { - return camel; - } - const snake = Number(asset?.expires_at); - if (Number.isFinite(snake)) { - return snake; - } - return null; -}; +export const resolveAssetExpiresAt = (asset?: { download?: { expires_at: number } | null } | null): number | null => + asset?.download?.expires_at ?? null; + +export const resolveAssetUrl = (asset?: { download?: { url: string } | null } | null): string | null => + asset?.download?.url ?? null; export type EnsureAssetUrl = ( documentId: Identifier, @@ -78,7 +66,7 @@ export const getAssetFromVersion = (currentVersion: Nullable { @@ -140,10 +128,11 @@ export class AssetView { } if (ordinal === 1 && this.asset) { - if (this.asset.url || this.asset.metadata) { + const primaryUrl = resolveAssetUrl(this.asset); + if (primaryUrl || this.asset.metadata) { return { ordinal: 1, - url: this.asset.url || null, + url: primaryUrl || null, metadata: this.asset.metadata || null, expires_at: resolveAssetExpiresAt(this.asset), }; @@ -195,9 +184,7 @@ export const resolveDocumentAssetUrl = ( const view = createAssetView(asset); const object = view.getPrimaryObject(); const url = object?.url || view.getPrimaryUrl(); - const expiresAt = Number.isFinite(object?.expires_at) - ? Number(object?.expires_at) - : resolveAssetExpiresAt(asset); + const expiresAt = object?.expires_at ?? resolveAssetExpiresAt(asset); const now = Date.now(); if (url && (!expiresAt || expiresAt > now)) { return url; @@ -214,20 +201,20 @@ export const resolveDocumentAssetUrl = ( }; class AssetManager { - api: AxiosInstance | null; + fetchAsset: ((id: Identifier) => Promise) | null; assetPresignTtlMs: number; assetCache: Map; assetInflight: Map>; - constructor({ api, assetPresignTtlMs }: { api: AxiosInstance | null; assetPresignTtlMs: number }) { - this.api = api; + constructor({ fetchAsset, assetPresignTtlMs }: { fetchAsset: ((id: Identifier) => Promise) | null; assetPresignTtlMs: number }) { + this.fetchAsset = fetchAsset; this.assetPresignTtlMs = assetPresignTtlMs; this.assetCache = new Map(); this.assetInflight = new Map(); } - setApi(api: AxiosInstance | null) { - this.api = api; + setFetchAsset(fetchAsset: ((id: Identifier) => Promise) | null) { + this.fetchAsset = fetchAsset; } rememberAsset(entry?: Nullable) { @@ -242,7 +229,7 @@ class AssetManager { { force = false }: { force?: boolean } = {}, ): Promise> { if (!documentId || !asset?.id) { - return Promise.resolve(asset ?? null); + return Promise.resolve(asset); } const baseAsset = this.assetCache.get(asset.id) || asset; @@ -253,12 +240,13 @@ class AssetManager { const isPrimarySatisfied = () => { const object = view.getObject(1); if (object?.url) { - const objectExpiresAt = Number.isFinite(object.expires_at) ? Number(object.expires_at) : null; + const objectExpiresAt = object.expires_at ?? null; if (!objectExpiresAt || objectExpiresAt > now) { return true; } } - if (baseAsset.url && (!assetExpiresAt || assetExpiresAt > now)) { + const assetUrl = resolveAssetUrl(baseAsset); + if (assetUrl && (!assetExpiresAt || assetExpiresAt > now)) { return true; } return false; @@ -279,22 +267,20 @@ class AssetManager { return this.assetInflight.get(inflightKey); } - if (!this.api) { - return Promise.reject(new Error('AssetManager API client is not configured.')); + if (!this.fetchAsset) { + return Promise.reject(new Error('AssetManager fetcher is not configured.')); } - const request: Promise = this.api - .get(`/assets/${asset.id}`) - .then(({ data }) => { + const request: Promise = this.fetchAsset(asset.id) + .then((data) => { + if (!data) return null; const cachedEntry = this.assetCache.get(asset.id) || baseAsset; const combined = { ...cachedEntry, ...asset, ...data }; - const expiresAt = - resolveAssetExpiresAt(data) - ?? resolveAssetExpiresAt(combined) - ?? Date.now() + this.assetPresignTtlMs; + const expires_at = resolveAssetExpiresAt(combined); const entry = { ...combined, - expiresAt, + url: resolveAssetUrl(combined), + expires_at, }; this.rememberAsset(entry); diff --git a/frontend/src/desktop/DesktopDocumentCard.tsx b/frontend/src/desktop/DesktopDocumentCard.tsx index 944e88f..9a1504b 100644 --- a/frontend/src/desktop/DesktopDocumentCard.tsx +++ b/frontend/src/desktop/DesktopDocumentCard.tsx @@ -30,7 +30,7 @@ interface DesktopDocumentCardProps { getDocumentAsset?: (...args: any[]) => unknown; handleNavigatorSnapshot?: (...args: any[]) => void; cardPointerHandlers?: React.HTMLAttributes; - onInspectDocument?: (id: string | number) => void; + onDocumentActivate?: (id: string | number) => void; onTagDragEnter?: (event: React.DragEvent, docId: string | number) => void; onTagDragOver?: (event: React.DragEvent, docId: string | number) => void; onTagDragLeave?: (event: React.DragEvent, docId: string | number) => void; @@ -57,7 +57,7 @@ const DesktopDocumentCard: React.FC = ({ getDocumentAsset, handleNavigatorSnapshot, cardPointerHandlers, - onInspectDocument, + onDocumentActivate, onTagDragEnter, onTagDragOver, onTagDragLeave, @@ -117,7 +117,7 @@ const DesktopDocumentCard: React.FC = ({ onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { preventAll(event); - onInspectDocument?.(doc.id); + onDocumentActivate?.(doc.id); } }} > diff --git a/frontend/src/desktop/DesktopWorkspace.tsx b/frontend/src/desktop/DesktopWorkspace.tsx index fe0337b..49b74b2 100644 --- a/frontend/src/desktop/DesktopWorkspace.tsx +++ b/frontend/src/desktop/DesktopWorkspace.tsx @@ -9,7 +9,7 @@ import React, { } from 'react'; import { resolveDocumentAssetUrl } from '../asset_manager'; import type { EnsureAssetUrl, GetAsset } from '../asset_manager'; -import { formatTransform } from './math'; +import { formatTransform } from '../utils/math'; import useDocumentDrag from './useDocumentDrag'; import PreviewZoomOverlay from '../detail/PreviewZoomOverlay'; import { @@ -28,12 +28,13 @@ import usePreviewMetadata from './hooks/usePreviewMetadata'; import '../styles/workspace/workspace-layout.css'; import '../styles/workspace/workspace-items.css'; import '../styles/workspace/workspace-cards.css'; +import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext'; type Identifier = string | number; type TagLike = { id?: Identifier | null; label?: string; color?: string | null } | null; -type DocumentLinkLike = { url?: string | null; contentType?: string | null }; -type OverlaySource = { url: string; alt?: string | null; contentType?: string | null }; +type DocumentLinkLike = { url?: string | null; mimeType?: string | null }; +type OverlaySource = { url: string; alt?: string | null; mimeType?: string | null }; export interface DeskDocument { id?: Identifier | null; @@ -68,7 +69,7 @@ interface OverlayOriginTransform { interface OverlayDisplay { url: string; alt?: string | null; - contentType?: string | null; + mimeType?: string | null; } interface DocumentSizeInfo { @@ -118,19 +119,17 @@ type WorkspaceSnapshotState = { }; interface DesktopWorkspaceProps { - documents?: DeskDocument[]; - onInspectDocument?: (...args: unknown[]) => void; - onEntryPointer?: (...args: unknown[]) => void; - onDocumentStackSelect?: (docIds: Identifier[]) => void; - onPromoteSelection?: (...args: unknown[]) => void; - onAssignTagToDocument?: (...args: unknown[]) => void; + entries?: DeskDocument[]; + onDocumentActivate?: (...args: unknown[]) => void; + onDocumentClick?: (...args: unknown[]) => void; + onDocumentTagDrop?: (...args: unknown[]) => void; ensureAssetUrl?: EnsureAssetUrl; getDocumentAsset?: GetAsset; - activeTagIds?: Array; - selectedDocumentIds?: Identifier[]; - onClearSelection?: () => void; + activeTagFilters?: Array; tenantId?: Identifier | null; viewId?: string | null; + documentLinks?: Map | null; + ensureDownloadUrl?: (docId: Identifier, options?: { force?: boolean }) => Promise; } interface DesktopWorkspaceViewProps { @@ -167,12 +166,12 @@ interface DesktopWorkspaceViewProps { overlayOriginRect: DOMRect | null; overlayOriginTransform: OverlayOriginTransform | null; overlayDocument: DeskDocument | null; - onEntryPointer?: DesktopWorkspaceProps['onEntryPointer']; - onDocumentStackSelect?: DesktopWorkspaceProps['onDocumentStackSelect']; - onPromoteSelection?: DesktopWorkspaceProps['onPromoteSelection']; - selectedDocumentIds: Identifier[]; - onClearSelection?: DesktopWorkspaceProps['onClearSelection']; + onDocumentClick?: DesktopWorkspaceProps['onDocumentClick']; + onDocumentStackSelect?: (docIds: Identifier[], event?: PointerEvent | MouseEvent | null) => void; + onPromoteSelection?: (docId: Identifier | null) => void; documentLookup: Map; + selectedDocumentIds: Identifier[]; + onClearSelection: () => void; resolveBaseMetrics: (doc: DeskDocument | null, cardWidth: number, cardHeight: number) => { baseWidth: number; baseHeight: number; @@ -184,7 +183,7 @@ interface DesktopWorkspaceViewProps { openOverlayForDoc: (docId: Identifier | null, originInfo?: OverlayOriginHint | null) => void; recalcVisibleDocIds: () => void; dragSettings: DragSettings; - onInspectDocument?: DesktopWorkspaceProps['onInspectDocument']; + onDocumentActivate?: DesktopWorkspaceProps['onDocumentActivate']; markLayoutDirty: () => void; } @@ -193,23 +192,60 @@ const DEBUG_FOCUS = false; const defaultGetDocumentAsset: GetAsset = () => null; const DesktopWorkspace: React.FC = ({ - documents = [], - onInspectDocument = null, - onEntryPointer = null, - onDocumentStackSelect = null, - onPromoteSelection = null, - onAssignTagToDocument = null, + entries = [], + onDocumentActivate = null, + onDocumentClick = null, + onDocumentTagDrop = null, ensureAssetUrl = null, getDocumentAsset = defaultGetDocumentAsset, - activeTagIds = [], - selectedDocumentIds = [], - onClearSelection = null, + activeTagFilters = [], tenantId = null, viewId = 'default', documentLinks, ensureDownloadUrl, }) => { - const items = useMemo(() => documents, [documents]); + const { + selectedDocumentIds, + clearSelection, + handleEntrySelection, + promoteSelectionOrder, + } = useWorkspaceSelectionContext(); + const items = useMemo( + () => (Array.isArray(entries) ? entries.filter((doc): doc is DeskDocument => Boolean(doc)) : []), + [entries], + ); + + const getDocRowKey = useCallback((id: Identifier | null) => (id != null ? `document:${id}` : null), []); + + const handleStackSelect = useCallback( + (docIds: Identifier[], event?: PointerEvent | MouseEvent | null) => { + if (!Array.isArray(docIds) || docIds.length === 0) { + return; + } + const syntheticEvent = event || ({ + metaKey: true, + ctrlKey: true, + preventDefault: () => {}, + } as unknown as PointerEvent); + docIds.forEach((id) => { + const key = getDocRowKey(id); + if (key) { + handleEntrySelection(key, syntheticEvent); + } + }); + }, + [getDocRowKey, handleEntrySelection], + ); + + const handlePromoteSelection = useCallback( + (docId: Identifier | null) => { + const key = getDocRowKey(docId); + if (key && promoteSelectionOrder) { + promoteSelectionOrder(key); + } + }, + [getDocRowKey, promoteSelectionOrder], + ); const allowLayoutPersistence = Boolean(tenantId && viewId && viewId.startsWith('folder:')); const documentLinkMap = documentLinks instanceof Map ? documentLinks : null; @@ -380,17 +416,17 @@ const DesktopWorkspace: React.FC = ({ [applySnapshotDimensions], ); const activeTagSet = useMemo>(() => { - if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) { + if (!Array.isArray(activeTagFilters) || activeTagFilters.length === 0) { return new Set(); } const set = new Set(); - activeTagIds.forEach((id) => { + activeTagFilters.forEach((id) => { if (id != null) { set.add(String(id)); } }); return set; - }, [activeTagIds]); + }, [activeTagFilters]); useLayoutEffect(() => { const container = containerRef.current; @@ -408,9 +444,21 @@ const DesktopWorkspace: React.FC = ({ commitSize(); - const observer = new ResizeObserver(commitSize); + let rafId: number | null = null; + const observer = new ResizeObserver(() => { + if (rafId != null) return; + rafId = requestAnimationFrame(() => { + rafId = null; + commitSize(); + }); + }); observer.observe(container); - return () => observer.disconnect(); + return () => { + observer.disconnect(); + if (rafId != null) { + cancelAnimationFrame(rafId); + } + }; }, [engine]); const resolvePreviewDimensions = useCallback( @@ -468,7 +516,7 @@ const DesktopWorkspace: React.FC = ({ const tagInteractions = useDeskTagInteractions({ engine, - onAssignTagToDocument, + onAssignTagToDocument: onDocumentTagDrop, requestCanvasFocus, }); @@ -553,8 +601,7 @@ const DesktopWorkspace: React.FC = ({ }; } - const docContentType = doc?.content_type ?? null; - const versionContentType = (doc?.current_version as { version?: { content_type?: string | null } } | null)?.version?.content_type ?? null; + const docMimeType = doc?.mime_type ?? null; const applyEntry = (entry?: DocumentLinkLike | null) => { if (!entry?.url) { @@ -563,8 +610,8 @@ const DesktopWorkspace: React.FC = ({ } setOverlaySource({ url: entry.url, - alt: doc.title as string | undefined, - contentType: entry.contentType || docContentType || versionContentType || undefined, + alt: doc.title, + mimeType: docMimeType || undefined, }); }; @@ -771,11 +818,13 @@ const DesktopWorkspace: React.FC = ({ overlayOriginRect, overlayOriginTransform, overlayDocument, - onEntryPointer, - onDocumentStackSelect, - onPromoteSelection, + onDocumentClick, + handleStackSelect, + handlePromoteSelection, + onDocumentStackSelect: handleStackSelect, + onPromoteSelection: handlePromoteSelection, selectedDocumentIds, - onClearSelection, + onClearSelection: clearSelection, documentLookup, resolveBaseMetrics, bringToFront, @@ -784,7 +833,7 @@ const DesktopWorkspace: React.FC = ({ openOverlayForDoc, recalcVisibleDocIds, dragSettings, - onInspectDocument, + onDocumentActivate, markLayoutDirty, }), [ @@ -816,10 +865,9 @@ const DesktopWorkspace: React.FC = ({ items, layoutRef, layoutSnapshot, - onClearSelection, - onDocumentStackSelect, - onEntryPointer, - onPromoteSelection, + onDocumentClick, + handleStackSelect, + handlePromoteSelection, openOverlayForDoc, overlayDisplay, overlayOriginRect, @@ -832,7 +880,8 @@ const DesktopWorkspace: React.FC = ({ resolveBaseMetrics, setDraggingId, selectedDocumentIds, - onInspectDocument, + clearSelection, + onDocumentActivate, markLayoutDirty, tagDropTargetId, visibleDocIds, @@ -874,7 +923,7 @@ function DesktopWorkspaceView({ overlayOriginRect, overlayOriginTransform, overlayDocument, - onEntryPointer, + onDocumentClick, onDocumentStackSelect, onPromoteSelection, selectedDocumentIds, @@ -887,7 +936,7 @@ function DesktopWorkspaceView({ openOverlayForDoc, recalcVisibleDocIds, dragSettings, - onInspectDocument, + onDocumentActivate, markLayoutDirty, dragTransformsRef, }: DesktopWorkspaceViewProps) { @@ -907,8 +956,7 @@ function DesktopWorkspaceView({ recalcVisibleDocIds, settings: dragSettings, containerRef, - onInspectDocument, - onDocumentStackSelect, + onDocumentActivate, selectedDocumentIds, markLayoutDirty, }) as { @@ -928,10 +976,10 @@ function DesktopWorkspaceView({ handlePointerMove, handlePointerUp, handlePointerCancel, - onEntryPointer, + onDocumentClick, onDocumentStackSelect, onPromoteSelection, - onInspectDocument, + onDocumentActivate, selectedDocumentIds, openOverlayForDoc, }) as { @@ -956,7 +1004,7 @@ function DesktopWorkspaceView({ <>
{ if (event.target === event.currentTarget) { - onClearSelection?.(); + onClearSelection(); } focusShell(); }} @@ -971,7 +1019,7 @@ function DesktopWorkspaceView({ onDrop={handleCanvasDrop} onPointerDown={(event) => { if (event.target === event.currentTarget) { - onClearSelection?.(); + onClearSelection(); } focusShell(); }} @@ -1062,7 +1110,7 @@ function DesktopWorkspaceView({ getDocumentAsset={getDocumentAsset} handleNavigatorSnapshot={handleNavigatorSnapshot} cardPointerHandlers={cardPointerHandlers} - onInspectDocument={onInspectDocument} + onDocumentActivate={onDocumentActivate} onTagDragEnter={handleTagDragEnterDoc} onTagDragOver={handleTagDragOverDoc} onTagDragLeave={handleTagDragLeaveDoc} diff --git a/frontend/src/desktop/hooks/usePreviewMetadata.ts b/frontend/src/desktop/hooks/usePreviewMetadata.ts index 3133fff..cbb9023 100644 --- a/frontend/src/desktop/hooks/usePreviewMetadata.ts +++ b/frontend/src/desktop/hooks/usePreviewMetadata.ts @@ -1,6 +1,4 @@ import { useEffect, useState } from 'react'; -import { createAssetView } from '../../asset_manager'; - interface DocumentLike { id?: string | number; current_version?: unknown; @@ -47,8 +45,7 @@ const usePreviewMetadata = ( const resolveAsset = (type: string) => getDocumentAsset?.(doc, type) ?? null; let asset = resolveAsset('preview') || resolveAsset('thumbnail'); - let view = createAssetView(asset); - let metadata = view.getPrimaryMetadata(); + let metadata = (asset?.metadata as { width?: number; height?: number } | null) || null; const hasDimensions = (meta: { width?: number | string; height?: number | string } | null) => Number.isFinite(Number(meta?.width)) && @@ -58,11 +55,10 @@ const usePreviewMetadata = ( if (!hasDimensions(metadata) && ensureAssetUrl && docId && asset?.id) { try { - const ensured = await ensureAssetUrl(doc.id, asset, { force: true }); + const ensured = await ensureAssetUrl(doc.id, asset); if (ensured) { asset = ensured; - view = createAssetView(asset); - metadata = view.getPrimaryMetadata(); + metadata = (asset?.metadata as { width?: number; height?: number } | null) || null; } } catch (error) { console.warn('[desk] ensureDocumentSize metadata fetch failed', error); diff --git a/frontend/src/desktop/math.ts b/frontend/src/desktop/math.ts deleted file mode 100644 index fcf93d7..0000000 --- a/frontend/src/desktop/math.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { clamp } from '../utils/math'; - -export const formatTransform = ( - x: number, - y: number, - rotation = 0, - scale = 1, -): string => `translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`; diff --git a/frontend/src/desktop/pointer/useDeskPointer.js b/frontend/src/desktop/pointer/useDeskPointer.js index 5c87d61..383acaa 100644 --- a/frontend/src/desktop/pointer/useDeskPointer.js +++ b/frontend/src/desktop/pointer/useDeskPointer.js @@ -32,10 +32,10 @@ export const useDeskPointer = ({ handlePointerMove, handlePointerUp, handlePointerCancel, - onEntryPointer, + onDocumentClick, onDocumentStackSelect, onPromoteSelection, - onInspectDocument, + onDocumentActivate, selectedDocumentIds, openOverlayForDoc = null, }) => { @@ -242,16 +242,16 @@ export const useDeskPointer = ({ stackHits, }); - if (intent.selectedAtDown) { - safeInvoke(onPromoteSelection, doc.id, event); - } + if (intent.selectedAtDown) { + safeInvoke(onPromoteSelection, doc.id, event); + } - applyClickPlanImmediately({ - intent, - event, - onEntryPointer, - onDocumentStackSelect, - }); + applyClickPlanImmediately({ + intent, + event, + onEntryPointer: onDocumentClick, + onDocumentStackSelect, + }); pointerIntentRef.current = intent; @@ -272,7 +272,7 @@ export const useDeskPointer = ({ [ handlePointerDown, onPromoteSelection, - onEntryPointer, + onDocumentClick, onDocumentStackSelect, resolveStackDocIds, resetLongPressState, @@ -308,7 +308,7 @@ export const useDeskPointer = ({ finalizeClickSelection({ intent: pointerState, event, - onEntryPointer, + onEntryPointer: onDocumentClick, onDocumentStackSelect, }); @@ -325,7 +325,7 @@ export const useDeskPointer = ({ const stillSelected = Array.isArray(selectedDocumentIds) && selectedDocumentIds.includes(doc.id); if (isPrimaryRelease && stillSelected) { - safeInvoke(onInspectDocument, doc.id); + safeInvoke(onDocumentActivate, doc.id); } } } @@ -335,9 +335,9 @@ export const useDeskPointer = ({ }, [ handlePointerUp, - onInspectDocument, + onDocumentActivate, onDocumentStackSelect, - onEntryPointer, + onDocumentClick, resetLongPressState, selectedDocumentIds, ], diff --git a/frontend/src/desktop/useDocumentDrag.ts b/frontend/src/desktop/useDocumentDrag.ts index b031b31..a237e40 100644 --- a/frontend/src/desktop/useDocumentDrag.ts +++ b/frontend/src/desktop/useDocumentDrag.ts @@ -7,7 +7,7 @@ import { } from 'react'; import type { PointerEvent as ReactPointerEvent } from 'react'; import { preventAll, safeInvoke } from './events'; -import { clamp } from './math'; +import { clamp } from '../utils/math'; import usePointerTap from '../ui/usePointerTap'; import { MIN_TIMESTEP, @@ -108,7 +108,7 @@ interface UseDocumentDragOptions { recalcVisibleDocIds: () => void; settings?: DragSettings; containerRef?: RefObject; - onInspectDocument?: (docId: Identifier | null, event?: PointerEvent | ReactPointerEvent) => void; + onDocumentActivate?: (docId: Identifier | null, event?: PointerEvent | ReactPointerEvent) => void; onDocumentStackSelect?: ( docIds: Identifier[], event: PointerEvent | ReactPointerEvent, @@ -208,15 +208,15 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { bringToFront, setDraggingId, canvasSize, - openOverlayForDoc, - recalcVisibleDocIds, - settings, - containerRef: providedContainerRef, - onInspectDocument, - onDocumentStackSelect, - selectedDocumentIds = [], - markLayoutDirty, - } = options; + openOverlayForDoc, + recalcVisibleDocIds, + settings, + containerRef: providedContainerRef, + onDocumentActivate, + onDocumentStackSelect, + selectedDocumentIds = [], + markLayoutDirty, +} = options; const fallbackContainerRef = useRef(null); const containerRef = providedContainerRef ?? fallbackContainerRef; @@ -246,7 +246,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { openOverlayForDoc?.(data.docId, data.originInfo); return; } - onInspectDocument?.(data.docId, event); + onDocumentActivate?.(data.docId, event); }, }); const dragStateRef = useRef(null); diff --git a/frontend/src/desktop/workspaceEngine.ts b/frontend/src/desktop/workspaceEngine.ts index 0bf2ad5..4cf8e70 100644 --- a/frontend/src/desktop/workspaceEngine.ts +++ b/frontend/src/desktop/workspaceEngine.ts @@ -1,4 +1,4 @@ -import { clamp, formatTransform } from './math'; +import { clamp, formatTransform } from '../utils/math'; import { fetchLayoutRecords, upsertLayoutRecords } from './db'; type DocumentId = string; diff --git a/frontend/src/detail/PreviewZoomOverlay.tsx b/frontend/src/detail/PreviewZoomOverlay.tsx index e7db638..e095ca4 100644 --- a/frontend/src/detail/PreviewZoomOverlay.tsx +++ b/frontend/src/detail/PreviewZoomOverlay.tsx @@ -6,7 +6,7 @@ import PdfViewer from '../preview/PdfViewer'; type DocumentLike = { id?: string | number; title?: string; - content_type?: string | null; + mime_type?: string | null; [key: string]: unknown; }; @@ -23,13 +23,13 @@ type DisplayKind = 'image' | 'pdf'; type DocumentLink = { url: string; alt?: string; - contentType?: string | null; + mimeType?: string | null; }; type DocumentLikeWithPreview = DocumentLike & { documentLink?: DocumentLink }; const determineDisplayKind = (entry?: DocumentLink | null): DisplayKind => { - const type = entry?.contentType?.toLowerCase?.() || ''; + const type = entry?.mimeType?.toLowerCase?.() || ''; if (type.includes('pdf')) { return 'pdf'; } @@ -214,22 +214,22 @@ const PreviewZoomOverlay: React.FC = ({ const key = event.key; if (key === ' ' || key === 'Space' || key === 'Spacebar') { - const target = event.target; - if (target instanceof HTMLElement) { - const tag = target.tagName ? target.tagName.toLowerCase() : ''; - if ( - target.isContentEditable - || tag === 'input' - || tag === 'textarea' - || tag === 'select' - ) { - return; - } + const target = event.target; + if (target instanceof HTMLElement) { + const tag = target.tagName ? target.tagName.toLowerCase() : ''; + if ( + target.isContentEditable + || tag === 'input' + || tag === 'textarea' + || tag === 'select' + ) { + return; } - event.preventDefault(); - onClose(); - return; } + event.preventDefault(); + onClose(); + return; + } if (key === 'Escape') { event.preventDefault(); @@ -264,6 +264,7 @@ const PreviewZoomOverlay: React.FC = ({ if (isPdfDisplay) { return; } + event.stopPropagation(); toggleZoomAtPoint(event.clientX ?? 0, event.clientY ?? 0); }; @@ -322,11 +323,11 @@ const PreviewZoomOverlay: React.FC = ({ >
{ - if (event.target === event.currentTarget) { - onClose(); - } - }} + onClick={(event) => { + if (event.target === event.currentTarget) { + onClose(); + } + }} >
Promise; detailPanelControlRef: MutableRefObject<{ open?: (args?: { documentIds?: Identifier[] }) => void; close?: () => void } | null>; detailFolderFetchRef: MutableRefObject>; - documentLinks: Map; previewDocumentId?: Identifier | null; activePreviewId?: Identifier | null; openDocumentPreview?: (args: { documentIds: Identifier[] }) => void; @@ -67,7 +61,6 @@ interface UseDetailWorkspaceResult { inspectDocument: (docId: Identifier | null) => void; previewActive: boolean; previewWorkspaceDocument: DocumentLike | null; - documentLink: DocumentLink; resolveThumbnailUrlForDoc: (doc: DocumentLike | null) => string | null; resolveFolderPath: (folderId?: Identifier | 'root') => Array<{ id: Identifier | 'root'; name: string }>; } @@ -81,7 +74,6 @@ const useDetailWorkspace = ({ ensureFolderData, detailPanelControlRef, detailFolderFetchRef, - documentLinks, previewDocumentId, activePreviewId, openDocumentPreview, @@ -239,11 +231,6 @@ const useDetailWorkspace = ({ [folderNodes], ); - const documentLink = useMemo( - () => (detailPanelDocument ? documentLinks.get(detailPanelDocument.id) || null : null), - [detailPanelDocument, documentLinks], - ); - const previewWorkspaceDocument = useMemo(() => { if (!previewDocumentId) { return null; @@ -285,7 +272,6 @@ const useDetailWorkspace = ({ tagLookupById, onTagAdd: handleDocumentTagAdd, onTagRemove: handleTagRemove, - documentLink, onOpenPreview: openDocumentPreview, activePreviewId, onUpdateTitle: handleDocumentTitleUpdate, @@ -299,6 +285,8 @@ const useDetailWorkspace = ({ onFolderNavigate: selectFolder, onClose: handleDetailPanelClose, resolveFolderPath, + folderNodes, + ensureFolderData, }), [ activePreviewId, @@ -313,11 +301,12 @@ const useDetailWorkspace = ({ handleDocumentIssuedUpdate, handleDocumentTitleUpdate, handleTagRemove, + folderNodes, + ensureFolderData, openDocumentPreview, resolveApiPath, resolveFolderPath, selectFolder, - documentLink, tags, tagLookupById, ], @@ -332,7 +321,6 @@ const useDetailWorkspace = ({ inspectDocument, previewActive, previewWorkspaceDocument, - documentLink, resolveThumbnailUrlForDoc, resolveFolderPath, }; diff --git a/frontend/src/documents/DocumentSummarySection.tsx b/frontend/src/documents/DocumentSummarySection.tsx index e73bd75..62b30a7 100644 --- a/frontend/src/documents/DocumentSummarySection.tsx +++ b/frontend/src/documents/DocumentSummarySection.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react'; +import { Link } from 'react-router-dom'; import { EditIcon, IconX, CheckIcon, PlusIcon } from '../ui/icons'; import SelectionAssignmentMenu, { SelectionAssignmentMenuItem, @@ -12,6 +13,7 @@ import { } from '../utils/date'; import { describeDocumentSummary, type DocumentSummaryRow } from './documentSummary'; import { isPlainObject } from '../utils/typeGuards'; +import { useFolderManager } from '../folders/FolderManagerContext'; type Identifier = string | number; @@ -31,6 +33,7 @@ interface DocumentLike { id?: Identifier; title?: string; issued_at?: string | null; + folder_id?: string | null; current_version?: { version_number?: number } | null; tags?: TagEntry[]; correspondents?: CorrespondentEntry[]; @@ -71,6 +74,7 @@ export interface DocumentSummarySectionProps { onCorrespondentRemove?: (payload: { documentId: Identifier | undefined; correspondentId: Identifier | undefined }) => void; onUpdateTitle?: (docId: Identifier | undefined, title: string) => Promise | boolean; onUpdateIssued?: (docId: Identifier | undefined, timestamp: number | null) => Promise | boolean; + onFolderNavigate?: (folderId: string | null) => void; layout?: 'default' | 'compact'; } @@ -456,8 +460,10 @@ const DocumentSummarySection: React.FC = ({ onCorrespondentRemove, onUpdateTitle, onUpdateIssued, + onFolderNavigate, layout = 'default', }) => { + const folderManager = useFolderManager(); const isCompactLayout = layout === 'compact'; const summaryRows = useMemo(() => describeDocumentSummary(document), [document]); const issuedDateLabel = useMemo( @@ -489,7 +495,7 @@ const DocumentSummarySection: React.FC = ({ const extraSummaryRows = useMemo(() => { const rows: DocumentSummaryRow[] = []; const currentVersionNumber = document?.current_version?.version_number; - if (Number.isFinite(currentVersionNumber)) { + if (currentVersionNumber != null) { rows.push({ key: 'current-version', label: 'Current version', @@ -499,6 +505,39 @@ const DocumentSummarySection: React.FC = ({ return rows; }, [document?.current_version?.version_number]); + const resolvedFolderId = document?.folder_id ?? null; + + const [folderName, setFolderName] = useState(() => folderManager.getNameSync(resolvedFolderId)); + + useEffect(() => { + let active = true; + const cached = folderManager.getNameSync(resolvedFolderId); + setFolderName(cached); + if (!cached && resolvedFolderId != null) { + folderManager.resolveName(resolvedFolderId).then((name) => { + if (active) { + setFolderName(name); + } + }).catch(() => {}); + } + return () => { + active = false; + }; + }, [resolvedFolderId, folderManager]); + + const folderHref = resolvedFolderId == null ? '/documents' : `/documents/folder/${resolvedFolderId}`; + + const handleFolderClick = useCallback( + (event: React.MouseEvent) => { + if (!onFolderNavigate) { + return; + } + event.preventDefault(); + onFolderNavigate(resolvedFolderId); + }, + [onFolderNavigate, resolvedFolderId], + ); + const [titleDraft, setTitleDraft] = useState(''); const [titleSaving, setTitleSaving] = useState(false); const [titleError, setTitleError] = useState(null); @@ -741,11 +780,22 @@ const DocumentSummarySection: React.FC = ({ /> ); + const folderValueContent = ( + + {folderName} + + ); + const summaryRowOverrides = { title: { valueContent: titleMetaDisplay, error: titleError }, issued: { valueContent: issuedDisplay, error: issuedError }, tags: { valueContent: tagsValueContent }, correspondents: { valueContent: correspondentsValueContent }, + folder: { valueContent: folderValueContent }, } as Record; const baseRows: MetaItem[] = [...summaryRows, ...extraSummaryRows].map((row) => { diff --git a/frontend/src/documents/DocumentThumbnailImage.tsx b/frontend/src/documents/DocumentThumbnailImage.tsx index 1e0679b..70548c9 100644 --- a/frontend/src/documents/DocumentThumbnailImage.tsx +++ b/frontend/src/documents/DocumentThumbnailImage.tsx @@ -3,7 +3,7 @@ import type { CSSProperties, JSX, MutableRefObject } from 'react'; import { getAssetFromVersion, resolveDocumentAssetUrl, - createAssetView, + resolveAssetUrl, } from '../asset_manager'; import type { DocumentLike as AssetManagerDocumentLike, @@ -101,13 +101,13 @@ const DocumentThumbnailImage = ({ () => getAssetFromVersion(document?.current_version, 'thumbnail'), [document?.current_version], ); - const thumbnailView = useMemo(() => createAssetView(thumbnailAsset), [thumbnailAsset]); - const primaryMetadata = thumbnailView.getPrimaryMetadata() || {}; - const assetWidth = Number(primaryMetadata?.width); - const assetHeight = Number(primaryMetadata?.height); + const thumbnailMetadata = (thumbnailAsset?.metadata as { width?: number; height?: number } | null) || null; + const assetWidth = thumbnailMetadata?.width; + const assetHeight = thumbnailMetadata?.height; const dimensions = useMemo(() => { - if (!Number.isFinite(assetWidth) || assetWidth <= 0 || !Number.isFinite(assetHeight) || assetHeight <= 0) { + const hasDimensions = typeof assetWidth === 'number' && assetWidth > 0 && typeof assetHeight === 'number' && assetHeight > 0; + if (!hasDimensions) { return { width: resolvedMaxSize, height: resolvedMaxSize }; } const scale = Math.min(1, resolvedMaxSize / assetWidth, resolvedMaxSize / assetHeight); @@ -136,8 +136,8 @@ const DocumentThumbnailImage = ({ if (getDocumentAsset) { options.getAsset = getDocumentAsset; } - return resolveDocumentAssetUrl(document, 'thumbnail', options); - }, [document, ensureAssetUrl, getDocumentAsset, isVisible]); + return resolveDocumentAssetUrl(document, 'thumbnail', options) || resolveAssetUrl(thumbnailAsset); + }, [document, ensureAssetUrl, getDocumentAsset, isVisible, thumbnailAsset]); const pageCount = getPageCount(document); const showMultiPageBadge = Number.isFinite(pageCount) && pageCount > 1; @@ -147,11 +147,11 @@ const DocumentThumbnailImage = ({ } const aspectRatio = useMemo(() => { - if (Number.isFinite(assetWidth) && Number.isFinite(assetHeight) && assetWidth > 0 && assetHeight > 0) { - return assetWidth / assetHeight; + if (dimensions.width > 0 && dimensions.height > 0) { + return dimensions.width / dimensions.height; } return null; - }, [assetWidth, assetHeight]); + }, [dimensions.height, dimensions.width]); useEffect(() => { const node = visibilityRef.current; diff --git a/frontend/src/documents/DocumentsList.tsx b/frontend/src/documents/DocumentsList.tsx index 62cc7ec..570e71d 100644 --- a/frontend/src/documents/DocumentsList.tsx +++ b/frontend/src/documents/DocumentsList.tsx @@ -60,7 +60,6 @@ export type DocumentEventHandler = (document: DocumentLike, event: MouseEvent | null; draggedFolderId?: Identifier | 'root' | null; ensureAssetUrl?: (...args: any[]) => unknown; @@ -91,7 +90,6 @@ export interface DocumentsListProps { const DocumentsList: React.FC = ({ entries, - focusedRowKey, draggingDocumentIdsSet, draggedFolderId, ensureAssetUrl, @@ -185,7 +183,6 @@ const DocumentsList: React.FC = ({ const canDragFolder = folder.id !== 'root'; const isDraggingFolder = draggedFolderId === folder.id; const isSelectedFolder = selectedFolderIdsSet?.has(folder.id); - const rowKey = `folder:${folder.id}`; const canRenameFolder = Boolean(onFolderRename) && folder.id !== 'root'; const isFolderEditing = editingFolderId === folder.id; const folderDraftValue = isFolderEditing ? folderDraft : folder.name; @@ -198,9 +195,7 @@ const DocumentsList: React.FC = ({ return ( onFolderClick?.(folder, event)} onDoubleClick={(event) => { diff --git a/frontend/src/documents/DocumentsManager.ts b/frontend/src/documents/DocumentsManager.ts index dba5090..357ad5f 100644 --- a/frontend/src/documents/DocumentsManager.ts +++ b/frontend/src/documents/DocumentsManager.ts @@ -15,6 +15,8 @@ class DocumentsManager { private listeners: Set<() => void>; + private emitScheduled: boolean; + constructor( fetchDocument?: FetchDocument, ) { @@ -22,10 +24,18 @@ class DocumentsManager { this.fetcher = fetchDocument; this.inflight = new Map(); this.listeners = new Set(); + this.emitScheduled = false; } private emit() { - this.listeners.forEach((fn) => fn()); + if (this.emitScheduled) { + return; + } + this.emitScheduled = true; + setTimeout(() => { + this.emitScheduled = false; + this.listeners.forEach((fn) => fn()); + }, 0); } subscribe(listener: () => void) { diff --git a/frontend/src/documents/SelectionFloatingActions.tsx b/frontend/src/documents/SelectionFloatingActions.tsx index c0b9ea6..b4374e8 100644 --- a/frontend/src/documents/SelectionFloatingActions.tsx +++ b/frontend/src/documents/SelectionFloatingActions.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { getFolderTree } from '../lib/apiClient'; import { TrashIcon, AnalyzeIcon, @@ -6,11 +7,10 @@ import { FolderOutlineIcon, TagIcon, CorrespondentIcon, - LoaderIcon, } from '../ui/icons'; import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './SelectionAssignmentMenu'; import SelectionSummary from './SelectionSummary'; -import { api, useAppState } from '../app/appState'; +import { useAppState } from '../app/appState'; import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext'; const ROOT_FOLDER_LABEL = 'Documents'; @@ -286,13 +286,11 @@ const SelectionFloatingActions: React.FC = ({ const tagLookupMap = tagLookupById instanceof Map ? tagLookupById : null; const [remoteFolderOptions, setRemoteFolderOptions] = useState(null); - const [loadingFolders, setLoadingFolders] = useState(false); const folderTreeFetchRef = useRef | null>(null); useEffect(() => { setRemoteFolderOptions(null); folderTreeFetchRef.current = null; - setLoadingFolders(false); }, [tenantId, token]); const requestFolderTree = useCallback(async (): Promise => { @@ -310,9 +308,8 @@ const SelectionFloatingActions: React.FC = ({ } const fetchPromise = (async () => { - setLoadingFolders(true); try { - const { data } = await api.get('/folders/tree'); + const data = await getFolderTree(); const options = buildFolderTreeOptions(data); setRemoteFolderOptions(options); return options; @@ -321,7 +318,6 @@ const SelectionFloatingActions: React.FC = ({ setRemoteFolderOptions([]); return []; } finally { - setLoadingFolders(false); folderTreeFetchRef.current = null; } })(); @@ -353,9 +349,7 @@ const SelectionFloatingActions: React.FC = ({ const documentCount = documentIdList.length; const folderCount = folderIdList.length; - const totalCount = Number.isFinite(selectionCount) - ? Number(selectionCount) - : documentCount + folderCount; + const totalCount = selectionCount ?? documentCount + folderCount; const selectedDocuments = useMemo(() => { if (!documentIdList.length || !(documentLookupMap instanceof Map)) { @@ -520,19 +514,15 @@ const SelectionFloatingActions: React.FC = ({ label="Move" triggerContent={( - {loadingFolders ? ( - )} items={moveAssignments} placeholder="Search folders…" - emptyMessage={loadingFolders ? 'Loading folders…' : 'No folders'} + emptyMessage="No folders" onToggle={(item) => handleMoveSelectionToFolder(item?.payload || item)} - disabled={!documentCount || (loadingFolders && !moveAssignments.length)} + disabled={!documentCount} createLabel={null} showStateIndicators={false} showCounts={false} diff --git a/frontend/src/documents/documentActions.ts b/frontend/src/documents/documentActions.ts index 099639c..c1875c2 100644 --- a/frontend/src/documents/documentActions.ts +++ b/frontend/src/documents/documentActions.ts @@ -13,14 +13,14 @@ export type DocumentLike = OcrDocumentLike; const asyncFalse = async () => false; const resolveDocumentDownloadHref = (document?: DocumentLike | null, resolveApiPath?: ResolveApiPath | null): string | null => { - if (!document || !resolveApiPath) { + if (!document) { return null; } - const downloadPath = (document.current_version as { download_path?: string | null } | null)?.download_path; - if (!downloadPath) { + const downloadUrl = (document.current_version as { download?: { url: string } | null } | null)?.download?.url; + if (!downloadUrl) { return null; } - return resolveApiPath(downloadPath); + return resolveApiPath ? resolveApiPath(downloadUrl) : downloadUrl; }; const hasDocumentOcrAsset = (document?: DocumentLike | null, getDocumentAsset?: GetDocumentAsset | null): boolean => { diff --git a/frontend/src/documents/documentSummary.ts b/frontend/src/documents/documentSummary.ts index 9e6467f..e883a64 100644 --- a/frontend/src/documents/documentSummary.ts +++ b/frontend/src/documents/documentSummary.ts @@ -1,5 +1,6 @@ import { formatFileSize } from '../utils/format'; import { formatDateTime as defaultFormatDateTime } from '../utils/date'; +import { DEFAULT_FOLDER_NAME } from '../app/appLayoutUtils'; interface DocumentPageMetadata { page_count?: number | string | null; @@ -23,12 +24,14 @@ export interface SummaryDocument { title?: string | null; original_name?: string | null; filename?: string | null; - content_type?: string | null; + mime_type?: string | null; + folder_id?: string | null; + folder_name?: string; current_version?: DocumentVersion | null; created_at?: string | null; updated_at?: string | null; issued_at?: string | null; - folder_path?: string | null; + folder_path?: string; tags?: TagEntry[] | null; correspondents?: CorrespondentEntry[] | null; } @@ -37,7 +40,7 @@ interface DescribeSummaryOptions { formatDateTime?: typeof defaultFormatDateTime; } -export type DocumentSummaryRowType = 'text' | 'editable-title' | 'editable-issued' | 'tags' | 'correspondents'; +export type DocumentSummaryRowType = 'text' | 'editable-title' | 'editable-issued' | 'tags' | 'correspondents' | 'folder'; export interface DocumentSummaryRow { key: string; @@ -69,7 +72,7 @@ export interface MetadataDocumentLike { updated_at?: string | null; filename?: string | null; original_name?: string | null; - content_type?: string | null; + mime_type?: string | null; metadata?: DocumentMetadataPayload | null; current_version?: { checksum?: string | null } | null; } @@ -86,6 +89,7 @@ export const describeDocumentSummary = (document?: SummaryDocument | null, optio const metadata = doc.current_version?.metadata || null; const pageCount = coercePageCount(metadata); const pageCountLabel = Number.isFinite(pageCount) ? String(pageCount) : '—'; + const folderLabel = doc.folder_id == null ? DEFAULT_FOLDER_NAME : `Folder ${doc.folder_id}`; const tags = sanitizeArray(doc.tags); const correspondents = sanitizeArray(doc.correspondents); const tagLabels = tags.map((tag) => tag.label).filter(Boolean) as string[]; @@ -99,8 +103,9 @@ export const describeDocumentSummary = (document?: SummaryDocument | null, optio { key: 'issued', label: 'Issued', value: formatDateLabel(doc.issued_at), kind: 'editable-issued' }, { key: 'created', label: 'Created at', value: formatDateLabel(doc.created_at) }, { key: 'updated', label: 'Updated at', value: formatDateLabel(doc.updated_at) }, + { key: 'folder', label: 'Folder', value: folderLabel, kind: 'folder' }, { key: 'size', label: 'Size', value: sizeLabel }, - { key: 'content-type', label: 'Content type', value: doc.content_type || 'Unknown' }, + { key: 'mime-type', label: 'MIME type', value: doc.mime_type || 'Unknown' }, { key: 'pages', label: 'Pages', value: pageCountLabel }, { key: 'filename', label: 'Filename', value: doc.filename }, { key: 'original-filename', label: 'Original filename', value: doc.original_name }, diff --git a/frontend/src/documents/hooks/useBulkDocumentActions.ts b/frontend/src/documents/hooks/useBulkDocumentActions.ts index 47d436a..88ae110 100644 --- a/frontend/src/documents/hooks/useBulkDocumentActions.ts +++ b/frontend/src/documents/hooks/useBulkDocumentActions.ts @@ -1,12 +1,8 @@ import { useCallback } from 'react'; +import { assignCorrespondentsBulk } from '../../lib/apiClient'; export type Identifier = string | number; -type ApiClient = { - post: (url: string, payload: unknown) => Promise<{ data: T } | T>; - delete: (url: string) => Promise; -}; - type BulkAssignmentResponse = { assigned?: number; removed?: number; @@ -17,22 +13,19 @@ type CorrespondentAssignment = { }; interface UseBulkDocumentActionsArgs { - api: ApiClient; resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[]; correspondentLookupByName: Map; handleCorrespondentCreate: (payload: { name: string }) => Promise<{ id?: Identifier } | null>; setStatusMessage: (message: string, variant?: string) => void; selectedDocumentIds?: Identifier[]; selectedFolderIds?: Identifier[]; - handleDocumentsDelete: (ids: Identifier[], options?: { showMessage?: boolean; manageLoading?: boolean }) => Promise; - handleFolderDelete: (id: Identifier, options?: { showMessage?: boolean; manageLoading?: boolean }) => Promise; + handleDocumentsDelete: (ids: Identifier[], options?: { showMessage?: boolean }) => Promise; + handleFolderDelete: (id: Identifier, options?: { showMessage?: boolean }) => Promise; clearDocumentSelection: () => void; - setLoading: (value: boolean) => void; updateDocumentCaches?: (id: Identifier, updater: (doc: any) => any) => void; } const useBulkDocumentActions = ({ - api, resolveTargetDocumentIds, correspondentLookupByName, handleCorrespondentCreate, @@ -42,7 +35,6 @@ const useBulkDocumentActions = ({ handleDocumentsDelete, handleFolderDelete, clearDocumentSelection, - setLoading, updateDocumentCaches, }: UseBulkDocumentActionsArgs) => { const handleBulkCorrespondentAdd = useCallback( @@ -72,7 +64,7 @@ const useBulkDocumentActions = ({ return; } - const response = await api.post('/documents/bulk/correspondents', { + const response: BulkAssignmentResponse = await assignCorrespondentsBulk({ document_ids: targets, assignments: [ { @@ -82,7 +74,7 @@ const useBulkDocumentActions = ({ action: 'add', }); - const { assigned = 0, removed = 0 } = 'data' in response ? response.data : response; + const { assigned = 0, removed = 0 } = response; if (updateDocumentCaches && target.id) { targets.forEach((docId) => { @@ -118,7 +110,6 @@ const useBulkDocumentActions = ({ } }, [ - api, correspondentLookupByName, handleCorrespondentCreate, resolveTargetDocumentIds, @@ -141,17 +132,17 @@ const useBulkDocumentActions = ({ return; } - const normalizedAssignments = assignments.map((entry) => ({ - correspondent_id: entry.correspondent_id, - })); + const normalizedAssignments = assignments.map((entry) => ({ + correspondent_id: entry.correspondent_id, + })); - const response = await api.post('/documents/bulk/correspondents', { + const response: BulkAssignmentResponse = await assignCorrespondentsBulk({ document_ids: targets, assignments: normalizedAssignments, action: 'remove', }); - const { assigned = 0, removed = 0 } = 'data' in response ? response.data : response; + const { assigned = 0, removed = 0 } = response; if (updateDocumentCaches) { targets.forEach((docId) => { updateDocumentCaches(docId, (doc) => { @@ -178,12 +169,12 @@ const useBulkDocumentActions = ({ } else if (assigned > 0) { const assignedSuffix = assigned === 1 ? '' : 's'; setStatusMessage(`Correspondent updated ${assigned} link${assignedSuffix}.`, 'info'); - } else { - setStatusMessage('No correspondents changed.', 'info'); - } - }, - [api, resolveTargetDocumentIds, setStatusMessage, updateDocumentCaches], - ); + } else { + setStatusMessage('No correspondents changed.', 'info'); + } + }, + [resolveTargetDocumentIds, setStatusMessage, updateDocumentCaches], +); const handleDeleteSelection = useCallback(async () => { const docIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds : []; @@ -209,25 +200,20 @@ const useBulkDocumentActions = ({ return; } - setLoading(true); let docsOk = true; let foldersOk = true; - try { - if (docIds.length) { - docsOk = await handleDocumentsDelete(docIds, { showMessage: false, manageLoading: false }); - } + if (docIds.length) { + docsOk = await handleDocumentsDelete(docIds, { showMessage: false }); + } - if (folderIds.length) { - for (const folderId of folderIds) { - const success = await handleFolderDelete(folderId, { showMessage: false, manageLoading: false }); - if (!success) { - foldersOk = false; - } + if (folderIds.length) { + for (const folderId of folderIds) { + const success = await handleFolderDelete(folderId, { showMessage: false }); + if (!success) { + foldersOk = false; } } - } finally { - setLoading(false); } if (!docsOk || !foldersOk) { @@ -252,7 +238,6 @@ const useBulkDocumentActions = ({ handleFolderDelete, selectedDocumentIds, selectedFolderIds, - setLoading, setStatusMessage, ]); diff --git a/frontend/src/documents/hooks/useDocumentsPanelProps.ts b/frontend/src/documents/hooks/useDocumentsPanelProps.ts index 7c687bd..2112984 100644 --- a/frontend/src/documents/hooks/useDocumentsPanelProps.ts +++ b/frontend/src/documents/hooks/useDocumentsPanelProps.ts @@ -5,7 +5,7 @@ type Identifier = string | number; interface DocumentLinkLike { url?: string | null; - contentType?: string | null; + mimeType?: string | null; } export interface Breadcrumb { @@ -55,7 +55,7 @@ export interface UseDocumentsPanelPropsArgs { clearDocumentSelection?: () => void; handleDeleteSelection?: () => void; handleEntryPointerCore?: (...args: unknown[]) => void; - inspectDocument?: (docId: Identifier | null, metadata?: unknown) => void; + onDocumentActivate?: (docId: Identifier | null, metadata?: unknown) => void; tags?: unknown[]; correspondents?: unknown[]; documentLookup?: unknown; @@ -107,7 +107,7 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => { handleDocumentsViewModeChange, handleDeleteSelection, handleEntryPointerCore, - inspectDocument, + onDocumentActivate, tags, correspondents, documentLookup, @@ -159,7 +159,7 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => { onViewModeChange: handleDocumentsViewModeChange, onDeleteSelection: handleDeleteSelection, onEntryPointer: handleEntryPointerCore, - onInspectDocument: inspectDocument, + onDocumentActivate, tags, correspondents, documentLookup, @@ -206,7 +206,7 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => { handleFolderDragEnd, handleFolderDragStart, handleFolderRename, - inspectDocument, + onDocumentActivate, moveDocumentsToFolder, openDocumentPreview, refreshCurrentFolder, diff --git a/frontend/src/documents/panel/DocumentsPanel.tsx b/frontend/src/documents/panel/DocumentsPanel.tsx index ff57976..175add7 100644 --- a/frontend/src/documents/panel/DocumentsPanel.tsx +++ b/frontend/src/documents/panel/DocumentsPanel.tsx @@ -38,7 +38,7 @@ interface DocumentsPanelProps extends DocumentsPanelInnerProps { const defaultGetDocumentAsset = (_doc?: unknown, _type?: string) => null; -export type DocumentLinkLike = { url?: string | null; contentType?: string | null }; +export type DocumentLinkLike = { url?: string | null; mimeType?: string | null }; const DocumentsPanelInner: React.FC = ({ headerLeading = null, @@ -61,7 +61,7 @@ const DocumentsPanelInner: React.FC = ({ onDocumentDragEnd, onDocumentRename, onEntryPointer = null, - onInspectDocument = null, + onDocumentActivate = null, tagLookupById, activeCorrespondentIds = [], ensureAssetUrl = null, @@ -257,7 +257,7 @@ const DocumentsPanelInner: React.FC = ({ const isDeskView = viewMode === 'desk'; type Identifier = string | number; - type ZoomSource = { url: string; alt?: string | null; contentType?: string | null }; +type ZoomSource = { url: string; alt?: string | null; mimeType?: string | null }; const [previewDocId, setPreviewDocId] = useState(null); @@ -290,9 +290,7 @@ const DocumentsPanelInner: React.FC = ({ cancelled = true; }; } - const docContentType = previewDoc.content_type; - const versionContentType = previewDoc.current_version?.version?.content_type; - const contentFallback = docContentType || versionContentType || null; + const documentMimeType = previewDoc.mime_type; const applyEntry = (entry?: DocumentLinkLike | null) => { if (!entry?.url) { @@ -302,7 +300,7 @@ const DocumentsPanelInner: React.FC = ({ setPreviewZoomSource({ url: entry.url, alt: previewDoc.title, - contentType: entry.contentType || contentFallback || undefined, + mimeType: documentMimeType, }); }; @@ -370,9 +368,9 @@ const DocumentsPanelInner: React.FC = ({ handleDocumentPreviewZoom(doc); return; } - onInspectDocument?.(doc.id); + onDocumentActivate?.(doc.id); }, - [handleDocumentPreviewZoom, onInspectDocument], + [handleDocumentPreviewZoom, onDocumentActivate], ); const navigableRows = useMemo( diff --git a/frontend/src/documents/useEntryPointer.ts b/frontend/src/documents/useEntryPointer.ts index 7a89665..b79bccc 100644 --- a/frontend/src/documents/useEntryPointer.ts +++ b/frontend/src/documents/useEntryPointer.ts @@ -29,7 +29,7 @@ interface UseEntryPointerOptions { resolveDocumentRowKey?: (id: string | number) => string | null; resolveFolderRowKey?: (id: string | number) => string | null; onSelectEntry?: (entry: WorkspaceEntry, event?: PointerEventLike | null, metadata?: EntryPointerMetadata) => void; - onInspectDocument?: (id: string | number, metadata?: EntryPointerMetadata) => void; + onDocumentActivate?: (id: string | number, metadata?: EntryPointerMetadata) => void; } export interface EntryPointerMetadata { @@ -44,7 +44,7 @@ export const useEntryPointer = ({ resolveDocumentRowKey, resolveFolderRowKey, onSelectEntry, - onInspectDocument, + onDocumentActivate, }: UseEntryPointerOptions) => useCallback( (entry?: WorkspaceEntry | null, event?: PointerEventLike | null) => { @@ -70,10 +70,10 @@ export const useEntryPointer = ({ onSelectEntry?.(entry, event, metadata); if (type === 'document' && !modifierClick && primaryClick) { - onInspectDocument?.(id, metadata); + onDocumentActivate?.(id, metadata); } }, - [resolveDocumentRowKey, resolveFolderRowKey, onSelectEntry, onInspectDocument], + [resolveDocumentRowKey, resolveFolderRowKey, onSelectEntry, onDocumentActivate], ); export default useEntryPointer; diff --git a/frontend/src/folders/FolderManagerContext.tsx b/frontend/src/folders/FolderManagerContext.tsx new file mode 100644 index 0000000..9fbb1a6 --- /dev/null +++ b/frontend/src/folders/FolderManagerContext.tsx @@ -0,0 +1,59 @@ +import React, { createContext, useContext, useMemo, type ReactNode } from 'react'; +import { DEFAULT_FOLDER_NAME } from '../app/appLayoutUtils'; + +type FolderId = string | null; + +export interface FolderManager { + getNameSync: (folderId: FolderId) => string | null; + resolveName: (folderId: FolderId) => Promise; +} + +const defaultManager: FolderManager = { + getNameSync: (folderId) => (folderId == null ? DEFAULT_FOLDER_NAME : `Folder ${folderId}`), + resolveName: async (folderId) => (folderId == null ? DEFAULT_FOLDER_NAME : `Folder ${folderId}`), +}; + +const FolderManagerContext = createContext(defaultManager); + +interface FolderManagerProviderProps { + folderNodes?: Map; + ensureFolderData?: (folderId: string | 'root', options?: { force?: boolean; includeDocuments?: boolean }) => Promise; + children: ReactNode; +} + +export const FolderManagerProvider: React.FC = ({ + folderNodes, + ensureFolderData, + children, +}) => { + const value = useMemo(() => { + if (!folderNodes || !ensureFolderData) { + return defaultManager; + } + + const getNameSync = (folderId: FolderId) => { + if (folderId == null) return DEFAULT_FOLDER_NAME; + return folderNodes.get(folderId)?.name ?? null; + }; + + const resolveName = async (folderId: FolderId) => { + const cached = getNameSync(folderId); + if (cached) return cached; + if (folderId == null) return DEFAULT_FOLDER_NAME; + await ensureFolderData(folderId, { includeDocuments: false }); + return getNameSync(folderId) ?? `Folder ${folderId}`; + }; + + return { getNameSync, resolveName }; + }, [folderNodes, ensureFolderData]); + + return ( + + {children} + + ); +}; + +export const useFolderManager = (): FolderManager => useContext(FolderManagerContext); + +export default FolderManagerContext; diff --git a/frontend/src/hooks/documents/useAuthManager.ts b/frontend/src/hooks/documents/useAuthManager.ts index 444908b..0f46ec8 100644 --- a/frontend/src/hooks/documents/useAuthManager.ts +++ b/frontend/src/hooks/documents/useAuthManager.ts @@ -1,30 +1,18 @@ import { useCallback, useEffect, useRef } from 'react'; import type { MutableRefObject } from 'react'; -import type { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'; -import { AxiosHeaders } from 'axios'; +import { clearAuthToken, logoutSession, refreshSession, setAuthToken } from '../../lib/apiClient'; type AppStatus = string; type AppDispatch = (action: { type: string; [key: string]: unknown }) => void; -type NotifyApiError = (error: unknown, fallbackMessage: string, variant?: string) => void; - type SetStatusMessage = (message: string, variant?: string) => void; -type SetLoading = (state: boolean) => void; - -interface RetryableAxiosRequestConfig extends InternalAxiosRequestConfig { - _retry?: boolean; -} - interface UseAuthManagerArgs { - apiClient: AxiosInstance; token?: string | null; appStatus: AppStatus; appDispatch: AppDispatch; - notifyApiError: NotifyApiError; setStatusMessage: SetStatusMessage; - setLoading: SetLoading; } interface UseAuthManagerResult { @@ -33,40 +21,22 @@ interface UseAuthManagerResult { handleLogout: () => Promise; } -const ensureAxiosHeaders = ( - headers?: InternalAxiosRequestConfig['headers'], -): AxiosHeaders => { - if (headers instanceof AxiosHeaders) { - return headers; - } - return AxiosHeaders.from(headers || {}); -}; - -const setHeaderAuthorization = (config: InternalAxiosRequestConfig, token: string): void => { - const headers = ensureAxiosHeaders(config.headers); - headers.set('Authorization', `Bearer ${token}`); - config.headers = headers; -}; - const useAuthManager = ({ - apiClient, token, appStatus, appDispatch, - notifyApiError, setStatusMessage, - setLoading, }: UseAuthManagerArgs): UseAuthManagerResult => { const tokenRef = useRef(token); - const refreshPromiseRef = useRef | null>(null); const initialRefreshAttemptedRef = useRef(Boolean(token)); const refreshAccessToken = useCallback(async (): Promise => { console.log('[Auth] Attempting to refresh access token…'); appDispatch({ type: 'TOKEN_REFRESH_START' }); try { - const { data } = await apiClient.post<{ access_token?: string; tenant?: unknown }>('/auth/refresh'); + const data = await refreshSession(); if (data?.access_token) { + setAuthToken(data.access_token); appDispatch({ type: 'TOKEN_REFRESH_SUCCESS', token: data.access_token, @@ -81,7 +51,7 @@ const useAuthManager = ({ appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: (error as Error)?.message || null }); throw error; } - }, [apiClient, appDispatch]); + }, [appDispatch]); useEffect(() => { tokenRef.current = token; @@ -95,90 +65,17 @@ const useAuthManager = ({ } }, [token, appStatus, refreshAccessToken]); - useEffect(() => { - const requestInterceptor = apiClient.interceptors.request.use((config) => { - const currentToken = tokenRef.current; - if (currentToken) { - const headers = ensureAxiosHeaders(config.headers); - if (!headers.has('Authorization')) { - headers.set('Authorization', `Bearer ${currentToken}`); - } - config.headers = headers; - } - return config; - }); - - const responseInterceptor = apiClient.interceptors.response.use( - (response) => response, - async (error) => { - const axiosError = error as AxiosError & { config?: RetryableAxiosRequestConfig }; - const { response, config } = axiosError; - if (!response || !config) { - return Promise.reject(error); - } - - const status = response.status; - const url = String(config?.url ?? ''); - const isAuthRoute = url.includes('/auth/login') || url.includes('/auth/refresh'); - - if (status === 401 && !config._retry && !isAuthRoute) { - console.warn('[Auth] 401 received for', url, '- attempting token refresh'); - - if (!refreshPromiseRef.current) { - refreshPromiseRef.current = (async () => { - try { - return await refreshAccessToken(); - } finally { - refreshPromiseRef.current = null; - } - })(); - } - - try { - const newToken = await refreshPromiseRef.current; - if (!newToken) { - throw new Error('No token returned from refresh'); - } - config._retry = true; - setHeaderAuthorization(config, newToken); - console.log('[Auth] Retrying original request', url); - try { - return await apiClient(config); - } catch (retryError) { - if ((retryError as AxiosError)?.response?.status === 401) { - notifyApiError(retryError, 'Session expired. Please log in again.'); - } - throw retryError; - } - } catch (refreshError) { - console.warn('[Auth] Refresh failed, clearing session'); - notifyApiError(refreshError, 'Session expired. Please log in again.'); - return Promise.reject(refreshError); - } - } - - return Promise.reject(error); - }, - ); - - return () => { - apiClient.interceptors.request.eject(requestInterceptor); - apiClient.interceptors.response.eject(responseInterceptor); - }; - }, [apiClient, notifyApiError, refreshAccessToken]); - const handleLogout = useCallback(async () => { try { - setLoading(true); - await apiClient.post('/auth/logout'); + await logoutSession(); } catch (error) { console.warn('[Auth] Failed to revoke refresh token during logout', error); } finally { - setLoading(false); + clearAuthToken(); appDispatch({ type: 'LOGOUT' }); setStatusMessage('Logged out.', 'info'); } - }, [apiClient, appDispatch, setLoading, setStatusMessage]); + }, [appDispatch, setStatusMessage]); return { tokenRef, refreshAccessToken, handleLogout }; }; diff --git a/frontend/src/hooks/documents/useDocumentDragHandlers.ts b/frontend/src/hooks/documents/useDocumentDragHandlers.ts index 3ff67f2..1ff8d28 100644 --- a/frontend/src/hooks/documents/useDocumentDragHandlers.ts +++ b/frontend/src/hooks/documents/useDocumentDragHandlers.ts @@ -1,9 +1,9 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import type { DragEvent } from 'react'; -import { isPlainObject, isFunctionValue } from '../../utils/typeGuards'; type Identifier = string | number; -type FolderIdentifier = Identifier | 'root'; +type FolderIdentifier = string | 'root'; +type FolderInput = FolderIdentifier | number; interface DocumentLike { id?: Identifier | null; @@ -24,7 +24,7 @@ type HandleEntrySelectionFn = ( interface UseDocumentDragHandlersOptions { selectedEntries: string[]; selectedDocumentIds: Identifier[]; - selectedFolderIds: FolderIdentifier[]; + selectedFolderIds: FolderInput[]; applySelection: ApplySelectionFn; handleEntrySelection: HandleEntrySelectionFn; documentLookup: Map; @@ -49,6 +49,10 @@ const useDocumentDragHandlers = ({ documentsViewMode, }: UseDocumentDragHandlersOptions) => { const dragPreviewRef = useRef(null); + const normalizedFolderIds = useMemo( + () => selectedFolderIds.map((id) => (id === 'root' ? 'root' : String(id))) as FolderIdentifier[], + [selectedFolderIds], + ); const destroyDragPreview = useCallback(() => { const node = dragPreviewRef.current; @@ -61,7 +65,7 @@ const useDocumentDragHandlers = ({ useEffect(() => destroyDragPreview, [destroyDragPreview]); const createDragPreview = useCallback( - ({ documents = [], folders = [] }: { documents?: DocumentLike[]; folders?: Array } = {}) => { + ({ documents = [], folders = [] }: { documents?: DocumentLike[]; folders?: FolderIdentifier[] } = {}) => { destroyDragPreview(); const docEntries = (documents || []).filter(Boolean); @@ -145,17 +149,7 @@ const useDocumentDragHandlers = ({ } } else { const payload = item.payload; - const folderId = (() => { - if (isPlainObject(payload) && 'id' in payload) { - return (payload as { id?: FolderIdentifier }).id ?? null; - } - const maybeTrim = (payload as { trim?: () => string })?.trim; - if (isFunctionValue(maybeTrim)) { - const nextValue = maybeTrim.call(payload); - return nextValue || null; - } - return null; - })(); + const folderId = payload as FolderIdentifier; const rowEl = folderId ? (document.getElementById(`folder-row-${folderId}`) || document.getElementById(`folder-card-${folderId}`)) @@ -300,28 +294,29 @@ const useDocumentDragHandlers = ({ ); const handleFolderDragStart = useCallback( - (event: DragEvent, folderId: FolderIdentifier) => { - if (folderId === 'root') { + (event: DragEvent, folderId: FolderInput) => { + const normalizedFolderId: FolderIdentifier = folderId === 'root' ? 'root' : String(folderId); + if (normalizedFolderId === 'root') { return; } event.stopPropagation(); - const folderKey = resolveFolderRowKey(folderId); + const folderKey = resolveFolderRowKey(normalizedFolderId); const isAlreadySelected = folderKey ? selectedEntries.includes(folderKey) : false; - let effectiveFolderSelection: FolderIdentifier[] = selectedFolderIds; + let effectiveFolderSelection: FolderIdentifier[] = normalizedFolderIds; let effectiveDocumentSelection: Identifier[] = selectedDocumentIds; if (!isAlreadySelected && folderKey) { - effectiveFolderSelection = [folderId]; + effectiveFolderSelection = [normalizedFolderId]; effectiveDocumentSelection = []; handleEntrySelection(folderKey, { preventDefault: () => {} }); } const uniqueFolders = effectiveFolderSelection.length ? Array.from(new Set(effectiveFolderSelection.filter(Boolean))) - : [folderId]; + : [normalizedFolderId]; - setDraggedFolderId(folderId); + setDraggedFolderId(normalizedFolderId); if (effectiveDocumentSelection.length) { setDraggedDocumentIds(effectiveDocumentSelection); } @@ -360,7 +355,7 @@ const useDocumentDragHandlers = ({ } }, [ - selectedFolderIds, + normalizedFolderIds, selectedEntries, selectedDocumentIds, handleEntrySelection, diff --git a/frontend/src/hooks/documents/useDocumentMutations.ts b/frontend/src/hooks/documents/useDocumentMutations.ts index 44bab9b..d3b85ac 100644 --- a/frontend/src/hooks/documents/useDocumentMutations.ts +++ b/frontend/src/hooks/documents/useDocumentMutations.ts @@ -2,6 +2,17 @@ import { useCallback } from 'react'; import { isPlainObject } from '../../utils/typeGuards'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { DEFAULT_FOLDER_NAME, getRowId, isDocumentRowKey } from '../../app/appLayoutUtils'; +import { + addDocumentTags, + createTag, + deleteDocumentTag, + deleteFolder, + moveDocumentsBulk, + moveDocumentToFolder, + queueDocumentReanalysis, + trashDocument, + updateDocument, +} from '../../lib/apiClient'; type DocumentId = string | number; type FolderId = DocumentId | 'root'; @@ -35,12 +46,6 @@ type NotifyApiError = (error: unknown, fallbackMessage?: string) => void; type SetStatusMessage = (message: string, level?: StatusLevel) => void; -interface ApiClient { - post(url: string, data?: unknown, config?: Record): Promise<{ data: T }>; - patch(url: string, data?: unknown, config?: Record): Promise<{ data: T }>; - delete(url: string, config?: Record): Promise<{ data: T }>; -} - interface Tag { id: DocumentId; label: string; @@ -85,7 +90,6 @@ interface DocumentTagExtras { interface DeleteOptions { showMessage?: boolean; - manageLoading?: boolean; } interface TagAttachArgs { @@ -101,11 +105,9 @@ interface TagRemoveOptions { interface FolderDeleteOptions { showMessage?: boolean; - manageLoading?: boolean; } interface UseDocumentMutationsArgs { - api: ApiClient; token?: string | null; documentLookup: Map; folderLabelMap: Map; @@ -125,7 +127,6 @@ interface UseDocumentMutationsArgs { focusedRowKey: string | null; notifyApiError: NotifyApiError; setStatusMessage: SetStatusMessage; - setLoading: (next: boolean) => void; mapDocumentCaches: MapDocumentCaches; applySelectedFolder: ApplySelectedFolder; folderNodes: Map; @@ -181,7 +182,6 @@ const normalizeDocumentId = (value: unknown): DocumentId | null => { }; const useDocumentMutations = ({ - api, token, documentLookup, folderLabelMap, @@ -201,7 +201,6 @@ const useDocumentMutations = ({ focusedRowKey, notifyApiError, setStatusMessage, - setLoading, mapDocumentCaches, applySelectedFolder, folderNodes, @@ -282,16 +281,11 @@ const useDocumentMutations = ({ const id = getRowId(key); return id ? !uniqueIdSet.has(id as DocumentId) : true; }); - - setLoading(true); try { if (uniqueIds.length === 1) { - await api.patch(`/documents/${uniqueIds[0]}/folder`, { folder_id: target }); + await moveDocumentToFolder(uniqueIds[0], target); } else { - await api.post('/documents/bulk/move', { - document_ids: uniqueIds, - folder_id: target, - }); + await moveDocumentsBulk(uniqueIds, target); } const count = uniqueIds.length; @@ -377,12 +371,9 @@ const useDocumentMutations = ({ } catch (error) { const message = (error as Record)?.response?.data?.error || 'Failed to move documents.'; notifyApiError(error, message); - } finally { - setLoading(false); } }, [ - api, documentLookup, folderLabelMap, ensureFolderData, @@ -400,7 +391,6 @@ const useDocumentMutations = ({ focusedRowKey, notifyApiError, setStatusMessage, - setLoading, mapDocumentCaches, ], ); @@ -411,25 +401,20 @@ const useDocumentMutations = ({ setStatusMessage('Log in to manage assets.', 'error'); return; } - setLoading(true); try { - await api.post(`/documents/${documentId}/assets`, null, { - params: { force: true }, - }); + await queueDocumentReanalysis(documentId, { force: true }); setStatusMessage('Document re-analysis queued.', 'info'); await refreshCurrentFolder(); } catch (error) { const message = (error as Record)?.response?.data?.error || 'Failed to request thumbnail generation.'; notifyApiError(error, message); - } finally { - setLoading(false); } }, - [api, token, refreshCurrentFolder, notifyApiError, setStatusMessage, setLoading], + [token, refreshCurrentFolder, notifyApiError, setStatusMessage], ); const handleDocumentsDelete = useCallback( - async (documentIds: DocumentId[], { showMessage = true, manageLoading = true }: DeleteOptions = {}) => { + async (documentIds: DocumentId[], { showMessage = true }: DeleteOptions = {}) => { if (!documentIds || documentIds.length === 0) { return false; } @@ -439,12 +424,8 @@ const useDocumentMutations = ({ return false; } - if (manageLoading) { - setLoading(true); - } - try { - await Promise.all(documentIds.map((documentId) => api.post(`/documents/${documentId}/trash`))); + await Promise.all(documentIds.map((documentId) => trashDocument(documentId))); removeDocumentsFromCaches(documentIds); @@ -461,14 +442,9 @@ const useDocumentMutations = ({ const message = (error as Record)?.response?.data?.error || 'Failed to delete documents.'; notifyApiError(error, message); return false; - } finally { - if (manageLoading) { - setLoading(false); - } } }, [ - api, token, documentLookup, removeDocumentsFromCaches, @@ -476,7 +452,6 @@ const useDocumentMutations = ({ closeDocumentPreview, notifyApiError, setStatusMessage, - setLoading, ], ); @@ -487,10 +462,8 @@ const useDocumentMutations = ({ setStatusMessage('Document title cannot be empty.', 'error'); return false; } - - setLoading(true); try { - const { data } = await api.patch(`/documents/${documentId}`, { title: trimmed }); + const data = await updateDocument(documentId, { title: trimmed }); const updatedDocument = extractDocumentFromResponse?.(data); if (updatedDocument && ingestDocuments) { @@ -510,27 +483,21 @@ const useDocumentMutations = ({ const message = (error as Record)?.response?.data?.error || 'Failed to update document title.'; notifyApiError(error, message); return false; - } finally { - setLoading(false); } }, [ - api, extractDocumentFromResponse, ingestDocuments, notifyApiError, - setLoading, setStatusMessage, updateDocumentCaches, ], ); const handleDocumentIssuedUpdate = useCallback( - async (documentId: DocumentId, nextIssuedDate: number | null) => { - setLoading(true); - const payload = { issued_at: nextIssuedDate || null }; + async (documentId: DocumentId, nextIssuedDate: number | null) => {const payload = { issued_at: nextIssuedDate || null }; try { - const { data } = await api.patch(`/documents/${documentId}`, payload); + const data = await updateDocument(documentId, payload); const updatedDocument = extractDocumentFromResponse?.(data); if (updatedDocument && ingestDocuments) { @@ -551,16 +518,12 @@ const useDocumentMutations = ({ const message = (error as Record)?.response?.data?.error || 'Failed to update issued date.'; notifyApiError(error, message); return false; - } finally { - setLoading(false); } }, [ - api, extractDocumentFromResponse, ingestDocuments, notifyApiError, - setLoading, setStatusMessage, updateDocumentCaches, ], @@ -585,7 +548,7 @@ const useDocumentMutations = ({ }; try { - await api.post(`/documents/${documentId}/tags`, { tag_ids: [cachedTag.id] }); + await addDocumentTags(documentId, [cachedTag.id]); updateDocumentCaches(documentId, (doc) => { if (!doc) { return doc; @@ -604,7 +567,7 @@ const useDocumentMutations = ({ return false; } }, - [api, notifyApiError, setStatusMessage, updateDocumentCaches], + [notifyApiError, setStatusMessage, updateDocumentCaches], ); const handleDocumentTagAdd = useCallback( @@ -622,8 +585,8 @@ const useDocumentMutations = ({ } try { if (!tag) { - const payload = tagManager.buildPayload({ label: normalizedLabel }); - const { data } = await api.post('/tags', payload); + const payload = tagManager.buildPayload({ label: normalizedLabel }) as { label: string; color?: string | null }; + const data = await createTag(payload); tag = data as Tag; await refreshTags(); } @@ -638,7 +601,7 @@ const useDocumentMutations = ({ notifyApiError(error, 'Failed to assign tag.'); } }, - [api, tags, refreshTags, attachTagToDocument, notifyApiError, tagManager], + [tags, refreshTags, attachTagToDocument, notifyApiError, tagManager], ); const handleDocumentTagAttach = useCallback( @@ -707,7 +670,7 @@ const useDocumentMutations = ({ } try { - await api.delete(`/documents/${documentId}/tags/${tagId}`); + await deleteDocumentTag(documentId, tagId); applyTagRemovalToCaches(documentId, tagId); if (refreshTagList) { await refreshTags(); @@ -722,11 +685,11 @@ const useDocumentMutations = ({ return false; } }, - [api, applyTagRemovalToCaches, notifyApiError, refreshTags, setStatusMessage], + [applyTagRemovalToCaches, notifyApiError, refreshTags, setStatusMessage], ); const handleFolderDelete = useCallback( - async (folderId?: FolderId, { showMessage = true, manageLoading = true }: FolderDeleteOptions = {}) => { + async (folderId?: FolderId, { showMessage = true }: FolderDeleteOptions = {}) => { if (!token) { if (showMessage) { setStatusMessage('Log in to manage folders.', 'error'); @@ -740,10 +703,6 @@ const useDocumentMutations = ({ return false; } - if (manageLoading) { - setLoading(true); - } - try { const contents = await ensureFolderData(folderId, { force: true, @@ -758,7 +717,7 @@ const useDocumentMutations = ({ return false; } - await api.delete(`/folders/${folderId}`); + await deleteFolder(folderId); setFolderNodes((prev: Map) => { const next = new Map(prev); @@ -809,14 +768,9 @@ const useDocumentMutations = ({ setStatusMessage(message, 'error'); } return false; - } finally { - if (manageLoading) { - setLoading(false); - } } }, [ - api, token, ensureFolderData, selectedFolder, @@ -827,7 +781,6 @@ const useDocumentMutations = ({ setFolderContents, notifyApiError, setStatusMessage, - setLoading, ], ); diff --git a/frontend/src/hooks/documents/useDocumentTagging.ts b/frontend/src/hooks/documents/useDocumentTagging.ts index 2f2a4f6..404cb54 100644 --- a/frontend/src/hooks/documents/useDocumentTagging.ts +++ b/frontend/src/hooks/documents/useDocumentTagging.ts @@ -24,7 +24,6 @@ interface UseDocumentTaggingArgs { resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[]; notifyApiError: (error: unknown, message: string) => void; setStatusMessage: (message: string, variant?: string) => void; - setLoading: (state: boolean) => void; updateDocumentCaches?: (id: Identifier, updater: (doc: TagRecord | null) => TagRecord | null) => void; } @@ -50,7 +49,6 @@ const useDocumentTagging = ({ resolveTargetDocumentIds, notifyApiError, setStatusMessage, - setLoading, updateDocumentCaches, }: UseDocumentTaggingArgs) => { const bulkTagOperation = useCallback( @@ -80,7 +78,6 @@ const useDocumentTagging = ({ }).filter(Boolean); } - setLoading(true); try { if (action === 'add') { const createdIds: Identifier[] = []; @@ -175,8 +172,6 @@ const useDocumentTagging = ({ (action === 'add' ? 'Failed to assign tags.' : 'Failed to remove tags.'); notifyApiError(error, message); return { ok: false, reason: 'request-failed' }; - } finally { - setLoading(false); } }, [ @@ -184,7 +179,6 @@ const useDocumentTagging = ({ tags, refreshTags, notifyApiError, - setLoading, tagManager, apiClient, updateDocumentCaches, @@ -265,7 +259,6 @@ const useDocumentTagging = ({ return; } - setLoading(true); try { const response = await apiClient.post<{ queued?: number }>( '/documents/bulk/reanalyze', @@ -286,11 +279,9 @@ const useDocumentTagging = ({ const message = error.response?.data?.error || 'Failed to queue document re-analysis.'; notifyApiError(error, message); - } finally { - setLoading(false); } }, - [resolveTargetDocumentIds, notifyApiError, setStatusMessage, setLoading, apiClient], + [resolveTargetDocumentIds, notifyApiError, setStatusMessage, apiClient], ); return { diff --git a/frontend/src/hooks/documents/useDocumentUploads.ts b/frontend/src/hooks/documents/useDocumentUploads.ts index 9d5a41a..9f96637 100644 --- a/frontend/src/hooks/documents/useDocumentUploads.ts +++ b/frontend/src/hooks/documents/useDocumentUploads.ts @@ -2,6 +2,7 @@ import { useCallback, useRef, useState } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import useFileDrop from './useFileDrop'; import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils'; +import { fetchDocument } from '../../lib/apiClient'; type Identifier = string | number; type FolderId = Identifier | 'root' | null; @@ -101,7 +102,6 @@ interface UseDocumentUploadsArgs { currentFolderName?: string | null; ensureFolderData: (folderId: FolderId, options?: { force?: boolean; prefetchDepth?: number }) => Promise; refreshCurrentFolder: () => Promise; - setLoading: (state: boolean) => void; shellRef: MutableRefObject; notifyApiError?: NotifyApiError; setStatusMessage?: SetStatusMessage; @@ -132,7 +132,6 @@ const useDocumentUploads = ({ currentFolderName, ensureFolderData, refreshCurrentFolder, - setLoading, shellRef, notifyApiError, setStatusMessage, @@ -174,8 +173,7 @@ const useDocumentUploads = ({ let conflictDocument = null; if (conflictId) { try { - const { data } = await apiClient.get(`/documents/${conflictId}`); - conflictDocument = (data as any)?.document ?? data ?? null; + conflictDocument = await fetchDocument(conflictId); } catch (fetchError) { console.warn('[Uploads] failed to fetch conflict document', fetchError); } @@ -395,8 +393,6 @@ const useDocumentUploads = ({ return; } - setLoading(true); - try { folderPathCacheRef.current.clear(); @@ -472,8 +468,6 @@ const useDocumentUploads = ({ Object.assign(item, patch); }); console.error('[Uploads] batch failed', error); - } finally { - setLoading(false); } }, [ @@ -483,7 +477,6 @@ const useDocumentUploads = ({ refreshCurrentFolder, selectedFolder, ensureFolderData, - setLoading, appendQueueItems, updateQueueItem, ], diff --git a/frontend/src/hooks/documents/useDocumentsWorkspace.ts b/frontend/src/hooks/documents/useDocumentsWorkspace.ts index 921fcd9..df9582f 100644 --- a/frontend/src/hooks/documents/useDocumentsWorkspace.ts +++ b/frontend/src/hooks/documents/useDocumentsWorkspace.ts @@ -16,9 +16,10 @@ import { import AssetManager, { getAssetFromVersion } from '../../asset_manager'; import useApiError from '../useApiError'; import TagManager from '../../tag_manager'; -import usePasskeys from '../../settings/usePasskeys'; import { useManagementModals } from '../../app/useManagementModals'; -import { api, useAppDispatch, useAppState } from '../../app/appState'; +import { useAppDispatch, useAppState } from '../../app/appState'; +import { fetchAsset } from '../../lib/apiClient'; +import { useApi } from '../../app/ApiContext'; import useWorkspaceSelection from '../../app/useWorkspaceSelection'; import { useEntryPointer as useEntryPointerCore } from '../../documents/useEntryPointer'; import { isTagTransferEvent } from '../../documents/tagTransfer'; @@ -29,7 +30,6 @@ import useDocumentPreview from '../../app/useDocumentPreview'; import useSidebarProps from '../../sidebar/useSidebarProps'; import { ASSET_PRESIGN_TTL_MS, - DEFAULT_FOLDER_NAME, DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD, createRootNode, @@ -44,18 +44,20 @@ import { import useDocumentsSearch from '../../app/useDocumentsSearch'; import useDocumentsStore from './store/useDocumentsStore'; import useAuthManager from './useAuthManager'; -import useTags from './useTags'; -import useCorrespondents from './useCorrespondents'; import useTenantManager from './useTenantManager'; import useDocuments from './useDocuments'; +import { fetchDocument } from '../../lib/apiClient'; import useFolderTree from './useFolderTree'; import useFolderTreeActions from './useFolderTreeActions'; import useDocumentTagging from './useDocumentTagging'; -import useDocumentCorrespondentActions from './useDocumentCorrespondentActions'; import useDocumentUploads from './useDocumentUploads'; import useDocumentDragHandlers from './useDocumentDragHandlers'; import useDocumentMutations from './useDocumentMutations'; import useDetailWorkspace from '../../detail/useDetailWorkspace'; +import useWorkspaceTaxonomies from './useWorkspaceTaxonomies'; +import useWorkspaceBreadcrumbs from './useWorkspaceBreadcrumbs'; +import useWorkspaceDeskProps from './useWorkspaceDeskProps'; +import useWorkspaceSelectionSync from './useWorkspaceSelectionSync'; const EntryType = Object.freeze({ document: 'document', @@ -152,6 +154,7 @@ const useDocumentsWorkspace = ({ tenant, tenants: tenantOptionsRaw = [], } = appState; + const { client: apiClient } = useApi(); const tenantRecord = (tenant ?? null) as TenantOption | null; const tenantNameCandidate = tenantRecord?.name ?? tenantRecord?.slug ?? null; @@ -175,16 +178,12 @@ const useDocumentsWorkspace = ({ reportApiError(error, { message: fallbackMessage, variant }), [reportApiError], ); - const [loading, setLoading] = useState(false); const [creatingFolder, setCreatingFolder] = useState(false); const { tokenRef, handleLogout } = useAuthManager({ - apiClient: api, token, appStatus, appDispatch, - notifyApiError, setStatusMessage, - setLoading, }); const breadcrumbFetchRef = useRef(new Set()); @@ -219,7 +218,11 @@ const useDocumentsWorkspace = ({ const shellRef = useRef(null); const assetManagerRef = useRef(null); if (!assetManagerRef.current) { - assetManagerRef.current = new AssetManager({ api, assetPresignTtlMs: ASSET_PRESIGN_TTL_MS }); + const fetcher = async (id: Identifier) => { + const asset = await fetchAsset(id); + return (asset as unknown) as any; + }; + assetManagerRef.current = new AssetManager({ fetchAsset: fetcher, assetPresignTtlMs: ASSET_PRESIGN_TTL_MS }); } const assetManager = assetManagerRef.current; @@ -238,7 +241,7 @@ const useDocumentsWorkspace = ({ if (!documentId) { return null; } - const { data } = await api.get(`/documents/${documentId}`); + const data = await fetchDocument(documentId); return extractDocumentFromResponse(data); }, [extractDocumentFromResponse], @@ -345,7 +348,7 @@ const useDocumentsWorkspace = ({ isInvalidFolderDrop, } = useFolderTree({ initialSelectedFolder: routeFolderId || 'root', - apiClient: api, + apiClient, tenantIdRef, documentsSortFieldRef: activeSortFieldRef, documentsSortDirectionRef: activeSortDirectionRef, @@ -368,7 +371,7 @@ const useDocumentsWorkspace = ({ isFilterActive, documentsFilterValue, } = useDocumentsSearch({ - api, + api: apiClient, token, selectedFolder, navigate, @@ -378,7 +381,6 @@ const useDocumentsWorkspace = ({ documentsSortField, documentsSortDirection, notifyApiError, - setLoading, setSearchIncludeDescendants, documentsManager, }); @@ -449,8 +451,6 @@ const useDocumentsWorkspace = ({ routeDocumentId: previewDocumentId, documentsManager, selectedFolder, - api, - resolveApiPath, notifyApiError, navigate, locationPathname: location.pathname, @@ -478,17 +478,7 @@ const useDocumentsWorkspace = ({ const bootstrapInitializedRef = useRef(false); const detailFolderFetchRef = useRef(new Set()); - - useEffect(() => { - if (!showingSearchResults) { - return; - } - setSelectedEntries([]); - setSelectionOrder([]); - selectionOrderRef.current = []; - selectionAnchorRef.current = null; - setFocusedDocumentId(null); - }, [ + useWorkspaceSelectionSync({ showingSearchResults, searchQuery, setSelectedEntries, @@ -496,7 +486,11 @@ const useDocumentsWorkspace = ({ selectionOrderRef, selectionAnchorRef, setFocusedDocumentId, - ]); + selectedDocumentIds, + activePreviewId, + setActivePreviewId, + selectionInitializedRef, + }); const { tags, @@ -505,59 +499,17 @@ const useDocumentsWorkspace = ({ handleTagUpdate, handleTagDelete, setTags, - } = useTags({ - apiClient: api, - notifyApiError, - setStatusMessage, - tagManager, - tenantIdRef, - setActiveTagFilters, - mapDocumentCaches, - }); - - - useEffect(() => { - tenantIdRef.current = currentTenantId; - }, [currentTenantId]); - - - useEffect(() => { - if (!selectedDocumentIds.length) { - return; - } - if (!selectedDocumentIds.includes(activePreviewId)) { - setActivePreviewId(selectedDocumentIds[selectedDocumentIds.length - 1]); - } - selectionInitializedRef.current = true; - }, [selectedDocumentIds, activePreviewId, selectionInitializedRef]); - - - const tagLookupById = useMemo(() => { - const map = new Map(); - tags.forEach((tag) => { - if (tag?.id) { - map.set(tag.id, tag); - } - }); - return map; - }, [tags]); - - const { + tagLookupById, correspondents, refreshCorrespondents, handleCorrespondentCreate, handleCorrespondentUpdate, handleCorrespondentDelete, setCorrespondents, - } = useCorrespondents({ - apiClient: api, - notifyApiError, - setStatusMessage, - tenantIdRef, - mapDocumentCaches, - }); - - const { + correspondentLookupByName, + handleDocumentCorrespondentAttach, + handleCorrespondentRemove, + handleCorrespondentAdd, passkeys, passkeysSupported, passkeysLoading, @@ -566,10 +518,16 @@ const useDocumentsWorkspace = ({ refreshPasskeys, registerPasskey, revokePasskey, - } = usePasskeys({ - api, + } = useWorkspaceTaxonomies({ + apiClient, notifyApiError, setStatusMessage, + tagManager, + tenantIdRef, + currentTenantId, + setActiveTagFilters, + mapDocumentCaches, + updateDocumentCaches, token, }); @@ -587,33 +545,25 @@ const useDocumentsWorkspace = ({ ); const refreshCurrentFolder = useCallback(async () => { - setLoading(true); - try { - const contents = await ensureFolderData(selectedFolder, { - force: true, - prefetchDepth: 1, - }); - applySelectedFolder(selectedFolder, contents); - } catch (error) { - notifyApiError(error, 'Failed to refresh folder.'); - } finally { - setLoading(false); - } - }, [selectedFolder, ensureFolderData, applySelectedFolder, notifyApiError]); + const contents = await ensureFolderData(selectedFolder, { + force: true, + prefetchDepth: 1, + }); + applySelectedFolder(selectedFolder, contents); + }, [selectedFolder, ensureFolderData, applySelectedFolder]); const { handleBulkTagAddFromDetail, handleBulkTagRemoveFromDetail, handleBulkSelectionReanalyze, } = useDocumentTagging({ - apiClient: api, + apiClient, tags, tagManager, refreshTags, resolveTargetDocumentIds, notifyApiError, setStatusMessage, - setLoading, updateDocumentCaches, }); @@ -625,7 +575,7 @@ const useDocumentsWorkspace = ({ clearUploadQueue, resetUploadsState, } = useDocumentUploads({ - apiClient: api, + apiClient, token, selectedFolder, currentFolderName, @@ -633,7 +583,6 @@ const useDocumentsWorkspace = ({ refreshCurrentFolder, notifyApiError, setStatusMessage, - setLoading, shellRef, }); @@ -656,20 +605,6 @@ const useDocumentsWorkspace = ({ documentsViewMode, }); - const { - correspondentLookupByName, - handleDocumentCorrespondentAttach, - handleCorrespondentRemove, - handleCorrespondentAdd, - } = useDocumentCorrespondentActions({ - apiClient: api, - correspondents, - handleCorrespondentCreate, - notifyApiError, - setStatusMessage, - updateDocumentCaches, - }); - useEffect(() => { if (!activeSortRefreshReadyRef.current) { activeSortRefreshReadyRef.current = true; @@ -816,7 +751,6 @@ const useDocumentsWorkspace = ({ handleDocumentIssuedUpdate, handleTagRemove, } = useDocumentMutations({ - api, token, documentLookup, folderLabelMap, @@ -836,7 +770,6 @@ const useDocumentsWorkspace = ({ focusedRowKey, notifyApiError, setStatusMessage, - setLoading, mapDocumentCaches, applySelectedFolder, folderNodes, @@ -862,7 +795,6 @@ const useDocumentsWorkspace = ({ handleFolderDelete, folderClickHandlers, } = useFolderTreeActions({ - api, token, folderNodes, setFolderNodes, @@ -874,7 +806,6 @@ const useDocumentsWorkspace = ({ applySelectedFolder, notifyApiError, setStatusMessage, - setLoading, setFolderContents, setCurrentFolder, setSearchResultIds, @@ -913,18 +844,10 @@ const useDocumentsWorkspace = ({ isFolderRowKey, }); const initializeAfterLogin = useCallback(async () => { - setLoading(true); - try { - await Promise.all([refreshTags(), refreshCorrespondents()]); - const initialFolder = routeFolderId && routeFolderId !== 'root' ? routeFolderId : 'root'; - await loadFolder(initialFolder, { showLoading: false }); - } catch (error) { - notifyApiError(error, 'Failed to initialize data.'); - throw error; - } finally { - setLoading(false); - } - }, [refreshTags, refreshCorrespondents, routeFolderId, loadFolder, notifyApiError]); + await Promise.all([refreshTags(), refreshCorrespondents()]); + const initialFolder = routeFolderId && routeFolderId !== 'root' ? routeFolderId : 'root'; + await loadFolder(initialFolder, {} ); + }, [refreshTags, refreshCorrespondents, routeFolderId, loadFolder]); useEffect(() => { if (!token) { @@ -997,7 +920,6 @@ const useDocumentsWorkspace = ({ handleBulkCorrespondentRemove, handleDeleteSelection, } = useBulkDocumentActions({ - api, resolveTargetDocumentIds, correspondentLookupByName, handleCorrespondentCreate, @@ -1007,7 +929,6 @@ const useDocumentsWorkspace = ({ handleDocumentsDelete, handleFolderDelete, clearDocumentSelection, - setLoading, updateDocumentCaches, }); @@ -1233,7 +1154,6 @@ const useDocumentsWorkspace = ({ inspectDocument, previewActive, previewWorkspaceDocument, - documentLink, resolveFolderPath, } = useDetailWorkspace({ documents: viewDocuments, @@ -1244,7 +1164,6 @@ const useDocumentsWorkspace = ({ ensureFolderData, detailPanelControlRef, detailFolderFetchRef, - documentLinks, previewDocumentId, activePreviewId, openDocumentPreview: openDocumentPreviewForDetail, @@ -1295,94 +1214,21 @@ const useDocumentsWorkspace = ({ }, }); - const { breadcrumbs, missingBreadcrumbAncestors } = useMemo(() => { - const chain = []; - const seen = new Set(); - const pending = new Set(); - let currentId = selectedFolder || 'root'; - let guard = 0; - - while (currentId && !seen.has(currentId) && guard < 32) { - guard += 1; - seen.add(currentId); - - if (currentId === 'root') { - chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME }); - currentId = null; - break; - } - - const node = folderNodes.get(currentId); - if (node) { - chain.push({ id: currentId, name: node.name || 'Folder' }); - currentId = node.parentId ?? 'root'; - continue; - } - - let fallbackName = '…'; - let parentId = null; - - if (currentFolder && currentFolder.id === currentId) { - fallbackName = currentFolder.name; - parentId = currentFolder.parent_id ?? 'root'; - } - - chain.push({ id: currentId, name: fallbackName }); - pending.add(currentId); - currentId = parentId; - } - - if (!chain.some((crumb) => crumb.id === 'root')) { - chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME }); - } - - const ordered = []; - const seenOrdered = new Set(); - chain - .slice() - .reverse() - .forEach((crumb) => { - if (!seenOrdered.has(crumb.id)) { - seenOrdered.add(crumb.id); - ordered.push(crumb); - } - }); - - return { breadcrumbs: ordered, missingBreadcrumbAncestors: Array.from(pending) }; - }, [selectedFolder, folderNodes, currentFolder]); - - useEffect(() => { - if (!missingBreadcrumbAncestors.length) { - return; - } - - missingBreadcrumbAncestors.forEach((folderId) => { - if (!folderId || folderId === 'root') { - return; - } - if (breadcrumbFetchRef.current.has(folderId)) { - return; - } - - breadcrumbFetchRef.current.add(folderId); - ensureFolderData(folderId, { force: false }) - .catch((error) => { - console.warn('Failed to preload breadcrumb ancestor', folderId, error); - }) - .finally(() => { - breadcrumbFetchRef.current.delete(folderId); - }); - }); - }, [missingBreadcrumbAncestors, ensureFolderData]); + const breadcrumbs = useWorkspaceBreadcrumbs({ + selectedFolder, + folderNodes, + currentFolder, + breadcrumbFetchRef, + ensureFolderData, + }); const { handleTenantSelect } = useTenantManager({ - apiClient: api, + apiClient, appDispatch, currentTenantId, resetWorkspaceState, setStatusMessage, notifyApiError, - setLoading, refreshTags, refreshCorrespondents, loadFolder, @@ -1393,93 +1239,27 @@ const useDocumentsWorkspace = ({ }); - const handleDeskDocumentStackSelect = useCallback( - (docIds: Array) => { - if (!Array.isArray(docIds) || docIds.length === 0) { - return; - } - - const rowKeys = docIds - .map((id) => resolveDocumentRowKey(id as Identifier)) - .filter((value): value is string => typeof value === 'string'); - - if (!rowKeys.length) { - return; - } - - const nextKeys = [...selectedEntries]; - rowKeys.forEach((key) => { - if (!nextKeys.includes(key)) { - nextKeys.push(key); - } - }); - - const anchor = (rowKeys[0] - || selectionAnchorRef.current - || nextKeys[nextKeys.length - 1]) as Identifier | string | null; - - applySelection(nextKeys, { - anchor, - interactedKeys: rowKeys, - }); - }, - [applySelection, selectedEntries, selectionAnchorRef], - ); - - const deskViewId = useMemo(() => { - if (showingSearchResults) { - const trimmedQuery = searchQuery.trim(); - const tagsKey = [...activeTagFilters].sort().join(','); - const correspondentsKey = [...activeCorrespondentFilters].sort().join(','); - return `search:${trimmedQuery}|tags:${tagsKey}|corr:${correspondentsKey}`; - } - - const folderKey = selectedFolder && selectedFolder !== '' ? selectedFolder : 'root'; - return `folder:${folderKey}`; - }, [ + const deskWorkspaceProps = useWorkspaceDeskProps({ + viewDocuments, + inspectDocumentForDesk, + handleEntryPointer: handleEntryPointerCore, + selectedEntries, + selectionAnchorRef, + applySelection, + resolveDocumentRowKey, showingSearchResults, searchQuery, activeTagFilters, activeCorrespondentFilters, selectedFolder, - ]); - - const deskWorkspaceProps = useMemo( - () => ({ - documents: viewDocuments, - onInspectDocument: inspectDocumentForDesk, - onEntryPointer: handleEntryPointerCore, - onDocumentStackSelect: handleDeskDocumentStackSelect, - onPromoteSelection: promoteSelectionOrder, - onAssignTagToDocument: handleDocumentTagDrop, - ensureAssetUrl, - getDocumentAsset, - activeTagIds: activeTagFilters, - selectedDocumentIds, - onClearSelection: clearDocumentSelection, - tenantId: currentTenantId, - viewId: deskViewId, - documentLinks, - ensureDownloadUrl, - }), - [ - viewDocuments, - inspectDocumentForDesk, - handleEntryPointerCore, - handleDeskDocumentStackSelect, - promoteSelectionOrder, - handleDocumentTagDrop, - ensureAssetUrl, - getDocumentAsset, - activeTagFilters, - selectedDocumentIds, - clearDocumentSelection, - currentTenantId, - deskViewId, - documentLinks, - ensureDownloadUrl, - ], - ); + promoteSelectionOrder, + handleDocumentTagDrop, + ensureAssetUrl, + getDocumentAsset, + currentTenantId, + documentLinks, + ensureDownloadUrl, + }); const documentsPanelProps = useDocumentsPanelProps({ currentFolderName, @@ -1513,7 +1293,7 @@ const useDocumentsWorkspace = ({ clearDocumentSelection, handleDeleteSelection, handleEntryPointerCore, - inspectDocument, + onDocumentActivate: inspectDocument, tags, correspondents, documentLookup, @@ -1554,7 +1334,6 @@ const useDocumentsWorkspace = ({ correspondents, handleCorrespondentCreate, appStatus, - loading, previewActive, handleLogout, status, @@ -1599,7 +1378,6 @@ const useDocumentsWorkspace = ({ revokePasskey, previewActive, previewWorkspaceDocument, - documentLink, previewDocumentId, closeDocumentPreview, handleThumbnailRegeneration, @@ -1650,7 +1428,6 @@ const useDocumentsWorkspace = ({ revokePasskey, previewActive, previewWorkspaceDocument, - documentLink, previewDocumentId, closeDocumentPreview, handleThumbnailRegeneration, diff --git a/frontend/src/hooks/documents/useFolderTreeActions.ts b/frontend/src/hooks/documents/useFolderTreeActions.ts index 40d0bf0..71ef6b4 100644 --- a/frontend/src/hooks/documents/useFolderTreeActions.ts +++ b/frontend/src/hooks/documents/useFolderTreeActions.ts @@ -1,6 +1,12 @@ import { useCallback, useMemo } from 'react'; import type { DragEvent } from 'react'; import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils'; +import { + createFolder, + deleteFolder, + moveFolder as moveFolderRequest, + renameFolder as renameFolderRequest, +} from '../../lib/apiClient'; type FolderId = string | number; type FolderKey = FolderId | 'root'; @@ -22,12 +28,6 @@ interface FolderContentsState { [key: string]: unknown; } -interface ApiClient { - patch: (url: string, data?: unknown) => Promise; - post: (url: string, data?: unknown) => Promise<{ data: any }>; - delete: (url: string) => Promise; -} - interface EnsureFolderOptions { force?: boolean; includeDocuments?: boolean; @@ -35,7 +35,6 @@ interface EnsureFolderOptions { } interface LoadFolderOptions { - showLoading?: boolean; preserveSearch?: boolean; } @@ -53,7 +52,6 @@ interface FolderClickHandlers { } interface UseFolderTreeActionsOptions { - api: ApiClient; token?: string | null; folderNodes: Map; setFolderNodes: (updater: (prev: Map) => Map) => void; @@ -65,7 +63,6 @@ interface UseFolderTreeActionsOptions { applySelectedFolder: (folderId: FolderKey, contents: any) => void; notifyApiError: (error: unknown, message?: string) => void; setStatusMessage: (message: string, level?: string) => void; - setLoading: (value: boolean) => void; setFolderContents: ( updater: (prev: Map) => Map, ) => void; @@ -84,7 +81,6 @@ interface UseFolderTreeActionsOptions { } const useFolderTreeActions = ({ - api, token, folderNodes, setFolderNodes, @@ -96,7 +92,6 @@ const useFolderTreeActions = ({ applySelectedFolder, notifyApiError, setStatusMessage, - setLoading, setFolderContents, setCurrentFolder, setSearchResultIds, @@ -129,7 +124,7 @@ const useFolderTreeActions = ({ const parent_id = targetKey === 'root' ? null : targetKey; try { - await api.patch(`/folders/${folderId}`, { parent_id }); + await moveFolderRequest(folderId, parent_id); setFolderNodes((prev) => { const next = new Map(prev); @@ -202,7 +197,6 @@ const useFolderTreeActions = ({ } }, [ - api, ensureFolderData, folderNodes, notifyApiError, @@ -214,12 +208,11 @@ const useFolderTreeActions = ({ ); const loadFolder = useCallback( - async (folderId: FolderKey | null, { showLoading = true, preserveSearch = false }: LoadFolderOptions = {}) => { + async (folderId: FolderKey | null, { preserveSearch = false }: LoadFolderOptions = {}) => { const targetId = folderId || 'root'; setSelectedFolder(targetId); await ensureFolderAncestorsLoaded(targetId); expandFolderAncestors(targetId); - if (showLoading) setLoading(true); try { const contents = await ensureFolderData(targetId, { force: true, prefetchDepth: 1 }); if (targetId !== 'root') { @@ -239,8 +232,6 @@ const useFolderTreeActions = ({ } } catch (error) { notifyApiError(error, 'Failed to load folder contents.'); - } finally { - if (showLoading) setLoading(false); } }, [ @@ -249,7 +240,6 @@ const useFolderTreeActions = ({ ensureFolderData, expandFolderAncestors, notifyApiError, - setLoading, setSearchResultIds, setSelectedFolder, ], @@ -292,10 +282,8 @@ const useFolderTreeActions = ({ setStatusMessage('Folder name cannot be empty.', 'error'); return false; } - - setLoading(true); try { - await api.patch(`/folders/${folderId}`, { name: trimmed }); + await renameFolderRequest(folderId, trimmed); setFolderNodes((prev) => { const next = new Map(prev); @@ -326,17 +314,13 @@ const useFolderTreeActions = ({ const message = error.response?.data?.error || 'Failed to rename folder.'; notifyApiError(error, message); return false; - } finally { - setLoading(false); } }, [ - api, notifyApiError, setCurrentFolder, setFolderContents, setFolderNodes, - setLoading, setStatusMessage, token, ], @@ -359,33 +343,37 @@ const useFolderTreeActions = ({ setCreatingFolder(true); let succeeded = false; try { - const { data } = await api.post('/folders', payload); + const data = await createFolder(payload); + const folderData = (data as { folder?: { id?: FolderKey; name?: string; parent_id?: FolderKey | null; children?: FolderKey[] } }).folder; + if (!folderData?.id) { + throw new Error('Folder creation failed.'); + } setStatusMessage('Folder created.', 'success'); setFolderNodes((prev) => { const next = new Map(prev); - const parentId = payload.parent_id || 'root'; + const parentId = folderData.parent_id ?? payload.parent_id ?? 'root'; const parentNode = next.get(parentId); if (parentNode) { next.set(parentId, { ...parentNode, - children: parentNode.children.concat([data.folder.id]), + children: parentNode.children.concat([folderData.id]), loaded: true, hasChildren: true, }); } - next.set(data.folder.id, { - id: data.folder.id, - name: data.folder.name, + next.set(folderData.id, { + id: folderData.id, + name: folderData.name ?? payload.name, parentId: parentId, - children: [], + children: folderData.children || [], expanded: false, loaded: false, - hasChildren: false, + hasChildren: Array.isArray(folderData.children) ? folderData.children.length > 0 : false, }); return next; }); await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 }); - await selectFolder(data.folder.id, { immediate: true }); + await selectFolder(folderData.id, { immediate: true }); succeeded = true; return true; } catch (error) { @@ -400,7 +388,6 @@ const useFolderTreeActions = ({ } }, [ - api, ensureFolderData, notifyApiError, selectFolder, @@ -413,7 +400,7 @@ const useFolderTreeActions = ({ ); const handleFolderDelete = useCallback( - async (folderId: FolderKey, { showMessage = true, manageLoading = true }: { showMessage?: boolean; manageLoading?: boolean } = {}) => { + async (folderId: FolderKey, { showMessage = true }: { showMessage?: boolean } = {}) => { if (!token) { if (showMessage) { setStatusMessage('Log in to manage folders.', 'error'); @@ -427,10 +414,6 @@ const useFolderTreeActions = ({ return false; } - if (manageLoading) { - setLoading(true); - } - try { const contents = await ensureFolderData(folderId, { force: true, @@ -445,7 +428,7 @@ const useFolderTreeActions = ({ return false; } - await api.delete(`/folders/${folderId}`); + await deleteFolder(folderId); setFolderNodes((prev) => { const next = new Map(prev); @@ -496,14 +479,9 @@ const useFolderTreeActions = ({ setStatusMessage(message, 'error'); } return false; - } finally { - if (manageLoading) { - setLoading(false); - } } }, [ - api, token, applySelectedFolder, ensureFolderData, @@ -512,7 +490,6 @@ const useFolderTreeActions = ({ selectedFolder, setFolderContents, setFolderNodes, - setLoading, setSelectedFolder, setStatusMessage, ], diff --git a/frontend/src/hooks/documents/useTenantManager.ts b/frontend/src/hooks/documents/useTenantManager.ts index e974ef5..192e181 100644 --- a/frontend/src/hooks/documents/useTenantManager.ts +++ b/frontend/src/hooks/documents/useTenantManager.ts @@ -19,10 +19,9 @@ interface UseTenantManagerOptions { resetWorkspaceState: () => void; setStatusMessage: (message: string, variant?: string) => void; notifyApiError: (error: unknown, message: string) => void; - setLoading: (state: boolean) => void; refreshTags: () => Promise; refreshCorrespondents: () => Promise; - loadFolder: (folderId: string, options?: { showLoading?: boolean; preserveSearch?: boolean }) => Promise; + loadFolder: (folderId: string, options?: { preserveSearch?: boolean }) => Promise; handleDocumentsViewModeChange: (mode: string) => void; navigate: NavigateFunction; tokenRef?: MutableRefObject; @@ -36,7 +35,6 @@ const useTenantManager = ({ resetWorkspaceState, setStatusMessage, notifyApiError, - setLoading, refreshTags, refreshCorrespondents, loadFolder, @@ -52,7 +50,6 @@ const useTenantManager = ({ return; } - setLoading(true); try { if (!refreshOnly) { setStatusMessage('Switching tenant…', 'info'); @@ -99,14 +96,12 @@ const useTenantManager = ({ navigate('/documents', { replace: true }); await Promise.all([refreshTags(), refreshCorrespondents()]); - await loadFolder('root', { showLoading: false, preserveSearch: false }); + await loadFolder('root', { preserveSearch: false }); const tenantLabel = data?.tenant?.name || data?.tenant?.id || 'tenant'; setStatusMessage(`Switched to ${tenantLabel}.`, 'info'); } catch (error) { notifyApiError(error, 'Failed to switch tenant.'); - } finally { - setLoading(false); } }, [ @@ -120,7 +115,6 @@ const useTenantManager = ({ refreshCorrespondents, refreshTags, resetWorkspaceState, - setLoading, setStatusMessage, tenantIdRef, tokenRef, diff --git a/frontend/src/hooks/documents/useWorkspaceBreadcrumbs.ts b/frontend/src/hooks/documents/useWorkspaceBreadcrumbs.ts new file mode 100644 index 0000000..b1b7922 --- /dev/null +++ b/frontend/src/hooks/documents/useWorkspaceBreadcrumbs.ts @@ -0,0 +1,105 @@ +import React, { useEffect, useMemo } from 'react'; +import { DEFAULT_FOLDER_NAME } from '../../app/appLayoutUtils'; + +type Identifier = string | number; +type FolderId = Identifier | 'root'; + +interface UseWorkspaceBreadcrumbsArgs { + selectedFolder: FolderId | null; + folderNodes: Map; + currentFolder: { id?: FolderId; name?: string | null; parentId?: FolderId | null; parent_id?: FolderId | null } | null; + breadcrumbFetchRef: React.MutableRefObject>; + ensureFolderData: (folderId: FolderId, options?: Record) => Promise; +} + +const useWorkspaceBreadcrumbs = ({ + selectedFolder, + folderNodes, + currentFolder, + breadcrumbFetchRef, + ensureFolderData, +}: UseWorkspaceBreadcrumbsArgs) => { + const { breadcrumbs, missingBreadcrumbAncestors } = useMemo(() => { + const chain: Array<{ id: FolderId; name?: string | null }> = []; + const seen = new Set(); + const pending = new Set(); + let currentId: FolderId | null = (selectedFolder || 'root') as FolderId; + let guard = 0; + + while (currentId && !seen.has(currentId) && guard < 32) { + guard += 1; + seen.add(currentId); + + if (currentId === 'root') { + chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME }); + currentId = null; + break; + } + + const node = folderNodes.get(currentId as FolderId); + if (node) { + chain.push({ id: currentId, name: node.name || 'Folder' }); + currentId = (node.parentId ?? node.parent_id ?? 'root') as FolderId; + continue; + } + + let fallbackName: string | null | undefined = '…'; + let parentId: FolderId | null | undefined = null; + + if (currentFolder && currentFolder.id === currentId) { + fallbackName = currentFolder.name; + parentId = (currentFolder.parent_id ?? currentFolder.parentId ?? 'root') as FolderId; + } + + chain.push({ id: currentId, name: fallbackName }); + pending.add(currentId); + currentId = parentId as FolderId | null; + } + + if (!chain.some((crumb) => crumb.id === 'root')) { + chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME }); + } + + const ordered: Array<{ id: FolderId; name?: string | null }> = []; + const seenOrdered = new Set(); + 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; diff --git a/frontend/src/hooks/documents/useWorkspaceDeskProps.ts b/frontend/src/hooks/documents/useWorkspaceDeskProps.ts new file mode 100644 index 0000000..655b969 --- /dev/null +++ b/frontend/src/hooks/documents/useWorkspaceDeskProps.ts @@ -0,0 +1,136 @@ +import { useCallback, useMemo } from 'react'; +import type { MutableRefObject } from 'react'; + +type Identifier = string | number; + +interface UseWorkspaceDeskPropsArgs { + viewDocuments: any[]; + inspectDocumentForDesk: (doc: any) => void; + handleEntryPointer: (params: { rowKey?: string | null; id?: Identifier | null; type?: string; event?: any }) => void; + selectedEntries: Array; + selectionAnchorRef: MutableRefObject; + applySelection: (rowKeys: Array, options?: { anchor?: Identifier | string | null; interactedKeys?: Array }) => void; + resolveDocumentRowKey: (id?: Identifier | null) => string | null; + showingSearchResults: boolean; + searchQuery: string; + activeTagFilters: Array; + activeCorrespondentFilters: Array; + selectedFolder: Identifier | 'root' | null; + promoteSelectionOrder: () => void; + handleDocumentTagDrop: (docId: Identifier, tagId: Identifier) => Promise | void; + ensureAssetUrl: (docId: Identifier, asset: any, options?: Record) => Promise | null; + getDocumentAsset: (doc: any, type: string) => any; + currentTenantId: Identifier | null; + documentLinks: Map | null; + ensureDownloadUrl?: (docId: Identifier, options?: { force?: boolean }) => Promise; +} + +const useWorkspaceDeskProps = ({ + viewDocuments, + inspectDocumentForDesk, + handleEntryPointer, + selectedEntries, + selectionAnchorRef, + applySelection, + resolveDocumentRowKey, + showingSearchResults, + searchQuery, + activeTagFilters, + activeCorrespondentFilters, + selectedFolder, + promoteSelectionOrder, + handleDocumentTagDrop, + ensureAssetUrl, + getDocumentAsset, + currentTenantId, + documentLinks, + ensureDownloadUrl, +}: UseWorkspaceDeskPropsArgs) => { + const handleDeskDocumentStackSelect = useCallback( + (docIds: Array) => { + if (!Array.isArray(docIds) || docIds.length === 0) { + return; + } + + const rowKeys = docIds + .map((id) => resolveDocumentRowKey(id as Identifier)) + .filter((value): value is string => typeof value === 'string'); + + if (!rowKeys.length) { + return; + } + + const nextKeys = [...selectedEntries]; + rowKeys.forEach((key) => { + if (!nextKeys.includes(key)) { + nextKeys.push(key); + } + }); + + const anchor = (rowKeys[0] + || selectionAnchorRef.current + || nextKeys[nextKeys.length - 1]) as Identifier | string | null; + + applySelection(nextKeys, { + anchor, + interactedKeys: rowKeys, + }); + }, + [applySelection, resolveDocumentRowKey, selectedEntries, selectionAnchorRef], + ); + + const deskViewId = useMemo(() => { + if (showingSearchResults) { + const trimmedQuery = searchQuery.trim(); + const tagsKey = [...activeTagFilters].sort().join(','); + const correspondentsKey = [...activeCorrespondentFilters].sort().join(','); + return `search:${trimmedQuery}|tags:${tagsKey}|corr:${correspondentsKey}`; + } + + const folderKey = selectedFolder && selectedFolder !== '' ? selectedFolder : 'root'; + return `folder:${folderKey}`; + }, [ + showingSearchResults, + searchQuery, + activeTagFilters, + activeCorrespondentFilters, + selectedFolder, + ]); + + const deskWorkspaceProps = useMemo( + () => ({ + entries: viewDocuments, + onDocumentActivate: inspectDocumentForDesk, + onDocumentClick: handleEntryPointer, + onDocumentStackSelect: handleDeskDocumentStackSelect, + onPromoteSelection: promoteSelectionOrder, + onDocumentTagDrop: handleDocumentTagDrop, + ensureAssetUrl, + getDocumentAsset, + activeTagFilters, + tenantId: currentTenantId, + viewId: deskViewId, + documentLinks, + ensureDownloadUrl, + }), + [ + viewDocuments, + inspectDocumentForDesk, + handleEntryPointer, + handleDeskDocumentStackSelect, + promoteSelectionOrder, + handleDocumentTagDrop, + ensureAssetUrl, + getDocumentAsset, + activeTagFilters, + currentTenantId, + deskViewId, + documentLinks, + ensureDownloadUrl, + ], + ); + + return deskWorkspaceProps; +}; + +export default useWorkspaceDeskProps; diff --git a/frontend/src/hooks/documents/useWorkspaceSelectionSync.ts b/frontend/src/hooks/documents/useWorkspaceSelectionSync.ts new file mode 100644 index 0000000..9f3236a --- /dev/null +++ b/frontend/src/hooks/documents/useWorkspaceSelectionSync.ts @@ -0,0 +1,63 @@ +import { useEffect } from 'react'; +import type { MutableRefObject } from 'react'; + +type Identifier = string | number; + +interface UseWorkspaceSelectionSyncArgs { + showingSearchResults: boolean; + searchQuery: string; + setSelectedEntries: (entries: Array) => void; + setSelectionOrder: (order: Array) => void; + selectionOrderRef: MutableRefObject>; + selectionAnchorRef: MutableRefObject; + setFocusedDocumentId: (id: Identifier | null) => void; + selectedDocumentIds: Identifier[]; + activePreviewId: Identifier | null; + setActivePreviewId: (id: Identifier | null) => void; + selectionInitializedRef: MutableRefObject; +} + +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; diff --git a/frontend/src/hooks/documents/useWorkspaceTaxonomies.ts b/frontend/src/hooks/documents/useWorkspaceTaxonomies.ts new file mode 100644 index 0000000..f77756c --- /dev/null +++ b/frontend/src/hooks/documents/useWorkspaceTaxonomies.ts @@ -0,0 +1,140 @@ +import { useEffect, useMemo } from 'react'; +import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; +import usePasskeys from '../../settings/usePasskeys'; +import TagManager from '../../tag_manager'; +import useCorrespondents from './useCorrespondents'; +import useDocumentCorrespondentActions from './useDocumentCorrespondentActions'; +import useTags from './useTags'; + +type Identifier = string | number; + +interface UseWorkspaceTaxonomiesArgs { + apiClient: any; + notifyApiError: (error: unknown, fallbackMessage?: string, variant?: string) => void; + setStatusMessage: (message: string, variant?: string) => void; + tagManager: TagManager; + tenantIdRef: MutableRefObject; + currentTenantId: Identifier | null; + setActiveTagFilters: Dispatch>; + mapDocumentCaches: (mapper: (doc: any) => any | undefined) => void; + updateDocumentCaches: (id: Identifier, updater: (doc: any) => any) => void; + token: string; +} + +const useWorkspaceTaxonomies = ({ + apiClient, + notifyApiError, + setStatusMessage, + tagManager, + tenantIdRef, + currentTenantId, + setActiveTagFilters, + mapDocumentCaches, + updateDocumentCaches, + token, +}: UseWorkspaceTaxonomiesArgs) => { + const { + tags, + refreshTags, + handleTagCreate, + handleTagUpdate, + handleTagDelete, + setTags, + } = useTags({ + apiClient, + notifyApiError, + setStatusMessage, + tagManager, + tenantIdRef, + setActiveTagFilters, + mapDocumentCaches, + }); + + useEffect(() => { + tenantIdRef.current = currentTenantId; + }, [currentTenantId, tenantIdRef]); + + const tagLookupById = useMemo(() => { + const map = new Map(); + tags.forEach((tag) => { + if (tag?.id) { + map.set(tag.id, tag); + } + }); + return map; + }, [tags]); + + const { + correspondents, + refreshCorrespondents, + handleCorrespondentCreate, + handleCorrespondentUpdate, + handleCorrespondentDelete, + setCorrespondents, + } = useCorrespondents({ + apiClient, + notifyApiError, + setStatusMessage, + tenantIdRef, + mapDocumentCaches, + }); + + const { + correspondentLookupByName, + handleDocumentCorrespondentAttach, + handleCorrespondentRemove, + handleCorrespondentAdd, + } = useDocumentCorrespondentActions({ + apiClient, + correspondents, + handleCorrespondentCreate, + notifyApiError, + setStatusMessage, + updateDocumentCaches, + }); + + const { + passkeys, + passkeysSupported, + passkeysLoading, + registeringPasskey, + revokingPasskeyId, + refreshPasskeys, + registerPasskey, + revokePasskey, + } = usePasskeys({ + notifyApiError, + setStatusMessage, + token, + }); + + return { + tags, + refreshTags, + handleTagCreate, + handleTagUpdate, + handleTagDelete, + setTags, + tagLookupById, + correspondents, + refreshCorrespondents, + handleCorrespondentCreate, + handleCorrespondentUpdate, + handleCorrespondentDelete, + setCorrespondents, + correspondentLookupByName, + handleDocumentCorrespondentAttach, + handleCorrespondentRemove, + handleCorrespondentAdd, + passkeys, + passkeysSupported, + passkeysLoading, + registeringPasskey, + revokingPasskeyId, + refreshPasskeys, + registerPasskey, + revokePasskey, + }; +}; + +export default useWorkspaceTaxonomies; diff --git a/frontend/src/hooks/useAssetNavigator.ts b/frontend/src/hooks/useAssetNavigator.ts index 02ad293..decf5de 100644 --- a/frontend/src/hooks/useAssetNavigator.ts +++ b/frontend/src/hooks/useAssetNavigator.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { createAssetView } from '../asset_manager'; +import { resolveAssetUrl } from '../asset_manager'; type Identifier = string | number; @@ -33,10 +33,8 @@ type EnsureAssetUrl = ( type GetAsset = (document: DocumentLike, assetType: string) => AssetLike | null; type AssetViewLike = { - getObject: (ordinal?: number) => AssetObject | null; - getObjects: () => AssetObject[]; - getPrimaryUrl: () => string | null; - getPrimaryMetadata: () => Record | null; + url: string | null; + metadata: Record | null; }; interface UseAssetNavigatorOptions { @@ -72,13 +70,15 @@ export const useAssetNavigator = ({ }, [document, assetType, getAsset]); const view = useMemo( - () => createAssetView(asset) as unknown as AssetViewLike, + () => ({ + url: resolveAssetUrl(asset), + metadata: (asset?.metadata as Record | null) || null, + }), [asset], ); - const currentObject = view.getObject(1) || view.getObjects()[0] || null; - const currentUrl = currentObject?.url ?? view.getPrimaryUrl() ?? null; - const currentMetadata = (currentObject?.metadata ?? view.getPrimaryMetadata()) || null; + const currentUrl = view.url || null; + const currentMetadata = view.metadata || null; const [isLoading, setIsLoading] = useState(false); diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 0b25cba..5cc80ba 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -2,10 +2,93 @@ import '@fontsource/inter/400.css'; import React from 'react'; import { createRoot } from 'react-dom/client'; -import { HashRouter } from 'react-router-dom'; +import { + HashRouter, + Navigate, + Outlet, + Route, + Routes, +} from 'react-router-dom'; import './styles/index.css'; +import DocumentsRoute from './app/DocumentsRoute'; +import DropOverlay from './app/DropOverlay'; +import LoginRoute from './app/LoginRoute'; +import SettingsRoute from './app/SettingsRoute'; import { AppStateProvider } from './app/appState'; -import AppRouter from './app/AppRouter'; +import { useDocumentsPreferences } from './app/useDocumentsPreferences'; +import { AppShellContext } from './appShellContext'; +import useDocumentsWorkspace from './hooks/documents/useDocumentsWorkspace'; +import UploadQueueOverlay from './app/UploadQueueOverlay'; + +const AppLayout: React.FC = () => { + const documentsPreferences = useDocumentsPreferences(); + const { + appStatus, + location, + shellRef, + dropOverlayState, + managementModals, + contextValue, + settingsOpen, + closeSettings, + } = useDocumentsWorkspace({ + documentsViewMode: documentsPreferences.documentsViewMode, + documentsSortField: documentsPreferences.documentsSortField, + documentsSortDirection: documentsPreferences.documentsSortDirection, + documentsSortFieldRef: documentsPreferences.documentsSortFieldRef, + documentsSortDirectionRef: documentsPreferences.documentsSortDirectionRef, + onDocumentsViewModeChange: documentsPreferences.handleDocumentsViewModeChange, + onDocumentsSortFieldChange: documentsPreferences.handleDocumentsSortFieldChange, + onDocumentsSortDirectionToggle: documentsPreferences.handleDocumentsSortDirectionToggle, + searchIncludeDescendants: documentsPreferences.searchIncludeDescendants, + onSetSearchIncludeDescendants: documentsPreferences.setSearchIncludeDescendants, + sortRefreshReadyRef: documentsPreferences.sortRefreshReadyRef, + }); + + if (['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) { + const redirectTarget = `${location.pathname}${location.search}${location.hash || ''}`; + return ( + + ); + } + + return ( + +
+ + + + {managementModals} + {settingsOpen ? ( + + ) : null} +
+
+ ); +}; + +const AppRouter: React.FC = () => ( + + } /> + }> + } /> + } /> + } /> + } /> + } /> + + +); const container = document.getElementById('app'); diff --git a/frontend/src/lib/apiClient.ts b/frontend/src/lib/apiClient.ts new file mode 100644 index 0000000..93f0df1 --- /dev/null +++ b/frontend/src/lib/apiClient.ts @@ -0,0 +1,360 @@ +import api from './api'; +import type { + ApiTokenRecord, + AssetResponse, + CapabilityResponse, + CapabilitySetResponse, + DownloadLink, + DocumentResponse, + FolderTreeNode, + Identifier, + PasskeySummary, + TenantSnippet, + TagResponse, +} from './apiTypes'; +import type { AxiosError, AxiosInstance, InternalAxiosRequestConfig, AxiosRequestConfig } from 'axios'; + +export const httpClient: Pick = { + get: api.get.bind(api), + post: api.post.bind(api), + patch: api.patch.bind(api), + delete: api.delete.bind(api), + defaults: api.defaults, +}; + +type AuthAwareRequestConfig = InternalAxiosRequestConfig & { + _retry?: boolean; + skipAuthRefresh?: boolean; +}; + +type AuthRequestConfig = AxiosRequestConfig & { + skipAuthRefresh?: boolean; +}; +type AuthRefreshHandlers = { + onRefreshSuccess?: (token: string, payload?: { tenant?: unknown }) => void; + onRefreshFailure?: (error: unknown) => void; +}; + +let refreshPromise: Promise | null = null; +let authRefreshHandlers: AuthRefreshHandlers = {}; + +const normalizeNumber = (value: unknown): number | undefined => { + const n = Number(value); + return Number.isFinite(n) ? n : undefined; +}; + +const normalizeDownload = (input?: DownloadLink | null): DownloadLink | null => { + if (!input?.url) { + return null; + } + const expires_at = normalizeNumber(input.expires_at); + if (!expires_at) { + return null; + } + return { url: input.url, expires_at }; +}; + +export const fetchDocument = async (id: Identifier): Promise => { + const { data } = await api.get<{ document?: DocumentResponse }>(`/documents/${id}`); + const doc = data?.document || (data as unknown as DocumentResponse); + if (doc?.current_version?.download) { + doc.current_version.download = normalizeDownload(doc.current_version.download); + } + return doc; +}; + +export const fetchAsset = async (id: Identifier): Promise => { + const { data } = await api.get(`/assets/${id}`); + const download = normalizeDownload(data.download); + return { + ...data, + download, + }; +}; + +export const listDocuments = async (params: Record = {}): Promise => { + const { data } = await api.get('/documents', { params }); + return Array.isArray(data) ? data : []; +}; + +export const getFolderTree = async (): Promise => { + const { data } = await api.get('/folders/tree'); + return Array.isArray(data) ? data : []; +}; + +export const listCapabilitySets = async (): Promise => { + const { data } = await api.get('/capability-sets'); + return Array.isArray(data) ? data : []; +}; + +export const listCapabilities = async (): Promise => { + const { data } = await api.get('/capabilities'); + return Array.isArray(data) ? data : []; +}; + +export const listApiTokens = async (): Promise => { + const { data } = await api.get('/profile/api-tokens'); + return Array.isArray(data) ? data : []; +}; + +export const listPasskeys = async (): Promise => { + const { data } = await api.get('/profile/passkeys'); + return Array.isArray(data) ? data : []; +}; + +export const moveDocumentsBulk = async (documentIds: Identifier[], folderId: Identifier | null): Promise => { + await api.post('/documents/bulk/move', { + document_ids: documentIds, + folder_id: folderId, + }); +}; + +export const queueDocumentReanalysis = async ( + documentId: Identifier, + options: { force?: boolean } = {}, +): Promise => { + const { force = false } = options; + await api.post(`/documents/${documentId}/assets`, null, { params: { force } }); +}; + +export const trashDocument = async (documentId: Identifier): Promise => { + await api.post(`/documents/${documentId}/trash`); +}; + +export const addDocumentTags = async (documentId: Identifier, tagIds: Identifier[]): Promise => { + await api.post(`/documents/${documentId}/tags`, { tag_ids: tagIds }); +}; + +export const createTag = async (payload: { label: string; color?: string | null }): Promise => { + const { data } = await api.post('/tags', payload); + return data; +}; + +export const createFolder = async (payload: { name: string; parent_id?: Identifier | null }): Promise => { + const { data } = await api.post('/folders', payload); + return data; +}; + +export const assignCorrespondentsBulk = async ( + payload: Record, +): Promise => { + const { data } = await api.post('/documents/bulk/correspondents', payload); + return data; +}; + +export const createApiToken = async (payload: { + capability_set_id: Identifier; + label?: string; + expires_at?: string; +}): Promise<{ token_info?: ApiTokenRecord; token?: string }> => { + const { data } = await api.post('/profile/api-tokens', payload); + return data as { token_info?: ApiTokenRecord; token?: string }; +}; + +export const regenerateApiToken = async ( + tokenId: Identifier, +): Promise<{ token_info?: ApiTokenRecord; token?: string }> => { + const { data } = await api.post(`/profile/api-tokens/${tokenId}/regenerate`); + return data as { token_info?: ApiTokenRecord; token?: string }; +}; + +export const startPasskeyRegistration = async (): Promise => { + const { data } = await api.post('/auth/passkeys/register/start', {}); + return data; +}; + +export const finishPasskeyRegistration = async (payload: unknown): Promise => { + const { data } = await api.post('/auth/passkeys/register/finish', payload); + return data; +}; + +export const startPasskeyLogin = async (username: string): Promise => { + const { data } = await api.post('/auth/passkeys/login/start', { username }); + return data; +}; + +export const finishPasskeyLogin = async (payload: unknown): Promise => { + const { data } = await api.post('/auth/passkeys/login/finish', payload); + return data; +}; + +export const performLogin = async (payload: Record): Promise => { + const { data } = await api.post('/auth/login', payload); + return data; +}; + +export const refreshSession = async (): Promise<{ access_token?: string; tenant?: unknown }> => { + const { data } = await api.post('/auth/refresh', undefined, { skipAuthRefresh: true } as AuthRequestConfig); + return data as { access_token?: string; tenant?: unknown }; +}; + +export const logoutSession = async (): Promise => { + await api.post('/auth/logout'); +}; + +export const selectTenant = async ( + payload: { tenant_id: Identifier }, + selectionToken: string, +): Promise => { + const { data } = await api.post('/auth/select-tenant', payload, { + headers: { + Authorization: `Bearer ${selectionToken}`, + }, + }); + return data; +}; + +export const startSignup = async (username: string): Promise => { + const { data } = await api.post('/auth/signup/start', { username }); + return data; +}; + +export const finishSignup = async (payload: unknown): Promise => { + const { data } = await api.post('/auth/signup/finish', payload); + return data; +}; + +export const updateDocument = async ( + id: Identifier, + payload: Record, +): Promise => { + const { data } = await api.patch(`/documents/${id}`, payload); + return data; +}; + +export const moveDocumentToFolder = async (id: Identifier, folderId: Identifier | null): Promise => { + await api.patch(`/documents/${id}/folder`, { folder_id: folderId }); +}; + +export const deleteDocumentTag = async (documentId: Identifier, tagId: Identifier): Promise => { + await api.delete(`/documents/${documentId}/tags/${tagId}`); +}; + +export const deleteFolder = async (folderId: Identifier): Promise => { + await api.delete(`/folders/${folderId}`); +}; + +export const moveFolder = async (folderId: Identifier, parentId: Identifier | null): Promise => { + await api.patch(`/folders/${folderId}`, { parent_id: parentId }); +}; + +export const renameFolder = async (folderId: Identifier, name: string): Promise => { + await api.patch(`/folders/${folderId}`, { name }); +}; + +export const createCapabilitySet = async ( + payload: { slug?: string; label?: string; capabilities: string[] }, +): Promise => { + const { data } = await api.post('/capability-sets', payload); + return data; +}; + +export const updateCapabilitySet = async ( + id: Identifier, + payload: { slug?: string; label?: string; capabilities?: string[] }, +): Promise => { + const { data } = await api.patch(`/capability-sets/${id}`, payload); + return data; +}; + +export const deleteCapabilitySet = async (id: Identifier): Promise => { + await api.delete(`/capability-sets/${id}`); +}; + +export const deleteApiToken = async (tokenId: Identifier): Promise => { + await api.delete(`/profile/api-tokens/${tokenId}`); +}; + +export const deletePasskey = async ( + passkeyId: Identifier, + options: { reason?: string } = {}, +): Promise => { + const query = options.reason ? `?reason=${encodeURIComponent(options.reason)}` : ''; + await api.delete(`/profile/passkeys/${passkeyId}${query}`); +}; + +export const listTenants = async (): Promise => { + const { data } = await api.get<{ tenants?: TenantSnippet[] } | TenantSnippet[]>('/tenants'); + if (Array.isArray(data)) { + return data; + } + return Array.isArray(data?.tenants) ? data.tenants : []; +}; + +export const setAuthToken = (token?: string | null) => { + if (token) { + api.defaults.headers.common.Authorization = `Bearer ${token}`; + } else { + delete api.defaults.headers.common.Authorization; + } +}; + +export const clearAuthToken = () => { + delete api.defaults.headers.common.Authorization; +}; + +export const setAuthRefreshHandlers = (handlers: AuthRefreshHandlers) => { + authRefreshHandlers = handlers; +}; + +const performTokenRefresh = async (): Promise => { + if (refreshPromise) { + return refreshPromise; + } + + refreshPromise = refreshSession() + .then((data) => { + const token = data?.access_token; + if (!token) { + throw new Error('Missing access token in refresh response'); + } + setAuthToken(token); + authRefreshHandlers.onRefreshSuccess?.(token, { tenant: data?.tenant }); + return token; + }) + .catch((error) => { + authRefreshHandlers.onRefreshFailure?.(error); + throw error; + }) + .finally(() => { + refreshPromise = null; + }); + + return refreshPromise; +}; + +api.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const response = error.response; + const config = (error.config || {}) as AuthAwareRequestConfig; + if (!response || response.status !== 401 || config._retry || config.skipAuthRefresh) { + return Promise.reject(error); + } + + config._retry = true; + + try { + const token = await performTokenRefresh(); + const headers = (config.headers ?? {}) as Record; + headers.Authorization = `Bearer ${token}`; + config.headers = headers as AuthAwareRequestConfig['headers']; + return api(config); + } catch (refreshError) { + return Promise.reject(refreshError); + } + }, +); + +export type { + DownloadLink, + DocumentResponse, + AssetResponse, + FolderTreeNode, + CapabilitySetResponse, + CapabilityResponse, + ApiTokenRecord, + PasskeySummary, + Identifier, + TenantSnippet, +} from './apiTypes'; diff --git a/frontend/src/lib/apiTypes.ts b/frontend/src/lib/apiTypes.ts new file mode 100644 index 0000000..3c2638c --- /dev/null +++ b/frontend/src/lib/apiTypes.ts @@ -0,0 +1,106 @@ +// Types aligned with OpenAPI schemas for common endpoints. + +export type Identifier = string | number; + +export interface DownloadLink { + url: string; + expires_at: number; +} + +export interface TagResponse { + id: string; + label: string; + color?: string | null; +} + +export interface CorrespondentResponse { + id: string; + name: string; + metadata: Record; +} + +export interface AssetResponse { + id: string; + asset_type: string; + mime_type: string; + metadata: Record; + download?: DownloadLink | null; + [key: string]: unknown; +} + +export interface DocumentVersionResponse { + id: string; + version_number: number; + size_bytes: number; + checksum: string; + created_at: string; + mime_type?: string | null; + metadata: Record; + download: DownloadLink; + assets?: AssetResponse[] | null; +} + +export interface DocumentResponse { + id: string; + filename: string; + title: string; + original_name: string; + mime_type?: string | null; + folder_id?: string | null; + created_at: string; + updated_at: string; + issued_at?: string | null; + metadata: Record; + tags: TagResponse[]; + correspondents?: CorrespondentResponse[]; + current_version?: DocumentVersionResponse | null; +} + +export interface FolderInfo { + id: string; + name: string; + parent_id?: string | null; + created_at?: string; + updated_at?: string; +} + +export interface FolderTreeNode extends FolderInfo { + children?: FolderTreeNode[]; +} + +export interface CapabilitySetResponse { + id: string; + slug: string; + is_system: boolean; + cap_version: number; + capabilities: string[]; +} + +export interface CapabilityResponse { + id?: string; + name: string; +} + +export interface TenantSnippet { + id: string; + name: string; +} + +export interface ApiTokenRecord { + id: string; + label?: string | null; + capability_set_id: string; + created_at: string; + last_used_at?: string | null; + expires_at?: string | null; +} + +export interface PasskeySummary { + id: string; + nickname?: string | null; + createdAt: string; + lastUsedAt?: string | null; + transports?: string[]; + revokedAt?: string | null; + revokedReason?: string | null; +} diff --git a/frontend/src/preview/DocumentViewerLayout.tsx b/frontend/src/preview/DocumentViewerLayout.tsx index 2b86e18..b23f133 100644 --- a/frontend/src/preview/DocumentViewerLayout.tsx +++ b/frontend/src/preview/DocumentViewerLayout.tsx @@ -7,7 +7,7 @@ import PdfViewer from './PdfViewer'; interface DocumentLike { id?: string | number; title?: string; - content_type?: string; + mime_type?: string; filename?: string; original_name?: string; [key: string]: unknown; @@ -15,7 +15,7 @@ interface DocumentLike { interface DocumentLink { url?: string; - contentType?: string; + mimeType?: string; filename?: string; } @@ -100,8 +100,8 @@ const DocumentViewerLayout = ({ return null; } - const normalizedContentType = (documentLink.contentType - || document.content_type + const normalizedMimeType = (documentLink.mimeType + || document.mime_type || '') .toLowerCase(); const normalizedFilename = documentLink.filename @@ -109,12 +109,12 @@ const DocumentViewerLayout = ({ || document.original_name || ''; const fileExtension = getFileExtension(normalizedFilename); - const isImage = normalizedContentType.startsWith('image/'); - const isPdf = normalizedContentType === 'application/pdf' - || normalizedContentType === 'application/x-pdf'; - const isAudio = normalizedContentType.startsWith('audio/') + const isImage = normalizedMimeType.startsWith('image/'); + const isPdf = normalizedMimeType === 'application/pdf' + || normalizedMimeType === 'application/x-pdf'; + const isAudio = normalizedMimeType.startsWith('audio/') || AUDIO_EXTENSIONS.has(fileExtension); - const isVideo = normalizedContentType.startsWith('video/') + const isVideo = normalizedMimeType.startsWith('video/') || VIDEO_EXTENSIONS.has(fileExtension); const mediaLabel = document.title || normalizedFilename @@ -170,7 +170,7 @@ const DocumentViewerLayout = ({ ); } - const displayContentType = document.content_type || documentLink.contentType || 'this file type'; + const displayMimeType = document.mime_type || documentLink.mimeType || 'this file type'; const displayFilename = documentLink.filename || document.filename || document.original_name @@ -179,7 +179,7 @@ const DocumentViewerLayout = ({ return (
- Preview is not available for {displayContentType} files. + Preview is not available for {displayMimeType} files.
{displayFilename}
; current_version?: { version_number?: number; - version?: { content_type?: string | null } | null; + download?: { url?: string | null; expires_at?: number } | null; + mime_type?: string | null; + filename?: string | null; } | null; documentLink?: { url: string; alt?: string; - contentType?: string | null; + mimeType?: string | null; } | null; [key: string]: unknown; } @@ -56,11 +59,6 @@ interface AssetLike { interface DocumentViewerPanelProps extends DocumentSummarySectionProps { document: DocumentLike | null; - documentLink?: { - url?: string; - contentType?: string | null; - filename?: string | null; - } | null; ensureAssetUrl?: (docId: string | number, asset: AssetLike, options?: { force?: boolean }) => Promise; getDocumentAsset?: (doc: DocumentLike | null, type: string) => AssetLike | null; ensurePreviewData?: (docId: string | number, options?: { signal?: AbortSignal }) => Promise; @@ -78,7 +76,6 @@ interface DocumentViewerPanelProps extends DocumentSummarySectionProps { export const createDocumentViewerHeaderActions = ({ document, actionState, - documentLink, onZoom, canZoom = false, }) => { @@ -86,7 +83,7 @@ export const createDocumentViewerHeaderActions = ({ return null; } - const downloadHref = actionState?.downloadHref || documentLink?.url; + const downloadHref = actionState?.downloadHref; if (!downloadHref && !(canZoom && onZoom)) { return null; } @@ -122,7 +119,6 @@ export const createDocumentViewerHeaderActions = ({ const DocumentViewerPanel: React.FC = ({ document, - documentLink, tagLookupById, tagOptions, onTagAdd, @@ -168,6 +164,16 @@ const DocumentViewerPanel: React.FC = ({ return Boolean(getDocumentAsset(document, 'ocr-text')); }, [document, getDocumentAsset]); + const navigateToFolder = useCallback( + (folderId) => { + const target = folderId == null + ? '/documents' + : `/documents/folder/${folderId}`; + navigate(target); + }, + [navigate], + ); + const summaryProps = useMemo( () => ({ tagLookupById, @@ -180,6 +186,7 @@ const DocumentViewerPanel: React.FC = ({ onCorrespondentRemove, onUpdateTitle, onUpdateIssued, + onFolderNavigate: navigateToFolder, }), [ tagLookupById, @@ -192,6 +199,7 @@ const DocumentViewerPanel: React.FC = ({ onCorrespondentRemove, onUpdateTitle, onUpdateIssued, + navigateToFolder, ], ); @@ -251,35 +259,30 @@ const DocumentViewerPanel: React.FC = ({ const [zoomOverlayOpen, setZoomOverlayOpen] = useState(false); - const fallbackDocumentLink = useMemo(() => { + const resolvedDocumentLink = useMemo(() => { if (!document) { return null; } - const downloadPath = document.current_version?.download_path; - if (!downloadPath) { - return null; - } - const href = resolveApiPath ? resolveApiPath(downloadPath) : downloadPath; + const downloadUrl = document.current_version?.download?.url; + const href = resolveApiPath ? resolveApiPath(downloadUrl) : downloadUrl; if (!href) { return null; } - const contentType = document.current_version?.version?.content_type || document.content_type || null; + const mimeType = document.mime_type; const filename = document.current_version?.filename || document.filename || document.title || null; return { url: href, - contentType, + mimeType, filename, }; }, [document, resolveApiPath]); - const effectiveDocumentLink = documentLink?.url ? documentLink : fallbackDocumentLink; - const handleZoomOpen = useCallback(() => { - if (!effectiveDocumentLink?.url) { + if (!resolvedDocumentLink?.url) { return; } setZoomOverlayOpen(true); - }, [effectiveDocumentLink?.url]); + }, [resolvedDocumentLink?.url]); const handleZoomClose = useCallback(() => { setZoomOverlayOpen(false); @@ -287,7 +290,7 @@ const DocumentViewerPanel: React.FC = ({ useEffect(() => { setZoomOverlayOpen(false); - }, [effectiveDocumentLink?.url, document?.id]); + }, [resolvedDocumentLink?.url, document?.id]); const panelRef = useRef(null); const isStackedLayout = useViewerLayoutMode(panelRef, document?.id); @@ -344,19 +347,6 @@ const DocumentViewerPanel: React.FC = ({ ]; }, [document, resolveFolderPath]); - const handleBreadcrumbNavigate = useCallback( - (crumb) => { - if (!crumb?.id) { - return; - } - const target = crumb.id === 'root' - ? '/documents' - : `/documents/folder/${crumb.id}`; - navigate(target); - }, - [navigate], - ); - const breadcrumbTrailEntries = useMemo(() => { if (!breadcrumbs.length) { return []; @@ -365,28 +355,25 @@ const DocumentViewerPanel: React.FC = ({ return breadcrumbs.map((crumb, index) => ({ id: crumb.id, label: crumb.name, - onClick: index < lastIndex ? () => handleBreadcrumbNavigate(crumb) : null, + onClick: index < lastIndex ? () => navigateToFolder(crumb.id) : null, })); - }, [breadcrumbs, handleBreadcrumbNavigate]); + }, [breadcrumbs, navigateToFolder]); const zoomDisplay = useMemo(() => { - if (!effectiveDocumentLink?.url || !document) { + if (!resolvedDocumentLink?.url || !document) { return null; } - const docContentType = document.content_type; - const versionContentType = document.current_version?.version?.content_type; - const normalizedContentType = effectiveDocumentLink.contentType || docContentType || versionContentType || null; + const normalizedMimeType = document.mime_type; return { - url: effectiveDocumentLink.url, + url: resolvedDocumentLink.url, alt: document.title, - contentType: normalizedContentType || undefined, + mimeType: normalizedMimeType, }; - }, [effectiveDocumentLink?.url, effectiveDocumentLink?.contentType, document]); + }, [document, resolvedDocumentLink?.url]); const headerActions = createDocumentViewerHeaderActions({ document, actionState, - documentLink: effectiveDocumentLink, onZoom: zoomDisplay ? handleZoomOpen : null, canZoom: Boolean(zoomDisplay), }); @@ -441,15 +428,15 @@ const DocumentViewerPanel: React.FC = ({ : null; const headerLeadingButtons = isSidebarVariant - ? [collapseButton, maximizeButton].filter(Boolean) - : [sidebarToggle, closeButton].filter(Boolean); - const headerLeadingContent = headerLeadingButtons.length - ? ( - <> - {headerLeadingButtons} - - ) - : null; + ? [ + collapseButton ? {collapseButton} : null, + maximizeButton ? {maximizeButton} : null, + ].filter(Boolean) + : [ + sidebarToggle ? {sidebarToggle} : null, + closeButton ? {closeButton} : null, + ].filter(Boolean); + const headerLeadingContent = headerLeadingButtons.length ? headerLeadingButtons : null; const resizeHandle = isSidebarVariant ? (