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

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