Merge remote-tracking branch 'ui/ui' into dev
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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})`;
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { clamp, formatTransform } from './math';
|
||||
import { clamp, formatTransform } from '../utils/math';
|
||||
import { fetchLayoutRecords, upsertLayoutRecords } from './db';
|
||||
|
||||
type DocumentId = string;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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');
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Vendored
-4
@@ -1,4 +0,0 @@
|
||||
declare module '*?url' {
|
||||
const url: string;
|
||||
export default url;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]';
|
||||
|
||||
Reference in New Issue
Block a user