typescript
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppShell } from '../appShellContext';
|
||||
import DocumentsLayout from './DocumentsLayout';
|
||||
@@ -8,7 +9,45 @@ import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
||||
import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext';
|
||||
import { PanelManagerProvider, usePanelManager } from './PanelManagerContext';
|
||||
|
||||
type Breadcrumb = { id?: string | number; name?: string; label?: string; title?: string };
|
||||
type Identifier = string | number;
|
||||
|
||||
type Breadcrumb = { id?: Identifier; name?: string; label?: string; title?: string };
|
||||
|
||||
type EnsureAssetUrl = (
|
||||
docId: Identifier,
|
||||
asset: unknown,
|
||||
options?: Record<string, unknown>,
|
||||
) => Promise<unknown> | void;
|
||||
|
||||
type EnsurePreviewData = (docId: Identifier, options?: Record<string, unknown>) => Promise<unknown>;
|
||||
type GetDocumentAsset = (document: unknown, assetType: string) => unknown;
|
||||
type ResolveApiPath = (path: string) => string;
|
||||
type NotifyApiError = (error: unknown, fallbackMessage?: string) => void;
|
||||
|
||||
interface DocumentsTableProps {
|
||||
breadcrumbs?: Breadcrumb[] | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DocumentsRouteAppShell {
|
||||
sidebarProps?: Record<string, unknown> | null;
|
||||
documentsTableProps?: DocumentsTableProps | null;
|
||||
detailPanelProps?: Record<string, unknown> | null;
|
||||
detailPanelOpen?: boolean;
|
||||
documentsViewMode?: string;
|
||||
deskWorkspaceProps?: Record<string, unknown> | null;
|
||||
openTagsModal?: () => void;
|
||||
openCorrespondentsModal?: () => void;
|
||||
previewWorkspaceDocument?: unknown;
|
||||
previewWorkspaceEntry?: unknown;
|
||||
previewDocumentId?: Identifier | null;
|
||||
closeDocumentPreview?: () => void;
|
||||
ensurePreviewData?: EnsurePreviewData;
|
||||
resolveApiPath?: ResolveApiPath;
|
||||
ensureAssetUrl?: EnsureAssetUrl;
|
||||
getDocumentAsset?: GetDocumentAsset;
|
||||
notifyApiError?: NotifyApiError;
|
||||
}
|
||||
|
||||
const DocumentsRouteContent: React.FC = () => {
|
||||
const {
|
||||
@@ -29,7 +68,7 @@ const DocumentsRouteContent: React.FC = () => {
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
notifyApiError,
|
||||
} = useAppShell();
|
||||
} = useAppShell() as DocumentsRouteAppShell;
|
||||
const navigate = useNavigate();
|
||||
const { collapsed: sidebarCollapsed } = useSidebarContext();
|
||||
const {
|
||||
@@ -37,18 +76,25 @@ const DocumentsRouteContent: React.FC = () => {
|
||||
expandSidebar,
|
||||
} = usePanelManager();
|
||||
|
||||
const safeSidebarProps = useMemo<Record<string, unknown>>(
|
||||
() => (sidebarProps && typeof sidebarProps === 'object' ? sidebarProps : {}),
|
||||
[sidebarProps],
|
||||
);
|
||||
|
||||
const sidebarPropsWithActions = useMemo(
|
||||
() => ({
|
||||
...sidebarProps,
|
||||
...safeSidebarProps,
|
||||
onManageTags: openTagsModal,
|
||||
onManageCorrespondents: openCorrespondentsModal,
|
||||
}),
|
||||
[sidebarProps, openTagsModal, openCorrespondentsModal],
|
||||
[safeSidebarProps, openTagsModal, openCorrespondentsModal],
|
||||
);
|
||||
|
||||
const sidebarHidden = sidebarCollapsed || sidebarSuppressed;
|
||||
|
||||
const breadcrumbs = documentsTableProps?.breadcrumbs || null;
|
||||
const breadcrumbs = Array.isArray(documentsTableProps?.breadcrumbs)
|
||||
? documentsTableProps?.breadcrumbs
|
||||
: null;
|
||||
const parentBreadcrumb = useMemo(() => {
|
||||
if (!Array.isArray(breadcrumbs) || breadcrumbs.length <= 1) {
|
||||
return null;
|
||||
@@ -114,11 +160,14 @@ const DocumentsRouteContent: React.FC = () => {
|
||||
|
||||
const variant = surface.variant || 'documents';
|
||||
|
||||
const surfaceDetail = (surface as { detail?: ReactNode }).detail || null;
|
||||
const hasDetail = Boolean(surfaceDetail);
|
||||
|
||||
const mainContentClass = `main-content main-content--${variant}${
|
||||
surface.detail ? ' main-content--has-detail' : ''
|
||||
hasDetail ? ' main-content--has-detail' : ''
|
||||
}`;
|
||||
const bodyClass = `main-content__body main-content__body--${variant}${
|
||||
surface.detail ? ' main-content__body--has-detail' : ''
|
||||
hasDetail ? ' main-content__body--has-detail' : ''
|
||||
}`;
|
||||
|
||||
const header = surface.header || null;
|
||||
@@ -153,26 +202,30 @@ const DocumentsRouteContent: React.FC = () => {
|
||||
<DocumentsLayout sidebarProps={sidebarPropsWithActions}>
|
||||
<div className={mainContentClass}>
|
||||
{header ? (
|
||||
<div className="main-content__header-wrapper">
|
||||
<>
|
||||
<div className="main-content__header-wrapper">
|
||||
<PanelHeader
|
||||
className="main-content__header"
|
||||
leading={header.leading}
|
||||
title={headerTitle}
|
||||
titleTag="h2"
|
||||
actions={header.actions}
|
||||
/>
|
||||
</div>
|
||||
{(header.selectionLabel || header.floatingActions) ? (
|
||||
<div className="panel-floating" aria-live="polite" aria-atomic="true">
|
||||
{header.selectionLabel ? (
|
||||
<span className="panel-floating__label">{header.selectionLabel}</span>
|
||||
) : null}
|
||||
{header.floatingActions || null}
|
||||
<div className="panel-floating-region" aria-live="polite" aria-atomic="true">
|
||||
<div className="panel-floating">
|
||||
{header.selectionLabel ? (
|
||||
<span className="panel-floating__label">{header.selectionLabel}</span>
|
||||
) : null}
|
||||
{header.floatingActions || null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<PanelHeader
|
||||
className="main-content__header"
|
||||
leading={header.leading}
|
||||
title={headerTitle}
|
||||
titleTag="h2"
|
||||
actions={header.actions}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className={bodyClass}>{surface.content}</div>
|
||||
{surface.detail || null}
|
||||
{surfaceDetail}
|
||||
</div>
|
||||
</DocumentsLayout>
|
||||
);
|
||||
|
||||
@@ -221,13 +221,18 @@ const LoginRoute: React.FC = () => {
|
||||
|
||||
const publicKey = preparePublicKeyRequestOptions({ publicKey: publicKeyOptions });
|
||||
setStatusMessage('Confirm the passkey prompt to continue.', 'info');
|
||||
const assertion = await navigator.credentials.get({ publicKey });
|
||||
const assertion = await navigator.credentials.get({ publicKey }) as PublicKeyCredential | null;
|
||||
|
||||
if (!assertion) {
|
||||
setStatusMessage('Passkey login cancelled.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(assertion instanceof PublicKeyCredential)) {
|
||||
setStatusMessage('Unexpected credential response.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const serialized = serializeAuthenticationCredential(assertion);
|
||||
const finishPayload = {
|
||||
challengeId,
|
||||
@@ -303,7 +308,11 @@ const LoginRoute: React.FC = () => {
|
||||
setStatusMessage('Signing you in…', 'info');
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
const payload: {
|
||||
magic_token: string;
|
||||
username?: string;
|
||||
preferred_tenant_id?: string | number;
|
||||
} = {
|
||||
magic_token: magicToken,
|
||||
};
|
||||
if (magicUsername) {
|
||||
@@ -419,13 +428,18 @@ const LoginRoute: React.FC = () => {
|
||||
|
||||
const publicKey = preparePublicKeyCreationOptions({ publicKey: publicKeyOptions });
|
||||
setStatusMessage('Confirm the passkey prompt to finish creating your account.', 'info');
|
||||
const credential = await navigator.credentials.create({ publicKey });
|
||||
const credential = await navigator.credentials.create({ publicKey }) as PublicKeyCredential | null;
|
||||
|
||||
if (!credential) {
|
||||
setStatusMessage('Signup cancelled.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
setStatusMessage('Unexpected credential response.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const serialized = serializeRegistrationCredential(credential);
|
||||
const finishPayload = {
|
||||
signup_token: signupToken,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import TagsPanel from '../tags/TagsPanel';
|
||||
import CorrespondentsPanel from '../correspondents/CorrespondentsPanel';
|
||||
import CorrespondentsPanel, { CorrespondentsPanelProps } from '../correspondents/CorrespondentsPanel';
|
||||
import PanelHeader from '../ui/PanelHeader';
|
||||
|
||||
const TAGS_MODAL = 'tags';
|
||||
@@ -135,6 +135,36 @@ export const useManagementModals = ({
|
||||
tags,
|
||||
]);
|
||||
|
||||
const handleCorrespondentCreateSafe = useCallback<CorrespondentsPanelProps['onCreate']>(
|
||||
async (payload) => {
|
||||
if (typeof onCorrespondentCreate !== 'function') {
|
||||
return undefined;
|
||||
}
|
||||
return onCorrespondentCreate(payload) ?? undefined;
|
||||
},
|
||||
[onCorrespondentCreate],
|
||||
);
|
||||
|
||||
const handleCorrespondentUpdateSafe = useCallback<CorrespondentsPanelProps['onUpdate']>(
|
||||
async (id, payload) => {
|
||||
if (typeof onCorrespondentUpdate !== 'function') {
|
||||
return;
|
||||
}
|
||||
await onCorrespondentUpdate(id, payload);
|
||||
},
|
||||
[onCorrespondentUpdate],
|
||||
);
|
||||
|
||||
const handleCorrespondentDeleteSafe = useCallback<CorrespondentsPanelProps['onDelete']>(
|
||||
async (id) => {
|
||||
if (typeof onCorrespondentDelete !== 'function') {
|
||||
return;
|
||||
}
|
||||
await onCorrespondentDelete(id);
|
||||
},
|
||||
[onCorrespondentDelete],
|
||||
);
|
||||
|
||||
const correspondentsModal = useMemo(() => {
|
||||
if (activeModal !== CORRESPONDENTS_MODAL) {
|
||||
return null;
|
||||
@@ -167,9 +197,9 @@ export const useManagementModals = ({
|
||||
<CorrespondentsPanel
|
||||
correspondents={correspondents}
|
||||
onRefresh={refreshCorrespondents}
|
||||
onCreate={onCorrespondentCreate}
|
||||
onUpdate={onCorrespondentUpdate}
|
||||
onDelete={onCorrespondentDelete}
|
||||
onCreate={handleCorrespondentCreateSafe}
|
||||
onUpdate={handleCorrespondentUpdateSafe}
|
||||
onDelete={handleCorrespondentDeleteSafe}
|
||||
onNotify={setStatusMessage}
|
||||
/>
|
||||
</div>
|
||||
@@ -180,9 +210,9 @@ export const useManagementModals = ({
|
||||
activeModal,
|
||||
closeActiveModal,
|
||||
correspondents,
|
||||
onCorrespondentCreate,
|
||||
onCorrespondentDelete,
|
||||
onCorrespondentUpdate,
|
||||
handleCorrespondentCreateSafe,
|
||||
handleCorrespondentDeleteSafe,
|
||||
handleCorrespondentUpdateSafe,
|
||||
refreshCorrespondents,
|
||||
setStatusMessage,
|
||||
]);
|
||||
|
||||
@@ -140,7 +140,6 @@ export const useWorkspaceSurface = ({
|
||||
onUpdateTitle,
|
||||
onUpdateIssued,
|
||||
resolveFolderPath,
|
||||
onFolderNavigate,
|
||||
} = detailExtras;
|
||||
return createDocumentViewerSurface({
|
||||
document: previewWorkspaceDocument,
|
||||
@@ -162,7 +161,6 @@ export const useWorkspaceSurface = ({
|
||||
onUpdateTitle,
|
||||
onUpdateIssued,
|
||||
resolveFolderPath,
|
||||
onFolderNavigate,
|
||||
});
|
||||
}, [
|
||||
showPreviewWorkspace,
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 462 KiB |
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 128 128" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:bevel;">
|
||||
<g transform="matrix(1,0,0,1,-117.44,-234.88)">
|
||||
<g transform="matrix(0.500026,0,0,0.500026,90.3513,215.477)">
|
||||
<g>
|
||||
<path d="M168.083,199.099L72.029,143.641" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(0.556777,0,0,0.556777,80.018,205.619)">
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M132.168,192.252L104.103,176.048" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M132.168,206.14L104.103,189.937" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M167.57,268.244L68.737,211.183" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M104.103,176.048L103.474,175.723L102.852,175.478L102.245,175.315L101.657,175.236L101.096,175.242L100.568,175.334L100.079,175.509L99.634,175.766L99.238,176.102L98.895,176.514L98.609,176.996L98.383,177.545L98.22,178.152L98.122,178.814L98.089,179.52L98.122,180.265L98.22,181.04L98.383,181.836L98.609,182.645L98.895,183.458L99.238,184.265L99.634,185.059L100.079,185.83L100.568,186.57L101.096,187.271L101.657,187.924L102.245,188.524L102.852,189.063L103.474,189.536L104.103,189.937" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M132.168,206.14L132.797,206.465L133.418,206.71L134.026,206.873L134.614,206.952L135.175,206.946L135.703,206.855L136.192,206.68L136.637,206.423L137.034,206.087L137.376,205.675L137.662,205.193L137.888,204.644L138.051,204.036L138.149,203.375L138.182,202.668L138.149,201.923L138.051,201.149L137.888,200.352L137.662,199.543L137.376,198.731L137.034,197.923L136.637,197.13L136.192,196.359L135.703,195.619L135.175,194.918L134.614,194.264L134.026,193.664L133.418,193.125L132.797,192.653L132.168,192.252" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M294.73,124.536L196.717,67.948" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(0.556777,0,0,0.556777,80.018,205.619)">
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M196.317,98.806L196.317,71.884" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M295.747,125.124L295.747,193.77" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M167.851,198.965L295.747,125.124" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M167.851,266.435L167.851,198.965" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M295.747,194.567L167.851,268.406" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M68.42,141.558L196.317,67.717" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.9164,0,0,1.9164,-51.1148,-197.163)">
|
||||
<path d="M68.42,211.001L68.42,178.595" style="fill:none;stroke:black;stroke-width:3.75px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,7.78289,-9.23527)">
|
||||
<path d="M255.894,171.556L265.958,177.367" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,7.78289,-9.23527)">
|
||||
<path d="M109.608,187.222L121.91,180.119" style="fill:none;stroke:black;stroke-width:8px;"/>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,117.44,234.88)">
|
||||
<path d="M20.204,53.429C19.122,53.646 18.067,52.943 17.851,51.86C17.634,50.778 18.337,49.723 19.419,49.507C32.861,46.817 55.928,36.028 66.499,27.302C67.35,26.599 68.612,26.719 69.314,27.571C70.017,28.422 69.897,29.684 69.045,30.386C58.078,39.44 34.149,50.639 20.204,53.429Z"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,117.44,234.88)">
|
||||
<path d="M67.109,30.448C66.113,29.972 65.691,28.777 66.167,27.781C66.643,26.785 67.838,26.364 68.834,26.84C81.354,32.825 101.513,41.54 107.158,43.063C108.224,43.351 108.856,44.449 108.569,45.515C108.281,46.581 107.182,47.212 106.117,46.925C100.373,45.376 79.848,36.539 67.109,30.448Z"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,117.44,234.88)">
|
||||
<path d="M59.739,53.406C60.43,52.839 61.443,52.789 62.195,53.343C63.083,53.999 63.272,55.252 62.616,56.14C62.573,56.199 62.083,56.711 61.028,57.286C58.509,58.66 51.351,61.791 41.049,60.655C31.881,59.645 25.742,56.597 18.948,53.577C17.939,53.128 17.485,51.946 17.933,50.937C18.381,49.928 19.564,49.473 20.573,49.922C27.007,52.782 32.805,55.722 41.487,56.68C51.662,57.801 58.36,54.284 59.678,53.445C59.698,53.433 59.72,53.418 59.739,53.406Z"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,117.44,234.88)">
|
||||
<path d="M105.62,43.391C106.569,42.827 107.798,43.14 108.361,44.089C108.925,45.038 108.612,46.266 107.663,46.83C99.112,51.908 86.613,56.607 84.3,57.143C83.382,57.356 77.624,58.751 71.659,58.922C67.399,59.044 63.055,58.498 60.067,56.682C59.124,56.109 58.823,54.878 59.396,53.934C59.97,52.991 61.201,52.69 62.144,53.264C64.56,54.732 68.1,55.023 71.545,54.924C77.138,54.763 82.536,53.446 83.397,53.246C85.599,52.736 97.48,48.225 105.62,43.391Z"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,117.44,234.88)">
|
||||
<path d="M36.623,60.052C35.546,59.811 34.868,58.74 35.109,57.663C35.351,56.586 36.421,55.907 37.498,56.149C55.185,60.116 65.349,60.658 86.099,52.366C87.124,51.956 88.288,52.455 88.698,53.48C89.108,54.505 88.608,55.67 87.583,56.08C65.826,64.775 55.169,64.211 36.623,60.052Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.8 KiB |
@@ -51,6 +51,13 @@ interface NavigatorSnapshot {
|
||||
height?: number | null;
|
||||
}
|
||||
|
||||
type OverlayOriginHint = {
|
||||
rotation?: number;
|
||||
scale?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
interface OverlayOriginTransform {
|
||||
rotation: number;
|
||||
scaleX: number;
|
||||
@@ -180,7 +187,7 @@ interface DesktopWorkspaceViewProps {
|
||||
bringToFront: (docId: Identifier | null | undefined) => void;
|
||||
setDraggingId: (value: string | null) => void;
|
||||
canvasSize: { width: number; height: number };
|
||||
openOverlayForDoc: (docId: Identifier | null | undefined, originInfo?: OverlayOriginTransform | null) => void;
|
||||
openOverlayForDoc: (docId: Identifier | null | undefined, originInfo?: OverlayOriginHint | null) => void;
|
||||
recalcVisibleDocIds: () => void;
|
||||
dragSettings: DragSettings;
|
||||
onInspectDocument?: DesktopWorkspaceProps['onInspectDocument'];
|
||||
@@ -340,7 +347,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
|
||||
if (existing && existing.width === normalized.width && existing.height === normalized.height) {
|
||||
return;
|
||||
}
|
||||
const next = new Map(docSizeMapRef.current);
|
||||
const next = new Map<string, DocumentSizeInfo>(docSizeMapRef.current);
|
||||
next.set(docKey, { ...normalized, source: 'snapshot' });
|
||||
docSizeMapRef.current = next;
|
||||
setDocSizeVersion((value) => value + 1);
|
||||
@@ -505,7 +512,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
const current = docSizeMapRef.current;
|
||||
const next = new Map(current);
|
||||
const next = new Map<string, DocumentSizeInfo>(current);
|
||||
const itemKeys = new Set(items.filter((doc) => doc?.id != null).map((doc) => String(doc.id)));
|
||||
let changed = false;
|
||||
|
||||
@@ -618,7 +625,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
|
||||
}, [draggingId, items, setDraggingId]);
|
||||
|
||||
const openOverlayForDoc = useCallback(
|
||||
(docId: Identifier | null | undefined, originInfo: OverlayOriginTransform | null = null) => {
|
||||
(docId: Identifier | null | undefined, originInfo: OverlayOriginHint | null = null) => {
|
||||
if (!docId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -126,8 +126,6 @@ const createDesktopSurface = ({
|
||||
title,
|
||||
subtitle,
|
||||
sidebarToggle,
|
||||
parentBreadcrumb,
|
||||
onNavigateParent,
|
||||
actions,
|
||||
breadcrumbs: workspaceProps?.breadcrumbs || null,
|
||||
selectionLabel: null,
|
||||
|
||||
@@ -21,8 +21,11 @@ export const preventAll = (event?: PreventableEvent | null): void => {
|
||||
|
||||
type AnyFn = (...args: unknown[]) => unknown;
|
||||
|
||||
export const safeInvoke = <Fn extends AnyFn>(fn: Fn | null | undefined, ...args: Parameters<Fn>): ReturnType<Fn> | undefined =>
|
||||
(fn ? fn(...args) : undefined);
|
||||
export const safeInvoke = <Fn extends AnyFn>(
|
||||
fn: Fn | null | undefined,
|
||||
...args: Parameters<Fn>
|
||||
): ReturnType<Fn> | undefined =>
|
||||
(fn ? (fn(...args) as ReturnType<Fn>) : undefined);
|
||||
|
||||
export const getPointerPosition = (
|
||||
event?: PointerLikeEvent | null,
|
||||
|
||||
@@ -65,8 +65,8 @@ export const createPointerIntent = ({
|
||||
const alreadySelected = selectedDocumentIds.includes(doc.id);
|
||||
const selectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0;
|
||||
|
||||
let clickAction = CLICK_ACTIONS.none;
|
||||
let dragAction = DRAG_ACTIONS.none;
|
||||
let clickAction: ClickAction = CLICK_ACTIONS.none;
|
||||
let dragAction: DragAction = DRAG_ACTIONS.none;
|
||||
|
||||
if (metaKey) {
|
||||
clickAction = CLICK_ACTIONS.addStack;
|
||||
@@ -79,8 +79,8 @@ export const createPointerIntent = ({
|
||||
dragAction = DRAG_ACTIONS.dragSelectSingle;
|
||||
}
|
||||
|
||||
const stackList = Array.isArray(stackHits) && stackHits.length > 0
|
||||
? stackHits.slice()
|
||||
const stackList: string[] = Array.isArray(stackHits) && stackHits.length > 0
|
||||
? stackHits.map((value) => String(value))
|
||||
: [String(doc.id)];
|
||||
|
||||
const stackDocIdsForDrag = dragAction === DRAG_ACTIONS.dragSelectStack ? stackList : null;
|
||||
@@ -160,9 +160,9 @@ export const applyLongPressSelection = ({ intent, stackDocIds, syntheticEvent, o
|
||||
return;
|
||||
}
|
||||
|
||||
const stackCopy = Array.isArray(stackDocIds) && stackDocIds.length > 0
|
||||
? stackDocIds.slice()
|
||||
: [intent.docId];
|
||||
const stackCopy: string[] = Array.isArray(stackDocIds) && stackDocIds.length > 0
|
||||
? stackDocIds.map((value) => String(value))
|
||||
: [String(intent.docId)];
|
||||
|
||||
safeInvoke(onDocumentStackSelect, stackCopy, syntheticEvent, { replace: true });
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ interface DragGroupItemInternal extends EngineGroupItem {
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
targetRotation?: number;
|
||||
initialRotation?: number;
|
||||
}
|
||||
|
||||
type EnsureDocumentSizeFn = (doc: DocumentLike | null | undefined) => DocumentSizeInfo | null;
|
||||
@@ -466,9 +467,9 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
||||
baseOffsetY,
|
||||
offsetX: baseOffsetX,
|
||||
offsetY: baseOffsetY,
|
||||
initialRotation,
|
||||
targetRotation: initialRotation,
|
||||
displayRotation: initialRotation,
|
||||
targetRotation,
|
||||
|
||||
} satisfies DragGroupItemInternal;
|
||||
});
|
||||
|
||||
@@ -897,6 +898,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
||||
if (state.moved) {
|
||||
commitActiveDragTransforms([state.docKey]);
|
||||
const inertiaState: EngineInertiaState = {
|
||||
docId: state.docKey,
|
||||
restRotation: state.restRotation,
|
||||
dynamicRotation: state.dynamicRotation,
|
||||
angularVelocity: state.angularVelocity,
|
||||
@@ -904,6 +906,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
dragScale: state.dragScale || 1,
|
||||
lastTimestamp: state.lastTimestamp,
|
||||
};
|
||||
const docId = state.docKey;
|
||||
finishDrag(event.pointerId);
|
||||
@@ -956,6 +959,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
||||
|
||||
commitActiveDragTransforms([state.docKey]);
|
||||
const inertiaState: EngineInertiaState = {
|
||||
docId: state.docKey,
|
||||
restRotation: state.restRotation,
|
||||
dynamicRotation: state.dynamicRotation,
|
||||
angularVelocity: state.angularVelocity,
|
||||
@@ -963,6 +967,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
dragScale: state.dragScale || 1,
|
||||
lastTimestamp: state.lastTimestamp,
|
||||
};
|
||||
const docId = state.docKey;
|
||||
finishDrag(event.pointerId);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isDocumentRowKey,
|
||||
} from '../app/appLayoutUtils';
|
||||
import type { DocumentInfoPanelProps } from '../documents/DocumentInfoPanel';
|
||||
import type { EnsureAssetUrl, GetDocumentAsset } from '../utils/ocr';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
@@ -51,8 +52,8 @@ interface UseDetailWorkspaceArgs {
|
||||
handleDocumentIssuedUpdate?: (docId: Identifier, issued: number | null) => Promise<boolean> | boolean;
|
||||
handleDocumentTagAdd?: (doc: DocumentLike, value: string, context?: { option?: unknown }) => void;
|
||||
handleTagRemove?: (...args: unknown[]) => void;
|
||||
ensureAssetUrl?: (...args: unknown[]) => unknown;
|
||||
getDocumentAsset?: (...args: unknown[]) => unknown;
|
||||
ensureAssetUrl?: EnsureAssetUrl;
|
||||
getDocumentAsset?: GetDocumentAsset;
|
||||
ensurePreviewData?: (docId: Identifier, options?: Record<string, unknown>) => Promise<DocumentLike | null | undefined>;
|
||||
correspondents?: unknown[];
|
||||
handleCorrespondentAdd?: (...args: unknown[]) => void;
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ReactNode } from 'react';
|
||||
import DocumentSummarySection, { DocumentSummarySectionProps } from './DocumentSummarySection';
|
||||
import { buildDocumentMetadataItems, extractDocumentMetadataPayload } from './documentMetadata';
|
||||
|
||||
type PanelTab = { id: string; label: string; render: () => ReactNode };
|
||||
type PanelTab = { id: string; label: string; render: (context?: Record<string, unknown>) => ReactNode };
|
||||
|
||||
type ContentState =
|
||||
| { status: 'idle'; data: null; error: null }
|
||||
@@ -13,7 +13,7 @@ type ContentState =
|
||||
| { status: 'unavailable'; data: null; error: null }
|
||||
| { status: 'error'; data: null; error: unknown };
|
||||
|
||||
interface DocumentInfoPanelProps {
|
||||
export interface DocumentInfoPanelProps {
|
||||
document: DocumentSummarySectionProps['document'];
|
||||
summaryProps?: Omit<DocumentSummarySectionProps, 'document' | 'detailItems' | 'layout'>;
|
||||
metadataItems?: Array<{ label: string; value?: string }>;
|
||||
|
||||
@@ -110,6 +110,31 @@ interface QuickAddEntry {
|
||||
original: QuickAddOption | string;
|
||||
}
|
||||
|
||||
const resolveOptionName = (source: unknown): string => {
|
||||
if (!source) {
|
||||
return '';
|
||||
}
|
||||
if (typeof source === 'string') {
|
||||
return source.trim();
|
||||
}
|
||||
if (typeof source === 'object') {
|
||||
const candidate = source as { name?: string; label?: string; trim?: () => string };
|
||||
if (typeof candidate.name === 'string' && candidate.name.trim()) {
|
||||
return candidate.name.trim();
|
||||
}
|
||||
if (typeof candidate.label === 'string' && candidate.label.trim()) {
|
||||
return candidate.label.trim();
|
||||
}
|
||||
if (typeof candidate.trim === 'function') {
|
||||
const viaTrim = candidate.trim();
|
||||
if (typeof viaTrim === 'string' && viaTrim.trim()) {
|
||||
return viaTrim.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeQuickAddOption = (option: QuickAddOption | string | null | undefined): QuickAddEntry | null => {
|
||||
if (option == null) {
|
||||
return null;
|
||||
@@ -348,16 +373,12 @@ export const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
|
||||
return;
|
||||
}
|
||||
const source = item.payload ?? item;
|
||||
const resolvedName =
|
||||
source?.name?.trim?.()
|
||||
|| source?.label?.trim?.()
|
||||
|| source?.trim?.()
|
||||
|| '';
|
||||
const resolvedName = resolveOptionName(source);
|
||||
if (!resolvedName) {
|
||||
return;
|
||||
}
|
||||
const payload = typeof source === 'object'
|
||||
? { ...source, name: resolvedName }
|
||||
const payload = (source && typeof source === 'object')
|
||||
? { ...(source as Record<string, unknown>), name: resolvedName }
|
||||
: { id: null, name: resolvedName };
|
||||
onAdd({ name: resolvedName, option: payload, input: null });
|
||||
},
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { CSSProperties, JSX, MutableRefObject } from 'react';
|
||||
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||
import {
|
||||
getAssetFromVersion,
|
||||
resolveDocumentAssetUrl,
|
||||
createAssetView,
|
||||
} from '../asset_manager';
|
||||
import type {
|
||||
DocumentLike as AssetManagerDocumentLike,
|
||||
AssetLike as AssetManagerAssetLike,
|
||||
EnsureAssetUrl as AssetManagerEnsureAssetUrl,
|
||||
GetAsset as AssetManagerGetAsset,
|
||||
} from '../asset_manager';
|
||||
|
||||
const DEFAULT_THUMBNAIL_SIZE = 48;
|
||||
|
||||
@@ -74,26 +84,10 @@ interface DocumentVersionLike {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DocumentLike {
|
||||
id?: Identifier;
|
||||
current_version?: DocumentVersionLike;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AssetLike {
|
||||
id?: Identifier;
|
||||
url?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type EnsureAssetUrl = (
|
||||
documentId: Identifier,
|
||||
asset: AssetLike,
|
||||
options?: { start?: number; limit?: number; [key: string]: unknown },
|
||||
) => Promise<unknown> | void;
|
||||
|
||||
type GetDocumentAsset = (document: DocumentLike | null | undefined, assetType: string) => AssetLike | null | undefined;
|
||||
type DocumentLike = AssetManagerDocumentLike;
|
||||
type AssetLike = AssetManagerAssetLike;
|
||||
type EnsureAssetUrl = AssetManagerEnsureAssetUrl;
|
||||
type GetDocumentAsset = AssetManagerGetAsset;
|
||||
|
||||
interface DocumentThumbnailImageProps {
|
||||
document?: DocumentLike | null;
|
||||
|
||||
@@ -127,9 +127,9 @@ const DocumentsGrid: React.FC<DocumentsGridProps> = ({
|
||||
submitEditing: submitDocumentEditing,
|
||||
savingId: savingDocumentId,
|
||||
attachInputRef: attachDocumentInputRef,
|
||||
} = useInlineRename(onDocumentRename, {
|
||||
getCurrentValue: (doc) => doc?.title ?? '',
|
||||
getEntityId: (doc) => doc?.id ?? null,
|
||||
} = useInlineRename<DocumentLike>(onDocumentRename, {
|
||||
getCurrentValue: (doc: DocumentLike) => doc?.title ?? '',
|
||||
getEntityId: (doc: DocumentLike) => doc?.id ?? null,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -141,9 +141,9 @@ const DocumentsGrid: React.FC<DocumentsGridProps> = ({
|
||||
submitEditing: submitFolderEditing,
|
||||
savingId: savingFolderId,
|
||||
attachInputRef: attachFolderInputRef,
|
||||
} = useInlineRename(onFolderRename, {
|
||||
getCurrentValue: (folder) => folder?.name ?? '',
|
||||
getEntityId: (folder) => folder?.id ?? null,
|
||||
} = useInlineRename<FolderLike>(onFolderRename, {
|
||||
getCurrentValue: (folder: FolderLike) => folder?.name ?? '',
|
||||
getEntityId: (folder: FolderLike) => folder?.id ?? null,
|
||||
});
|
||||
|
||||
const documentSelectionCount = selectedDocumentIdsSet?.size ?? 0;
|
||||
|
||||
@@ -132,9 +132,9 @@ const DocumentsList: React.FC<DocumentsListProps> = ({
|
||||
submitEditing: submitDocumentEditing,
|
||||
savingId: savingDocumentId,
|
||||
attachInputRef: attachDocumentInputRef,
|
||||
} = useInlineRename(onDocumentRename, {
|
||||
getCurrentValue: (doc) => doc?.title ?? '',
|
||||
getEntityId: (doc) => doc?.id ?? null,
|
||||
} = useInlineRename<DocumentLike>(onDocumentRename, {
|
||||
getCurrentValue: (doc: DocumentLike) => doc?.title ?? '',
|
||||
getEntityId: (doc: DocumentLike) => doc?.id ?? null,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -146,9 +146,9 @@ const DocumentsList: React.FC<DocumentsListProps> = ({
|
||||
submitEditing: submitFolderEditing,
|
||||
savingId: savingFolderId,
|
||||
attachInputRef: attachFolderInputRef,
|
||||
} = useInlineRename(onFolderRename, {
|
||||
getCurrentValue: (folder) => folder?.name ?? '',
|
||||
getEntityId: (folder) => folder?.id ?? null,
|
||||
} = useInlineRename<FolderLike>(onFolderRename, {
|
||||
getCurrentValue: (folder: FolderLike) => folder?.name ?? '',
|
||||
getEntityId: (folder: FolderLike) => folder?.id ?? null,
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -503,38 +503,82 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const showPrimaryButtons = Boolean(onBulkReanalyze || onDeleteSelection || onClearSelection);
|
||||
|
||||
const moveMenu = typeof onMoveDocumentsToFolder === 'function' ? (
|
||||
<SelectionAssignmentMenu
|
||||
label="Move"
|
||||
triggerContent={(
|
||||
<span className="quick-add__chip-label">
|
||||
{loadingFolders ? (
|
||||
<LoaderIcon className="icon-inline icon--spin" aria-hidden="true" />
|
||||
) : (
|
||||
<FolderOutlineIcon className="icon-inline" aria-hidden="true" />
|
||||
)}
|
||||
{' '}
|
||||
Move
|
||||
</span>
|
||||
)}
|
||||
items={moveAssignments}
|
||||
placeholder="Search folders…"
|
||||
emptyMessage={loadingFolders ? 'Loading folders…' : 'No folders'}
|
||||
onToggle={(item) => handleMoveSelectionToFolder(item?.payload || item)}
|
||||
disabled={!documentCount || (loadingFolders && !moveAssignments.length)}
|
||||
createLabel={null}
|
||||
showStateIndicators={false}
|
||||
showCounts={false}
|
||||
onOpenMenu={handleMoveMenuOpen}
|
||||
renderItemLabel={renderFolderLabel}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const primaryButtons = showPrimaryButtons ? (
|
||||
<div className="panel-floating__buttons">
|
||||
{typeof onBulkReanalyze === 'function' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button panel-floating-actions__button"
|
||||
onClick={() => onBulkReanalyze(documentIdList)}
|
||||
aria-label="Re-run analysis for selection"
|
||||
title="Re-run analysis for selection"
|
||||
disabled={documentIdList.length === 0}
|
||||
>
|
||||
<AnalyzeIcon className="icon-inline" />
|
||||
</button>
|
||||
) : null}
|
||||
{typeof onDeleteSelection === 'function' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button danger panel-floating-actions__button"
|
||||
onClick={onDeleteSelection}
|
||||
aria-label="Delete selected items"
|
||||
disabled={totalCount === 0}
|
||||
>
|
||||
<TrashIcon className="icon-inline" />
|
||||
</button>
|
||||
) : null}
|
||||
{typeof onClearSelection === 'function' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button panel-floating-actions__button"
|
||||
onClick={onClearSelection}
|
||||
aria-label="Clear selection"
|
||||
title="Clear selection"
|
||||
disabled={totalCount === 0}
|
||||
>
|
||||
<IconX className="icon-inline" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{summaryNode ? (
|
||||
<span className="panel-floating__label">{summaryNode}</span>
|
||||
) : null}
|
||||
<div className="panel-floating-actions">
|
||||
{typeof onMoveDocumentsToFolder === 'function' ? (
|
||||
<SelectionAssignmentMenu
|
||||
label="Move"
|
||||
triggerContent={(
|
||||
<span className="quick-add__chip-label">
|
||||
{loadingFolders ? (
|
||||
<LoaderIcon className="icon-inline icon--spin" aria-hidden="true" />
|
||||
) : (
|
||||
<FolderOutlineIcon className="icon-inline" aria-hidden="true" />
|
||||
)}
|
||||
{' '}
|
||||
Move
|
||||
</span>
|
||||
)}
|
||||
items={moveAssignments}
|
||||
placeholder="Search folders…"
|
||||
emptyMessage={loadingFolders ? 'Loading folders…' : 'No folders'}
|
||||
onToggle={(item) => handleMoveSelectionToFolder(item?.payload || item)}
|
||||
disabled={!documentCount || (loadingFolders && !moveAssignments.length)}
|
||||
createLabel={null}
|
||||
showStateIndicators={false}
|
||||
showCounts={false}
|
||||
onOpenMenu={handleMoveMenuOpen}
|
||||
renderItemLabel={renderFolderLabel}
|
||||
/>
|
||||
) : null}
|
||||
<div className="panel-floating-actions panel-floating-actions--assignments">
|
||||
{moveMenu}
|
||||
<SelectionAssignmentMenu
|
||||
label="Tags"
|
||||
triggerContent={(
|
||||
@@ -565,42 +609,8 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
|
||||
onCreate={handleCreateCorrespondentAssignment}
|
||||
disabled={!documentCount}
|
||||
/>
|
||||
{typeof onBulkReanalyze === 'function' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button panel-floating-actions__button"
|
||||
onClick={() => onBulkReanalyze(documentIdList)}
|
||||
aria-label="Re-run analysis for selection"
|
||||
title="Re-run analysis for selection"
|
||||
disabled={documentIdList.length === 0}
|
||||
>
|
||||
<AnalyzeIcon className="icon-inline" />
|
||||
</button>
|
||||
) : null}
|
||||
{typeof onDeleteSelection === 'function' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button danger panel-floating-actions__button"
|
||||
onClick={onDeleteSelection}
|
||||
aria-label="Delete selected items"
|
||||
disabled={totalCount === 0}
|
||||
>
|
||||
<TrashIcon className="icon-inline" />
|
||||
</button>
|
||||
) : null}
|
||||
{typeof onClearSelection === 'function' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button panel-floating-actions__button"
|
||||
onClick={onClearSelection}
|
||||
aria-label="Clear selection"
|
||||
title="Clear selection"
|
||||
disabled={totalCount === 0}
|
||||
>
|
||||
<IconX className="icon-inline" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{primaryButtons}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import { openOcrTextInNewTab } from '../utils/ocr';
|
||||
import type {
|
||||
EnsureAssetUrl,
|
||||
EnsurePreviewData,
|
||||
GetDocumentAsset,
|
||||
DocumentLike as OcrDocumentLike,
|
||||
} from '../utils/ocr';
|
||||
|
||||
type ResolveApiPath = (path: string) => string;
|
||||
type EnsurePreviewData = (id: string | number) => Promise<void>;
|
||||
type EnsureAssetUrl = (id: string | number, asset: unknown, options?: unknown) => Promise<unknown>;
|
||||
type GetDocumentAsset = (document: DocumentLike, type: string) => unknown;
|
||||
|
||||
interface DocumentVersion {
|
||||
download_path?: string | null;
|
||||
}
|
||||
|
||||
export interface DocumentLike {
|
||||
id?: string | number;
|
||||
current_version?: DocumentVersion | null;
|
||||
}
|
||||
export type DocumentLike = OcrDocumentLike;
|
||||
|
||||
const asyncFalse = async () => false;
|
||||
|
||||
@@ -20,7 +16,7 @@ const resolveDocumentDownloadHref = (document: DocumentLike | null | undefined,
|
||||
if (!document || !resolveApiPath) {
|
||||
return null;
|
||||
}
|
||||
const downloadPath = document.current_version?.download_path;
|
||||
const downloadPath = (document.current_version as { download_path?: string | null } | null | undefined)?.download_path;
|
||||
if (!downloadPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export interface UseDocumentsPanelPropsArgs {
|
||||
clearDocumentSelection?: () => void;
|
||||
handleDeleteSelection?: () => void;
|
||||
handleEntryPointerCore?: (...args: unknown[]) => void;
|
||||
inspectDocument?: (...args: unknown[]) => void;
|
||||
inspectDocument?: (docId: Identifier | null, metadata?: unknown) => void;
|
||||
handleEntrySelection?: (...args: unknown[]) => void;
|
||||
tags?: unknown[];
|
||||
correspondents?: unknown[];
|
||||
|
||||
@@ -19,6 +19,8 @@ interface DocumentsPanelProps {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const defaultGetDocumentAsset = (_doc?: unknown, _type?: string) => null;
|
||||
|
||||
const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
|
||||
currentFolderName,
|
||||
breadcrumbs,
|
||||
@@ -49,7 +51,7 @@ const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
|
||||
activeCorrespondentIds = [],
|
||||
onFocusedRowChange,
|
||||
ensureAssetUrl = null,
|
||||
getDocumentAsset = () => null,
|
||||
getDocumentAsset = defaultGetDocumentAsset,
|
||||
onTagClick,
|
||||
onCorrespondentClick,
|
||||
isSearchLoading = false,
|
||||
@@ -207,7 +209,7 @@ const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
|
||||
);
|
||||
|
||||
const handleDocumentActivate = useCallback(
|
||||
(doc, event) => {
|
||||
(doc, event?: React.MouseEvent | KeyboardEvent | null) => {
|
||||
if (!doc) {
|
||||
return;
|
||||
}
|
||||
@@ -223,7 +225,7 @@ const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
|
||||
handleDocumentPreviewZoom(doc);
|
||||
return;
|
||||
}
|
||||
onInspectDocument?.(doc.id, event);
|
||||
onInspectDocument?.(doc.id);
|
||||
},
|
||||
[handleDocumentPreviewZoom, onInspectDocument],
|
||||
);
|
||||
|
||||
@@ -128,8 +128,6 @@ const createDocumentsSurface = ({
|
||||
title,
|
||||
subtitle,
|
||||
sidebarToggle,
|
||||
parentBreadcrumb,
|
||||
onNavigateParent,
|
||||
actions,
|
||||
breadcrumbs,
|
||||
selectionLabel: null,
|
||||
|
||||
@@ -77,10 +77,22 @@ export const readTagTransferData = (dataTransfer: DataTransfer | null | undefine
|
||||
return null;
|
||||
};
|
||||
|
||||
type DragEventLike = DragEvent | { dataTransfer?: DataTransfer | null };
|
||||
type DragEventLike = DragEvent | DataTransfer | {
|
||||
dataTransfer?: DataTransfer | null;
|
||||
type?: string;
|
||||
preventDefault?: () => void;
|
||||
stopPropagation?: () => void;
|
||||
};
|
||||
|
||||
export const parseTagTransferPayload = (input: DataTransfer | DragEventLike | null): TagPayload | null => {
|
||||
const dataTransfer = input && 'dataTransfer' in input ? input.dataTransfer : input;
|
||||
let dataTransfer: DataTransfer | null = null;
|
||||
if (input) {
|
||||
if (typeof DataTransfer !== 'undefined' && input instanceof DataTransfer) {
|
||||
dataTransfer = input;
|
||||
} else if (typeof input === 'object' && 'dataTransfer' in input && input.dataTransfer) {
|
||||
dataTransfer = input.dataTransfer;
|
||||
}
|
||||
}
|
||||
const raw = readTagTransferData(dataTransfer || null);
|
||||
if (!raw) {
|
||||
return null;
|
||||
@@ -96,11 +108,19 @@ export const parseTagTransferPayload = (input: DataTransfer | DragEventLike | nu
|
||||
};
|
||||
|
||||
export const isTagTransferEvent = (event?: DragEventLike | null): boolean => {
|
||||
const types = event?.dataTransfer?.types;
|
||||
if (!event) {
|
||||
return false;
|
||||
}
|
||||
let types: DOMStringList | ReadonlyArray<string> | undefined;
|
||||
if (typeof DataTransfer !== 'undefined' && event instanceof DataTransfer) {
|
||||
types = event.types;
|
||||
} else if ('dataTransfer' in event && event.dataTransfer) {
|
||||
types = event.dataTransfer.types;
|
||||
}
|
||||
if (!types) {
|
||||
return false;
|
||||
}
|
||||
const typeList = Array.isArray(types) ? types : Array.from(types);
|
||||
const typeList = Array.isArray(types) ? [...types] : Array.from(types);
|
||||
return TAG_MIME_TYPES.some((type) => typeList.includes(type));
|
||||
};
|
||||
|
||||
|
||||
@@ -93,6 +93,24 @@ const useDocumentCorrespondentActions = ({
|
||||
[apiClient, refreshCurrentFolder, notifyApiError, setStatusMessage],
|
||||
);
|
||||
|
||||
const normalizeOption = (
|
||||
option: CorrespondentOption | string | null,
|
||||
): CorrespondentOption | null => {
|
||||
if (!option) {
|
||||
return null;
|
||||
}
|
||||
if (typeof option === 'object' && 'id' in option) {
|
||||
return option as CorrespondentOption;
|
||||
}
|
||||
if (typeof option === 'string') {
|
||||
const trimmed = option.trim();
|
||||
if (trimmed) {
|
||||
return { id: null, name: trimmed };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleCorrespondentAdd = useCallback(
|
||||
async ({ document, name, input = null, option = null }: { document?: { id?: string | number }; name?: string; input?: HTMLInputElement | null; option?: CorrespondentOption | string | null }) => {
|
||||
if (!document?.id) {
|
||||
@@ -104,12 +122,7 @@ const useDocumentCorrespondentActions = ({
|
||||
return;
|
||||
}
|
||||
|
||||
let target = null;
|
||||
if (option && option.id) {
|
||||
target = correspondentLookupByName.get(trimmed.toLowerCase()) || option;
|
||||
} else {
|
||||
target = correspondentLookupByName.get(trimmed.toLowerCase()) || null;
|
||||
}
|
||||
let target = correspondentLookupByName.get(trimmed.toLowerCase()) || normalizeOption(option);
|
||||
if (!target) {
|
||||
try {
|
||||
target = await handleCorrespondentCreate({ name: trimmed });
|
||||
|
||||
@@ -102,12 +102,18 @@ const useDocumentDragHandlers = ({
|
||||
if (item.type === 'document') {
|
||||
const doc = item.payload;
|
||||
const rowEl = doc?.id
|
||||
? document.getElementById(`document-row-${doc.id}`)
|
||||
|| document.getElementById(`document-card-${doc.id}`)
|
||||
? (document.getElementById(`document-row-${doc.id}`)
|
||||
|| document.getElementById(`document-card-${doc.id}`))
|
||||
: null;
|
||||
const wrapperEl = rowEl instanceof HTMLElement
|
||||
? rowEl.querySelector<HTMLElement>('.document-thumbnail-wrapper')
|
||||
: null;
|
||||
const thumbnailEl = rowEl instanceof HTMLElement
|
||||
? rowEl.querySelector<HTMLImageElement>('.document-thumbnail')
|
||||
: null;
|
||||
const placeholderEl = rowEl instanceof HTMLElement
|
||||
? rowEl.querySelector<HTMLElement>('.thumb-placeholder')
|
||||
: null;
|
||||
const wrapperEl = rowEl?.querySelector('.document-thumbnail-wrapper');
|
||||
const thumbnailEl = rowEl?.querySelector('.document-thumbnail');
|
||||
const placeholderEl = rowEl?.querySelector('.thumb-placeholder');
|
||||
const aspectAttr = wrapperEl?.dataset?.thumbnailAspect;
|
||||
const aspectRatio = aspectAttr ? parseFloat(aspectAttr) : null;
|
||||
|
||||
@@ -130,7 +136,7 @@ const useDocumentDragHandlers = ({
|
||||
layer.classList.add('document-drag-preview__item--image');
|
||||
layer.style.backgroundImage = `url("${thumbSrc}")`;
|
||||
} else if (placeholderEl instanceof HTMLElement) {
|
||||
const clone = placeholderEl.cloneNode(true);
|
||||
const clone = placeholderEl.cloneNode(true) as HTMLElement;
|
||||
clone.style.pointerEvents = 'none';
|
||||
layer.appendChild(clone);
|
||||
} else {
|
||||
@@ -138,27 +144,36 @@ const useDocumentDragHandlers = ({
|
||||
}
|
||||
} else {
|
||||
const payload = item.payload;
|
||||
const folderId = payload?.id ?? (typeof payload?.trim === 'function' ? payload : null);
|
||||
const folderId = (payload && typeof payload === 'object' && 'id' in payload)
|
||||
? (payload as { id?: FolderIdentifier }).id
|
||||
: (typeof (payload as { trim?: () => string })?.trim === 'function'
|
||||
? (payload as { trim: () => string }).trim()
|
||||
: null);
|
||||
const rowEl = folderId
|
||||
? document.getElementById(`folder-row-${folderId}`)
|
||||
|| document.getElementById(`folder-card-${folderId}`)
|
||||
? (document.getElementById(`folder-row-${folderId}`)
|
||||
|| document.getElementById(`folder-card-${folderId}`))
|
||||
: null;
|
||||
const iconEl = rowEl instanceof HTMLElement
|
||||
? rowEl.querySelector('.thumb-icon, .folder-card__icon')
|
||||
: null;
|
||||
const iconEl = rowEl?.querySelector('.thumb-icon, .folder-card__icon');
|
||||
layer.style.width = `${size}px`;
|
||||
layer.style.height = `${size}px`;
|
||||
layer.classList.add('document-drag-preview__item--folder');
|
||||
|
||||
let content = null;
|
||||
let content: HTMLElement | null = null;
|
||||
if (iconEl instanceof HTMLElement) {
|
||||
const cloneSource = iconEl.classList.contains('folder-card__icon')
|
||||
? iconEl.querySelector('svg') || iconEl
|
||||
: iconEl;
|
||||
content = cloneSource.cloneNode(true);
|
||||
content.classList.add('document-drag-preview__folder-thumb');
|
||||
const svg = content.querySelector('svg');
|
||||
if (svg) {
|
||||
svg.setAttribute('width', '48');
|
||||
svg.setAttribute('height', '48');
|
||||
const clone = cloneSource.cloneNode(true);
|
||||
if (clone instanceof HTMLElement) {
|
||||
content = clone;
|
||||
content.classList.add('document-drag-preview__folder-thumb');
|
||||
const svg = content.querySelector('svg');
|
||||
if (svg) {
|
||||
svg.setAttribute('width', '48');
|
||||
svg.setAttribute('height', '48');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -326,12 +326,12 @@ const useDocumentMutations = ({
|
||||
return filtered.length === prev.length ? prev : filtered;
|
||||
});
|
||||
setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id as DocumentId)));
|
||||
setFolderContents((prev) => {
|
||||
setFolderContents((prev: Map<FolderId, FolderContents>) => {
|
||||
if (!prev.size) {
|
||||
return prev;
|
||||
}
|
||||
let changed = false;
|
||||
const next = new Map(prev);
|
||||
const next = new Map<FolderId, FolderContents>(prev);
|
||||
movedDocs.forEach(({ id, sourceFolderId }) => {
|
||||
const sourceKey = (sourceFolderId || 'root') as FolderId;
|
||||
const entry = next.get(sourceKey);
|
||||
@@ -714,8 +714,8 @@ const useDocumentMutations = ({
|
||||
|
||||
await api.delete(`/folders/${folderId}`);
|
||||
|
||||
setFolderNodes((prev) => {
|
||||
const next = new Map(prev);
|
||||
setFolderNodes((prev: Map<FolderId, FolderNode>) => {
|
||||
const next = new Map<FolderId, FolderNode>(prev);
|
||||
const node = next.get(folderId);
|
||||
next.delete(folderId);
|
||||
if (node) {
|
||||
@@ -733,8 +733,8 @@ const useDocumentMutations = ({
|
||||
return next;
|
||||
});
|
||||
|
||||
setFolderContents((prev) => {
|
||||
const next = new Map(prev);
|
||||
setFolderContents((prev: Map<FolderId, FolderContents>) => {
|
||||
const next = new Map<FolderId, FolderContents>(prev);
|
||||
next.delete(folderId);
|
||||
return next;
|
||||
});
|
||||
|
||||
@@ -214,11 +214,15 @@ const useDocumentTagging = ({
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await apiClient.post('/documents/bulk/reanalyze', {
|
||||
document_ids: targetIds,
|
||||
force: true,
|
||||
});
|
||||
const queued = data?.queued ?? targetIds.length;
|
||||
const response = await apiClient.post<{ queued?: number }>(
|
||||
'/documents/bulk/reanalyze',
|
||||
{
|
||||
document_ids: targetIds,
|
||||
force: true,
|
||||
},
|
||||
);
|
||||
const payload = 'data' in response ? response.data : response;
|
||||
const queued = typeof payload?.queued === 'number' ? payload.queued : targetIds.length;
|
||||
setStatusMessage(
|
||||
`Queued re-analysis for ${queued} document${queued === 1 ? '' : 's'}.`,
|
||||
'success',
|
||||
|
||||
@@ -28,6 +28,7 @@ type UploadQueueItem = {
|
||||
interface UploadResponse {
|
||||
reused?: boolean;
|
||||
document?: unknown;
|
||||
folder?: { id?: FolderId };
|
||||
}
|
||||
|
||||
interface ApiClient {
|
||||
@@ -35,15 +36,19 @@ interface ApiClient {
|
||||
get<T = { document?: unknown }>(url: string): Promise<{ data: T }>;
|
||||
}
|
||||
|
||||
type StatusLevel = 'success' | 'info' | 'warning' | 'error' | string;
|
||||
type NotifyApiError = (error: unknown, fallbackMessage?: string) => void;
|
||||
type SetStatusMessage = (message: string, level?: StatusLevel) => void;
|
||||
|
||||
type DropOverlayState = {
|
||||
active: boolean;
|
||||
folderName: string;
|
||||
};
|
||||
|
||||
type FileSystemEntryLike = FileSystemFileEntryLike | FileSystemDirectoryEntryLike;
|
||||
type FileSystemEntryLike = FileSystemEntry;
|
||||
|
||||
type ExtendedDataTransferItem = DataTransferItem & {
|
||||
webkitGetAsEntry?: () => FileSystemEntryLike | null;
|
||||
webkitGetAsEntry?: () => FileSystemEntry | null;
|
||||
};
|
||||
|
||||
interface FileSystemDirectoryReaderLike {
|
||||
@@ -98,6 +103,8 @@ interface UseDocumentUploadsArgs {
|
||||
refreshCurrentFolder: () => Promise<void>;
|
||||
setLoading: (state: boolean) => void;
|
||||
shellRef: MutableRefObject<HTMLElement | null>;
|
||||
notifyApiError?: NotifyApiError;
|
||||
setStatusMessage?: SetStatusMessage;
|
||||
}
|
||||
|
||||
interface UseDocumentUploadsResult {
|
||||
@@ -127,6 +134,8 @@ const useDocumentUploads = ({
|
||||
refreshCurrentFolder,
|
||||
setLoading,
|
||||
shellRef,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
}: UseDocumentUploadsArgs): UseDocumentUploadsResult => {
|
||||
const [dropOverlayState, setDropOverlayState] = useState<DropOverlayState>({
|
||||
active: false,
|
||||
@@ -145,8 +154,8 @@ const useDocumentUploads = ({
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file.name);
|
||||
if (targetFolderId && targetFolderId !== 'root') {
|
||||
formData.append('folder_id', targetFolderId);
|
||||
if (targetFolderId != null && targetFolderId !== 'root') {
|
||||
formData.append('folder_id', String(targetFolderId));
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -179,11 +188,13 @@ const useDocumentUploads = ({
|
||||
};
|
||||
}
|
||||
const message = error.response?.data?.error || `Failed to upload ${file.name}.`;
|
||||
notifyApiError?.(error, message);
|
||||
setStatusMessage?.(message, 'error');
|
||||
const wrapped = Object.assign(new Error(message), { response: error.response });
|
||||
throw wrapped;
|
||||
}
|
||||
},
|
||||
[apiClient],
|
||||
[apiClient, notifyApiError, setStatusMessage],
|
||||
);
|
||||
|
||||
const appendQueueItems = useCallback((entries: FileEntry[], targetFolderId?: FolderId) => {
|
||||
@@ -235,9 +246,13 @@ const useDocumentUploads = ({
|
||||
segments: trimmedSegments,
|
||||
};
|
||||
|
||||
const { data } = await apiClient.post('/folders/path', payload);
|
||||
cache.set(cacheKey, data.folder.id);
|
||||
return data.folder.id;
|
||||
const { data } = await apiClient.post<{ folder?: { id?: FolderId | null } }>(
|
||||
'/folders/path',
|
||||
payload,
|
||||
);
|
||||
const resolvedId = (data?.folder?.id ?? null) as FolderId;
|
||||
cache.set(cacheKey, resolvedId);
|
||||
return resolvedId;
|
||||
},
|
||||
[apiClient],
|
||||
);
|
||||
@@ -284,7 +299,7 @@ const useDocumentUploads = ({
|
||||
if (entry.isFile) {
|
||||
const file = await new Promise<File>((resolve, reject) => {
|
||||
try {
|
||||
(entry as FileSystemFileEntryLike).file(resolve, reject);
|
||||
(entry as unknown as FileSystemFileEntryLike).file(resolve, reject);
|
||||
} catch (error) {
|
||||
console.warn('[Uploads] entry.file failed', error);
|
||||
reject(error as Error);
|
||||
@@ -295,7 +310,7 @@ const useDocumentUploads = ({
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
const nextAncestors = entry.name ? [...ancestors, entry.name] : [...ancestors];
|
||||
const reader = (entry as FileSystemDirectoryEntryLike).createReader();
|
||||
const reader = (entry as unknown as FileSystemDirectoryEntryLike).createReader();
|
||||
const entries = await readAllEntries(reader);
|
||||
for (const child of entries) {
|
||||
await walkEntry(child, nextAncestors);
|
||||
@@ -506,7 +521,6 @@ const useDocumentUploads = ({
|
||||
hasFiles,
|
||||
defaultFolderName: DEFAULT_FOLDER_NAME,
|
||||
dragCounterRef,
|
||||
dropOverlayState,
|
||||
setDropOverlayState,
|
||||
});
|
||||
|
||||
|
||||
@@ -57,6 +57,44 @@ const EntryType = Object.freeze({
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
type Identifier = string | number;
|
||||
type DocumentId = Identifier;
|
||||
type FolderId = Identifier | 'root';
|
||||
|
||||
interface DocumentLike {
|
||||
id?: DocumentId | null;
|
||||
title?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FolderNode {
|
||||
id: FolderId;
|
||||
name?: string | null;
|
||||
parentId?: FolderId | null;
|
||||
children: FolderId[];
|
||||
hasChildren?: boolean;
|
||||
expanded?: boolean;
|
||||
loaded?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FolderContentsEntry {
|
||||
folder?: { id?: FolderId; name?: string | null } | null;
|
||||
documents?: DocumentLike[];
|
||||
subfolders?: Array<{ id?: FolderId; name?: string | null; [key: string]: unknown }>;
|
||||
__includesDocuments?: boolean;
|
||||
__sortField?: string | null;
|
||||
__sortDirection?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface TenantOption {
|
||||
id?: Identifier | null;
|
||||
name?: string | null;
|
||||
slug?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UseDocumentsWorkspaceOptions {
|
||||
documentsViewMode?: string;
|
||||
documentsSortField?: string;
|
||||
@@ -118,9 +156,31 @@ const useDocumentsWorkspace = ({
|
||||
const routeFolderId = folderMatch?.params?.folderId || null;
|
||||
const routeDocumentId = docMatch?.params?.documentId || null;
|
||||
const previewDocumentId = routeDocumentId;
|
||||
const { status: appStatus, token, tenant, tenants: tenantOptions = [] } = appState;
|
||||
const tenantName = tenant?.name || tenant?.slug || null;
|
||||
const currentTenantId = tenant?.id || null;
|
||||
const {
|
||||
status: appStatus,
|
||||
token,
|
||||
tenant,
|
||||
tenants: tenantOptionsRaw = [],
|
||||
} = appState;
|
||||
|
||||
const tenantRecord = (tenant ?? null) as TenantOption | null;
|
||||
const tenantName: string | null = typeof tenantRecord?.name === 'string'
|
||||
? tenantRecord.name
|
||||
: typeof tenantRecord?.slug === 'string'
|
||||
? tenantRecord.slug
|
||||
: null;
|
||||
|
||||
const currentTenantId: Identifier | null = (() => {
|
||||
const value = tenantRecord?.id;
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return value as Identifier;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const tenantOptions: TenantOption[] = Array.isArray(tenantOptionsRaw)
|
||||
? (tenantOptionsRaw as TenantOption[])
|
||||
: [];
|
||||
const { status, setStatusMessage } = useDocumentsStore();
|
||||
const handleApiReport = useCallback(
|
||||
({ message, variant }) => setStatusMessage(message, variant),
|
||||
@@ -172,9 +232,9 @@ const useDocumentsWorkspace = ({
|
||||
documentsRouteMatch || documentsFolderRouteMatch || documentsDetailRouteMatch,
|
||||
);
|
||||
|
||||
const [draggedDocumentIds, setDraggedDocumentIds] = useState([]);
|
||||
const [draggedFolderId, setDraggedFolderId] = useState(null);
|
||||
const [activePreviewId, setActivePreviewId] = useState(routeDocumentId || null);
|
||||
const [draggedDocumentIds, setDraggedDocumentIds] = useState<DocumentId[]>([]);
|
||||
const [draggedFolderId, setDraggedFolderId] = useState<FolderId | null>(null);
|
||||
const [activePreviewId, setActivePreviewId] = useState<DocumentId | null>(routeDocumentId || null);
|
||||
const shellRef = useRef(null);
|
||||
const assetManagerRef = useRef(null);
|
||||
if (!assetManagerRef.current) {
|
||||
@@ -249,7 +309,9 @@ const useDocumentsWorkspace = ({
|
||||
],
|
||||
);
|
||||
|
||||
const [folderContents, setFolderContents] = useState(() => new Map());
|
||||
const [folderContents, setFolderContents] = useState<Map<FolderId, FolderContentsEntry>>(
|
||||
() => new Map(),
|
||||
);
|
||||
const folderContentsRef = useRef(folderContents);
|
||||
useEffect(() => {
|
||||
folderContentsRef.current = folderContents;
|
||||
@@ -363,6 +425,17 @@ const useDocumentsWorkspace = ({
|
||||
setActivePreviewId,
|
||||
});
|
||||
|
||||
const openDocumentPreviewForDetail = useCallback(
|
||||
({ documentIds }: { documentIds?: Identifier[] } = {}) => {
|
||||
const targetId = documentIds?.find((value): value is Identifier => value != null);
|
||||
if (targetId == null) {
|
||||
return;
|
||||
}
|
||||
openDocumentPreview(targetId, { replace: true });
|
||||
},
|
||||
[openDocumentPreview],
|
||||
);
|
||||
|
||||
const getDocumentAsset = useCallback((doc, type) => {
|
||||
if (!doc || !type) return null;
|
||||
return getAssetFromVersion(doc.current_version || null, type);
|
||||
@@ -688,12 +761,13 @@ const useDocumentsWorkspace = ({
|
||||
|
||||
|
||||
const removeDocumentsFromCaches = useCallback(
|
||||
(documentIds) => {
|
||||
if (!documentIds || documentIds.length === 0) {
|
||||
(documentIds: DocumentId[] | null | undefined) => {
|
||||
const safeIds = Array.isArray(documentIds) ? documentIds : [];
|
||||
if (!safeIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idSet = new Set(documentIds);
|
||||
const idSet = new Set<DocumentId>(safeIds);
|
||||
|
||||
setDocuments((prev) => prev.filter((doc) => !idSet.has(doc.id)));
|
||||
setSearchResults((prev) => {
|
||||
@@ -704,12 +778,12 @@ const useDocumentsWorkspace = ({
|
||||
return filtered.length === prev.length ? prev : filtered;
|
||||
});
|
||||
|
||||
setFolderContents((prev) => {
|
||||
setFolderContents((prev: Map<FolderId, FolderContentsEntry>) => {
|
||||
if (!prev.size) {
|
||||
return prev;
|
||||
}
|
||||
let changed = false;
|
||||
const next = new Map();
|
||||
const next = new Map<FolderId, FolderContentsEntry>();
|
||||
prev.forEach((contents, key) => {
|
||||
const docs = Array.isArray(contents?.documents) ? contents.documents : null;
|
||||
if (!docs || docs.length === 0) {
|
||||
@@ -1177,7 +1251,6 @@ const useDocumentsWorkspace = ({
|
||||
documents,
|
||||
searchResults,
|
||||
previewDocuments,
|
||||
focusedDocumentId,
|
||||
selectionOrder,
|
||||
selectedDocumentIds,
|
||||
documentLookup,
|
||||
@@ -1188,8 +1261,7 @@ const useDocumentsWorkspace = ({
|
||||
previewEntries,
|
||||
previewDocumentId,
|
||||
activePreviewId,
|
||||
openDocumentPreview,
|
||||
promoteSelectionOrder,
|
||||
openDocumentPreview: openDocumentPreviewForDetail,
|
||||
handleDocumentTitleUpdate,
|
||||
handleDocumentIssuedUpdate,
|
||||
handleDocumentTagAdd,
|
||||
@@ -1206,6 +1278,17 @@ const useDocumentsWorkspace = ({
|
||||
tagLookupById,
|
||||
});
|
||||
|
||||
const inspectDocumentForDesk = useCallback(
|
||||
(doc: DocumentLike | null) => {
|
||||
const docId = doc?.id;
|
||||
if (docId == null) {
|
||||
return;
|
||||
}
|
||||
inspectDocument(docId);
|
||||
},
|
||||
[inspectDocument],
|
||||
);
|
||||
|
||||
const handleEntryPointerCore = useEntryPointerCore({
|
||||
resolveDocumentRowKey,
|
||||
resolveFolderRowKey,
|
||||
@@ -1426,7 +1509,7 @@ const useDocumentsWorkspace = ({
|
||||
handleDocumentsViewModeChange,
|
||||
handleDeskExit: handleDeskExitSafe,
|
||||
refreshCurrentFolder,
|
||||
inspectDocument,
|
||||
inspectDocument: inspectDocumentForDesk,
|
||||
handleEntryPointerCore,
|
||||
promoteSelectionOrder,
|
||||
currentTenantId,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { MutableRefObject, useEffect } from 'react';
|
||||
|
||||
type FolderId = string | number | 'root' | null;
|
||||
|
||||
interface DropOverlayState {
|
||||
active: boolean;
|
||||
folderName: string | null;
|
||||
@@ -9,8 +11,8 @@ interface UseFileDropOptions {
|
||||
shellRef: MutableRefObject<HTMLElement | null>;
|
||||
token?: string | null;
|
||||
currentFolderName: string | null;
|
||||
selectedFolder: string | null;
|
||||
handleFileDrop: (dataTransfer: DataTransfer, folderId: string | null) => Promise<void>;
|
||||
selectedFolder: FolderId;
|
||||
handleFileDrop: (dataTransfer: DataTransfer, folderId: FolderId) => Promise<void>;
|
||||
hasFiles: (event: DragEvent) => boolean;
|
||||
defaultFolderName: string;
|
||||
dragCounterRef: MutableRefObject<number>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||
import {
|
||||
DEFAULT_FOLDER_NAME,
|
||||
createRootNode,
|
||||
@@ -9,6 +10,80 @@ import {
|
||||
resolveFolderRowKey,
|
||||
} from '../../app/appLayoutUtils';
|
||||
|
||||
type Identifier = string | number;
|
||||
type FolderId = Identifier | 'root';
|
||||
|
||||
interface DocumentLike {
|
||||
id?: Identifier | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FolderSummary {
|
||||
id?: FolderId;
|
||||
name?: string;
|
||||
parent_id?: FolderId | null;
|
||||
parentId?: FolderId | null;
|
||||
children?: FolderId[];
|
||||
subfolders?: FolderSummary[];
|
||||
has_children?: boolean;
|
||||
hasChildren?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FolderContentsEntry {
|
||||
folder?: FolderSummary | null;
|
||||
documents?: DocumentLike[];
|
||||
subfolders?: FolderSummary[];
|
||||
__includesDocuments?: boolean;
|
||||
__sortField?: string | null;
|
||||
__sortDirection?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FolderTreeNode extends FolderSummary {
|
||||
id: FolderId;
|
||||
children: FolderId[];
|
||||
expanded?: boolean;
|
||||
loaded?: boolean;
|
||||
hasChildren?: boolean;
|
||||
}
|
||||
|
||||
interface AssetManagerLike {
|
||||
hydrateDocuments: (docs: DocumentLike[]) => DocumentLike[];
|
||||
hydrateFolderContents: (payload: FolderContentsEntry) => FolderContentsEntry;
|
||||
}
|
||||
|
||||
interface ApiClient {
|
||||
get<T = FolderContentsEntry>(path: string, config?: { params?: Record<string, unknown> }): Promise<{ data: T }>;
|
||||
}
|
||||
|
||||
interface SelectionHelpers {
|
||||
focusedDocumentId: Identifier | null;
|
||||
setFocusedDocumentId: Dispatch<SetStateAction<Identifier | null>>;
|
||||
setSelectedEntries: Dispatch<SetStateAction<string[]>>;
|
||||
setSelectionOrder: Dispatch<SetStateAction<string[]>>;
|
||||
selectionOrderRef: MutableRefObject<string[] | null>;
|
||||
selectionAnchorRef: MutableRefObject<string | null>;
|
||||
}
|
||||
|
||||
interface UseFolderTreeOptions {
|
||||
initialSelectedFolder?: FolderId;
|
||||
assetManager: AssetManagerLike;
|
||||
apiClient: ApiClient;
|
||||
tenantIdRef: MutableRefObject<Identifier | null>;
|
||||
documentsSortFieldRef: MutableRefObject<string>;
|
||||
documentsSortDirectionRef: MutableRefObject<string>;
|
||||
selectionHelpers: SelectionHelpers;
|
||||
setDocuments: Dispatch<SetStateAction<DocumentLike[]>>;
|
||||
setFolderContents: Dispatch<SetStateAction<Map<FolderId, FolderContentsEntry>>>;
|
||||
folderContentsRef: MutableRefObject<Map<FolderId, FolderContentsEntry>>;
|
||||
}
|
||||
|
||||
interface FolderOption {
|
||||
id: FolderId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const useFolderTree = ({
|
||||
initialSelectedFolder = 'root',
|
||||
assetManager,
|
||||
@@ -20,15 +95,15 @@ const useFolderTree = ({
|
||||
setDocuments,
|
||||
setFolderContents,
|
||||
folderContentsRef,
|
||||
}) => {
|
||||
const [folderNodes, setFolderNodes] = useState(() => {
|
||||
const rootNode = createRootNode();
|
||||
}: UseFolderTreeOptions) => {
|
||||
const [folderNodes, setFolderNodes] = useState<Map<FolderId, FolderTreeNode>>(() => {
|
||||
const rootNode = createRootNode() as FolderTreeNode;
|
||||
return new Map([[rootNode.id, rootNode]]);
|
||||
});
|
||||
|
||||
const [selectedFolder, setSelectedFolder] = useState(initialSelectedFolder || 'root');
|
||||
const [currentFolder, setCurrentFolder] = useState(null);
|
||||
const [currentSubfolders, setCurrentSubfolders] = useState([]);
|
||||
const [selectedFolder, setSelectedFolder] = useState<FolderId>(initialSelectedFolder || 'root');
|
||||
const [currentFolder, setCurrentFolder] = useState<FolderSummary | null>(null);
|
||||
const [currentSubfolders, setCurrentSubfolders] = useState<FolderSummary[]>([]);
|
||||
|
||||
const {
|
||||
focusedDocumentId,
|
||||
@@ -40,8 +115,8 @@ const useFolderTree = ({
|
||||
} = selectionHelpers;
|
||||
|
||||
const applySelectedFolder = useCallback(
|
||||
(folderId, contents) => {
|
||||
const subfolders = contents?.subfolders ?? [];
|
||||
(folderId: FolderId, contents?: FolderContentsEntry | null) => {
|
||||
const subfolders = Array.isArray(contents?.subfolders) ? contents.subfolders : [];
|
||||
const docs = assetManager.hydrateDocuments(contents?.documents ?? []);
|
||||
const folderInfo = contents?.folder ?? null;
|
||||
|
||||
@@ -50,12 +125,12 @@ const useFolderTree = ({
|
||||
setCurrentFolder(folderInfo);
|
||||
|
||||
const availableDocKeys = docs
|
||||
.map((doc) => resolveDocumentRowKey(doc.id))
|
||||
.map((doc) => resolveDocumentRowKey(doc?.id as Identifier))
|
||||
.filter(Boolean);
|
||||
const availableDocKeySet = new Set(availableDocKeys);
|
||||
const availableFolderKeys = new Set(
|
||||
subfolders
|
||||
.map((folder) => resolveFolderRowKey(folder.id))
|
||||
.map((folder) => resolveFolderRowKey(folder?.id as Identifier))
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
@@ -102,20 +177,20 @@ const useFolderTree = ({
|
||||
],
|
||||
);
|
||||
|
||||
const expandFolderAncestors = useCallback((targetId) => {
|
||||
const expandFolderAncestors = useCallback((targetId: FolderId | null) => {
|
||||
if (!targetId || targetId === 'root') {
|
||||
setFolderNodes((prev) => {
|
||||
setFolderNodes((prev: Map<FolderId, FolderTreeNode>) => {
|
||||
const root = prev.get('root');
|
||||
if (root?.expanded) return prev;
|
||||
const next = new Map(prev);
|
||||
const next = new Map<FolderId, FolderTreeNode>(prev);
|
||||
next.set('root', { ...root, expanded: true });
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setFolderNodes((prev) => {
|
||||
const next = new Map(prev);
|
||||
setFolderNodes((prev: Map<FolderId, FolderTreeNode>) => {
|
||||
const next = new Map<FolderId, FolderTreeNode>(prev);
|
||||
let currentId = targetId;
|
||||
let guard = 0;
|
||||
while (currentId && guard < 32) {
|
||||
@@ -133,15 +208,21 @@ const useFolderTree = ({
|
||||
|
||||
const ensureFolderData = useCallback(
|
||||
async (
|
||||
folderId,
|
||||
folderId: FolderId,
|
||||
{
|
||||
includeDocuments = true,
|
||||
prefetchDepth = 0,
|
||||
force = false,
|
||||
sortField = documentsSortFieldRef.current,
|
||||
sortDirection = documentsSortDirectionRef.current,
|
||||
}: {
|
||||
includeDocuments?: boolean;
|
||||
prefetchDepth?: number;
|
||||
force?: boolean;
|
||||
sortField?: string;
|
||||
sortDirection?: string;
|
||||
} = {},
|
||||
) => {
|
||||
): Promise<FolderContentsEntry> => {
|
||||
const requestTenantId = tenantIdRef.current;
|
||||
const cached = folderContentsRef.current.get(folderId);
|
||||
const cachedSortField = cached?.__sortField || documentsSortFieldRef.current;
|
||||
@@ -168,7 +249,7 @@ const useFolderTree = ({
|
||||
}
|
||||
|
||||
const path = folderId === 'root' ? 'root' : folderId;
|
||||
const params = {};
|
||||
const params: Record<string, unknown> = {};
|
||||
if (!includeDocuments) {
|
||||
params.include_documents = false;
|
||||
} else {
|
||||
@@ -176,10 +257,12 @@ const useFolderTree = ({
|
||||
params.dir = sortDirection;
|
||||
}
|
||||
const requestConfig = Object.keys(params).length ? { params } : {};
|
||||
const { data } = await apiClient.get(`/folders/${path}/contents`, requestConfig);
|
||||
const { data } = await apiClient.get<FolderContentsEntry>(`/folders/${path}/contents`, requestConfig);
|
||||
const hydrated = assetManager.hydrateFolderContents(data);
|
||||
const childFolders = Array.isArray(data.subfolders) ? data.subfolders : [];
|
||||
const childIds = childFolders.map((child) => child.id);
|
||||
const childIds = childFolders
|
||||
.map((child) => (child?.id ?? null) as FolderId | null)
|
||||
.filter((id): id is FolderId => Boolean(id));
|
||||
|
||||
const enriched = {
|
||||
...hydrated,
|
||||
@@ -192,8 +275,8 @@ const useFolderTree = ({
|
||||
return enriched;
|
||||
}
|
||||
|
||||
setFolderNodes((prev) => {
|
||||
const next = new Map(prev);
|
||||
setFolderNodes((prev: Map<FolderId, FolderTreeNode>) => {
|
||||
const next = new Map<FolderId, FolderTreeNode>(prev);
|
||||
const existingNode = next.get(folderId) || {
|
||||
id: folderId,
|
||||
name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || 'Folder',
|
||||
@@ -215,7 +298,11 @@ const useFolderTree = ({
|
||||
});
|
||||
|
||||
childFolders.forEach((child) => {
|
||||
const childNode = next.get(child.id);
|
||||
const childId = (child?.id ?? null) as FolderId | null;
|
||||
if (!childId) {
|
||||
return;
|
||||
}
|
||||
const childNode = next.get(childId);
|
||||
const previousChildren = Array.isArray(childNode?.children) ? childNode.children : [];
|
||||
const childHasChildren = (() => {
|
||||
if (childNode?.loaded) {
|
||||
@@ -235,10 +322,10 @@ const useFolderTree = ({
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
next.set(child.id, {
|
||||
id: child.id,
|
||||
next.set(childId, {
|
||||
id: childId,
|
||||
name: child.name,
|
||||
parentId: child.parent_id ?? 'root',
|
||||
parentId: (child.parent_id ?? 'root') as FolderId,
|
||||
children: previousChildren,
|
||||
expanded: childNode?.expanded ?? false,
|
||||
loaded: childNode?.loaded ?? false,
|
||||
@@ -261,11 +348,11 @@ const useFolderTree = ({
|
||||
);
|
||||
}
|
||||
|
||||
setFolderContents((prev) => {
|
||||
setFolderContents((prev: Map<FolderId, FolderContentsEntry>) => {
|
||||
if (tenantIdRef.current !== requestTenantId) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
const next = new Map<FolderId, FolderContentsEntry>(prev);
|
||||
if (includeDocuments) {
|
||||
next.set(folderId, enriched);
|
||||
} else {
|
||||
@@ -302,7 +389,7 @@ const useFolderTree = ({
|
||||
);
|
||||
|
||||
const ensureFolderAncestorsLoaded = useCallback(
|
||||
async (targetId) => {
|
||||
async (targetId: FolderId | null) => {
|
||||
if (!targetId || targetId === 'root') {
|
||||
return;
|
||||
}
|
||||
@@ -323,7 +410,7 @@ const useFolderTree = ({
|
||||
);
|
||||
|
||||
const isInvalidFolderDrop = useCallback(
|
||||
(sourceId, targetId) => {
|
||||
(sourceId: FolderId | null, targetId: FolderId | null) => {
|
||||
if (!sourceId) return false;
|
||||
if (!targetId || targetId === 'root') {
|
||||
return false;
|
||||
@@ -349,9 +436,9 @@ const useFolderTree = ({
|
||||
);
|
||||
|
||||
const resetFolderTreeState = useCallback(() => {
|
||||
const rootNode = createRootNode();
|
||||
setFolderNodes(new Map([[rootNode.id, rootNode]]));
|
||||
setFolderContents(new Map());
|
||||
const rootNode = createRootNode() as FolderTreeNode;
|
||||
setFolderNodes(new Map<FolderId, FolderTreeNode>([[rootNode.id, rootNode]]));
|
||||
setFolderContents(new Map<FolderId, FolderContentsEntry>());
|
||||
setSelectedFolder('root');
|
||||
setCurrentFolder(null);
|
||||
setCurrentSubfolders([]);
|
||||
@@ -362,11 +449,11 @@ const useFolderTree = ({
|
||||
return currentFolder.name;
|
||||
}, [selectedFolder, currentFolder]);
|
||||
|
||||
const folderOptions = useMemo(() => {
|
||||
const cache = new Map();
|
||||
const computePath = (id) => {
|
||||
if (cache.has(id)) {
|
||||
return cache.get(id);
|
||||
const folderOptions: FolderOption[] = useMemo(() => {
|
||||
const cache = new Map<FolderId, string>();
|
||||
const computePath = (id: FolderId | null): string => {
|
||||
if (cache.has(id as FolderId)) {
|
||||
return cache.get(id as FolderId) as string;
|
||||
}
|
||||
if (!id || id === 'root') {
|
||||
cache.set('root', DEFAULT_FOLDER_NAME);
|
||||
@@ -376,7 +463,7 @@ const useFolderTree = ({
|
||||
if (!node) {
|
||||
return 'Folder';
|
||||
}
|
||||
const parentId = node.parentId || 'root';
|
||||
const parentId = (node.parentId || 'root') as FolderId;
|
||||
const parentPath = computePath(parentId);
|
||||
const name = node.name || 'Folder';
|
||||
const fullPath = parentId === 'root' ? name : `${parentPath}/${name}`;
|
||||
@@ -384,7 +471,7 @@ const useFolderTree = ({
|
||||
return fullPath;
|
||||
};
|
||||
|
||||
const entries = [];
|
||||
const entries: FolderOption[] = [];
|
||||
folderNodes.forEach((node, id) => {
|
||||
if (!node) return;
|
||||
entries.push({ id, label: computePath(id) });
|
||||
@@ -400,7 +487,7 @@ const useFolderTree = ({
|
||||
}, [folderNodes]);
|
||||
|
||||
const folderLabelMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
const map = new Map<FolderId, string>();
|
||||
folderOptions.forEach((option) => {
|
||||
map.set(option.id, option.label);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { NavigateFunction } from 'react-router-dom';
|
||||
interface ApiClient {
|
||||
get: (path: string) => Promise<{ data: unknown }>;
|
||||
post: (path: string, body?: unknown) => Promise<{ data: any }>;
|
||||
defaults: { headers: { common: Record<string, string> } };
|
||||
defaults: { headers: { common: Record<string, unknown> } };
|
||||
}
|
||||
|
||||
interface TenantOption {
|
||||
|
||||
@@ -16,7 +16,7 @@ if (!container) {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<AppStateProvider>
|
||||
<HashRouter hashType="hashbang">
|
||||
<HashRouter>
|
||||
<AppRouter />
|
||||
</HashRouter>
|
||||
</AppStateProvider>,
|
||||
|
||||
@@ -190,7 +190,7 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
|
||||
],
|
||||
);
|
||||
|
||||
const loadOcrContent = useCallback(async ({ signal } = {}) => {
|
||||
const loadOcrContent = useCallback(async ({ signal }: { signal?: AbortSignal } = {}) => {
|
||||
if (!document || !hasOcr || typeof getDocumentAsset !== 'function') {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export const useViewerLayoutMode = (
|
||||
|
||||
measure();
|
||||
|
||||
if (!('ResizeObserver' in window)) {
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', measure);
|
||||
return () => {
|
||||
if (frame) {
|
||||
|
||||
@@ -3,12 +3,20 @@ import { Navigate, useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { useAppShell } from '../appShellContext';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
interface DocumentViewerRouteContext {
|
||||
previewWorkspaceDocument?: { id?: Identifier } | null;
|
||||
ensurePreviewData?: (documentId: Identifier, options?: Record<string, unknown>) => Promise<unknown>;
|
||||
notifyApiError?: (error: unknown, message?: string) => void;
|
||||
}
|
||||
|
||||
const DocumentViewerRoute: React.FC = () => {
|
||||
const {
|
||||
previewWorkspaceDocument,
|
||||
ensurePreviewData,
|
||||
notifyApiError,
|
||||
} = useAppShell();
|
||||
} = useAppShell() as DocumentViewerRouteContext;
|
||||
const { documentId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -22,12 +30,12 @@ const DocumentViewerRoute: React.FC = () => {
|
||||
|
||||
const hydrate = async () => {
|
||||
try {
|
||||
await ensurePreviewData(documentId);
|
||||
await ensurePreviewData?.(documentId);
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
notifyApiError(error, 'Failed to open document preview.');
|
||||
notifyApiError?.(error, 'Failed to open document preview.');
|
||||
navigate('/documents', { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,19 +1,45 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }) => {
|
||||
const [capabilitySets, setCapabilitySets] = useState([]);
|
||||
type Identifier = string | number;
|
||||
|
||||
interface CapabilitySet {
|
||||
id?: Identifier;
|
||||
slug: string;
|
||||
label?: string;
|
||||
capabilities: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ApiClient {
|
||||
get<T = CapabilitySet[]>(path: string): Promise<{ data: T }>;
|
||||
post<T = CapabilitySet>(path: string, payload?: unknown): Promise<{ data: T }>;
|
||||
patch<T = CapabilitySet>(path: string, payload?: unknown): Promise<{ data: T }>;
|
||||
delete: (path: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface UseCapabilitySetsOptions {
|
||||
api: ApiClient;
|
||||
notifyApiError?: (error: unknown, message: string) => void;
|
||||
setStatusMessage?: (message: string, level?: string) => void;
|
||||
token?: string | null;
|
||||
}
|
||||
|
||||
const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }: UseCapabilitySetsOptions) => {
|
||||
const [capabilitySets, setCapabilitySets] = useState<CapabilitySet[]>([]);
|
||||
const [capabilitySetsLoading, setCapabilitySetsLoading] = useState(false);
|
||||
const [creatingCapabilitySet, setCreatingCapabilitySet] = useState(false);
|
||||
const [savingCapabilitySetId, setSavingCapabilitySetId] = useState(null);
|
||||
const [deletingCapabilitySetId, setDeletingCapabilitySetId] = useState(null);
|
||||
const [savingCapabilitySetId, setSavingCapabilitySetId] = useState<Identifier | null>(null);
|
||||
const [deletingCapabilitySetId, setDeletingCapabilitySetId] = useState<Identifier | null>(null);
|
||||
const [supportsCapabilitySetLabels, setSupportsCapabilitySetLabels] = useState(false);
|
||||
|
||||
const applyCapabilitySets = useCallback((updater) => {
|
||||
const applyCapabilitySets = useCallback((updater: CapabilitySet[] | ((prev: CapabilitySet[]) => CapabilitySet[])) => {
|
||||
setCapabilitySets((previous) => {
|
||||
const base = Array.isArray(previous) ? [...previous] : [];
|
||||
const next = typeof updater === 'function'
|
||||
? updater(base)
|
||||
: (Array.isArray(updater) ? [...updater] : base);
|
||||
: Array.isArray(updater)
|
||||
? [...updater]
|
||||
: base;
|
||||
const supportsLabels = next.some((item) => Object.prototype.hasOwnProperty.call(item || {}, 'label'));
|
||||
setSupportsCapabilitySetLabels(supportsLabels);
|
||||
return next;
|
||||
@@ -27,7 +53,7 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }) =>
|
||||
}
|
||||
setCapabilitySetsLoading(true);
|
||||
try {
|
||||
const { data } = await api.get('/capability-sets');
|
||||
const { data } = await api.get<CapabilitySet[]>('/capability-sets');
|
||||
applyCapabilitySets(Array.isArray(data) ? data : []);
|
||||
} catch (error) {
|
||||
notifyApiError?.(error, 'Failed to load capability sets.');
|
||||
@@ -45,7 +71,7 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }) =>
|
||||
}, [applyCapabilitySets, refreshCapabilitySets, token]);
|
||||
|
||||
const createCapabilitySet = useCallback(
|
||||
async ({ slug, label, capabilities } = {}) => {
|
||||
async ({ slug, label, capabilities }: { slug?: string; label?: string; capabilities?: string[] } = {}) => {
|
||||
if (creatingCapabilitySet) {
|
||||
return false;
|
||||
}
|
||||
@@ -55,7 +81,7 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }) =>
|
||||
}
|
||||
setCreatingCapabilitySet(true);
|
||||
try {
|
||||
const payload = {
|
||||
const payload: { slug?: string; label?: string; capabilities: string[] } = {
|
||||
capabilities,
|
||||
};
|
||||
const trimmedSlug = slug?.trim();
|
||||
@@ -67,12 +93,12 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }) =>
|
||||
payload.label = trimmedLabel;
|
||||
}
|
||||
|
||||
const { data } = await api.post('/capability-sets', payload);
|
||||
const { data } = await api.post<CapabilitySet>('/capability-sets', payload);
|
||||
if (data) {
|
||||
applyCapabilitySets((previous) => {
|
||||
const next = previous.filter((entry) => entry?.id !== data.id);
|
||||
next.push(data);
|
||||
next.sort((a, b) => a.slug.localeCompare(b.slug));
|
||||
next.sort((a, b) => (a.slug || '').localeCompare(b.slug || ''));
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
@@ -99,13 +125,16 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }) =>
|
||||
);
|
||||
|
||||
const updateCapabilitySet = useCallback(
|
||||
async (capabilitySetId, { slug, label, capabilities } = {}) => {
|
||||
async (
|
||||
capabilitySetId: Identifier | null,
|
||||
{ slug, label, capabilities }: { slug?: string; label?: string; capabilities?: string[] } = {},
|
||||
) => {
|
||||
if (!capabilitySetId) {
|
||||
return false;
|
||||
}
|
||||
setSavingCapabilitySetId(capabilitySetId);
|
||||
try {
|
||||
const payload = {};
|
||||
const payload: { slug?: string; label?: string; capabilities?: string[] } = {};
|
||||
if (slug !== undefined) {
|
||||
const trimmed = slug?.trim();
|
||||
if (trimmed) {
|
||||
@@ -126,7 +155,7 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }) =>
|
||||
payload.capabilities = capabilities;
|
||||
}
|
||||
|
||||
const { data } = await api.patch(`/capability-sets/${capabilitySetId}`, payload);
|
||||
const { data } = await api.patch<CapabilitySet>(`/capability-sets/${capabilitySetId}`, payload);
|
||||
if (data) {
|
||||
applyCapabilitySets((previous) => {
|
||||
let found = false;
|
||||
@@ -140,7 +169,7 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }) =>
|
||||
if (!found) {
|
||||
next.push(data);
|
||||
}
|
||||
next.sort((a, b) => a.slug.localeCompare(b.slug));
|
||||
next.sort((a, b) => (a.slug || '').localeCompare(b.slug || ''));
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
@@ -166,7 +195,7 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }) =>
|
||||
);
|
||||
|
||||
const deleteCapabilitySet = useCallback(
|
||||
async (capabilitySetId) => {
|
||||
async (capabilitySetId: Identifier | null) => {
|
||||
if (!capabilitySetId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -58,13 +58,13 @@ interface PasskeyRegisterPayload {
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
type RegisterPasskeyFailureReason = 'unsupported' | 'busy' | 'cancelled' | 'error';
|
||||
type RegisterPasskeyResult =
|
||||
export type RegisterPasskeyFailureReason = 'unsupported' | 'busy' | 'cancelled' | 'error';
|
||||
export type RegisterPasskeyResult =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: RegisterPasskeyFailureReason; message?: string };
|
||||
|
||||
type RevokePasskeyFailureReason = 'missing-id' | 'error';
|
||||
type RevokePasskeyResult =
|
||||
export type RevokePasskeyFailureReason = 'missing-id' | 'error';
|
||||
export type RevokePasskeyResult =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: RevokePasskeyFailureReason; message?: string };
|
||||
|
||||
|
||||
@@ -61,11 +61,21 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.panel-floating-region {
|
||||
position: sticky;
|
||||
top: 0.5rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
z-index: 6;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.panel-floating {
|
||||
position: absolute;
|
||||
top: calc(50% + 0.25rem);
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -45%);
|
||||
background: color-mix(in oklch, var(--surface) 100%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--border) 95%, transparent);
|
||||
padding: 0.45rem 0.85rem;
|
||||
@@ -73,14 +83,28 @@
|
||||
font-size: 0.95rem;
|
||||
font-weight: 400;
|
||||
color: var(--fg);
|
||||
box-shadow: 0 2px 6px color-mix(in oklch, var(--shadow-soft) 60%, transparent);
|
||||
box-shadow: 0 6px 24px color-mix(in oklch, var(--shadow-soft) 55%, transparent);
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: nowrap;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
z-index: 950000;
|
||||
width: auto;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.panel-floating__buttons {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
order: 2;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.panel-floating__label {
|
||||
order: 0;
|
||||
}
|
||||
|
||||
.panel-floating__label {
|
||||
@@ -128,6 +152,15 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.panel-floating-actions--assignments {
|
||||
display: flex;
|
||||
order: 1;
|
||||
flex-wrap: nowrap;
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.panel-floating-actions .quick-add {
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -155,6 +188,53 @@
|
||||
height: 1.35rem;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.panel-floating {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.panel-floating__label {
|
||||
order: 0;
|
||||
flex: 1 1 auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.panel-floating__buttons {
|
||||
order: 1;
|
||||
flex: 0 0 auto;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.panel-floating-actions--assignments {
|
||||
order: 2;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.panel-floating-actions__button {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.panel-floating {
|
||||
gap: 0.5rem;
|
||||
border-radius: 0.85rem;
|
||||
padding: 0.4rem 0.65rem;
|
||||
}
|
||||
|
||||
.panel-floating-actions {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.panel-floating-actions__button .icon-inline {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.documents-panel .documents-scroll {
|
||||
overflow-y: auto;
|
||||
background: transparent;
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
flex: 0 0 var(--sidebar-width);
|
||||
background: var(--bg);
|
||||
position: relative;
|
||||
z-index: 1000000;
|
||||
}
|
||||
|
||||
.sidebar--resizing {
|
||||
|
||||
@@ -39,9 +39,9 @@ const BreadcrumbTrail = ({
|
||||
() => (shouldTruncateFromStart ? normalized : normalized.slice().reverse()),
|
||||
[normalized, shouldTruncateFromStart],
|
||||
);
|
||||
const containerRef = useRef(null);
|
||||
const measurementRef = useRef(null);
|
||||
const ellipsisButtonRef = useRef(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const measurementRef = useRef<HTMLDivElement | null>(null);
|
||||
const ellipsisButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [availableWidth, setAvailableWidth] = useState(null);
|
||||
const [startIndex, setStartIndex] = useState(0);
|
||||
const measureRafRef = useRef(null);
|
||||
@@ -139,13 +139,17 @@ const BreadcrumbTrail = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const entryNodes = Array.from(measurement.querySelectorAll('[data-item-type="entry"]'));
|
||||
const entryNodes = Array.from(
|
||||
measurement.querySelectorAll('[data-item-type="entry"]'),
|
||||
) as HTMLElement[];
|
||||
if (!entryNodes.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const separatorNodes = Array.from(measurement.querySelectorAll('[data-item-type="separator"]'));
|
||||
const ellipsisNode = measurement.querySelector('[data-item-type="ellipsis"]');
|
||||
const separatorNodes = Array.from(
|
||||
measurement.querySelectorAll('[data-item-type="separator"]'),
|
||||
) as HTMLElement[];
|
||||
const ellipsisNode = measurement.querySelector('[data-item-type="ellipsis"]') as HTMLElement | null;
|
||||
|
||||
const originalEntryDisplay = entryNodes.map((node) => node.style.display);
|
||||
const originalSeparatorDisplay = separatorNodes.map((node) => node.style.display);
|
||||
|
||||
+20
-11
@@ -1,24 +1,30 @@
|
||||
import { createAssetView, resolveDocumentAssetUrl } from '../asset_manager';
|
||||
import type {
|
||||
DocumentLike as AssetManagerDocumentLike,
|
||||
DocumentVersionLike,
|
||||
AssetLike as AssetManagerAssetLike,
|
||||
GetAsset as AssetManagerGetAsset,
|
||||
} from '../asset_manager';
|
||||
|
||||
const BASE_FETCH_OPTIONS = { start: 1, limit: 1 } as const;
|
||||
|
||||
interface DocumentLike {
|
||||
id?: string | number;
|
||||
current_version?: unknown;
|
||||
interface DocumentVersion extends DocumentVersionLike {
|
||||
download_path?: string | null;
|
||||
}
|
||||
|
||||
interface AssetLike {
|
||||
id?: string | number;
|
||||
[key: string]: unknown;
|
||||
export interface DocumentLike extends AssetManagerDocumentLike {
|
||||
current_version?: DocumentVersion | null;
|
||||
}
|
||||
|
||||
type EnsurePreviewData = (id: string | number) => Promise<DocumentLike | null | undefined>;
|
||||
type GetDocumentAsset = (doc: DocumentLike, type: string) => AssetLike | null | undefined;
|
||||
type EnsureAssetUrl = (
|
||||
export type AssetLike = AssetManagerAssetLike;
|
||||
|
||||
export type EnsurePreviewData = (id: string | number) => Promise<DocumentLike | null | undefined>;
|
||||
export type EnsureAssetUrl = (
|
||||
id: string | number,
|
||||
asset: AssetLike,
|
||||
options?: { start?: number; limit?: number; force?: boolean },
|
||||
) => Promise<AssetLike | null | undefined>;
|
||||
export type GetDocumentAsset = AssetManagerGetAsset;
|
||||
|
||||
interface ResolveOcrTextUrlOptions {
|
||||
document: DocumentLike | null;
|
||||
@@ -62,10 +68,13 @@ export async function resolveOcrTextUrl({
|
||||
const baseView = createAssetView(asset);
|
||||
const hasUrl = Boolean(baseView.getPrimaryUrl());
|
||||
|
||||
let entry = asset;
|
||||
let entry: AssetLike = asset;
|
||||
if (ensureAssetUrl) {
|
||||
const ensureOptions = { ...BASE_FETCH_OPTIONS, force: !hasUrl };
|
||||
entry = (await ensureAssetUrl(docRef.id!, asset, ensureOptions)) || asset;
|
||||
const ensured = await ensureAssetUrl(docRef.id!, asset, ensureOptions);
|
||||
if (ensured) {
|
||||
entry = ensured;
|
||||
}
|
||||
}
|
||||
|
||||
const ensuredView = createAssetView(entry);
|
||||
|
||||
@@ -82,6 +82,13 @@ export const base64urlToUint8Array = (value?: string | null): Uint8Array => {
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const base64urlToBufferSource = (value: string): ArrayBuffer => {
|
||||
const bytes = base64urlToUint8Array(value);
|
||||
const clone = new Uint8Array(bytes.length);
|
||||
clone.set(bytes);
|
||||
return clone.buffer;
|
||||
};
|
||||
|
||||
const toUint8Array = (input?: ArrayBuffer | ArrayBufferView | ArrayLike<number> | null): Uint8Array => {
|
||||
if (!input) {
|
||||
return new Uint8Array();
|
||||
@@ -124,14 +131,14 @@ export const preparePublicKeyCreationOptions = (
|
||||
const publicKey: PublicKeyCredentialCreationOptions = { ...challengeResponse.publicKey };
|
||||
|
||||
if (publicKey.challenge && typeof publicKey.challenge === 'string') {
|
||||
publicKey.challenge = base64urlToUint8Array(publicKey.challenge);
|
||||
publicKey.challenge = base64urlToBufferSource(publicKey.challenge);
|
||||
}
|
||||
|
||||
if (publicKey.user?.id) {
|
||||
publicKey.user = {
|
||||
...publicKey.user,
|
||||
id: typeof publicKey.user.id === 'string'
|
||||
? base64urlToUint8Array(publicKey.user.id)
|
||||
? base64urlToBufferSource(publicKey.user.id)
|
||||
: publicKey.user.id,
|
||||
};
|
||||
}
|
||||
@@ -140,7 +147,7 @@ export const preparePublicKeyCreationOptions = (
|
||||
publicKey.excludeCredentials = publicKey.excludeCredentials.map((descriptor) => ({
|
||||
...descriptor,
|
||||
id: typeof descriptor.id === 'string'
|
||||
? base64urlToUint8Array(descriptor.id)
|
||||
? base64urlToBufferSource(descriptor.id)
|
||||
: descriptor.id,
|
||||
}));
|
||||
}
|
||||
@@ -162,14 +169,14 @@ export const preparePublicKeyRequestOptions = (
|
||||
const publicKey: PublicKeyCredentialRequestOptions = { ...challengeResponse.publicKey };
|
||||
|
||||
if (publicKey.challenge && typeof publicKey.challenge === 'string') {
|
||||
publicKey.challenge = base64urlToUint8Array(publicKey.challenge);
|
||||
publicKey.challenge = base64urlToBufferSource(publicKey.challenge);
|
||||
}
|
||||
|
||||
if (Array.isArray(publicKey.allowCredentials)) {
|
||||
publicKey.allowCredentials = publicKey.allowCredentials.map((descriptor) => ({
|
||||
...descriptor,
|
||||
id: typeof descriptor.id === 'string'
|
||||
? base64urlToUint8Array(descriptor.id)
|
||||
? base64urlToBufferSource(descriptor.id)
|
||||
: descriptor.id,
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user