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

This commit is contained in:
2025-11-24 20:53:41 +01:00
68 changed files with 1904 additions and 1296 deletions
+45
View File
@@ -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<ApiContextValue | null>(null);
export const ApiProvider: React.FC<PropsWithChildren<{ initialToken?: string | null }>> = ({
initialToken = null,
children,
}) => {
useEffect(() => {
if (initialToken) {
setAuthToken(initialToken);
}
}, [initialToken]);
const value = useMemo<ApiContextValue>(
() => ({
client: httpClient,
setAuthToken,
clearAuthToken,
}),
[],
);
return <ApiContext.Provider value={value}>{children}</ApiContext.Provider>;
};
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;
-67
View File
@@ -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 (
<Navigate
to="/account/login"
replace
state={{ from: redirectTarget }}
/>
);
}
return (
<AppShellContext.Provider value={contextValue}>
<div className="app-shell" ref={shellRef}>
<DropOverlay
active={dropOverlayState.active}
folderName={dropOverlayState.folderName}
/>
<UploadQueueOverlay
queue={contextValue.uploadQueue || []}
onClearQueue={contextValue.clearUploadQueue}
/>
<Outlet />
{managementModals}
{settingsOpen ? (
<SettingsRoute open onClose={closeSettings} />
) : null}
</div>
</AppShellContext.Provider>
);
};
export default AppLayout;
-20
View File
@@ -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 = () => (
<Routes>
<Route path="/account/login" element={<LoginRoute />} />
<Route element={<AppLayout />}>
<Route path="/" element={<Navigate to="/documents" replace />} />
<Route path="/documents" element={<DocumentsRoute />} />
<Route path="/documents/folder/:folderId" element={<DocumentsRoute />} />
<Route path="/documents/:documentId" element={<DocumentsRoute />} />
<Route path="*" element={<Navigate to="/documents" replace />} />
</Route>
</Routes>
);
export default AppRouter;
+8 -15
View File
@@ -131,25 +131,18 @@ const DocumentsRouteContent: React.FC = () => {
}, []);
const renderSurface = () => {
if (!surface) {
return (
<main className={`documents-main${sidebarHidden ? ' documents-main--sidebar-hidden' : ''}`}>
{!sidebarHidden ? <Sidebar {...sidebarPropsWithActions} /> : null}
<div className="main-content">
<div className="main-content__body" />
</div>
</main>
);
}
const layoutClass = `documents-main${sidebarHidden ? ' documents-main--sidebar-hidden' : ''}`;
const sidebarNode = !sidebarHidden ? <Sidebar {...sidebarPropsWithActions} /> : 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 (
<main className={`documents-main${sidebarHidden ? ' documents-main--sidebar-hidden' : ''}`}>
{!sidebarHidden ? <Sidebar {...sidebarPropsWithActions} /> : null}
<main className={layoutClass}>
{sidebarNode}
<div className="main-content">
<div className="main-content__body">{surface.content}</div>
{surfaceDetail}
{surfaceBody}
</div>
{surfaceDetail}
</main>
);
};
+37 -24
View File
@@ -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;
+3 -4
View File
@@ -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<SettingsRouteProps> = ({ 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<SettingsRouteProps> = ({ 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();
+4 -6
View File
@@ -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
+31 -12
View File
@@ -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<string, unknown> | 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 (
<AppStateContext.Provider value={stateValue}>
<AppDispatchContext.Provider value={dispatch}>
{children}
</AppDispatchContext.Provider>
</AppStateContext.Provider>
<ApiProvider initialToken={state.token}>
<AppStateContext.Provider value={stateValue}>
<AppDispatchContext.Provider value={dispatch}>
{children}
</AppDispatchContext.Provider>
</AppStateContext.Provider>
</ApiProvider>
);
};
@@ -265,4 +284,4 @@ const useAppDispatch = (): React.Dispatch<AppAction> => {
return context;
};
export { api, AppStateProvider, useAppState, useAppDispatch };
export { AppStateProvider, useAppState, useAppDispatch };
+15 -24
View File
@@ -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: <T = unknown>(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<DocumentLink | null> => {
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<DocumentLink | null> = (async () => {
try {
const docResponse = await api.get<{ document?: Record<string, any> }>(`/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,
],
);
+2 -7
View File
@@ -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<string, unknown> = {};
const trimmedQuery = searchQuery.trim();
@@ -227,7 +225,7 @@ const useDocumentsSearch = ({
if (documentsSortDirection) {
params.dir = documentsSortDirection;
}
const { data } = await api.get<unknown[]>('/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,
]);
+4 -4
View File
@@ -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(
+56 -29
View File
@@ -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 = (
<DocumentViewerPanel
variant="sidebar"
onCollapsePanel={onClose}
@@ -101,6 +109,16 @@ export const useWorkspaceSurface = ({
{...restDetailProps}
/>
);
if (folderNodes && ensureFolderData) {
return (
<FolderManagerProvider folderNodes={folderNodes} ensureFolderData={ensureFolderData}>
{viewer}
</FolderManagerProvider>
);
}
return (
<>{viewer}</>
);
})()
: null;
@@ -140,35 +158,44 @@ export const useWorkspaceSurface = ({
onUpdateTitle,
onUpdateIssued,
resolveFolderPath,
folderNodes,
ensureFolderData,
} = detailExtras;
return {
content: (
<DocumentViewerPanel
document={previewWorkspaceDocument || null}
documentLink={documentLink}
hydrateDocument={ensurePreviewData}
tagLookupById={tagLookupById}
tagOptions={tagOptions}
onTagAdd={onTagAdd}
onTagRemove={onTagRemove}
correspondents={correspondents}
onCorrespondentAdd={onCorrespondentAdd}
onCorrespondentRemove={onCorrespondentRemove}
onUpdateTitle={onUpdateTitle}
onUpdateIssued={onUpdateIssued}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
ensurePreviewData={ensurePreviewData}
resolveApiPath={resolveApiPath}
notifyApiError={notifyApiError}
sidebarToggle={sidebarToggle}
onClosePanel={closeDocumentPreview}
resolveFolderPath={resolveFolderPath}
/>
),
detail: null,
};
const viewer = (
<DocumentViewerPanel
document={previewWorkspaceDocument || null}
documentLink={documentLink}
hydrateDocument={ensurePreviewData}
tagLookupById={tagLookupById}
tagOptions={tagOptions}
onTagAdd={onTagAdd}
onTagRemove={onTagRemove}
correspondents={correspondents}
onCorrespondentAdd={onCorrespondentAdd}
onCorrespondentRemove={onCorrespondentRemove}
onUpdateTitle={onUpdateTitle}
onUpdateIssued={onUpdateIssued}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
ensurePreviewData={ensurePreviewData}
resolveApiPath={resolveApiPath}
notifyApiError={notifyApiError}
sidebarToggle={sidebarToggle}
onClosePanel={closeDocumentPreview}
resolveFolderPath={resolveFolderPath}
/>
);
const content = folderNodes && ensureFolderData
? (
<FolderManagerProvider folderNodes={folderNodes} ensureFolderData={ensureFolderData}>
{viewer}
</FolderManagerProvider>
)
: viewer;
return { content, detail: null };
}, [
showPreviewWorkspace,
previewWorkspaceDocument,
+29 -43
View File
@@ -1,5 +1,3 @@
import type { AxiosInstance } from 'axios';
export type Identifier = string | number;
type Nullable<T> = T | null;
@@ -8,7 +6,7 @@ export interface AssetObject {
ordinal?: number;
url?: string | null;
metadata?: Record<string, unknown> | 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<string, unknown> | null;
expiresAt?: number | null;
assets?: Record<string, AssetLike> | 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<DocumentVersionLike
if (!currentVersion) {
return null;
}
return getAssetFromGroup(currentVersion.assets ?? null, assetType);
return getAssetFromGroup(currentVersion.assets, assetType);
};
const normalizeAssetObjects = (objects?: AssetObject[] | null): AssetObject[] => {
@@ -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<AssetLike | null>) | null;
assetPresignTtlMs: number;
assetCache: Map<Identifier, AssetLike>;
assetInflight: Map<string, Promise<AssetLike | null>>;
constructor({ api, assetPresignTtlMs }: { api: AxiosInstance | null; assetPresignTtlMs: number }) {
this.api = api;
constructor({ fetchAsset, assetPresignTtlMs }: { fetchAsset: ((id: Identifier) => Promise<AssetLike | null>) | 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<AssetLike | null>) | null) {
this.fetchAsset = fetchAsset;
}
rememberAsset(entry?: Nullable<AssetLike>) {
@@ -242,7 +229,7 @@ class AssetManager {
{ force = false }: { force?: boolean } = {},
): Promise<Nullable<AssetLike>> {
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<AssetLike | null> = this.api
.get(`/assets/${asset.id}`)
.then(({ data }) => {
const request: Promise<AssetLike | null> = 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);
+3 -3
View File
@@ -30,7 +30,7 @@ interface DesktopDocumentCardProps {
getDocumentAsset?: (...args: any[]) => unknown;
handleNavigatorSnapshot?: (...args: any[]) => void;
cardPointerHandlers?: React.HTMLAttributes<HTMLDivElement>;
onInspectDocument?: (id: string | number) => void;
onDocumentActivate?: (id: string | number) => void;
onTagDragEnter?: (event: React.DragEvent<HTMLDivElement>, docId: string | number) => void;
onTagDragOver?: (event: React.DragEvent<HTMLDivElement>, docId: string | number) => void;
onTagDragLeave?: (event: React.DragEvent<HTMLDivElement>, docId: string | number) => void;
@@ -57,7 +57,7 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
getDocumentAsset,
handleNavigatorSnapshot,
cardPointerHandlers,
onInspectDocument,
onDocumentActivate,
onTagDragEnter,
onTagDragOver,
onTagDragLeave,
@@ -117,7 +117,7 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
preventAll(event);
onInspectDocument?.(doc.id);
onDocumentActivate?.(doc.id);
}
}}
>
+106 -58
View File
@@ -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<Identifier | null>;
selectedDocumentIds?: Identifier[];
onClearSelection?: () => void;
activeTagFilters?: Array<Identifier | null>;
tenantId?: Identifier | null;
viewId?: string | null;
documentLinks?: Map<Identifier, unknown> | null;
ensureDownloadUrl?: (docId: Identifier, options?: { force?: boolean }) => Promise<unknown>;
}
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<string, DeskDocument>;
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<DesktopWorkspaceProps> = ({
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<DeskDocument[]>(() => documents, [documents]);
const {
selectedDocumentIds,
clearSelection,
handleEntrySelection,
promoteSelectionOrder,
} = useWorkspaceSelectionContext();
const items = useMemo<DeskDocument[]>(
() => (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<DesktopWorkspaceProps> = ({
[applySnapshotDimensions],
);
const activeTagSet = useMemo<Set<string>>(() => {
if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) {
if (!Array.isArray(activeTagFilters) || activeTagFilters.length === 0) {
return new Set();
}
const set = new Set<string>();
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<DesktopWorkspaceProps> = ({
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<DesktopWorkspaceProps> = ({
const tagInteractions = useDeskTagInteractions({
engine,
onAssignTagToDocument,
onAssignTagToDocument: onDocumentTagDrop,
requestCanvasFocus,
});
@@ -553,8 +601,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
};
}
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<DesktopWorkspaceProps> = ({
}
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<DesktopWorkspaceProps> = ({
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<DesktopWorkspaceProps> = ({
openOverlayForDoc,
recalcVisibleDocIds,
dragSettings,
onInspectDocument,
onDocumentActivate,
markLayoutDirty,
}),
[
@@ -816,10 +865,9 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
items,
layoutRef,
layoutSnapshot,
onClearSelection,
onDocumentStackSelect,
onEntryPointer,
onPromoteSelection,
onDocumentClick,
handleStackSelect,
handlePromoteSelection,
openOverlayForDoc,
overlayDisplay,
overlayOriginRect,
@@ -832,7 +880,8 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
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({
<>
<div className="desk-shell" onPointerDown={(event) => {
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}
@@ -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);
-8
View File
@@ -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})`;
+16 -16
View File
@@ -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,
],
+12 -12
View File
@@ -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<HTMLElement>;
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<HTMLElement | null>(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<DragStateInternal | null>(null);
+1 -1
View File
@@ -1,4 +1,4 @@
import { clamp, formatTransform } from './math';
import { clamp, formatTransform } from '../utils/math';
import { fetchLayoutRecords, upsertLayoutRecords } from './db';
type DocumentId = string;
+23 -22
View File
@@ -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<PreviewZoomOverlayProps> = ({
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<PreviewZoomOverlayProps> = ({
if (isPdfDisplay) {
return;
}
event.stopPropagation();
toggleZoomAtPoint(event.clientX ?? 0, event.clientY ?? 0);
};
@@ -322,11 +323,11 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
>
<div
className={stageClassName}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<div
className={containerClassName}
+4 -16
View File
@@ -25,11 +25,6 @@ interface FolderNode {
parentId?: Identifier | 'root';
}
type DocumentLink = {
url?: string;
contentType?: string | null;
} | null;
interface UseDetailWorkspaceArgs {
documents: DocumentLike[];
selectionOrder: string[];
@@ -39,7 +34,6 @@ interface UseDetailWorkspaceArgs {
ensureFolderData: (folderId: Identifier | 'root', options?: { force?: boolean; includeDocuments?: boolean }) => Promise<void>;
detailPanelControlRef: MutableRefObject<{ open?: (args?: { documentIds?: Identifier[] }) => void; close?: () => void } | null>;
detailFolderFetchRef: MutableRefObject<Set<Identifier | 'root'>>;
documentLinks: Map<Identifier, DocumentLink>;
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,
};
@@ -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> | boolean;
onUpdateIssued?: (docId: Identifier | undefined, timestamp: number | null) => Promise<boolean> | boolean;
onFolderNavigate?: (folderId: string | null) => void;
layout?: 'default' | 'compact';
}
@@ -456,8 +460,10 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
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<DocumentSummarySectionProps> = ({
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<DocumentSummarySectionProps> = ({
return rows;
}, [document?.current_version?.version_number]);
const resolvedFolderId = document?.folder_id ?? null;
const [folderName, setFolderName] = useState<string | null>(() => 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<DocumentSummarySectionProps> = ({
/>
);
const folderValueContent = (
<Link
className="document-summary__folder-link"
to={folderHref}
onClick={handleFolderClick}
>
{folderName}
</Link>
);
const summaryRowOverrides = {
title: { valueContent: titleMetaDisplay, error: titleError },
issued: { valueContent: issuedDisplay, error: issuedError },
tags: { valueContent: tagsValueContent },
correspondents: { valueContent: correspondentsValueContent },
folder: { valueContent: folderValueContent },
} as Record<string, { valueContent?: React.ReactNode | null; error?: string | null }>;
const baseRows: MetaItem[] = [...summaryRows, ...extraSummaryRows].map((row) => {
@@ -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;
+1 -6
View File
@@ -60,7 +60,6 @@ export type DocumentEventHandler = (document: DocumentLike, event: MouseEvent<HT
export interface DocumentsListProps {
entries: DocumentsListEntry[];
focusedRowKey?: string | null;
draggingDocumentIdsSet?: Set<Identifier> | null;
draggedFolderId?: Identifier | 'root' | null;
ensureAssetUrl?: (...args: any[]) => unknown;
@@ -91,7 +90,6 @@ export interface DocumentsListProps {
const DocumentsList: React.FC<DocumentsListProps> = ({
entries,
focusedRowKey,
draggingDocumentIdsSet,
draggedFolderId,
ensureAssetUrl,
@@ -185,7 +183,6 @@ const DocumentsList: React.FC<DocumentsListProps> = ({
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<DocumentsListProps> = ({
return (
<tr
key={entry.key}
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${
focusedRowKey === rowKey ? ' focused' : ''
}${isSelectedFolder ? ' selected' : ''}`}
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${isSelectedFolder ? ' selected' : ''}`}
id={`folder-row-${folder.id}`}
onClick={(event) => onFolderClick?.(folder, event)}
onDoubleClick={(event) => {
+11 -1
View File
@@ -15,6 +15,8 @@ class DocumentsManager<T extends ManagedDocument = ManagedDocument> {
private listeners: Set<() => void>;
private emitScheduled: boolean;
constructor(
fetchDocument?: FetchDocument,
) {
@@ -22,10 +24,18 @@ class DocumentsManager<T extends ManagedDocument = ManagedDocument> {
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) {
@@ -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<SelectionFloatingActionsProps> = ({
const tagLookupMap = tagLookupById instanceof Map ? tagLookupById : null;
const [remoteFolderOptions, setRemoteFolderOptions] = useState<SelectionAssignmentMenuItem[] | null>(null);
const [loadingFolders, setLoadingFolders] = useState(false);
const folderTreeFetchRef = useRef<Promise<SelectionAssignmentMenuItem[]> | null>(null);
useEffect(() => {
setRemoteFolderOptions(null);
folderTreeFetchRef.current = null;
setLoadingFolders(false);
}, [tenantId, token]);
const requestFolderTree = useCallback(async (): Promise<SelectionAssignmentMenuItem[]> => {
@@ -310,9 +308,8 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
}
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<SelectionFloatingActionsProps> = ({
setRemoteFolderOptions([]);
return [];
} finally {
setLoadingFolders(false);
folderTreeFetchRef.current = null;
}
})();
@@ -353,9 +349,7 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
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<DocumentLike[]>(() => {
if (!documentIdList.length || !(documentLookupMap instanceof Map)) {
@@ -520,19 +514,15 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
label="Move"
triggerContent={(
<span className="quick-add__chip-label" title="Move">
{loadingFolders ? (
<LoaderIcon className="icon-inline icon--spin" aria-hidden="true" />
) : (
<FolderOutlineIcon className="icon-inline" aria-hidden="true" />
)}
<FolderOutlineIcon className="icon-inline" aria-hidden="true" />
<span className="quick-add__chip-text" aria-hidden="true">Move</span>
</span>
)}
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}
+4 -4
View File
@@ -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 => {
+10 -5
View File
@@ -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<TagEntry>(doc.tags);
const correspondents = sanitizeArray<CorrespondentEntry>(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 },
@@ -1,12 +1,8 @@
import { useCallback } from 'react';
import { assignCorrespondentsBulk } from '../../lib/apiClient';
export type Identifier = string | number;
type ApiClient = {
post: <T = { data: unknown }>(url: string, payload: unknown) => Promise<{ data: T } | T>;
delete: (url: string) => Promise<unknown>;
};
type BulkAssignmentResponse = {
assigned?: number;
removed?: number;
@@ -17,22 +13,19 @@ type CorrespondentAssignment = {
};
interface UseBulkDocumentActionsArgs {
api: ApiClient;
resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[];
correspondentLookupByName: Map<string, { id?: Identifier }>;
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<boolean>;
handleFolderDelete: (id: Identifier, options?: { showMessage?: boolean; manageLoading?: boolean }) => Promise<boolean>;
handleDocumentsDelete: (ids: Identifier[], options?: { showMessage?: boolean }) => Promise<boolean>;
handleFolderDelete: (id: Identifier, options?: { showMessage?: boolean }) => Promise<boolean>;
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<BulkAssignmentResponse>('/documents/bulk/correspondents', {
const response: BulkAssignmentResponse = await assignCorrespondentsBulk<BulkAssignmentResponse>({
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<BulkAssignmentResponse>('/documents/bulk/correspondents', {
const response: BulkAssignmentResponse = await assignCorrespondentsBulk<BulkAssignmentResponse>({
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,
]);
@@ -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,
@@ -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<DocumentsPanelInnerProps> = ({
headerLeading = null,
@@ -61,7 +61,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
onDocumentDragEnd,
onDocumentRename,
onEntryPointer = null,
onInspectDocument = null,
onDocumentActivate = null,
tagLookupById,
activeCorrespondentIds = [],
ensureAssetUrl = null,
@@ -257,7 +257,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
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<Identifier | null>(null);
@@ -290,9 +290,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
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<DocumentsPanelInnerProps> = ({
setPreviewZoomSource({
url: entry.url,
alt: previewDoc.title,
contentType: entry.contentType || contentFallback || undefined,
mimeType: documentMimeType,
});
};
@@ -370,9 +368,9 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
handleDocumentPreviewZoom(doc);
return;
}
onInspectDocument?.(doc.id);
onDocumentActivate?.(doc.id);
},
[handleDocumentPreviewZoom, onInspectDocument],
[handleDocumentPreviewZoom, onDocumentActivate],
);
const navigableRows = useMemo(
+4 -4
View File
@@ -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;
@@ -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<string>;
}
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<FolderManager>(defaultManager);
interface FolderManagerProviderProps {
folderNodes?: Map<string | 'root', { name?: string | null }>;
ensureFolderData?: (folderId: string | 'root', options?: { force?: boolean; includeDocuments?: boolean }) => Promise<void>;
children: ReactNode;
}
export const FolderManagerProvider: React.FC<FolderManagerProviderProps> = ({
folderNodes,
ensureFolderData,
children,
}) => {
const value = useMemo<FolderManager>(() => {
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 (
<FolderManagerContext.Provider value={value}>
{children}
</FolderManagerContext.Provider>
);
};
export const useFolderManager = (): FolderManager => useContext(FolderManagerContext);
export default FolderManagerContext;
+7 -110
View File
@@ -1,30 +1,18 @@
import { useCallback, useEffect, useRef } from 'react';
import type { MutableRefObject } from 'react';
import type { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
import { AxiosHeaders } from 'axios';
import { clearAuthToken, logoutSession, refreshSession, setAuthToken } from '../../lib/apiClient';
type AppStatus = string;
type AppDispatch = (action: { type: string; [key: string]: unknown }) => void;
type NotifyApiError = (error: unknown, fallbackMessage: string, variant?: string) => void;
type SetStatusMessage = (message: string, variant?: string) => void;
type SetLoading = (state: boolean) => void;
interface RetryableAxiosRequestConfig extends InternalAxiosRequestConfig {
_retry?: boolean;
}
interface UseAuthManagerArgs {
apiClient: AxiosInstance;
token?: string | null;
appStatus: AppStatus;
appDispatch: AppDispatch;
notifyApiError: NotifyApiError;
setStatusMessage: SetStatusMessage;
setLoading: SetLoading;
}
interface UseAuthManagerResult {
@@ -33,40 +21,22 @@ interface UseAuthManagerResult {
handleLogout: () => Promise<void>;
}
const ensureAxiosHeaders = (
headers?: InternalAxiosRequestConfig['headers'],
): AxiosHeaders => {
if (headers instanceof AxiosHeaders) {
return headers;
}
return AxiosHeaders.from(headers || {});
};
const setHeaderAuthorization = (config: InternalAxiosRequestConfig, token: string): void => {
const headers = ensureAxiosHeaders(config.headers);
headers.set('Authorization', `Bearer ${token}`);
config.headers = headers;
};
const useAuthManager = ({
apiClient,
token,
appStatus,
appDispatch,
notifyApiError,
setStatusMessage,
setLoading,
}: UseAuthManagerArgs): UseAuthManagerResult => {
const tokenRef = useRef<string | null>(token);
const refreshPromiseRef = useRef<Promise<string> | null>(null);
const initialRefreshAttemptedRef = useRef(Boolean(token));
const refreshAccessToken = useCallback(async (): Promise<string> => {
console.log('[Auth] Attempting to refresh access token…');
appDispatch({ type: 'TOKEN_REFRESH_START' });
try {
const { data } = await apiClient.post<{ access_token?: string; tenant?: unknown }>('/auth/refresh');
const data = await refreshSession();
if (data?.access_token) {
setAuthToken(data.access_token);
appDispatch({
type: 'TOKEN_REFRESH_SUCCESS',
token: data.access_token,
@@ -81,7 +51,7 @@ const useAuthManager = ({
appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: (error as Error)?.message || null });
throw error;
}
}, [apiClient, appDispatch]);
}, [appDispatch]);
useEffect(() => {
tokenRef.current = token;
@@ -95,90 +65,17 @@ const useAuthManager = ({
}
}, [token, appStatus, refreshAccessToken]);
useEffect(() => {
const requestInterceptor = apiClient.interceptors.request.use((config) => {
const currentToken = tokenRef.current;
if (currentToken) {
const headers = ensureAxiosHeaders(config.headers);
if (!headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${currentToken}`);
}
config.headers = headers;
}
return config;
});
const responseInterceptor = apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const axiosError = error as AxiosError & { config?: RetryableAxiosRequestConfig };
const { response, config } = axiosError;
if (!response || !config) {
return Promise.reject(error);
}
const status = response.status;
const url = String(config?.url ?? '');
const isAuthRoute = url.includes('/auth/login') || url.includes('/auth/refresh');
if (status === 401 && !config._retry && !isAuthRoute) {
console.warn('[Auth] 401 received for', url, '- attempting token refresh');
if (!refreshPromiseRef.current) {
refreshPromiseRef.current = (async () => {
try {
return await refreshAccessToken();
} finally {
refreshPromiseRef.current = null;
}
})();
}
try {
const newToken = await refreshPromiseRef.current;
if (!newToken) {
throw new Error('No token returned from refresh');
}
config._retry = true;
setHeaderAuthorization(config, newToken);
console.log('[Auth] Retrying original request', url);
try {
return await apiClient(config);
} catch (retryError) {
if ((retryError as AxiosError)?.response?.status === 401) {
notifyApiError(retryError, 'Session expired. Please log in again.');
}
throw retryError;
}
} catch (refreshError) {
console.warn('[Auth] Refresh failed, clearing session');
notifyApiError(refreshError, 'Session expired. Please log in again.');
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
},
);
return () => {
apiClient.interceptors.request.eject(requestInterceptor);
apiClient.interceptors.response.eject(responseInterceptor);
};
}, [apiClient, notifyApiError, refreshAccessToken]);
const handleLogout = useCallback(async () => {
try {
setLoading(true);
await apiClient.post('/auth/logout');
await logoutSession();
} catch (error) {
console.warn('[Auth] Failed to revoke refresh token during logout', error);
} finally {
setLoading(false);
clearAuthToken();
appDispatch({ type: 'LOGOUT' });
setStatusMessage('Logged out.', 'info');
}
}, [apiClient, appDispatch, setLoading, setStatusMessage]);
}, [appDispatch, setStatusMessage]);
return { tokenRef, refreshAccessToken, handleLogout };
};
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import type { DragEvent } from 'react';
import { isPlainObject, isFunctionValue } from '../../utils/typeGuards';
type Identifier = string | number;
type FolderIdentifier = Identifier | 'root';
type FolderIdentifier = string | 'root';
type FolderInput = FolderIdentifier | number;
interface DocumentLike {
id?: Identifier | null;
@@ -24,7 +24,7 @@ type HandleEntrySelectionFn = (
interface UseDocumentDragHandlersOptions {
selectedEntries: string[];
selectedDocumentIds: Identifier[];
selectedFolderIds: FolderIdentifier[];
selectedFolderIds: FolderInput[];
applySelection: ApplySelectionFn;
handleEntrySelection: HandleEntrySelectionFn;
documentLookup: Map<Identifier, DocumentLike>;
@@ -49,6 +49,10 @@ const useDocumentDragHandlers = ({
documentsViewMode,
}: UseDocumentDragHandlersOptions) => {
const dragPreviewRef = useRef<HTMLDivElement | null>(null);
const normalizedFolderIds = useMemo(
() => selectedFolderIds.map((id) => (id === 'root' ? 'root' : String(id))) as FolderIdentifier[],
[selectedFolderIds],
);
const destroyDragPreview = useCallback(() => {
const node = dragPreviewRef.current;
@@ -61,7 +65,7 @@ const useDocumentDragHandlers = ({
useEffect(() => destroyDragPreview, [destroyDragPreview]);
const createDragPreview = useCallback(
({ documents = [], folders = [] }: { documents?: DocumentLike[]; folders?: Array<FolderIdentifier | Identifier> } = {}) => {
({ documents = [], folders = [] }: { documents?: DocumentLike[]; folders?: FolderIdentifier[] } = {}) => {
destroyDragPreview();
const docEntries = (documents || []).filter(Boolean);
@@ -145,17 +149,7 @@ const useDocumentDragHandlers = ({
}
} else {
const payload = item.payload;
const folderId = (() => {
if (isPlainObject(payload) && 'id' in payload) {
return (payload as { id?: FolderIdentifier }).id ?? null;
}
const maybeTrim = (payload as { trim?: () => string })?.trim;
if (isFunctionValue(maybeTrim)) {
const nextValue = maybeTrim.call(payload);
return nextValue || null;
}
return null;
})();
const folderId = payload as FolderIdentifier;
const rowEl = folderId
? (document.getElementById(`folder-row-${folderId}`)
|| document.getElementById(`folder-card-${folderId}`))
@@ -300,28 +294,29 @@ const useDocumentDragHandlers = ({
);
const handleFolderDragStart = useCallback(
(event: DragEvent<HTMLElement>, folderId: FolderIdentifier) => {
if (folderId === 'root') {
(event: DragEvent<HTMLElement>, folderId: FolderInput) => {
const normalizedFolderId: FolderIdentifier = folderId === 'root' ? 'root' : String(folderId);
if (normalizedFolderId === 'root') {
return;
}
event.stopPropagation();
const folderKey = resolveFolderRowKey(folderId);
const folderKey = resolveFolderRowKey(normalizedFolderId);
const isAlreadySelected = folderKey ? selectedEntries.includes(folderKey) : false;
let effectiveFolderSelection: FolderIdentifier[] = selectedFolderIds;
let effectiveFolderSelection: FolderIdentifier[] = normalizedFolderIds;
let effectiveDocumentSelection: Identifier[] = selectedDocumentIds;
if (!isAlreadySelected && folderKey) {
effectiveFolderSelection = [folderId];
effectiveFolderSelection = [normalizedFolderId];
effectiveDocumentSelection = [];
handleEntrySelection(folderKey, { preventDefault: () => {} });
}
const uniqueFolders = effectiveFolderSelection.length
? Array.from(new Set(effectiveFolderSelection.filter(Boolean)))
: [folderId];
: [normalizedFolderId];
setDraggedFolderId(folderId);
setDraggedFolderId(normalizedFolderId);
if (effectiveDocumentSelection.length) {
setDraggedDocumentIds(effectiveDocumentSelection);
}
@@ -360,7 +355,7 @@ const useDocumentDragHandlers = ({
}
},
[
selectedFolderIds,
normalizedFolderIds,
selectedEntries,
selectedDocumentIds,
handleEntrySelection,
@@ -2,6 +2,17 @@ import { useCallback } from 'react';
import { isPlainObject } from '../../utils/typeGuards';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { DEFAULT_FOLDER_NAME, getRowId, isDocumentRowKey } from '../../app/appLayoutUtils';
import {
addDocumentTags,
createTag,
deleteDocumentTag,
deleteFolder,
moveDocumentsBulk,
moveDocumentToFolder,
queueDocumentReanalysis,
trashDocument,
updateDocument,
} from '../../lib/apiClient';
type DocumentId = string | number;
type FolderId = DocumentId | 'root';
@@ -35,12 +46,6 @@ type NotifyApiError = (error: unknown, fallbackMessage?: string) => void;
type SetStatusMessage = (message: string, level?: StatusLevel) => void;
interface ApiClient {
post<T = unknown>(url: string, data?: unknown, config?: Record<string, unknown>): Promise<{ data: T }>;
patch<T = unknown>(url: string, data?: unknown, config?: Record<string, unknown>): Promise<{ data: T }>;
delete<T = unknown>(url: string, config?: Record<string, unknown>): Promise<{ data: T }>;
}
interface Tag {
id: DocumentId;
label: string;
@@ -85,7 +90,6 @@ interface DocumentTagExtras {
interface DeleteOptions {
showMessage?: boolean;
manageLoading?: boolean;
}
interface TagAttachArgs {
@@ -101,11 +105,9 @@ interface TagRemoveOptions {
interface FolderDeleteOptions {
showMessage?: boolean;
manageLoading?: boolean;
}
interface UseDocumentMutationsArgs {
api: ApiClient;
token?: string | null;
documentLookup: Map<DocumentId, DocumentLike>;
folderLabelMap: Map<FolderId, string>;
@@ -125,7 +127,6 @@ interface UseDocumentMutationsArgs {
focusedRowKey: string | null;
notifyApiError: NotifyApiError;
setStatusMessage: SetStatusMessage;
setLoading: (next: boolean) => void;
mapDocumentCaches: MapDocumentCaches;
applySelectedFolder: ApplySelectedFolder;
folderNodes: Map<FolderId, FolderNode>;
@@ -181,7 +182,6 @@ const normalizeDocumentId = (value: unknown): DocumentId | null => {
};
const useDocumentMutations = ({
api,
token,
documentLookup,
folderLabelMap,
@@ -201,7 +201,6 @@ const useDocumentMutations = ({
focusedRowKey,
notifyApiError,
setStatusMessage,
setLoading,
mapDocumentCaches,
applySelectedFolder,
folderNodes,
@@ -282,16 +281,11 @@ const useDocumentMutations = ({
const id = getRowId(key);
return id ? !uniqueIdSet.has(id as DocumentId) : true;
});
setLoading(true);
try {
if (uniqueIds.length === 1) {
await api.patch(`/documents/${uniqueIds[0]}/folder`, { folder_id: target });
await moveDocumentToFolder(uniqueIds[0], target);
} else {
await api.post('/documents/bulk/move', {
document_ids: uniqueIds,
folder_id: target,
});
await moveDocumentsBulk(uniqueIds, target);
}
const count = uniqueIds.length;
@@ -377,12 +371,9 @@ const useDocumentMutations = ({
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to move documents.';
notifyApiError(error, message);
} finally {
setLoading(false);
}
},
[
api,
documentLookup,
folderLabelMap,
ensureFolderData,
@@ -400,7 +391,6 @@ const useDocumentMutations = ({
focusedRowKey,
notifyApiError,
setStatusMessage,
setLoading,
mapDocumentCaches,
],
);
@@ -411,25 +401,20 @@ const useDocumentMutations = ({
setStatusMessage('Log in to manage assets.', 'error');
return;
}
setLoading(true);
try {
await api.post(`/documents/${documentId}/assets`, null, {
params: { force: true },
});
await queueDocumentReanalysis(documentId, { force: true });
setStatusMessage('Document re-analysis queued.', 'info');
await refreshCurrentFolder();
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to request thumbnail generation.';
notifyApiError(error, message);
} finally {
setLoading(false);
}
},
[api, token, refreshCurrentFolder, notifyApiError, setStatusMessage, setLoading],
[token, refreshCurrentFolder, notifyApiError, setStatusMessage],
);
const handleDocumentsDelete = useCallback(
async (documentIds: DocumentId[], { showMessage = true, manageLoading = true }: DeleteOptions = {}) => {
async (documentIds: DocumentId[], { showMessage = true }: DeleteOptions = {}) => {
if (!documentIds || documentIds.length === 0) {
return false;
}
@@ -439,12 +424,8 @@ const useDocumentMutations = ({
return false;
}
if (manageLoading) {
setLoading(true);
}
try {
await Promise.all(documentIds.map((documentId) => api.post(`/documents/${documentId}/trash`)));
await Promise.all(documentIds.map((documentId) => trashDocument(documentId)));
removeDocumentsFromCaches(documentIds);
@@ -461,14 +442,9 @@ const useDocumentMutations = ({
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to delete documents.';
notifyApiError(error, message);
return false;
} finally {
if (manageLoading) {
setLoading(false);
}
}
},
[
api,
token,
documentLookup,
removeDocumentsFromCaches,
@@ -476,7 +452,6 @@ const useDocumentMutations = ({
closeDocumentPreview,
notifyApiError,
setStatusMessage,
setLoading,
],
);
@@ -487,10 +462,8 @@ const useDocumentMutations = ({
setStatusMessage('Document title cannot be empty.', 'error');
return false;
}
setLoading(true);
try {
const { data } = await api.patch(`/documents/${documentId}`, { title: trimmed });
const data = await updateDocument(documentId, { title: trimmed });
const updatedDocument = extractDocumentFromResponse?.(data);
if (updatedDocument && ingestDocuments) {
@@ -510,27 +483,21 @@ const useDocumentMutations = ({
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to update document title.';
notifyApiError(error, message);
return false;
} finally {
setLoading(false);
}
},
[
api,
extractDocumentFromResponse,
ingestDocuments,
notifyApiError,
setLoading,
setStatusMessage,
updateDocumentCaches,
],
);
const handleDocumentIssuedUpdate = useCallback(
async (documentId: DocumentId, nextIssuedDate: number | null) => {
setLoading(true);
const payload = { issued_at: nextIssuedDate || null };
async (documentId: DocumentId, nextIssuedDate: number | null) => {const payload = { issued_at: nextIssuedDate || null };
try {
const { data } = await api.patch(`/documents/${documentId}`, payload);
const data = await updateDocument(documentId, payload);
const updatedDocument = extractDocumentFromResponse?.(data);
if (updatedDocument && ingestDocuments) {
@@ -551,16 +518,12 @@ const useDocumentMutations = ({
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to update issued date.';
notifyApiError(error, message);
return false;
} finally {
setLoading(false);
}
},
[
api,
extractDocumentFromResponse,
ingestDocuments,
notifyApiError,
setLoading,
setStatusMessage,
updateDocumentCaches,
],
@@ -585,7 +548,7 @@ const useDocumentMutations = ({
};
try {
await api.post(`/documents/${documentId}/tags`, { tag_ids: [cachedTag.id] });
await addDocumentTags(documentId, [cachedTag.id]);
updateDocumentCaches(documentId, (doc) => {
if (!doc) {
return doc;
@@ -604,7 +567,7 @@ const useDocumentMutations = ({
return false;
}
},
[api, notifyApiError, setStatusMessage, updateDocumentCaches],
[notifyApiError, setStatusMessage, updateDocumentCaches],
);
const handleDocumentTagAdd = useCallback(
@@ -622,8 +585,8 @@ const useDocumentMutations = ({
}
try {
if (!tag) {
const payload = tagManager.buildPayload({ label: normalizedLabel });
const { data } = await api.post('/tags', payload);
const payload = tagManager.buildPayload({ label: normalizedLabel }) as { label: string; color?: string | null };
const data = await createTag(payload);
tag = data as Tag;
await refreshTags();
}
@@ -638,7 +601,7 @@ const useDocumentMutations = ({
notifyApiError(error, 'Failed to assign tag.');
}
},
[api, tags, refreshTags, attachTagToDocument, notifyApiError, tagManager],
[tags, refreshTags, attachTagToDocument, notifyApiError, tagManager],
);
const handleDocumentTagAttach = useCallback(
@@ -707,7 +670,7 @@ const useDocumentMutations = ({
}
try {
await api.delete(`/documents/${documentId}/tags/${tagId}`);
await deleteDocumentTag(documentId, tagId);
applyTagRemovalToCaches(documentId, tagId);
if (refreshTagList) {
await refreshTags();
@@ -722,11 +685,11 @@ const useDocumentMutations = ({
return false;
}
},
[api, applyTagRemovalToCaches, notifyApiError, refreshTags, setStatusMessage],
[applyTagRemovalToCaches, notifyApiError, refreshTags, setStatusMessage],
);
const handleFolderDelete = useCallback(
async (folderId?: FolderId, { showMessage = true, manageLoading = true }: FolderDeleteOptions = {}) => {
async (folderId?: FolderId, { showMessage = true }: FolderDeleteOptions = {}) => {
if (!token) {
if (showMessage) {
setStatusMessage('Log in to manage folders.', 'error');
@@ -740,10 +703,6 @@ const useDocumentMutations = ({
return false;
}
if (manageLoading) {
setLoading(true);
}
try {
const contents = await ensureFolderData(folderId, {
force: true,
@@ -758,7 +717,7 @@ const useDocumentMutations = ({
return false;
}
await api.delete(`/folders/${folderId}`);
await deleteFolder(folderId);
setFolderNodes((prev: Map<FolderId, FolderNode>) => {
const next = new Map<FolderId, FolderNode>(prev);
@@ -809,14 +768,9 @@ const useDocumentMutations = ({
setStatusMessage(message, 'error');
}
return false;
} finally {
if (manageLoading) {
setLoading(false);
}
}
},
[
api,
token,
ensureFolderData,
selectedFolder,
@@ -827,7 +781,6 @@ const useDocumentMutations = ({
setFolderContents,
notifyApiError,
setStatusMessage,
setLoading,
],
);
@@ -24,7 +24,6 @@ interface UseDocumentTaggingArgs {
resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[];
notifyApiError: (error: unknown, message: string) => void;
setStatusMessage: (message: string, variant?: string) => void;
setLoading: (state: boolean) => void;
updateDocumentCaches?: (id: Identifier, updater: (doc: TagRecord | null) => TagRecord | null) => void;
}
@@ -50,7 +49,6 @@ const useDocumentTagging = ({
resolveTargetDocumentIds,
notifyApiError,
setStatusMessage,
setLoading,
updateDocumentCaches,
}: UseDocumentTaggingArgs) => {
const bulkTagOperation = useCallback(
@@ -80,7 +78,6 @@ const useDocumentTagging = ({
}).filter(Boolean);
}
setLoading(true);
try {
if (action === 'add') {
const createdIds: Identifier[] = [];
@@ -175,8 +172,6 @@ const useDocumentTagging = ({
(action === 'add' ? 'Failed to assign tags.' : 'Failed to remove tags.');
notifyApiError(error, message);
return { ok: false, reason: 'request-failed' };
} finally {
setLoading(false);
}
},
[
@@ -184,7 +179,6 @@ const useDocumentTagging = ({
tags,
refreshTags,
notifyApiError,
setLoading,
tagManager,
apiClient,
updateDocumentCaches,
@@ -265,7 +259,6 @@ const useDocumentTagging = ({
return;
}
setLoading(true);
try {
const response = await apiClient.post<{ queued?: number }>(
'/documents/bulk/reanalyze',
@@ -286,11 +279,9 @@ const useDocumentTagging = ({
const message =
error.response?.data?.error || 'Failed to queue document re-analysis.';
notifyApiError(error, message);
} finally {
setLoading(false);
}
},
[resolveTargetDocumentIds, notifyApiError, setStatusMessage, setLoading, apiClient],
[resolveTargetDocumentIds, notifyApiError, setStatusMessage, apiClient],
);
return {
@@ -2,6 +2,7 @@ import { useCallback, useRef, useState } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import useFileDrop from './useFileDrop';
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils';
import { fetchDocument } from '../../lib/apiClient';
type Identifier = string | number;
type FolderId = Identifier | 'root' | null;
@@ -101,7 +102,6 @@ interface UseDocumentUploadsArgs {
currentFolderName?: string | null;
ensureFolderData: (folderId: FolderId, options?: { force?: boolean; prefetchDepth?: number }) => Promise<void>;
refreshCurrentFolder: () => Promise<void>;
setLoading: (state: boolean) => void;
shellRef: MutableRefObject<HTMLElement | null>;
notifyApiError?: NotifyApiError;
setStatusMessage?: SetStatusMessage;
@@ -132,7 +132,6 @@ const useDocumentUploads = ({
currentFolderName,
ensureFolderData,
refreshCurrentFolder,
setLoading,
shellRef,
notifyApiError,
setStatusMessage,
@@ -174,8 +173,7 @@ const useDocumentUploads = ({
let conflictDocument = null;
if (conflictId) {
try {
const { data } = await apiClient.get(`/documents/${conflictId}`);
conflictDocument = (data as any)?.document ?? data ?? null;
conflictDocument = await fetchDocument(conflictId);
} catch (fetchError) {
console.warn('[Uploads] failed to fetch conflict document', fetchError);
}
@@ -395,8 +393,6 @@ const useDocumentUploads = ({
return;
}
setLoading(true);
try {
folderPathCacheRef.current.clear();
@@ -472,8 +468,6 @@ const useDocumentUploads = ({
Object.assign(item, patch);
});
console.error('[Uploads] batch failed', error);
} finally {
setLoading(false);
}
},
[
@@ -483,7 +477,6 @@ const useDocumentUploads = ({
refreshCurrentFolder,
selectedFolder,
ensureFolderData,
setLoading,
appendQueueItems,
updateQueueItem,
],
@@ -16,9 +16,10 @@ import {
import AssetManager, { getAssetFromVersion } from '../../asset_manager';
import useApiError from '../useApiError';
import TagManager from '../../tag_manager';
import usePasskeys from '../../settings/usePasskeys';
import { useManagementModals } from '../../app/useManagementModals';
import { api, useAppDispatch, useAppState } from '../../app/appState';
import { useAppDispatch, useAppState } from '../../app/appState';
import { fetchAsset } from '../../lib/apiClient';
import { useApi } from '../../app/ApiContext';
import useWorkspaceSelection from '../../app/useWorkspaceSelection';
import { useEntryPointer as useEntryPointerCore } from '../../documents/useEntryPointer';
import { isTagTransferEvent } from '../../documents/tagTransfer';
@@ -29,7 +30,6 @@ import useDocumentPreview from '../../app/useDocumentPreview';
import useSidebarProps from '../../sidebar/useSidebarProps';
import {
ASSET_PRESIGN_TTL_MS,
DEFAULT_FOLDER_NAME,
DEFAULT_SORT_DIRECTION,
DEFAULT_SORT_FIELD,
createRootNode,
@@ -44,18 +44,20 @@ import {
import useDocumentsSearch from '../../app/useDocumentsSearch';
import useDocumentsStore from './store/useDocumentsStore';
import useAuthManager from './useAuthManager';
import useTags from './useTags';
import useCorrespondents from './useCorrespondents';
import useTenantManager from './useTenantManager';
import useDocuments from './useDocuments';
import { fetchDocument } from '../../lib/apiClient';
import useFolderTree from './useFolderTree';
import useFolderTreeActions from './useFolderTreeActions';
import useDocumentTagging from './useDocumentTagging';
import useDocumentCorrespondentActions from './useDocumentCorrespondentActions';
import useDocumentUploads from './useDocumentUploads';
import useDocumentDragHandlers from './useDocumentDragHandlers';
import useDocumentMutations from './useDocumentMutations';
import useDetailWorkspace from '../../detail/useDetailWorkspace';
import useWorkspaceTaxonomies from './useWorkspaceTaxonomies';
import useWorkspaceBreadcrumbs from './useWorkspaceBreadcrumbs';
import useWorkspaceDeskProps from './useWorkspaceDeskProps';
import useWorkspaceSelectionSync from './useWorkspaceSelectionSync';
const EntryType = Object.freeze({
document: 'document',
@@ -152,6 +154,7 @@ const useDocumentsWorkspace = ({
tenant,
tenants: tenantOptionsRaw = [],
} = appState;
const { client: apiClient } = useApi();
const tenantRecord = (tenant ?? null) as TenantOption | null;
const tenantNameCandidate = tenantRecord?.name ?? tenantRecord?.slug ?? null;
@@ -175,16 +178,12 @@ const useDocumentsWorkspace = ({
reportApiError(error, { message: fallbackMessage, variant }),
[reportApiError],
);
const [loading, setLoading] = useState(false);
const [creatingFolder, setCreatingFolder] = useState(false);
const { tokenRef, handleLogout } = useAuthManager({
apiClient: api,
token,
appStatus,
appDispatch,
notifyApiError,
setStatusMessage,
setLoading,
});
const breadcrumbFetchRef = useRef(new Set());
@@ -219,7 +218,11 @@ const useDocumentsWorkspace = ({
const shellRef = useRef(null);
const assetManagerRef = useRef(null);
if (!assetManagerRef.current) {
assetManagerRef.current = new AssetManager({ api, assetPresignTtlMs: ASSET_PRESIGN_TTL_MS });
const fetcher = async (id: Identifier) => {
const asset = await fetchAsset(id);
return (asset as unknown) as any;
};
assetManagerRef.current = new AssetManager({ fetchAsset: fetcher, assetPresignTtlMs: ASSET_PRESIGN_TTL_MS });
}
const assetManager = assetManagerRef.current;
@@ -238,7 +241,7 @@ const useDocumentsWorkspace = ({
if (!documentId) {
return null;
}
const { data } = await api.get(`/documents/${documentId}`);
const data = await fetchDocument(documentId);
return extractDocumentFromResponse(data);
},
[extractDocumentFromResponse],
@@ -345,7 +348,7 @@ const useDocumentsWorkspace = ({
isInvalidFolderDrop,
} = useFolderTree({
initialSelectedFolder: routeFolderId || 'root',
apiClient: api,
apiClient,
tenantIdRef,
documentsSortFieldRef: activeSortFieldRef,
documentsSortDirectionRef: activeSortDirectionRef,
@@ -368,7 +371,7 @@ const useDocumentsWorkspace = ({
isFilterActive,
documentsFilterValue,
} = useDocumentsSearch({
api,
api: apiClient,
token,
selectedFolder,
navigate,
@@ -378,7 +381,6 @@ const useDocumentsWorkspace = ({
documentsSortField,
documentsSortDirection,
notifyApiError,
setLoading,
setSearchIncludeDescendants,
documentsManager,
});
@@ -449,8 +451,6 @@ const useDocumentsWorkspace = ({
routeDocumentId: previewDocumentId,
documentsManager,
selectedFolder,
api,
resolveApiPath,
notifyApiError,
navigate,
locationPathname: location.pathname,
@@ -478,17 +478,7 @@ const useDocumentsWorkspace = ({
const bootstrapInitializedRef = useRef(false);
const detailFolderFetchRef = useRef(new Set());
useEffect(() => {
if (!showingSearchResults) {
return;
}
setSelectedEntries([]);
setSelectionOrder([]);
selectionOrderRef.current = [];
selectionAnchorRef.current = null;
setFocusedDocumentId(null);
}, [
useWorkspaceSelectionSync({
showingSearchResults,
searchQuery,
setSelectedEntries,
@@ -496,7 +486,11 @@ const useDocumentsWorkspace = ({
selectionOrderRef,
selectionAnchorRef,
setFocusedDocumentId,
]);
selectedDocumentIds,
activePreviewId,
setActivePreviewId,
selectionInitializedRef,
});
const {
tags,
@@ -505,59 +499,17 @@ const useDocumentsWorkspace = ({
handleTagUpdate,
handleTagDelete,
setTags,
} = useTags({
apiClient: api,
notifyApiError,
setStatusMessage,
tagManager,
tenantIdRef,
setActiveTagFilters,
mapDocumentCaches,
});
useEffect(() => {
tenantIdRef.current = currentTenantId;
}, [currentTenantId]);
useEffect(() => {
if (!selectedDocumentIds.length) {
return;
}
if (!selectedDocumentIds.includes(activePreviewId)) {
setActivePreviewId(selectedDocumentIds[selectedDocumentIds.length - 1]);
}
selectionInitializedRef.current = true;
}, [selectedDocumentIds, activePreviewId, selectionInitializedRef]);
const tagLookupById = useMemo(() => {
const map = new Map();
tags.forEach((tag) => {
if (tag?.id) {
map.set(tag.id, tag);
}
});
return map;
}, [tags]);
const {
tagLookupById,
correspondents,
refreshCorrespondents,
handleCorrespondentCreate,
handleCorrespondentUpdate,
handleCorrespondentDelete,
setCorrespondents,
} = useCorrespondents({
apiClient: api,
notifyApiError,
setStatusMessage,
tenantIdRef,
mapDocumentCaches,
});
const {
correspondentLookupByName,
handleDocumentCorrespondentAttach,
handleCorrespondentRemove,
handleCorrespondentAdd,
passkeys,
passkeysSupported,
passkeysLoading,
@@ -566,10 +518,16 @@ const useDocumentsWorkspace = ({
refreshPasskeys,
registerPasskey,
revokePasskey,
} = usePasskeys({
api,
} = useWorkspaceTaxonomies({
apiClient,
notifyApiError,
setStatusMessage,
tagManager,
tenantIdRef,
currentTenantId,
setActiveTagFilters,
mapDocumentCaches,
updateDocumentCaches,
token,
});
@@ -587,33 +545,25 @@ const useDocumentsWorkspace = ({
);
const refreshCurrentFolder = useCallback(async () => {
setLoading(true);
try {
const contents = await ensureFolderData(selectedFolder, {
force: true,
prefetchDepth: 1,
});
applySelectedFolder(selectedFolder, contents);
} catch (error) {
notifyApiError(error, 'Failed to refresh folder.');
} finally {
setLoading(false);
}
}, [selectedFolder, ensureFolderData, applySelectedFolder, notifyApiError]);
const contents = await ensureFolderData(selectedFolder, {
force: true,
prefetchDepth: 1,
});
applySelectedFolder(selectedFolder, contents);
}, [selectedFolder, ensureFolderData, applySelectedFolder]);
const {
handleBulkTagAddFromDetail,
handleBulkTagRemoveFromDetail,
handleBulkSelectionReanalyze,
} = useDocumentTagging({
apiClient: api,
apiClient,
tags,
tagManager,
refreshTags,
resolveTargetDocumentIds,
notifyApiError,
setStatusMessage,
setLoading,
updateDocumentCaches,
});
@@ -625,7 +575,7 @@ const useDocumentsWorkspace = ({
clearUploadQueue,
resetUploadsState,
} = useDocumentUploads({
apiClient: api,
apiClient,
token,
selectedFolder,
currentFolderName,
@@ -633,7 +583,6 @@ const useDocumentsWorkspace = ({
refreshCurrentFolder,
notifyApiError,
setStatusMessage,
setLoading,
shellRef,
});
@@ -656,20 +605,6 @@ const useDocumentsWorkspace = ({
documentsViewMode,
});
const {
correspondentLookupByName,
handleDocumentCorrespondentAttach,
handleCorrespondentRemove,
handleCorrespondentAdd,
} = useDocumentCorrespondentActions({
apiClient: api,
correspondents,
handleCorrespondentCreate,
notifyApiError,
setStatusMessage,
updateDocumentCaches,
});
useEffect(() => {
if (!activeSortRefreshReadyRef.current) {
activeSortRefreshReadyRef.current = true;
@@ -816,7 +751,6 @@ const useDocumentsWorkspace = ({
handleDocumentIssuedUpdate,
handleTagRemove,
} = useDocumentMutations({
api,
token,
documentLookup,
folderLabelMap,
@@ -836,7 +770,6 @@ const useDocumentsWorkspace = ({
focusedRowKey,
notifyApiError,
setStatusMessage,
setLoading,
mapDocumentCaches,
applySelectedFolder,
folderNodes,
@@ -862,7 +795,6 @@ const useDocumentsWorkspace = ({
handleFolderDelete,
folderClickHandlers,
} = useFolderTreeActions({
api,
token,
folderNodes,
setFolderNodes,
@@ -874,7 +806,6 @@ const useDocumentsWorkspace = ({
applySelectedFolder,
notifyApiError,
setStatusMessage,
setLoading,
setFolderContents,
setCurrentFolder,
setSearchResultIds,
@@ -913,18 +844,10 @@ const useDocumentsWorkspace = ({
isFolderRowKey,
});
const initializeAfterLogin = useCallback(async () => {
setLoading(true);
try {
await Promise.all([refreshTags(), refreshCorrespondents()]);
const initialFolder = routeFolderId && routeFolderId !== 'root' ? routeFolderId : 'root';
await loadFolder(initialFolder, { showLoading: false });
} catch (error) {
notifyApiError(error, 'Failed to initialize data.');
throw error;
} finally {
setLoading(false);
}
}, [refreshTags, refreshCorrespondents, routeFolderId, loadFolder, notifyApiError]);
await Promise.all([refreshTags(), refreshCorrespondents()]);
const initialFolder = routeFolderId && routeFolderId !== 'root' ? routeFolderId : 'root';
await loadFolder(initialFolder, {} );
}, [refreshTags, refreshCorrespondents, routeFolderId, loadFolder]);
useEffect(() => {
if (!token) {
@@ -997,7 +920,6 @@ const useDocumentsWorkspace = ({
handleBulkCorrespondentRemove,
handleDeleteSelection,
} = useBulkDocumentActions({
api,
resolveTargetDocumentIds,
correspondentLookupByName,
handleCorrespondentCreate,
@@ -1007,7 +929,6 @@ const useDocumentsWorkspace = ({
handleDocumentsDelete,
handleFolderDelete,
clearDocumentSelection,
setLoading,
updateDocumentCaches,
});
@@ -1233,7 +1154,6 @@ const useDocumentsWorkspace = ({
inspectDocument,
previewActive,
previewWorkspaceDocument,
documentLink,
resolveFolderPath,
} = useDetailWorkspace({
documents: viewDocuments,
@@ -1244,7 +1164,6 @@ const useDocumentsWorkspace = ({
ensureFolderData,
detailPanelControlRef,
detailFolderFetchRef,
documentLinks,
previewDocumentId,
activePreviewId,
openDocumentPreview: openDocumentPreviewForDetail,
@@ -1295,94 +1214,21 @@ const useDocumentsWorkspace = ({
},
});
const { breadcrumbs, missingBreadcrumbAncestors } = useMemo(() => {
const chain = [];
const seen = new Set();
const pending = new Set();
let currentId = selectedFolder || 'root';
let guard = 0;
while (currentId && !seen.has(currentId) && guard < 32) {
guard += 1;
seen.add(currentId);
if (currentId === 'root') {
chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
currentId = null;
break;
}
const node = folderNodes.get(currentId);
if (node) {
chain.push({ id: currentId, name: node.name || 'Folder' });
currentId = node.parentId ?? 'root';
continue;
}
let fallbackName = '…';
let parentId = null;
if (currentFolder && currentFolder.id === currentId) {
fallbackName = currentFolder.name;
parentId = currentFolder.parent_id ?? 'root';
}
chain.push({ id: currentId, name: fallbackName });
pending.add(currentId);
currentId = parentId;
}
if (!chain.some((crumb) => crumb.id === 'root')) {
chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
}
const ordered = [];
const seenOrdered = new Set();
chain
.slice()
.reverse()
.forEach((crumb) => {
if (!seenOrdered.has(crumb.id)) {
seenOrdered.add(crumb.id);
ordered.push(crumb);
}
});
return { breadcrumbs: ordered, missingBreadcrumbAncestors: Array.from(pending) };
}, [selectedFolder, folderNodes, currentFolder]);
useEffect(() => {
if (!missingBreadcrumbAncestors.length) {
return;
}
missingBreadcrumbAncestors.forEach((folderId) => {
if (!folderId || folderId === 'root') {
return;
}
if (breadcrumbFetchRef.current.has(folderId)) {
return;
}
breadcrumbFetchRef.current.add(folderId);
ensureFolderData(folderId, { force: false })
.catch((error) => {
console.warn('Failed to preload breadcrumb ancestor', folderId, error);
})
.finally(() => {
breadcrumbFetchRef.current.delete(folderId);
});
});
}, [missingBreadcrumbAncestors, ensureFolderData]);
const breadcrumbs = useWorkspaceBreadcrumbs({
selectedFolder,
folderNodes,
currentFolder,
breadcrumbFetchRef,
ensureFolderData,
});
const { handleTenantSelect } = useTenantManager({
apiClient: api,
apiClient,
appDispatch,
currentTenantId,
resetWorkspaceState,
setStatusMessage,
notifyApiError,
setLoading,
refreshTags,
refreshCorrespondents,
loadFolder,
@@ -1393,93 +1239,27 @@ const useDocumentsWorkspace = ({
});
const handleDeskDocumentStackSelect = useCallback(
(docIds: Array<Identifier | string>) => {
if (!Array.isArray(docIds) || docIds.length === 0) {
return;
}
const rowKeys = docIds
.map((id) => resolveDocumentRowKey(id as Identifier))
.filter((value): value is string => typeof value === 'string');
if (!rowKeys.length) {
return;
}
const nextKeys = [...selectedEntries];
rowKeys.forEach((key) => {
if (!nextKeys.includes(key)) {
nextKeys.push(key);
}
});
const anchor = (rowKeys[0]
|| selectionAnchorRef.current
|| nextKeys[nextKeys.length - 1]) as Identifier | string | null;
applySelection(nextKeys, {
anchor,
interactedKeys: rowKeys,
});
},
[applySelection, selectedEntries, selectionAnchorRef],
);
const deskViewId = useMemo(() => {
if (showingSearchResults) {
const trimmedQuery = searchQuery.trim();
const tagsKey = [...activeTagFilters].sort().join(',');
const correspondentsKey = [...activeCorrespondentFilters].sort().join(',');
return `search:${trimmedQuery}|tags:${tagsKey}|corr:${correspondentsKey}`;
}
const folderKey = selectedFolder && selectedFolder !== '' ? selectedFolder : 'root';
return `folder:${folderKey}`;
}, [
const deskWorkspaceProps = useWorkspaceDeskProps({
viewDocuments,
inspectDocumentForDesk,
handleEntryPointer: handleEntryPointerCore,
selectedEntries,
selectionAnchorRef,
applySelection,
resolveDocumentRowKey,
showingSearchResults,
searchQuery,
activeTagFilters,
activeCorrespondentFilters,
selectedFolder,
]);
const deskWorkspaceProps = useMemo(
() => ({
documents: viewDocuments,
onInspectDocument: inspectDocumentForDesk,
onEntryPointer: handleEntryPointerCore,
onDocumentStackSelect: handleDeskDocumentStackSelect,
onPromoteSelection: promoteSelectionOrder,
onAssignTagToDocument: handleDocumentTagDrop,
ensureAssetUrl,
getDocumentAsset,
activeTagIds: activeTagFilters,
selectedDocumentIds,
onClearSelection: clearDocumentSelection,
tenantId: currentTenantId,
viewId: deskViewId,
documentLinks,
ensureDownloadUrl,
}),
[
viewDocuments,
inspectDocumentForDesk,
handleEntryPointerCore,
handleDeskDocumentStackSelect,
promoteSelectionOrder,
handleDocumentTagDrop,
ensureAssetUrl,
getDocumentAsset,
activeTagFilters,
selectedDocumentIds,
clearDocumentSelection,
currentTenantId,
deskViewId,
documentLinks,
ensureDownloadUrl,
],
);
promoteSelectionOrder,
handleDocumentTagDrop,
ensureAssetUrl,
getDocumentAsset,
currentTenantId,
documentLinks,
ensureDownloadUrl,
});
const documentsPanelProps = useDocumentsPanelProps({
currentFolderName,
@@ -1513,7 +1293,7 @@ const useDocumentsWorkspace = ({
clearDocumentSelection,
handleDeleteSelection,
handleEntryPointerCore,
inspectDocument,
onDocumentActivate: inspectDocument,
tags,
correspondents,
documentLookup,
@@ -1554,7 +1334,6 @@ const useDocumentsWorkspace = ({
correspondents,
handleCorrespondentCreate,
appStatus,
loading,
previewActive,
handleLogout,
status,
@@ -1599,7 +1378,6 @@ const useDocumentsWorkspace = ({
revokePasskey,
previewActive,
previewWorkspaceDocument,
documentLink,
previewDocumentId,
closeDocumentPreview,
handleThumbnailRegeneration,
@@ -1650,7 +1428,6 @@ const useDocumentsWorkspace = ({
revokePasskey,
previewActive,
previewWorkspaceDocument,
documentLink,
previewDocumentId,
closeDocumentPreview,
handleThumbnailRegeneration,
@@ -1,6 +1,12 @@
import { useCallback, useMemo } from 'react';
import type { DragEvent } from 'react';
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils';
import {
createFolder,
deleteFolder,
moveFolder as moveFolderRequest,
renameFolder as renameFolderRequest,
} from '../../lib/apiClient';
type FolderId = string | number;
type FolderKey = FolderId | 'root';
@@ -22,12 +28,6 @@ interface FolderContentsState {
[key: string]: unknown;
}
interface ApiClient {
patch: (url: string, data?: unknown) => Promise<any>;
post: (url: string, data?: unknown) => Promise<{ data: any }>;
delete: (url: string) => Promise<any>;
}
interface EnsureFolderOptions {
force?: boolean;
includeDocuments?: boolean;
@@ -35,7 +35,6 @@ interface EnsureFolderOptions {
}
interface LoadFolderOptions {
showLoading?: boolean;
preserveSearch?: boolean;
}
@@ -53,7 +52,6 @@ interface FolderClickHandlers {
}
interface UseFolderTreeActionsOptions {
api: ApiClient;
token?: string | null;
folderNodes: Map<FolderKey, FolderNode>;
setFolderNodes: (updater: (prev: Map<FolderKey, FolderNode>) => Map<FolderKey, FolderNode>) => void;
@@ -65,7 +63,6 @@ interface UseFolderTreeActionsOptions {
applySelectedFolder: (folderId: FolderKey, contents: any) => void;
notifyApiError: (error: unknown, message?: string) => void;
setStatusMessage: (message: string, level?: string) => void;
setLoading: (value: boolean) => void;
setFolderContents: (
updater: (prev: Map<FolderKey, FolderContentsState>) => Map<FolderKey, FolderContentsState>,
) => void;
@@ -84,7 +81,6 @@ interface UseFolderTreeActionsOptions {
}
const useFolderTreeActions = ({
api,
token,
folderNodes,
setFolderNodes,
@@ -96,7 +92,6 @@ const useFolderTreeActions = ({
applySelectedFolder,
notifyApiError,
setStatusMessage,
setLoading,
setFolderContents,
setCurrentFolder,
setSearchResultIds,
@@ -129,7 +124,7 @@ const useFolderTreeActions = ({
const parent_id = targetKey === 'root' ? null : targetKey;
try {
await api.patch(`/folders/${folderId}`, { parent_id });
await moveFolderRequest(folderId, parent_id);
setFolderNodes((prev) => {
const next = new Map(prev);
@@ -202,7 +197,6 @@ const useFolderTreeActions = ({
}
},
[
api,
ensureFolderData,
folderNodes,
notifyApiError,
@@ -214,12 +208,11 @@ const useFolderTreeActions = ({
);
const loadFolder = useCallback(
async (folderId: FolderKey | null, { showLoading = true, preserveSearch = false }: LoadFolderOptions = {}) => {
async (folderId: FolderKey | null, { preserveSearch = false }: LoadFolderOptions = {}) => {
const targetId = folderId || 'root';
setSelectedFolder(targetId);
await ensureFolderAncestorsLoaded(targetId);
expandFolderAncestors(targetId);
if (showLoading) setLoading(true);
try {
const contents = await ensureFolderData(targetId, { force: true, prefetchDepth: 1 });
if (targetId !== 'root') {
@@ -239,8 +232,6 @@ const useFolderTreeActions = ({
}
} catch (error) {
notifyApiError(error, 'Failed to load folder contents.');
} finally {
if (showLoading) setLoading(false);
}
},
[
@@ -249,7 +240,6 @@ const useFolderTreeActions = ({
ensureFolderData,
expandFolderAncestors,
notifyApiError,
setLoading,
setSearchResultIds,
setSelectedFolder,
],
@@ -292,10 +282,8 @@ const useFolderTreeActions = ({
setStatusMessage('Folder name cannot be empty.', 'error');
return false;
}
setLoading(true);
try {
await api.patch(`/folders/${folderId}`, { name: trimmed });
await renameFolderRequest(folderId, trimmed);
setFolderNodes((prev) => {
const next = new Map(prev);
@@ -326,17 +314,13 @@ const useFolderTreeActions = ({
const message = error.response?.data?.error || 'Failed to rename folder.';
notifyApiError(error, message);
return false;
} finally {
setLoading(false);
}
},
[
api,
notifyApiError,
setCurrentFolder,
setFolderContents,
setFolderNodes,
setLoading,
setStatusMessage,
token,
],
@@ -359,33 +343,37 @@ const useFolderTreeActions = ({
setCreatingFolder(true);
let succeeded = false;
try {
const { data } = await api.post('/folders', payload);
const data = await createFolder(payload);
const folderData = (data as { folder?: { id?: FolderKey; name?: string; parent_id?: FolderKey | null; children?: FolderKey[] } }).folder;
if (!folderData?.id) {
throw new Error('Folder creation failed.');
}
setStatusMessage('Folder created.', 'success');
setFolderNodes((prev) => {
const next = new Map(prev);
const parentId = payload.parent_id || 'root';
const parentId = folderData.parent_id ?? payload.parent_id ?? 'root';
const parentNode = next.get(parentId);
if (parentNode) {
next.set(parentId, {
...parentNode,
children: parentNode.children.concat([data.folder.id]),
children: parentNode.children.concat([folderData.id]),
loaded: true,
hasChildren: true,
});
}
next.set(data.folder.id, {
id: data.folder.id,
name: data.folder.name,
next.set(folderData.id, {
id: folderData.id,
name: folderData.name ?? payload.name,
parentId: parentId,
children: [],
children: folderData.children || [],
expanded: false,
loaded: false,
hasChildren: false,
hasChildren: Array.isArray(folderData.children) ? folderData.children.length > 0 : false,
});
return next;
});
await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 });
await selectFolder(data.folder.id, { immediate: true });
await selectFolder(folderData.id, { immediate: true });
succeeded = true;
return true;
} catch (error) {
@@ -400,7 +388,6 @@ const useFolderTreeActions = ({
}
},
[
api,
ensureFolderData,
notifyApiError,
selectFolder,
@@ -413,7 +400,7 @@ const useFolderTreeActions = ({
);
const handleFolderDelete = useCallback(
async (folderId: FolderKey, { showMessage = true, manageLoading = true }: { showMessage?: boolean; manageLoading?: boolean } = {}) => {
async (folderId: FolderKey, { showMessage = true }: { showMessage?: boolean } = {}) => {
if (!token) {
if (showMessage) {
setStatusMessage('Log in to manage folders.', 'error');
@@ -427,10 +414,6 @@ const useFolderTreeActions = ({
return false;
}
if (manageLoading) {
setLoading(true);
}
try {
const contents = await ensureFolderData(folderId, {
force: true,
@@ -445,7 +428,7 @@ const useFolderTreeActions = ({
return false;
}
await api.delete(`/folders/${folderId}`);
await deleteFolder(folderId);
setFolderNodes((prev) => {
const next = new Map(prev);
@@ -496,14 +479,9 @@ const useFolderTreeActions = ({
setStatusMessage(message, 'error');
}
return false;
} finally {
if (manageLoading) {
setLoading(false);
}
}
},
[
api,
token,
applySelectedFolder,
ensureFolderData,
@@ -512,7 +490,6 @@ const useFolderTreeActions = ({
selectedFolder,
setFolderContents,
setFolderNodes,
setLoading,
setSelectedFolder,
setStatusMessage,
],
@@ -19,10 +19,9 @@ interface UseTenantManagerOptions {
resetWorkspaceState: () => void;
setStatusMessage: (message: string, variant?: string) => void;
notifyApiError: (error: unknown, message: string) => void;
setLoading: (state: boolean) => void;
refreshTags: () => Promise<void>;
refreshCorrespondents: () => Promise<void>;
loadFolder: (folderId: string, options?: { showLoading?: boolean; preserveSearch?: boolean }) => Promise<void>;
loadFolder: (folderId: string, options?: { preserveSearch?: boolean }) => Promise<void>;
handleDocumentsViewModeChange: (mode: string) => void;
navigate: NavigateFunction;
tokenRef?: MutableRefObject<string | null>;
@@ -36,7 +35,6 @@ const useTenantManager = ({
resetWorkspaceState,
setStatusMessage,
notifyApiError,
setLoading,
refreshTags,
refreshCorrespondents,
loadFolder,
@@ -52,7 +50,6 @@ const useTenantManager = ({
return;
}
setLoading(true);
try {
if (!refreshOnly) {
setStatusMessage('Switching tenant…', 'info');
@@ -99,14 +96,12 @@ const useTenantManager = ({
navigate('/documents', { replace: true });
await Promise.all([refreshTags(), refreshCorrespondents()]);
await loadFolder('root', { showLoading: false, preserveSearch: false });
await loadFolder('root', { preserveSearch: false });
const tenantLabel = data?.tenant?.name || data?.tenant?.id || 'tenant';
setStatusMessage(`Switched to ${tenantLabel}.`, 'info');
} catch (error) {
notifyApiError(error, 'Failed to switch tenant.');
} finally {
setLoading(false);
}
},
[
@@ -120,7 +115,6 @@ const useTenantManager = ({
refreshCorrespondents,
refreshTags,
resetWorkspaceState,
setLoading,
setStatusMessage,
tenantIdRef,
tokenRef,
@@ -0,0 +1,105 @@
import React, { useEffect, useMemo } from 'react';
import { DEFAULT_FOLDER_NAME } from '../../app/appLayoutUtils';
type Identifier = string | number;
type FolderId = Identifier | 'root';
interface UseWorkspaceBreadcrumbsArgs {
selectedFolder: FolderId | null;
folderNodes: Map<FolderId, { id?: FolderId; name?: string | null; parentId?: FolderId | null; parent_id?: FolderId | null }>;
currentFolder: { id?: FolderId; name?: string | null; parentId?: FolderId | null; parent_id?: FolderId | null } | null;
breadcrumbFetchRef: React.MutableRefObject<Set<FolderId>>;
ensureFolderData: (folderId: FolderId, options?: Record<string, unknown>) => Promise<unknown>;
}
const useWorkspaceBreadcrumbs = ({
selectedFolder,
folderNodes,
currentFolder,
breadcrumbFetchRef,
ensureFolderData,
}: UseWorkspaceBreadcrumbsArgs) => {
const { breadcrumbs, missingBreadcrumbAncestors } = useMemo(() => {
const chain: Array<{ id: FolderId; name?: string | null }> = [];
const seen = new Set<FolderId>();
const pending = new Set<FolderId>();
let currentId: FolderId | null = (selectedFolder || 'root') as FolderId;
let guard = 0;
while (currentId && !seen.has(currentId) && guard < 32) {
guard += 1;
seen.add(currentId);
if (currentId === 'root') {
chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
currentId = null;
break;
}
const node = folderNodes.get(currentId as FolderId);
if (node) {
chain.push({ id: currentId, name: node.name || 'Folder' });
currentId = (node.parentId ?? node.parent_id ?? 'root') as FolderId;
continue;
}
let fallbackName: string | null | undefined = '…';
let parentId: FolderId | null | undefined = null;
if (currentFolder && currentFolder.id === currentId) {
fallbackName = currentFolder.name;
parentId = (currentFolder.parent_id ?? currentFolder.parentId ?? 'root') as FolderId;
}
chain.push({ id: currentId, name: fallbackName });
pending.add(currentId);
currentId = parentId as FolderId | null;
}
if (!chain.some((crumb) => crumb.id === 'root')) {
chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
}
const ordered: Array<{ id: FolderId; name?: string | null }> = [];
const seenOrdered = new Set<FolderId>();
chain
.slice()
.reverse()
.forEach((crumb) => {
if (!seenOrdered.has(crumb.id)) {
seenOrdered.add(crumb.id);
ordered.push(crumb);
}
});
return { breadcrumbs: ordered, missingBreadcrumbAncestors: Array.from(pending) };
}, [selectedFolder, folderNodes, currentFolder]);
useEffect(() => {
if (!missingBreadcrumbAncestors.length) {
return;
}
missingBreadcrumbAncestors.forEach((folderId) => {
if (!folderId || folderId === 'root') {
return;
}
if (breadcrumbFetchRef.current.has(folderId)) {
return;
}
breadcrumbFetchRef.current.add(folderId);
ensureFolderData(folderId, { force: false })
.catch((error) => {
console.warn('Failed to preload breadcrumb ancestor', folderId, error);
})
.finally(() => {
breadcrumbFetchRef.current.delete(folderId);
});
});
}, [missingBreadcrumbAncestors, ensureFolderData, breadcrumbFetchRef]);
return breadcrumbs;
};
export default useWorkspaceBreadcrumbs;
@@ -0,0 +1,136 @@
import { useCallback, useMemo } from 'react';
import type { MutableRefObject } from 'react';
type Identifier = string | number;
interface UseWorkspaceDeskPropsArgs {
viewDocuments: any[];
inspectDocumentForDesk: (doc: any) => void;
handleEntryPointer: (params: { rowKey?: string | null; id?: Identifier | null; type?: string; event?: any }) => void;
selectedEntries: Array<string | number>;
selectionAnchorRef: MutableRefObject<Identifier | string | null>;
applySelection: (rowKeys: Array<string | number>, options?: { anchor?: Identifier | string | null; interactedKeys?: Array<string | number> }) => void;
resolveDocumentRowKey: (id?: Identifier | null) => string | null;
showingSearchResults: boolean;
searchQuery: string;
activeTagFilters: Array<string | number>;
activeCorrespondentFilters: Array<string | number>;
selectedFolder: Identifier | 'root' | null;
promoteSelectionOrder: () => void;
handleDocumentTagDrop: (docId: Identifier, tagId: Identifier) => Promise<void> | void;
ensureAssetUrl: (docId: Identifier, asset: any, options?: Record<string, unknown>) => Promise<any> | null;
getDocumentAsset: (doc: any, type: string) => any;
currentTenantId: Identifier | null;
documentLinks: Map<Identifier, unknown> | null;
ensureDownloadUrl?: (docId: Identifier, options?: { force?: boolean }) => Promise<unknown>;
}
const useWorkspaceDeskProps = ({
viewDocuments,
inspectDocumentForDesk,
handleEntryPointer,
selectedEntries,
selectionAnchorRef,
applySelection,
resolveDocumentRowKey,
showingSearchResults,
searchQuery,
activeTagFilters,
activeCorrespondentFilters,
selectedFolder,
promoteSelectionOrder,
handleDocumentTagDrop,
ensureAssetUrl,
getDocumentAsset,
currentTenantId,
documentLinks,
ensureDownloadUrl,
}: UseWorkspaceDeskPropsArgs) => {
const handleDeskDocumentStackSelect = useCallback(
(docIds: Array<Identifier | string>) => {
if (!Array.isArray(docIds) || docIds.length === 0) {
return;
}
const rowKeys = docIds
.map((id) => resolveDocumentRowKey(id as Identifier))
.filter((value): value is string => typeof value === 'string');
if (!rowKeys.length) {
return;
}
const nextKeys = [...selectedEntries];
rowKeys.forEach((key) => {
if (!nextKeys.includes(key)) {
nextKeys.push(key);
}
});
const anchor = (rowKeys[0]
|| selectionAnchorRef.current
|| nextKeys[nextKeys.length - 1]) as Identifier | string | null;
applySelection(nextKeys, {
anchor,
interactedKeys: rowKeys,
});
},
[applySelection, resolveDocumentRowKey, selectedEntries, selectionAnchorRef],
);
const deskViewId = useMemo(() => {
if (showingSearchResults) {
const trimmedQuery = searchQuery.trim();
const tagsKey = [...activeTagFilters].sort().join(',');
const correspondentsKey = [...activeCorrespondentFilters].sort().join(',');
return `search:${trimmedQuery}|tags:${tagsKey}|corr:${correspondentsKey}`;
}
const folderKey = selectedFolder && selectedFolder !== '' ? selectedFolder : 'root';
return `folder:${folderKey}`;
}, [
showingSearchResults,
searchQuery,
activeTagFilters,
activeCorrespondentFilters,
selectedFolder,
]);
const deskWorkspaceProps = useMemo(
() => ({
entries: viewDocuments,
onDocumentActivate: inspectDocumentForDesk,
onDocumentClick: handleEntryPointer,
onDocumentStackSelect: handleDeskDocumentStackSelect,
onPromoteSelection: promoteSelectionOrder,
onDocumentTagDrop: handleDocumentTagDrop,
ensureAssetUrl,
getDocumentAsset,
activeTagFilters,
tenantId: currentTenantId,
viewId: deskViewId,
documentLinks,
ensureDownloadUrl,
}),
[
viewDocuments,
inspectDocumentForDesk,
handleEntryPointer,
handleDeskDocumentStackSelect,
promoteSelectionOrder,
handleDocumentTagDrop,
ensureAssetUrl,
getDocumentAsset,
activeTagFilters,
currentTenantId,
deskViewId,
documentLinks,
ensureDownloadUrl,
],
);
return deskWorkspaceProps;
};
export default useWorkspaceDeskProps;
@@ -0,0 +1,63 @@
import { useEffect } from 'react';
import type { MutableRefObject } from 'react';
type Identifier = string | number;
interface UseWorkspaceSelectionSyncArgs {
showingSearchResults: boolean;
searchQuery: string;
setSelectedEntries: (entries: Array<string | number>) => void;
setSelectionOrder: (order: Array<string | number>) => void;
selectionOrderRef: MutableRefObject<Array<string | number>>;
selectionAnchorRef: MutableRefObject<Identifier | string | null>;
setFocusedDocumentId: (id: Identifier | null) => void;
selectedDocumentIds: Identifier[];
activePreviewId: Identifier | null;
setActivePreviewId: (id: Identifier | null) => void;
selectionInitializedRef: MutableRefObject<boolean>;
}
const useWorkspaceSelectionSync = ({
showingSearchResults,
searchQuery,
setSelectedEntries,
setSelectionOrder,
selectionOrderRef,
selectionAnchorRef,
setFocusedDocumentId,
selectedDocumentIds,
activePreviewId,
setActivePreviewId,
selectionInitializedRef,
}: UseWorkspaceSelectionSyncArgs) => {
useEffect(() => {
if (!showingSearchResults) {
return;
}
setSelectedEntries([]);
setSelectionOrder([]);
selectionOrderRef.current = [];
selectionAnchorRef.current = null;
setFocusedDocumentId(null);
}, [
showingSearchResults,
searchQuery,
setSelectedEntries,
setSelectionOrder,
selectionOrderRef,
selectionAnchorRef,
setFocusedDocumentId,
]);
useEffect(() => {
if (!selectedDocumentIds.length) {
return;
}
if (!selectedDocumentIds.includes(activePreviewId as Identifier)) {
setActivePreviewId(selectedDocumentIds[selectedDocumentIds.length - 1]);
}
selectionInitializedRef.current = true;
}, [selectedDocumentIds, activePreviewId, selectionInitializedRef, setActivePreviewId]);
};
export default useWorkspaceSelectionSync;
@@ -0,0 +1,140 @@
import { useEffect, useMemo } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import usePasskeys from '../../settings/usePasskeys';
import TagManager from '../../tag_manager';
import useCorrespondents from './useCorrespondents';
import useDocumentCorrespondentActions from './useDocumentCorrespondentActions';
import useTags from './useTags';
type Identifier = string | number;
interface UseWorkspaceTaxonomiesArgs {
apiClient: any;
notifyApiError: (error: unknown, fallbackMessage?: string, variant?: string) => void;
setStatusMessage: (message: string, variant?: string) => void;
tagManager: TagManager;
tenantIdRef: MutableRefObject<Identifier | null>;
currentTenantId: Identifier | null;
setActiveTagFilters: Dispatch<SetStateAction<Identifier[]>>;
mapDocumentCaches: (mapper: (doc: any) => any | undefined) => void;
updateDocumentCaches: (id: Identifier, updater: (doc: any) => any) => void;
token: string;
}
const useWorkspaceTaxonomies = ({
apiClient,
notifyApiError,
setStatusMessage,
tagManager,
tenantIdRef,
currentTenantId,
setActiveTagFilters,
mapDocumentCaches,
updateDocumentCaches,
token,
}: UseWorkspaceTaxonomiesArgs) => {
const {
tags,
refreshTags,
handleTagCreate,
handleTagUpdate,
handleTagDelete,
setTags,
} = useTags({
apiClient,
notifyApiError,
setStatusMessage,
tagManager,
tenantIdRef,
setActiveTagFilters,
mapDocumentCaches,
});
useEffect(() => {
tenantIdRef.current = currentTenantId;
}, [currentTenantId, tenantIdRef]);
const tagLookupById = useMemo(() => {
const map = new Map();
tags.forEach((tag) => {
if (tag?.id) {
map.set(tag.id, tag);
}
});
return map;
}, [tags]);
const {
correspondents,
refreshCorrespondents,
handleCorrespondentCreate,
handleCorrespondentUpdate,
handleCorrespondentDelete,
setCorrespondents,
} = useCorrespondents({
apiClient,
notifyApiError,
setStatusMessage,
tenantIdRef,
mapDocumentCaches,
});
const {
correspondentLookupByName,
handleDocumentCorrespondentAttach,
handleCorrespondentRemove,
handleCorrespondentAdd,
} = useDocumentCorrespondentActions({
apiClient,
correspondents,
handleCorrespondentCreate,
notifyApiError,
setStatusMessage,
updateDocumentCaches,
});
const {
passkeys,
passkeysSupported,
passkeysLoading,
registeringPasskey,
revokingPasskeyId,
refreshPasskeys,
registerPasskey,
revokePasskey,
} = usePasskeys({
notifyApiError,
setStatusMessage,
token,
});
return {
tags,
refreshTags,
handleTagCreate,
handleTagUpdate,
handleTagDelete,
setTags,
tagLookupById,
correspondents,
refreshCorrespondents,
handleCorrespondentCreate,
handleCorrespondentUpdate,
handleCorrespondentDelete,
setCorrespondents,
correspondentLookupByName,
handleDocumentCorrespondentAttach,
handleCorrespondentRemove,
handleCorrespondentAdd,
passkeys,
passkeysSupported,
passkeysLoading,
registeringPasskey,
revokingPasskeyId,
refreshPasskeys,
registerPasskey,
revokePasskey,
};
};
export default useWorkspaceTaxonomies;
+9 -9
View File
@@ -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<string, unknown> | null;
url: string | null;
metadata: Record<string, unknown> | null;
};
interface UseAssetNavigatorOptions {
@@ -72,13 +70,15 @@ export const useAssetNavigator = ({
}, [document, assetType, getAsset]);
const view = useMemo<AssetViewLike>(
() => createAssetView(asset) as unknown as AssetViewLike,
() => ({
url: resolveAssetUrl(asset),
metadata: (asset?.metadata as Record<string, unknown> | 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);
+85 -2
View File
@@ -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 (
<Navigate
to="/account/login"
replace
state={{ from: redirectTarget }}
/>
);
}
return (
<AppShellContext.Provider value={contextValue}>
<div className="app-shell" ref={shellRef}>
<DropOverlay
active={dropOverlayState.active}
folderName={dropOverlayState.folderName}
/>
<UploadQueueOverlay
queue={contextValue.uploadQueue || []}
onClearQueue={contextValue.clearUploadQueue}
/>
<Outlet />
{managementModals}
{settingsOpen ? (
<SettingsRoute open onClose={closeSettings} />
) : null}
</div>
</AppShellContext.Provider>
);
};
const AppRouter: React.FC = () => (
<Routes>
<Route path="/account/login" element={<LoginRoute />} />
<Route element={<AppLayout />}>
<Route path="/" element={<Navigate to="/documents" replace />} />
<Route path="/documents" element={<DocumentsRoute />} />
<Route path="/documents/folder/:folderId" element={<DocumentsRoute />} />
<Route path="/documents/:documentId" element={<DocumentsRoute />} />
<Route path="*" element={<Navigate to="/documents" replace />} />
</Route>
</Routes>
);
const container = document.getElementById('app');
+360
View File
@@ -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<AxiosInstance, 'get' | 'post' | 'patch' | 'delete' | 'defaults'> = {
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<string> | 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<DocumentResponse> => {
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<AssetResponse> => {
const { data } = await api.get<AssetResponse>(`/assets/${id}`);
const download = normalizeDownload(data.download);
return {
...data,
download,
};
};
export const listDocuments = async (params: Record<string, unknown> = {}): Promise<DocumentResponse[]> => {
const { data } = await api.get<DocumentResponse[]>('/documents', { params });
return Array.isArray(data) ? data : [];
};
export const getFolderTree = async (): Promise<FolderTreeNode[]> => {
const { data } = await api.get<FolderTreeNode[]>('/folders/tree');
return Array.isArray(data) ? data : [];
};
export const listCapabilitySets = async (): Promise<CapabilitySetResponse[]> => {
const { data } = await api.get<CapabilitySetResponse[]>('/capability-sets');
return Array.isArray(data) ? data : [];
};
export const listCapabilities = async (): Promise<CapabilityResponse[]> => {
const { data } = await api.get<CapabilityResponse[]>('/capabilities');
return Array.isArray(data) ? data : [];
};
export const listApiTokens = async (): Promise<ApiTokenRecord[]> => {
const { data } = await api.get<ApiTokenRecord[]>('/profile/api-tokens');
return Array.isArray(data) ? data : [];
};
export const listPasskeys = async (): Promise<PasskeySummary[]> => {
const { data } = await api.get<PasskeySummary[]>('/profile/passkeys');
return Array.isArray(data) ? data : [];
};
export const moveDocumentsBulk = async (documentIds: Identifier[], folderId: Identifier | null): Promise<void> => {
await api.post('/documents/bulk/move', {
document_ids: documentIds,
folder_id: folderId,
});
};
export const queueDocumentReanalysis = async (
documentId: Identifier,
options: { force?: boolean } = {},
): Promise<void> => {
const { force = false } = options;
await api.post(`/documents/${documentId}/assets`, null, { params: { force } });
};
export const trashDocument = async (documentId: Identifier): Promise<void> => {
await api.post(`/documents/${documentId}/trash`);
};
export const addDocumentTags = async (documentId: Identifier, tagIds: Identifier[]): Promise<void> => {
await api.post(`/documents/${documentId}/tags`, { tag_ids: tagIds });
};
export const createTag = async (payload: { label: string; color?: string | null }): Promise<TagResponse> => {
const { data } = await api.post<TagResponse>('/tags', payload);
return data;
};
export const createFolder = async (payload: { name: string; parent_id?: Identifier | null }): Promise<unknown> => {
const { data } = await api.post('/folders', payload);
return data;
};
export const assignCorrespondentsBulk = async <T = unknown>(
payload: Record<string, unknown>,
): Promise<T> => {
const { data } = await api.post<T>('/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<unknown> => {
const { data } = await api.post('/auth/passkeys/register/start', {});
return data;
};
export const finishPasskeyRegistration = async (payload: unknown): Promise<unknown> => {
const { data } = await api.post('/auth/passkeys/register/finish', payload);
return data;
};
export const startPasskeyLogin = async (username: string): Promise<unknown> => {
const { data } = await api.post('/auth/passkeys/login/start', { username });
return data;
};
export const finishPasskeyLogin = async (payload: unknown): Promise<unknown> => {
const { data } = await api.post('/auth/passkeys/login/finish', payload);
return data;
};
export const performLogin = async (payload: Record<string, unknown>): Promise<unknown> => {
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<void> => {
await api.post('/auth/logout');
};
export const selectTenant = async (
payload: { tenant_id: Identifier },
selectionToken: string,
): Promise<unknown> => {
const { data } = await api.post('/auth/select-tenant', payload, {
headers: {
Authorization: `Bearer ${selectionToken}`,
},
});
return data;
};
export const startSignup = async (username: string): Promise<unknown> => {
const { data } = await api.post('/auth/signup/start', { username });
return data;
};
export const finishSignup = async (payload: unknown): Promise<unknown> => {
const { data } = await api.post('/auth/signup/finish', payload);
return data;
};
export const updateDocument = async (
id: Identifier,
payload: Record<string, unknown>,
): Promise<DocumentResponse> => {
const { data } = await api.patch<DocumentResponse>(`/documents/${id}`, payload);
return data;
};
export const moveDocumentToFolder = async (id: Identifier, folderId: Identifier | null): Promise<void> => {
await api.patch(`/documents/${id}/folder`, { folder_id: folderId });
};
export const deleteDocumentTag = async (documentId: Identifier, tagId: Identifier): Promise<void> => {
await api.delete(`/documents/${documentId}/tags/${tagId}`);
};
export const deleteFolder = async (folderId: Identifier): Promise<void> => {
await api.delete(`/folders/${folderId}`);
};
export const moveFolder = async (folderId: Identifier, parentId: Identifier | null): Promise<void> => {
await api.patch(`/folders/${folderId}`, { parent_id: parentId });
};
export const renameFolder = async (folderId: Identifier, name: string): Promise<void> => {
await api.patch(`/folders/${folderId}`, { name });
};
export const createCapabilitySet = async (
payload: { slug?: string; label?: string; capabilities: string[] },
): Promise<CapabilitySetResponse & { label?: string }> => {
const { data } = await api.post<CapabilitySetResponse & { label?: string }>('/capability-sets', payload);
return data;
};
export const updateCapabilitySet = async (
id: Identifier,
payload: { slug?: string; label?: string; capabilities?: string[] },
): Promise<CapabilitySetResponse & { label?: string }> => {
const { data } = await api.patch<CapabilitySetResponse & { label?: string }>(`/capability-sets/${id}`, payload);
return data;
};
export const deleteCapabilitySet = async (id: Identifier): Promise<void> => {
await api.delete(`/capability-sets/${id}`);
};
export const deleteApiToken = async (tokenId: Identifier): Promise<void> => {
await api.delete(`/profile/api-tokens/${tokenId}`);
};
export const deletePasskey = async (
passkeyId: Identifier,
options: { reason?: string } = {},
): Promise<void> => {
const query = options.reason ? `?reason=${encodeURIComponent(options.reason)}` : '';
await api.delete(`/profile/passkeys/${passkeyId}${query}`);
};
export const listTenants = async (): Promise<TenantSnippet[]> => {
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<string> => {
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<string, unknown>;
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';
+106
View File
@@ -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<string, unknown>;
}
export interface AssetResponse {
id: string;
asset_type: string;
mime_type: string;
metadata: Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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;
}
+11 -11
View File
@@ -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 (
<div className="document-viewer__unsupported">
<div className="document-viewer__unsupported-message">
Preview is not available for {displayContentType} files.
Preview is not available for {displayMimeType} files.
</div>
<div className="document-viewer__unsupported-filename">{displayFilename}</div>
<a
+44 -57
View File
@@ -32,17 +32,20 @@ import { usePanelResizeBindings } from '../app/PanelManagerContext';
interface DocumentLike {
id?: string | number;
title?: string;
content_type?: string | null;
mime_type?: string | null;
issued_at?: string | null;
folder_id?: string | null;
correspondents?: Array<{ id?: string | number; name?: string }>;
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<unknown>;
getDocumentAsset?: (doc: DocumentLike | null, type: string) => AssetLike | null;
ensurePreviewData?: (docId: string | number, options?: { signal?: AbortSignal }) => Promise<DocumentLike | null>;
@@ -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<DocumentViewerPanelProps> = ({
document,
documentLink,
tagLookupById,
tagOptions,
onTagAdd,
@@ -168,6 +164,16 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
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<DocumentViewerPanelProps> = ({
onCorrespondentRemove,
onUpdateTitle,
onUpdateIssued,
onFolderNavigate: navigateToFolder,
}),
[
tagLookupById,
@@ -192,6 +199,7 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
onCorrespondentRemove,
onUpdateTitle,
onUpdateIssued,
navigateToFolder,
],
);
@@ -251,35 +259,30 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
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<DocumentViewerPanelProps> = ({
useEffect(() => {
setZoomOverlayOpen(false);
}, [effectiveDocumentLink?.url, document?.id]);
}, [resolvedDocumentLink?.url, document?.id]);
const panelRef = useRef<HTMLDivElement | HTMLFormElement | HTMLElement | null>(null);
const isStackedLayout = useViewerLayoutMode(panelRef, document?.id);
@@ -344,19 +347,6 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
];
}, [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<DocumentViewerPanelProps> = ({
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<DocumentViewerPanelProps> = ({
: null;
const headerLeadingButtons = isSidebarVariant
? [collapseButton, maximizeButton].filter(Boolean)
: [sidebarToggle, closeButton].filter(Boolean);
const headerLeadingContent = headerLeadingButtons.length
? (
<>
{headerLeadingButtons}
</>
)
: null;
? [
collapseButton ? <React.Fragment key="collapse-button">{collapseButton}</React.Fragment> : null,
maximizeButton ? <React.Fragment key="maximize-button">{maximizeButton}</React.Fragment> : null,
].filter(Boolean)
: [
sidebarToggle ? <React.Fragment key="sidebar-toggle">{sidebarToggle}</React.Fragment> : null,
closeButton ? <React.Fragment key="close-button">{closeButton}</React.Fragment> : null,
].filter(Boolean);
const headerLeadingContent = headerLeadingButtons.length ? headerLeadingButtons : null;
const resizeHandle = isSidebarVariant ? (
<button
@@ -486,7 +473,7 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
<section className={viewerClassName}>
<DocumentViewerLayout
document={document}
documentLink={effectiveDocumentLink}
documentLink={resolvedDocumentLink}
summaryProps={summaryProps}
metadataPayload={metadataPayload}
contentTabConfig={contentTabConfig}
+2 -2
View File
@@ -82,8 +82,8 @@ const resolvePdfWasmBaseUrl = (): string => {
const PdfViewer = ({ src, title, className, viewportRef }: PdfViewerProps): JSX.Element => {
const containerRef = useRef<HTMLDivElement>(null);
const [viewportWidth, setViewportWidth] = useState(() => window.innerWidth);
const [viewportHeight, setViewportHeight] = useState(() => window.innerHeight);
const [viewportWidth, setViewportWidth] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
const [renderWidth, setRenderWidth] = useState(0);
const [status, setStatus] = useState<RenderStatus>('idle');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
+18 -29
View File
@@ -1,14 +1,11 @@
import { useCallback, useState } from 'react';
interface ApiTokenRecord {
id?: string | number;
label?: string;
expires_at?: string;
created_at?: string;
last_used_at?: string;
capability_set_id?: string | number;
[key: string]: unknown;
}
import {
createApiToken,
deleteApiToken,
listApiTokens,
regenerateApiToken,
type ApiTokenRecord,
} from '../lib/apiClient';
interface ApiTokensResponse {
token_info?: ApiTokenRecord;
@@ -22,11 +19,6 @@ interface CreateTokenArgs {
}
interface UseApiTokensArgs {
api: {
get: <T = unknown>(url: string) => Promise<{ data: T }>;
post: <T = unknown>(url: string, payload?: unknown) => Promise<{ data: T }>;
delete: (url: string) => Promise<unknown>;
};
notifyApiError?: (error: unknown, message: string) => void;
setStatusMessage?: (message: string, variant?: string) => void;
token?: string | null;
@@ -46,9 +38,9 @@ interface UseApiTokensResult {
dismissSecret: () => void;
}
const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }: UseApiTokensArgs): UseApiTokensResult => {
const useApiTokens = ({ notifyApiError, setStatusMessage, token }: UseApiTokensArgs): UseApiTokensResult => {
const [tokens, setTokens] = useState<ApiTokenRecord[]>([]);
const [loading, setLoading] = useState(false);
const [loading] = useState(false);
const [creating, setCreating] = useState(false);
const [deletingId, setDeletingId] = useState<string | number | null>(null);
const [regeneratingId, setRegeneratingId] = useState<string | number | null>(null);
@@ -58,16 +50,13 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }: UseApiTo
if (!token) {
return;
}
setLoading(true);
try {
const { data } = await api.get<ApiTokenRecord[]>('/profile/api-tokens');
const data = await listApiTokens();
setTokens(Array.isArray(data) ? data : []);
} catch (error) {
notifyApiError?.(error, 'Failed to load API tokens.');
} finally {
setLoading(false);
}
}, [api, notifyApiError, token]);
}, [notifyApiError, token]);
const create = useCallback(
async ({ label, expires_at, capability_set_id }: CreateTokenArgs = {}) => {
@@ -76,7 +65,7 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }: UseApiTo
}
setCreating(true);
try {
const payload: Record<string, unknown> = { capability_set_id };
const payload: { capability_set_id: string | number; label?: string; expires_at?: string } = { capability_set_id };
if (label) {
payload.label = label;
}
@@ -84,7 +73,7 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }: UseApiTo
payload.expires_at = expires_at;
}
const { data } = await api.post<ApiTokensResponse>('/profile/api-tokens', payload);
const data = await createApiToken(payload) as ApiTokensResponse;
if (data?.token_info) {
setTokens((previous) => {
const filtered = previous.filter((entry) => entry.id !== data.token_info?.id);
@@ -107,7 +96,7 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }: UseApiTo
setCreating(false);
}
},
[api, creating, notifyApiError, refresh, setStatusMessage],
[creating, notifyApiError, refresh, setStatusMessage],
);
const revoke = useCallback(
@@ -117,7 +106,7 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }: UseApiTo
}
setDeletingId(tokenId);
try {
await api.delete(`/profile/api-tokens/${tokenId}`);
await deleteApiToken(tokenId);
await refresh();
setStatusMessage?.('API token revoked.', 'success');
return true;
@@ -128,7 +117,7 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }: UseApiTo
setDeletingId(null);
}
},
[api, notifyApiError, refresh, setStatusMessage],
[notifyApiError, refresh, setStatusMessage],
);
const regenerate = useCallback(
@@ -138,7 +127,7 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }: UseApiTo
}
setRegeneratingId(tokenId);
try {
const { data } = await api.post<ApiTokensResponse>(`/profile/api-tokens/${tokenId}/regenerate`);
const data = await regenerateApiToken(tokenId) as ApiTokensResponse;
if (data?.token_info) {
setTokens((previous) => {
let found = false;
@@ -171,7 +160,7 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }: UseApiTo
setRegeneratingId(null);
}
},
[api, notifyApiError, refresh, setStatusMessage],
[notifyApiError, refresh, setStatusMessage],
);
const dismissSecret = useCallback(() => {
+5 -13
View File
@@ -1,16 +1,12 @@
import { useCallback, useEffect, useState } from 'react';
interface CapabilitiesApi {
get: (path: string) => Promise<{ data: unknown }>;
}
import { listCapabilities } from '../lib/apiClient';
interface UseCapabilitiesOptions {
api: CapabilitiesApi;
notifyApiError?: (error: unknown, fallbackMessage: string) => void;
token?: string | null;
}
const useCapabilities = ({ api, notifyApiError, token }: UseCapabilitiesOptions) => {
const useCapabilities = ({ notifyApiError, token }: UseCapabilitiesOptions) => {
const [capabilities, setCapabilities] = useState<string[]>([]);
const [capabilitiesLoading, setCapabilitiesLoading] = useState(false);
@@ -21,19 +17,15 @@ const useCapabilities = ({ api, notifyApiError, token }: UseCapabilitiesOptions)
}
setCapabilitiesLoading(true);
try {
const { data } = await api.get('/capabilities');
if (Array.isArray(data)) {
setCapabilities(data as string[]);
} else {
setCapabilities([]);
}
const data = await listCapabilities();
setCapabilities(Array.isArray(data) ? data.map((item) => item.name) : []);
} catch (error) {
notifyApiError?.(error, 'Failed to load capabilities.');
setCapabilities([]);
} finally {
setCapabilitiesLoading(false);
}
}, [api, notifyApiError, token]);
}, [notifyApiError, token]);
useEffect(() => {
if (token) {
+13 -17
View File
@@ -1,4 +1,10 @@
import { useCallback, useEffect, useState } from 'react';
import {
createCapabilitySet as createCapabilitySetRequest,
deleteCapabilitySet as deleteCapabilitySetRequest,
listCapabilitySets,
updateCapabilitySet as updateCapabilitySetRequest,
} from '../lib/apiClient';
type Identifier = string | number;
@@ -10,21 +16,13 @@ interface CapabilitySet {
[key: string]: unknown;
}
interface ApiClient {
get<T = CapabilitySet[]>(path: string): Promise<{ data: T }>;
post<T = CapabilitySet>(path: string, payload?: unknown): Promise<{ data: T }>;
patch<T = CapabilitySet>(path: string, payload?: unknown): Promise<{ data: T }>;
delete: (path: string) => Promise<void>;
}
interface UseCapabilitySetsOptions {
api: ApiClient;
notifyApiError?: (error: unknown, message: string) => void;
setStatusMessage?: (message: string, level?: string) => void;
token?: string | null;
}
const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }: UseCapabilitySetsOptions) => {
const useCapabilitySets = ({ notifyApiError, setStatusMessage, token }: UseCapabilitySetsOptions) => {
const [capabilitySets, setCapabilitySets] = useState<CapabilitySet[]>([]);
const [capabilitySetsLoading, setCapabilitySetsLoading] = useState(false);
const [creatingCapabilitySet, setCreatingCapabilitySet] = useState(false);
@@ -51,14 +49,14 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }: Use
}
setCapabilitySetsLoading(true);
try {
const { data } = await api.get<CapabilitySet[]>('/capability-sets');
const data = await listCapabilitySets();
applyCapabilitySets(Array.isArray(data) ? data : []);
} catch (error) {
notifyApiError?.(error, 'Failed to load capability sets.');
} finally {
setCapabilitySetsLoading(false);
}
}, [api, applyCapabilitySets, notifyApiError, token]);
}, [applyCapabilitySets, notifyApiError, token]);
useEffect(() => {
if (token) {
@@ -91,7 +89,7 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }: Use
payload.label = trimmedLabel;
}
const { data } = await api.post<CapabilitySet>('/capability-sets', payload);
const data = await createCapabilitySetRequest(payload);
if (data) {
applyCapabilitySets((previous) => {
const next = previous.filter((entry) => entry?.id !== data.id);
@@ -112,7 +110,6 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }: Use
}
},
[
api,
applyCapabilitySets,
creatingCapabilitySet,
notifyApiError,
@@ -153,7 +150,7 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }: Use
payload.capabilities = capabilities;
}
const { data } = await api.patch<CapabilitySet>(`/capability-sets/${capabilitySetId}`, payload);
const data = await updateCapabilitySetRequest(capabilitySetId, payload);
if (data) {
applyCapabilitySets((previous) => {
let found = false;
@@ -183,7 +180,6 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }: Use
}
},
[
api,
applyCapabilitySets,
notifyApiError,
refreshCapabilitySets,
@@ -199,7 +195,7 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }: Use
}
setDeletingCapabilitySetId(capabilitySetId);
try {
await api.delete(`/capability-sets/${capabilitySetId}`);
await deleteCapabilitySetRequest(capabilitySetId);
applyCapabilitySets((previous) => previous.filter((entry) => entry?.id !== capabilitySetId));
setStatusMessage?.('Capability set deleted.', 'success');
return true;
@@ -210,7 +206,7 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }: Use
setDeletingCapabilitySetId(null);
}
},
[api, applyCapabilitySets, notifyApiError, setStatusMessage],
[applyCapabilitySets, notifyApiError, setStatusMessage],
);
return {
+14 -17
View File
@@ -6,12 +6,12 @@ import {
preparePublicKeyCreationOptions,
serializeRegistrationCredential,
} from '../utils/webauthn';
type ApiClient = {
get: (path: string) => Promise<{ data: unknown }>;
post: (path: string, body?: unknown) => Promise<{ data: unknown }>;
delete: (path: string) => Promise<{ data: unknown }>;
};
import {
deletePasskey,
finishPasskeyRegistration,
listPasskeys,
startPasskeyRegistration,
} from '../lib/apiClient';
type StatusMessageFn = (message: string, variant?: string) => void;
type NotifyApiErrorFn = (error: unknown, message: string) => void;
@@ -69,7 +69,6 @@ export type RevokePasskeyResult =
| { ok: false; reason: RevokePasskeyFailureReason; message?: string };
interface UsePasskeysArgs {
api: ApiClient;
notifyApiError: NotifyApiErrorFn;
setStatusMessage: StatusMessageFn;
token?: string | null;
@@ -89,7 +88,7 @@ interface UsePasskeysResult {
) => Promise<RevokePasskeyResult>;
}
const usePasskeys = ({ api, notifyApiError, setStatusMessage, token }: UsePasskeysArgs): UsePasskeysResult => {
const usePasskeys = ({ notifyApiError, setStatusMessage, token }: UsePasskeysArgs): UsePasskeysResult => {
const [passkeys, setPasskeys] = useState<PasskeyRecord[]>([]);
const [passkeysSupported, setPasskeysSupported] = useState<boolean | null>(null);
const [passkeysLoading, setPasskeysLoading] = useState(false);
@@ -102,8 +101,7 @@ const usePasskeys = ({ api, notifyApiError, setStatusMessage, token }: UsePasske
}
setPasskeysLoading(true);
try {
const { data } = await api.get('/profile/passkeys');
const passkeyData = Array.isArray(data) ? (data as PasskeyRecord[]) : [];
const passkeyData = await listPasskeys();
setPasskeys(passkeyData);
setPasskeysSupported(true);
} catch (error) {
@@ -117,7 +115,7 @@ const usePasskeys = ({ api, notifyApiError, setStatusMessage, token }: UsePasske
} finally {
setPasskeysLoading(false);
}
}, [api, notifyApiError, token]);
}, [notifyApiError, token]);
const registerPasskey = useCallback(
async ({ nickname }: { nickname?: string } = {}): Promise<RegisterPasskeyResult> => {
@@ -132,7 +130,7 @@ const usePasskeys = ({ api, notifyApiError, setStatusMessage, token }: UsePasske
setRegisteringPasskey(true);
try {
const { data } = await api.post('/auth/passkeys/register/start', {});
const data = await startPasskeyRegistration();
const challengeData = data as PasskeyChallengeResponse | undefined;
const challengeId = challengeData?.challengeId || challengeData?.challenge_id;
const publicKeyOptions =
@@ -164,7 +162,7 @@ const usePasskeys = ({ api, notifyApiError, setStatusMessage, token }: UsePasske
payload.nickname = trimmedNickname;
}
await api.post('/auth/passkeys/register/finish', payload);
await finishPasskeyRegistration(payload);
await refreshPasskeys();
setPasskeysSupported(true);
setStatusMessage('Passkey registered.', 'success');
@@ -188,7 +186,7 @@ const usePasskeys = ({ api, notifyApiError, setStatusMessage, token }: UsePasske
setRegisteringPasskey(false);
}
},
[api, notifyApiError, refreshPasskeys, registeringPasskey, setStatusMessage],
[notifyApiError, refreshPasskeys, registeringPasskey, setStatusMessage],
);
const revokePasskey = useCallback(
@@ -201,8 +199,7 @@ const usePasskeys = ({ api, notifyApiError, setStatusMessage, token }: UsePasske
}
setRevokingPasskeyId(passkeyId);
try {
const query = reason ? `?reason=${encodeURIComponent(reason)}` : '';
await api.delete(`/profile/passkeys/${passkeyId}${query}`);
await deletePasskey(passkeyId, { reason });
await refreshPasskeys();
setStatusMessage('Passkey revoked.', 'success');
return { ok: true };
@@ -214,7 +211,7 @@ const usePasskeys = ({ api, notifyApiError, setStatusMessage, token }: UsePasske
setRevokingPasskeyId(null);
}
},
[api, notifyApiError, refreshPasskeys, setStatusMessage],
[notifyApiError, refreshPasskeys, setStatusMessage],
);
return {
-5
View File
@@ -88,7 +88,6 @@ interface UseSidebarPropsArgs {
| null
| void;
appStatus: string;
loading: boolean;
previewActive: boolean;
handleLogout: () => void | Promise<void>;
status: StatusMessage | null;
@@ -122,7 +121,6 @@ interface SidebarHookResult {
correspondents: CorrespondentOption[];
onCreateCorrespondent: (name: string) => void;
appStatus: string;
loading: boolean;
previewActive: boolean;
onLogout: UseSidebarPropsArgs['handleLogout'];
status: StatusMessage | null;
@@ -151,7 +149,6 @@ const useSidebarProps = ({
correspondents,
handleCorrespondentCreate,
appStatus,
loading,
previewActive,
handleLogout,
status,
@@ -185,7 +182,6 @@ const useSidebarProps = ({
correspondents,
onCreateCorrespondent: (name) => handleCorrespondentCreate({ name }),
appStatus,
loading,
previewActive,
onLogout: handleLogout,
status,
@@ -228,7 +224,6 @@ const useSidebarProps = ({
handlePromptCreateFolder,
handleTagCreate,
handleTenantSelect,
loading,
openSettings,
previewActive,
draggedFolderId,
+5 -1
View File
@@ -27,6 +27,10 @@
outline-offset: -2px;
}
.documents-panel--view-desk {
padding: 0;
}
.documents-grid .folder-card.is-drop-target {
outline: 2px dashed var(--accent-strong, var(--accent));
outline-offset: 2px;
@@ -77,7 +81,7 @@
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -45%);
transform: translateX(-50%);
background: color-mix(in oklch, var(--surface) 100%, transparent);
border: 1px solid color-mix(in oklch, var(--border) 95%, transparent);
padding: 0.45rem 0.85rem;
+2 -2
View File
@@ -455,8 +455,8 @@
flex-direction: column;
gap: var(--pdf-viewer-stack-padding, 0);
align-items: center;
--pdf-viewer-viewport-width: 100vw;
--pdf-viewer-viewport-height: 100vh;
--pdf-viewer-viewport-width: 100%;
--pdf-viewer-viewport-height: 100%;
padding: var(--pdf-viewer-stack-padding, 0);
width: 100%;
box-sizing: border-box;
+5 -18
View File
@@ -36,25 +36,8 @@
border-left: 1px solid var(--border);
}
.main-content__body {
position: relative;
flex: 1 1 auto;
flex-direction: column;
display: flex;
min-height: 0;
}
.main-content__body > * {
min-width: 0;
min-height: 0;
}
.main-content__body--workspace.main-content__body > .desk-shell {
margin-right: calc(-1 * var(--detail-panel-width));
}
.main-content {
padding-right: var(--detail-panel-width);
padding-right: 0;
}
.documents-main--sidebar-hidden {
@@ -74,6 +57,10 @@
color: var(--muted-subtle);
}
.main-content .panel-header {
margin-right: var(--detail-panel-width);
}
.main-content__breadcrumbs {
flex: 1 1 auto;
overflow: hidden;
+2 -4
View File
@@ -67,11 +67,9 @@
.modal--panel {
width: 80vw;
max-width: 960px;
max-height: 90vh;
height: 90vh;
padding: 0;
border-radius: 12px;
border-radius: 1rem;
overflow: hidden;
gap: 0;
}
+12 -11
View File
@@ -23,44 +23,45 @@
display: flex;
align-items: center;
justify-content: center;
max-width: 95vw;
max-height: 95vh;
max-width: 99vw;
max-height: 99vh;
z-index: 3000000;
}
.preview-zoom__stage--pdf {
width: 100%;
height: 100%;
max-width: none;
max-height: none;
max-width: 99vw;
max-height: 99vh;
}
.preview-zoom__image {
max-width: 95vw;
max-height: 95vh;
max-width: 99vw;
max-height: 99vh;
width: auto;
height: auto;
box-shadow: 0 32px 120px var(--shadow-deep);
}
.preview-zoom__scroll {
display: flex;
align-items: center;
justify-content: center;
max-width: 95vw;
max-height: 95vh;
max-width: 99vw;
max-height: 99vh;
}
.preview-zoom__scroll--pdf {
width: 100%;
height: 100%;
max-width: none;
max-height: none;
max-width: 99vw;
max-height: 99vh;
padding: 0;
box-sizing: border-box;
overflow: auto;
align-items: flex-start;
justify-content: center;
--pdf-viewer-stack-padding: 2rem;
--pdf-viewer-stack-padding: 1vh;
}
.preview-zoom__scroll:focus {
+1 -1
View File
@@ -4,6 +4,7 @@
gap: 1.5rem;
height: 90vh;
padding: 1.5rem;
overflow: hidden;
}
.settings-modal__sidebar {
@@ -334,4 +335,3 @@ fieldset.settings-form__field legend {
.settings-notice__actions button {
flex: 0 0 auto;
}
-4
View File
@@ -1,4 +0,0 @@
declare module '*?url' {
const url: string;
export default url;
}
+8
View File
@@ -8,6 +8,14 @@ export const clamp = (value: number, min: number, max: number): number => {
return value;
};
export const formatTransform = (
x: number,
y: number,
rotation = 0,
scale = 1,
): string => `translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`;
export default {
clamp,
formatTransform,
};
+5 -10
View File
@@ -1,4 +1,4 @@
import { createAssetView, resolveDocumentAssetUrl } from '../asset_manager';
import { resolveDocumentAssetUrl, resolveAssetUrl } from '../asset_manager';
import type {
DocumentLike as AssetManagerDocumentLike,
DocumentVersionLike,
@@ -6,12 +6,8 @@ import type {
GetAsset as AssetManagerGetAsset,
} from '../asset_manager';
interface DocumentVersion extends DocumentVersionLike {
download_path?: string | null;
}
export interface DocumentLike extends AssetManagerDocumentLike {
current_version?: DocumentVersion | null;
current_version?: DocumentVersionLike | null;
}
export type AssetLike = AssetManagerAssetLike;
@@ -63,8 +59,8 @@ export async function resolveOcrTextUrl({
return null;
}
const baseView = createAssetView(asset);
const hasUrl = Boolean(baseView.getPrimaryUrl());
const baseUrl = resolveAssetUrl(asset);
const hasUrl = Boolean(baseUrl);
let entry: AssetLike = asset;
if (ensureAssetUrl) {
@@ -75,8 +71,7 @@ export async function resolveOcrTextUrl({
}
}
const ensuredView = createAssetView(entry);
const directUrl = ensuredView.getPrimaryUrl();
const directUrl = resolveAssetUrl(entry);
if (directUrl) {
return directUrl;
}
-3
View File
@@ -5,6 +5,3 @@ export const isPlainObject = (value: unknown): value is Record<string, unknown>
export const isStringValue = (value: unknown): value is string =>
objectToString.call(value) === '[object String]';
export const isFunctionValue = <T extends (...args: unknown[]) => unknown>(value: unknown): value is T =>
objectToString.call(value) === '[object Function]';