typescript

This commit is contained in:
2025-11-13 02:37:16 +01:00
parent ada089c05b
commit 6d55e61cee
43 changed files with 923 additions and 406 deletions
+75 -22
View File
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo } from 'react'; import React, { useCallback, useEffect, useMemo } from 'react';
import type { ReactNode } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAppShell } from '../appShellContext'; import { useAppShell } from '../appShellContext';
import DocumentsLayout from './DocumentsLayout'; import DocumentsLayout from './DocumentsLayout';
@@ -8,7 +9,45 @@ import BreadcrumbTrail from '../ui/BreadcrumbTrail';
import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext'; import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext';
import { PanelManagerProvider, usePanelManager } from './PanelManagerContext'; 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 DocumentsRouteContent: React.FC = () => {
const { const {
@@ -29,7 +68,7 @@ const DocumentsRouteContent: React.FC = () => {
ensureAssetUrl, ensureAssetUrl,
getDocumentAsset, getDocumentAsset,
notifyApiError, notifyApiError,
} = useAppShell(); } = useAppShell() as DocumentsRouteAppShell;
const navigate = useNavigate(); const navigate = useNavigate();
const { collapsed: sidebarCollapsed } = useSidebarContext(); const { collapsed: sidebarCollapsed } = useSidebarContext();
const { const {
@@ -37,18 +76,25 @@ const DocumentsRouteContent: React.FC = () => {
expandSidebar, expandSidebar,
} = usePanelManager(); } = usePanelManager();
const safeSidebarProps = useMemo<Record<string, unknown>>(
() => (sidebarProps && typeof sidebarProps === 'object' ? sidebarProps : {}),
[sidebarProps],
);
const sidebarPropsWithActions = useMemo( const sidebarPropsWithActions = useMemo(
() => ({ () => ({
...sidebarProps, ...safeSidebarProps,
onManageTags: openTagsModal, onManageTags: openTagsModal,
onManageCorrespondents: openCorrespondentsModal, onManageCorrespondents: openCorrespondentsModal,
}), }),
[sidebarProps, openTagsModal, openCorrespondentsModal], [safeSidebarProps, openTagsModal, openCorrespondentsModal],
); );
const sidebarHidden = sidebarCollapsed || sidebarSuppressed; const sidebarHidden = sidebarCollapsed || sidebarSuppressed;
const breadcrumbs = documentsTableProps?.breadcrumbs || null; const breadcrumbs = Array.isArray(documentsTableProps?.breadcrumbs)
? documentsTableProps?.breadcrumbs
: null;
const parentBreadcrumb = useMemo(() => { const parentBreadcrumb = useMemo(() => {
if (!Array.isArray(breadcrumbs) || breadcrumbs.length <= 1) { if (!Array.isArray(breadcrumbs) || breadcrumbs.length <= 1) {
return null; return null;
@@ -114,11 +160,14 @@ const DocumentsRouteContent: React.FC = () => {
const variant = surface.variant || 'documents'; const variant = surface.variant || 'documents';
const surfaceDetail = (surface as { detail?: ReactNode }).detail || null;
const hasDetail = Boolean(surfaceDetail);
const mainContentClass = `main-content main-content--${variant}${ 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}${ 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; const header = surface.header || null;
@@ -153,26 +202,30 @@ const DocumentsRouteContent: React.FC = () => {
<DocumentsLayout sidebarProps={sidebarPropsWithActions}> <DocumentsLayout sidebarProps={sidebarPropsWithActions}>
<div className={mainContentClass}> <div className={mainContentClass}>
{header ? ( {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) ? ( {(header.selectionLabel || header.floatingActions) ? (
<div className="panel-floating" aria-live="polite" aria-atomic="true"> <div className="panel-floating-region" aria-live="polite" aria-atomic="true">
{header.selectionLabel ? ( <div className="panel-floating">
<span className="panel-floating__label">{header.selectionLabel}</span> {header.selectionLabel ? (
) : null} <span className="panel-floating__label">{header.selectionLabel}</span>
{header.floatingActions || null} ) : null}
{header.floatingActions || null}
</div>
</div> </div>
) : null} ) : null}
<PanelHeader </>
className="main-content__header"
leading={header.leading}
title={headerTitle}
titleTag="h2"
actions={header.actions}
/>
</div>
) : null} ) : null}
<div className={bodyClass}>{surface.content}</div> <div className={bodyClass}>{surface.content}</div>
{surface.detail || null} {surfaceDetail}
</div> </div>
</DocumentsLayout> </DocumentsLayout>
); );
+17 -3
View File
@@ -221,13 +221,18 @@ const LoginRoute: React.FC = () => {
const publicKey = preparePublicKeyRequestOptions({ publicKey: publicKeyOptions }); const publicKey = preparePublicKeyRequestOptions({ publicKey: publicKeyOptions });
setStatusMessage('Confirm the passkey prompt to continue.', 'info'); 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) { if (!assertion) {
setStatusMessage('Passkey login cancelled.', 'info'); setStatusMessage('Passkey login cancelled.', 'info');
return; return;
} }
if (!(assertion instanceof PublicKeyCredential)) {
setStatusMessage('Unexpected credential response.', 'error');
return;
}
const serialized = serializeAuthenticationCredential(assertion); const serialized = serializeAuthenticationCredential(assertion);
const finishPayload = { const finishPayload = {
challengeId, challengeId,
@@ -303,7 +308,11 @@ const LoginRoute: React.FC = () => {
setStatusMessage('Signing you in…', 'info'); setStatusMessage('Signing you in…', 'info');
try { try {
const payload = { const payload: {
magic_token: string;
username?: string;
preferred_tenant_id?: string | number;
} = {
magic_token: magicToken, magic_token: magicToken,
}; };
if (magicUsername) { if (magicUsername) {
@@ -419,13 +428,18 @@ const LoginRoute: React.FC = () => {
const publicKey = preparePublicKeyCreationOptions({ publicKey: publicKeyOptions }); const publicKey = preparePublicKeyCreationOptions({ publicKey: publicKeyOptions });
setStatusMessage('Confirm the passkey prompt to finish creating your account.', 'info'); 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) { if (!credential) {
setStatusMessage('Signup cancelled.', 'info'); setStatusMessage('Signup cancelled.', 'info');
return; return;
} }
if (!(credential instanceof PublicKeyCredential)) {
setStatusMessage('Unexpected credential response.', 'error');
return;
}
const serialized = serializeRegistrationCredential(credential); const serialized = serializeRegistrationCredential(credential);
const finishPayload = { const finishPayload = {
signup_token: signupToken, signup_token: signupToken,
+37 -7
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import TagsPanel from '../tags/TagsPanel'; import TagsPanel from '../tags/TagsPanel';
import CorrespondentsPanel from '../correspondents/CorrespondentsPanel'; import CorrespondentsPanel, { CorrespondentsPanelProps } from '../correspondents/CorrespondentsPanel';
import PanelHeader from '../ui/PanelHeader'; import PanelHeader from '../ui/PanelHeader';
const TAGS_MODAL = 'tags'; const TAGS_MODAL = 'tags';
@@ -135,6 +135,36 @@ export const useManagementModals = ({
tags, 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(() => { const correspondentsModal = useMemo(() => {
if (activeModal !== CORRESPONDENTS_MODAL) { if (activeModal !== CORRESPONDENTS_MODAL) {
return null; return null;
@@ -167,9 +197,9 @@ export const useManagementModals = ({
<CorrespondentsPanel <CorrespondentsPanel
correspondents={correspondents} correspondents={correspondents}
onRefresh={refreshCorrespondents} onRefresh={refreshCorrespondents}
onCreate={onCorrespondentCreate} onCreate={handleCorrespondentCreateSafe}
onUpdate={onCorrespondentUpdate} onUpdate={handleCorrespondentUpdateSafe}
onDelete={onCorrespondentDelete} onDelete={handleCorrespondentDeleteSafe}
onNotify={setStatusMessage} onNotify={setStatusMessage}
/> />
</div> </div>
@@ -180,9 +210,9 @@ export const useManagementModals = ({
activeModal, activeModal,
closeActiveModal, closeActiveModal,
correspondents, correspondents,
onCorrespondentCreate, handleCorrespondentCreateSafe,
onCorrespondentDelete, handleCorrespondentDeleteSafe,
onCorrespondentUpdate, handleCorrespondentUpdateSafe,
refreshCorrespondents, refreshCorrespondents,
setStatusMessage, setStatusMessage,
]); ]);
-2
View File
@@ -140,7 +140,6 @@ export const useWorkspaceSurface = ({
onUpdateTitle, onUpdateTitle,
onUpdateIssued, onUpdateIssued,
resolveFolderPath, resolveFolderPath,
onFolderNavigate,
} = detailExtras; } = detailExtras;
return createDocumentViewerSurface({ return createDocumentViewerSurface({
document: previewWorkspaceDocument, document: previewWorkspaceDocument,
@@ -162,7 +161,6 @@ export const useWorkspaceSurface = ({
onUpdateTitle, onUpdateTitle,
onUpdateIssued, onUpdateIssued,
resolveFolderPath, resolveFolderPath,
onFolderNavigate,
}); });
}, [ }, [
showPreviewWorkspace, 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

+75
View File
@@ -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

+11 -4
View File
@@ -51,6 +51,13 @@ interface NavigatorSnapshot {
height?: number | null; height?: number | null;
} }
type OverlayOriginHint = {
rotation?: number;
scale?: number;
width?: number;
height?: number;
};
interface OverlayOriginTransform { interface OverlayOriginTransform {
rotation: number; rotation: number;
scaleX: number; scaleX: number;
@@ -180,7 +187,7 @@ interface DesktopWorkspaceViewProps {
bringToFront: (docId: Identifier | null | undefined) => void; bringToFront: (docId: Identifier | null | undefined) => void;
setDraggingId: (value: string | null) => void; setDraggingId: (value: string | null) => void;
canvasSize: { width: number; height: number }; 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; recalcVisibleDocIds: () => void;
dragSettings: DragSettings; dragSettings: DragSettings;
onInspectDocument?: DesktopWorkspaceProps['onInspectDocument']; onInspectDocument?: DesktopWorkspaceProps['onInspectDocument'];
@@ -340,7 +347,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
if (existing && existing.width === normalized.width && existing.height === normalized.height) { if (existing && existing.width === normalized.width && existing.height === normalized.height) {
return; return;
} }
const next = new Map(docSizeMapRef.current); const next = new Map<string, DocumentSizeInfo>(docSizeMapRef.current);
next.set(docKey, { ...normalized, source: 'snapshot' }); next.set(docKey, { ...normalized, source: 'snapshot' });
docSizeMapRef.current = next; docSizeMapRef.current = next;
setDocSizeVersion((value) => value + 1); setDocSizeVersion((value) => value + 1);
@@ -505,7 +512,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
useEffect(() => { useEffect(() => {
const current = docSizeMapRef.current; 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))); const itemKeys = new Set(items.filter((doc) => doc?.id != null).map((doc) => String(doc.id)));
let changed = false; let changed = false;
@@ -618,7 +625,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
}, [draggingId, items, setDraggingId]); }, [draggingId, items, setDraggingId]);
const openOverlayForDoc = useCallback( const openOverlayForDoc = useCallback(
(docId: Identifier | null | undefined, originInfo: OverlayOriginTransform | null = null) => { (docId: Identifier | null | undefined, originInfo: OverlayOriginHint | null = null) => {
if (!docId) { if (!docId) {
return; return;
} }
@@ -126,8 +126,6 @@ const createDesktopSurface = ({
title, title,
subtitle, subtitle,
sidebarToggle, sidebarToggle,
parentBreadcrumb,
onNavigateParent,
actions, actions,
breadcrumbs: workspaceProps?.breadcrumbs || null, breadcrumbs: workspaceProps?.breadcrumbs || null,
selectionLabel: null, selectionLabel: null,
+5 -2
View File
@@ -21,8 +21,11 @@ export const preventAll = (event?: PreventableEvent | null): void => {
type AnyFn = (...args: unknown[]) => unknown; type AnyFn = (...args: unknown[]) => unknown;
export const safeInvoke = <Fn extends AnyFn>(fn: Fn | null | undefined, ...args: Parameters<Fn>): ReturnType<Fn> | undefined => export const safeInvoke = <Fn extends AnyFn>(
(fn ? fn(...args) : undefined); fn: Fn | null | undefined,
...args: Parameters<Fn>
): ReturnType<Fn> | undefined =>
(fn ? (fn(...args) as ReturnType<Fn>) : undefined);
export const getPointerPosition = ( export const getPointerPosition = (
event?: PointerLikeEvent | null, event?: PointerLikeEvent | null,
+7 -7
View File
@@ -65,8 +65,8 @@ export const createPointerIntent = ({
const alreadySelected = selectedDocumentIds.includes(doc.id); const alreadySelected = selectedDocumentIds.includes(doc.id);
const selectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; const selectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0;
let clickAction = CLICK_ACTIONS.none; let clickAction: ClickAction = CLICK_ACTIONS.none;
let dragAction = DRAG_ACTIONS.none; let dragAction: DragAction = DRAG_ACTIONS.none;
if (metaKey) { if (metaKey) {
clickAction = CLICK_ACTIONS.addStack; clickAction = CLICK_ACTIONS.addStack;
@@ -79,8 +79,8 @@ export const createPointerIntent = ({
dragAction = DRAG_ACTIONS.dragSelectSingle; dragAction = DRAG_ACTIONS.dragSelectSingle;
} }
const stackList = Array.isArray(stackHits) && stackHits.length > 0 const stackList: string[] = Array.isArray(stackHits) && stackHits.length > 0
? stackHits.slice() ? stackHits.map((value) => String(value))
: [String(doc.id)]; : [String(doc.id)];
const stackDocIdsForDrag = dragAction === DRAG_ACTIONS.dragSelectStack ? stackList : null; const stackDocIdsForDrag = dragAction === DRAG_ACTIONS.dragSelectStack ? stackList : null;
@@ -160,9 +160,9 @@ export const applyLongPressSelection = ({ intent, stackDocIds, syntheticEvent, o
return; return;
} }
const stackCopy = Array.isArray(stackDocIds) && stackDocIds.length > 0 const stackCopy: string[] = Array.isArray(stackDocIds) && stackDocIds.length > 0
? stackDocIds.slice() ? stackDocIds.map((value) => String(value))
: [intent.docId]; : [String(intent.docId)];
safeInvoke(onDocumentStackSelect, stackCopy, syntheticEvent, { replace: true }); safeInvoke(onDocumentStackSelect, stackCopy, syntheticEvent, { replace: true });
+7 -2
View File
@@ -56,6 +56,7 @@ interface DragGroupItemInternal extends EngineGroupItem {
offsetX?: number; offsetX?: number;
offsetY?: number; offsetY?: number;
targetRotation?: number; targetRotation?: number;
initialRotation?: number;
} }
type EnsureDocumentSizeFn = (doc: DocumentLike | null | undefined) => DocumentSizeInfo | null; type EnsureDocumentSizeFn = (doc: DocumentLike | null | undefined) => DocumentSizeInfo | null;
@@ -466,9 +467,9 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
baseOffsetY, baseOffsetY,
offsetX: baseOffsetX, offsetX: baseOffsetX,
offsetY: baseOffsetY, offsetY: baseOffsetY,
initialRotation, targetRotation: initialRotation,
displayRotation: initialRotation, displayRotation: initialRotation,
targetRotation,
} satisfies DragGroupItemInternal; } satisfies DragGroupItemInternal;
}); });
@@ -897,6 +898,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
if (state.moved) { if (state.moved) {
commitActiveDragTransforms([state.docKey]); commitActiveDragTransforms([state.docKey]);
const inertiaState: EngineInertiaState = { const inertiaState: EngineInertiaState = {
docId: state.docKey,
restRotation: state.restRotation, restRotation: state.restRotation,
dynamicRotation: state.dynamicRotation, dynamicRotation: state.dynamicRotation,
angularVelocity: state.angularVelocity, angularVelocity: state.angularVelocity,
@@ -904,6 +906,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
width: state.width, width: state.width,
height: state.height, height: state.height,
dragScale: state.dragScale || 1, dragScale: state.dragScale || 1,
lastTimestamp: state.lastTimestamp,
}; };
const docId = state.docKey; const docId = state.docKey;
finishDrag(event.pointerId); finishDrag(event.pointerId);
@@ -956,6 +959,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
commitActiveDragTransforms([state.docKey]); commitActiveDragTransforms([state.docKey]);
const inertiaState: EngineInertiaState = { const inertiaState: EngineInertiaState = {
docId: state.docKey,
restRotation: state.restRotation, restRotation: state.restRotation,
dynamicRotation: state.dynamicRotation, dynamicRotation: state.dynamicRotation,
angularVelocity: state.angularVelocity, angularVelocity: state.angularVelocity,
@@ -963,6 +967,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
width: state.width, width: state.width,
height: state.height, height: state.height,
dragScale: state.dragScale || 1, dragScale: state.dragScale || 1,
lastTimestamp: state.lastTimestamp,
}; };
const docId = state.docKey; const docId = state.docKey;
finishDrag(event.pointerId); finishDrag(event.pointerId);
+3 -2
View File
@@ -8,6 +8,7 @@ import {
isDocumentRowKey, isDocumentRowKey,
} from '../app/appLayoutUtils'; } from '../app/appLayoutUtils';
import type { DocumentInfoPanelProps } from '../documents/DocumentInfoPanel'; import type { DocumentInfoPanelProps } from '../documents/DocumentInfoPanel';
import type { EnsureAssetUrl, GetDocumentAsset } from '../utils/ocr';
type Identifier = string | number; type Identifier = string | number;
@@ -51,8 +52,8 @@ interface UseDetailWorkspaceArgs {
handleDocumentIssuedUpdate?: (docId: Identifier, issued: number | null) => Promise<boolean> | boolean; handleDocumentIssuedUpdate?: (docId: Identifier, issued: number | null) => Promise<boolean> | boolean;
handleDocumentTagAdd?: (doc: DocumentLike, value: string, context?: { option?: unknown }) => void; handleDocumentTagAdd?: (doc: DocumentLike, value: string, context?: { option?: unknown }) => void;
handleTagRemove?: (...args: unknown[]) => void; handleTagRemove?: (...args: unknown[]) => void;
ensureAssetUrl?: (...args: unknown[]) => unknown; ensureAssetUrl?: EnsureAssetUrl;
getDocumentAsset?: (...args: unknown[]) => unknown; getDocumentAsset?: GetDocumentAsset;
ensurePreviewData?: (docId: Identifier, options?: Record<string, unknown>) => Promise<DocumentLike | null | undefined>; ensurePreviewData?: (docId: Identifier, options?: Record<string, unknown>) => Promise<DocumentLike | null | undefined>;
correspondents?: unknown[]; correspondents?: unknown[];
handleCorrespondentAdd?: (...args: unknown[]) => void; handleCorrespondentAdd?: (...args: unknown[]) => void;
+2 -2
View File
@@ -3,7 +3,7 @@ import type { ReactNode } from 'react';
import DocumentSummarySection, { DocumentSummarySectionProps } from './DocumentSummarySection'; import DocumentSummarySection, { DocumentSummarySectionProps } from './DocumentSummarySection';
import { buildDocumentMetadataItems, extractDocumentMetadataPayload } from './documentMetadata'; 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 = type ContentState =
| { status: 'idle'; data: null; error: null } | { status: 'idle'; data: null; error: null }
@@ -13,7 +13,7 @@ type ContentState =
| { status: 'unavailable'; data: null; error: null } | { status: 'unavailable'; data: null; error: null }
| { status: 'error'; data: null; error: unknown }; | { status: 'error'; data: null; error: unknown };
interface DocumentInfoPanelProps { export interface DocumentInfoPanelProps {
document: DocumentSummarySectionProps['document']; document: DocumentSummarySectionProps['document'];
summaryProps?: Omit<DocumentSummarySectionProps, 'document' | 'detailItems' | 'layout'>; summaryProps?: Omit<DocumentSummarySectionProps, 'document' | 'detailItems' | 'layout'>;
metadataItems?: Array<{ label: string; value?: string }>; metadataItems?: Array<{ label: string; value?: string }>;
@@ -110,6 +110,31 @@ interface QuickAddEntry {
original: QuickAddOption | string; 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 => { const normalizeQuickAddOption = (option: QuickAddOption | string | null | undefined): QuickAddEntry | null => {
if (option == null) { if (option == null) {
return null; return null;
@@ -348,16 +373,12 @@ export const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
return; return;
} }
const source = item.payload ?? item; const source = item.payload ?? item;
const resolvedName = const resolvedName = resolveOptionName(source);
source?.name?.trim?.()
|| source?.label?.trim?.()
|| source?.trim?.()
|| '';
if (!resolvedName) { if (!resolvedName) {
return; return;
} }
const payload = typeof source === 'object' const payload = (source && typeof source === 'object')
? { ...source, name: resolvedName } ? { ...(source as Record<string, unknown>), name: resolvedName }
: { id: null, name: resolvedName }; : { id: null, name: resolvedName };
onAdd({ name: resolvedName, option: payload, input: null }); onAdd({ name: resolvedName, option: payload, input: null });
}, },
@@ -1,6 +1,16 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties, JSX, MutableRefObject } 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; const DEFAULT_THUMBNAIL_SIZE = 48;
@@ -74,26 +84,10 @@ interface DocumentVersionLike {
[key: string]: unknown; [key: string]: unknown;
} }
interface DocumentLike { type DocumentLike = AssetManagerDocumentLike;
id?: Identifier; type AssetLike = AssetManagerAssetLike;
current_version?: DocumentVersionLike; type EnsureAssetUrl = AssetManagerEnsureAssetUrl;
[key: string]: unknown; type GetDocumentAsset = AssetManagerGetAsset;
}
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;
interface DocumentThumbnailImageProps { interface DocumentThumbnailImageProps {
document?: DocumentLike | null; document?: DocumentLike | null;
+6 -6
View File
@@ -127,9 +127,9 @@ const DocumentsGrid: React.FC<DocumentsGridProps> = ({
submitEditing: submitDocumentEditing, submitEditing: submitDocumentEditing,
savingId: savingDocumentId, savingId: savingDocumentId,
attachInputRef: attachDocumentInputRef, attachInputRef: attachDocumentInputRef,
} = useInlineRename(onDocumentRename, { } = useInlineRename<DocumentLike>(onDocumentRename, {
getCurrentValue: (doc) => doc?.title ?? '', getCurrentValue: (doc: DocumentLike) => doc?.title ?? '',
getEntityId: (doc) => doc?.id ?? null, getEntityId: (doc: DocumentLike) => doc?.id ?? null,
}); });
const { const {
@@ -141,9 +141,9 @@ const DocumentsGrid: React.FC<DocumentsGridProps> = ({
submitEditing: submitFolderEditing, submitEditing: submitFolderEditing,
savingId: savingFolderId, savingId: savingFolderId,
attachInputRef: attachFolderInputRef, attachInputRef: attachFolderInputRef,
} = useInlineRename(onFolderRename, { } = useInlineRename<FolderLike>(onFolderRename, {
getCurrentValue: (folder) => folder?.name ?? '', getCurrentValue: (folder: FolderLike) => folder?.name ?? '',
getEntityId: (folder) => folder?.id ?? null, getEntityId: (folder: FolderLike) => folder?.id ?? null,
}); });
const documentSelectionCount = selectedDocumentIdsSet?.size ?? 0; const documentSelectionCount = selectedDocumentIdsSet?.size ?? 0;
+6 -6
View File
@@ -132,9 +132,9 @@ const DocumentsList: React.FC<DocumentsListProps> = ({
submitEditing: submitDocumentEditing, submitEditing: submitDocumentEditing,
savingId: savingDocumentId, savingId: savingDocumentId,
attachInputRef: attachDocumentInputRef, attachInputRef: attachDocumentInputRef,
} = useInlineRename(onDocumentRename, { } = useInlineRename<DocumentLike>(onDocumentRename, {
getCurrentValue: (doc) => doc?.title ?? '', getCurrentValue: (doc: DocumentLike) => doc?.title ?? '',
getEntityId: (doc) => doc?.id ?? null, getEntityId: (doc: DocumentLike) => doc?.id ?? null,
}); });
const { const {
@@ -146,9 +146,9 @@ const DocumentsList: React.FC<DocumentsListProps> = ({
submitEditing: submitFolderEditing, submitEditing: submitFolderEditing,
savingId: savingFolderId, savingId: savingFolderId,
attachInputRef: attachFolderInputRef, attachInputRef: attachFolderInputRef,
} = useInlineRename(onFolderRename, { } = useInlineRename<FolderLike>(onFolderRename, {
getCurrentValue: (folder) => folder?.name ?? '', getCurrentValue: (folder: FolderLike) => folder?.name ?? '',
getEntityId: (folder) => folder?.id ?? null, getEntityId: (folder: FolderLike) => folder?.id ?? null,
}); });
@@ -503,38 +503,82 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
/> />
) : null; ) : 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 ( return (
<> <>
{summaryNode ? ( {summaryNode ? (
<span className="panel-floating__label">{summaryNode}</span> <span className="panel-floating__label">{summaryNode}</span>
) : null} ) : null}
<div className="panel-floating-actions"> <div className="panel-floating-actions panel-floating-actions--assignments">
{typeof onMoveDocumentsToFolder === 'function' ? ( {moveMenu}
<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}
<SelectionAssignmentMenu <SelectionAssignmentMenu
label="Tags" label="Tags"
triggerContent={( triggerContent={(
@@ -565,42 +609,8 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
onCreate={handleCreateCorrespondentAssignment} onCreate={handleCreateCorrespondentAssignment}
disabled={!documentCount} 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> </div>
{primaryButtons}
</> </>
); );
}; };
+8 -12
View File
@@ -1,18 +1,14 @@
import { openOcrTextInNewTab } from '../utils/ocr'; import { openOcrTextInNewTab } from '../utils/ocr';
import type {
EnsureAssetUrl,
EnsurePreviewData,
GetDocumentAsset,
DocumentLike as OcrDocumentLike,
} from '../utils/ocr';
type ResolveApiPath = (path: string) => string; 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 { export type DocumentLike = OcrDocumentLike;
download_path?: string | null;
}
export interface DocumentLike {
id?: string | number;
current_version?: DocumentVersion | null;
}
const asyncFalse = async () => false; const asyncFalse = async () => false;
@@ -20,7 +16,7 @@ const resolveDocumentDownloadHref = (document: DocumentLike | null | undefined,
if (!document || !resolveApiPath) { if (!document || !resolveApiPath) {
return null; 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) { if (!downloadPath) {
return null; return null;
} }
@@ -58,7 +58,7 @@ export interface UseDocumentsPanelPropsArgs {
clearDocumentSelection?: () => void; clearDocumentSelection?: () => void;
handleDeleteSelection?: () => void; handleDeleteSelection?: () => void;
handleEntryPointerCore?: (...args: unknown[]) => void; handleEntryPointerCore?: (...args: unknown[]) => void;
inspectDocument?: (...args: unknown[]) => void; inspectDocument?: (docId: Identifier | null, metadata?: unknown) => void;
handleEntrySelection?: (...args: unknown[]) => void; handleEntrySelection?: (...args: unknown[]) => void;
tags?: unknown[]; tags?: unknown[];
correspondents?: unknown[]; correspondents?: unknown[];
@@ -19,6 +19,8 @@ interface DocumentsPanelProps {
[key: string]: any; [key: string]: any;
} }
const defaultGetDocumentAsset = (_doc?: unknown, _type?: string) => null;
const DocumentsPanel: React.FC<DocumentsPanelProps> = ({ const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
currentFolderName, currentFolderName,
breadcrumbs, breadcrumbs,
@@ -49,7 +51,7 @@ const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
activeCorrespondentIds = [], activeCorrespondentIds = [],
onFocusedRowChange, onFocusedRowChange,
ensureAssetUrl = null, ensureAssetUrl = null,
getDocumentAsset = () => null, getDocumentAsset = defaultGetDocumentAsset,
onTagClick, onTagClick,
onCorrespondentClick, onCorrespondentClick,
isSearchLoading = false, isSearchLoading = false,
@@ -207,7 +209,7 @@ const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
); );
const handleDocumentActivate = useCallback( const handleDocumentActivate = useCallback(
(doc, event) => { (doc, event?: React.MouseEvent | KeyboardEvent | null) => {
if (!doc) { if (!doc) {
return; return;
} }
@@ -223,7 +225,7 @@ const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
handleDocumentPreviewZoom(doc); handleDocumentPreviewZoom(doc);
return; return;
} }
onInspectDocument?.(doc.id, event); onInspectDocument?.(doc.id);
}, },
[handleDocumentPreviewZoom, onInspectDocument], [handleDocumentPreviewZoom, onInspectDocument],
); );
@@ -128,8 +128,6 @@ const createDocumentsSurface = ({
title, title,
subtitle, subtitle,
sidebarToggle, sidebarToggle,
parentBreadcrumb,
onNavigateParent,
actions, actions,
breadcrumbs, breadcrumbs,
selectionLabel: null, selectionLabel: null,
+24 -4
View File
@@ -77,10 +77,22 @@ export const readTagTransferData = (dataTransfer: DataTransfer | null | undefine
return null; 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 => { 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); const raw = readTagTransferData(dataTransfer || null);
if (!raw) { if (!raw) {
return null; return null;
@@ -96,11 +108,19 @@ export const parseTagTransferPayload = (input: DataTransfer | DragEventLike | nu
}; };
export const isTagTransferEvent = (event?: DragEventLike | null): boolean => { 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) { if (!types) {
return false; 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)); return TAG_MIME_TYPES.some((type) => typeList.includes(type));
}; };
@@ -93,6 +93,24 @@ const useDocumentCorrespondentActions = ({
[apiClient, refreshCurrentFolder, notifyApiError, setStatusMessage], [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( const handleCorrespondentAdd = useCallback(
async ({ document, name, input = null, option = null }: { document?: { id?: string | number }; name?: string; input?: HTMLInputElement | null; option?: CorrespondentOption | string | null }) => { async ({ document, name, input = null, option = null }: { document?: { id?: string | number }; name?: string; input?: HTMLInputElement | null; option?: CorrespondentOption | string | null }) => {
if (!document?.id) { if (!document?.id) {
@@ -104,12 +122,7 @@ const useDocumentCorrespondentActions = ({
return; return;
} }
let target = null; let target = correspondentLookupByName.get(trimmed.toLowerCase()) || normalizeOption(option);
if (option && option.id) {
target = correspondentLookupByName.get(trimmed.toLowerCase()) || option;
} else {
target = correspondentLookupByName.get(trimmed.toLowerCase()) || null;
}
if (!target) { if (!target) {
try { try {
target = await handleCorrespondentCreate({ name: trimmed }); target = await handleCorrespondentCreate({ name: trimmed });
@@ -102,12 +102,18 @@ const useDocumentDragHandlers = ({
if (item.type === 'document') { if (item.type === 'document') {
const doc = item.payload; const doc = item.payload;
const rowEl = doc?.id const rowEl = doc?.id
? document.getElementById(`document-row-${doc.id}`) ? (document.getElementById(`document-row-${doc.id}`)
|| document.getElementById(`document-card-${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; : 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 aspectAttr = wrapperEl?.dataset?.thumbnailAspect;
const aspectRatio = aspectAttr ? parseFloat(aspectAttr) : null; const aspectRatio = aspectAttr ? parseFloat(aspectAttr) : null;
@@ -130,7 +136,7 @@ const useDocumentDragHandlers = ({
layer.classList.add('document-drag-preview__item--image'); layer.classList.add('document-drag-preview__item--image');
layer.style.backgroundImage = `url("${thumbSrc}")`; layer.style.backgroundImage = `url("${thumbSrc}")`;
} else if (placeholderEl instanceof HTMLElement) { } else if (placeholderEl instanceof HTMLElement) {
const clone = placeholderEl.cloneNode(true); const clone = placeholderEl.cloneNode(true) as HTMLElement;
clone.style.pointerEvents = 'none'; clone.style.pointerEvents = 'none';
layer.appendChild(clone); layer.appendChild(clone);
} else { } else {
@@ -138,27 +144,36 @@ const useDocumentDragHandlers = ({
} }
} else { } else {
const payload = item.payload; 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 const rowEl = folderId
? document.getElementById(`folder-row-${folderId}`) ? (document.getElementById(`folder-row-${folderId}`)
|| document.getElementById(`folder-card-${folderId}`) || document.getElementById(`folder-card-${folderId}`))
: null;
const iconEl = rowEl instanceof HTMLElement
? rowEl.querySelector('.thumb-icon, .folder-card__icon')
: null; : null;
const iconEl = rowEl?.querySelector('.thumb-icon, .folder-card__icon');
layer.style.width = `${size}px`; layer.style.width = `${size}px`;
layer.style.height = `${size}px`; layer.style.height = `${size}px`;
layer.classList.add('document-drag-preview__item--folder'); layer.classList.add('document-drag-preview__item--folder');
let content = null; let content: HTMLElement | null = null;
if (iconEl instanceof HTMLElement) { if (iconEl instanceof HTMLElement) {
const cloneSource = iconEl.classList.contains('folder-card__icon') const cloneSource = iconEl.classList.contains('folder-card__icon')
? iconEl.querySelector('svg') || iconEl ? iconEl.querySelector('svg') || iconEl
: iconEl; : iconEl;
content = cloneSource.cloneNode(true); const clone = cloneSource.cloneNode(true);
content.classList.add('document-drag-preview__folder-thumb'); if (clone instanceof HTMLElement) {
const svg = content.querySelector('svg'); content = clone;
if (svg) { content.classList.add('document-drag-preview__folder-thumb');
svg.setAttribute('width', '48'); const svg = content.querySelector('svg');
svg.setAttribute('height', '48'); if (svg) {
svg.setAttribute('width', '48');
svg.setAttribute('height', '48');
}
} }
} }
@@ -326,12 +326,12 @@ const useDocumentMutations = ({
return filtered.length === prev.length ? prev : filtered; return filtered.length === prev.length ? prev : filtered;
}); });
setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id as DocumentId))); setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id as DocumentId)));
setFolderContents((prev) => { setFolderContents((prev: Map<FolderId, FolderContents>) => {
if (!prev.size) { if (!prev.size) {
return prev; return prev;
} }
let changed = false; let changed = false;
const next = new Map(prev); const next = new Map<FolderId, FolderContents>(prev);
movedDocs.forEach(({ id, sourceFolderId }) => { movedDocs.forEach(({ id, sourceFolderId }) => {
const sourceKey = (sourceFolderId || 'root') as FolderId; const sourceKey = (sourceFolderId || 'root') as FolderId;
const entry = next.get(sourceKey); const entry = next.get(sourceKey);
@@ -714,8 +714,8 @@ const useDocumentMutations = ({
await api.delete(`/folders/${folderId}`); await api.delete(`/folders/${folderId}`);
setFolderNodes((prev) => { setFolderNodes((prev: Map<FolderId, FolderNode>) => {
const next = new Map(prev); const next = new Map<FolderId, FolderNode>(prev);
const node = next.get(folderId); const node = next.get(folderId);
next.delete(folderId); next.delete(folderId);
if (node) { if (node) {
@@ -733,8 +733,8 @@ const useDocumentMutations = ({
return next; return next;
}); });
setFolderContents((prev) => { setFolderContents((prev: Map<FolderId, FolderContents>) => {
const next = new Map(prev); const next = new Map<FolderId, FolderContents>(prev);
next.delete(folderId); next.delete(folderId);
return next; return next;
}); });
@@ -214,11 +214,15 @@ const useDocumentTagging = ({
setLoading(true); setLoading(true);
try { try {
const { data } = await apiClient.post('/documents/bulk/reanalyze', { const response = await apiClient.post<{ queued?: number }>(
document_ids: targetIds, '/documents/bulk/reanalyze',
force: true, {
}); document_ids: targetIds,
const queued = data?.queued ?? targetIds.length; force: true,
},
);
const payload = 'data' in response ? response.data : response;
const queued = typeof payload?.queued === 'number' ? payload.queued : targetIds.length;
setStatusMessage( setStatusMessage(
`Queued re-analysis for ${queued} document${queued === 1 ? '' : 's'}.`, `Queued re-analysis for ${queued} document${queued === 1 ? '' : 's'}.`,
'success', 'success',
@@ -28,6 +28,7 @@ type UploadQueueItem = {
interface UploadResponse { interface UploadResponse {
reused?: boolean; reused?: boolean;
document?: unknown; document?: unknown;
folder?: { id?: FolderId };
} }
interface ApiClient { interface ApiClient {
@@ -35,15 +36,19 @@ interface ApiClient {
get<T = { document?: unknown }>(url: string): Promise<{ data: T }>; 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 = { type DropOverlayState = {
active: boolean; active: boolean;
folderName: string; folderName: string;
}; };
type FileSystemEntryLike = FileSystemFileEntryLike | FileSystemDirectoryEntryLike; type FileSystemEntryLike = FileSystemEntry;
type ExtendedDataTransferItem = DataTransferItem & { type ExtendedDataTransferItem = DataTransferItem & {
webkitGetAsEntry?: () => FileSystemEntryLike | null; webkitGetAsEntry?: () => FileSystemEntry | null;
}; };
interface FileSystemDirectoryReaderLike { interface FileSystemDirectoryReaderLike {
@@ -98,6 +103,8 @@ interface UseDocumentUploadsArgs {
refreshCurrentFolder: () => Promise<void>; refreshCurrentFolder: () => Promise<void>;
setLoading: (state: boolean) => void; setLoading: (state: boolean) => void;
shellRef: MutableRefObject<HTMLElement | null>; shellRef: MutableRefObject<HTMLElement | null>;
notifyApiError?: NotifyApiError;
setStatusMessage?: SetStatusMessage;
} }
interface UseDocumentUploadsResult { interface UseDocumentUploadsResult {
@@ -127,6 +134,8 @@ const useDocumentUploads = ({
refreshCurrentFolder, refreshCurrentFolder,
setLoading, setLoading,
shellRef, shellRef,
notifyApiError,
setStatusMessage,
}: UseDocumentUploadsArgs): UseDocumentUploadsResult => { }: UseDocumentUploadsArgs): UseDocumentUploadsResult => {
const [dropOverlayState, setDropOverlayState] = useState<DropOverlayState>({ const [dropOverlayState, setDropOverlayState] = useState<DropOverlayState>({
active: false, active: false,
@@ -145,8 +154,8 @@ const useDocumentUploads = ({
const formData = new FormData(); const formData = new FormData();
formData.append('file', file, file.name); formData.append('file', file, file.name);
if (targetFolderId && targetFolderId !== 'root') { if (targetFolderId != null && targetFolderId !== 'root') {
formData.append('folder_id', targetFolderId); formData.append('folder_id', String(targetFolderId));
} }
try { try {
@@ -179,11 +188,13 @@ const useDocumentUploads = ({
}; };
} }
const message = error.response?.data?.error || `Failed to upload ${file.name}.`; 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 }); const wrapped = Object.assign(new Error(message), { response: error.response });
throw wrapped; throw wrapped;
} }
}, },
[apiClient], [apiClient, notifyApiError, setStatusMessage],
); );
const appendQueueItems = useCallback((entries: FileEntry[], targetFolderId?: FolderId) => { const appendQueueItems = useCallback((entries: FileEntry[], targetFolderId?: FolderId) => {
@@ -235,9 +246,13 @@ const useDocumentUploads = ({
segments: trimmedSegments, segments: trimmedSegments,
}; };
const { data } = await apiClient.post('/folders/path', payload); const { data } = await apiClient.post<{ folder?: { id?: FolderId | null } }>(
cache.set(cacheKey, data.folder.id); '/folders/path',
return data.folder.id; payload,
);
const resolvedId = (data?.folder?.id ?? null) as FolderId;
cache.set(cacheKey, resolvedId);
return resolvedId;
}, },
[apiClient], [apiClient],
); );
@@ -284,7 +299,7 @@ const useDocumentUploads = ({
if (entry.isFile) { if (entry.isFile) {
const file = await new Promise<File>((resolve, reject) => { const file = await new Promise<File>((resolve, reject) => {
try { try {
(entry as FileSystemFileEntryLike).file(resolve, reject); (entry as unknown as FileSystemFileEntryLike).file(resolve, reject);
} catch (error) { } catch (error) {
console.warn('[Uploads] entry.file failed', error); console.warn('[Uploads] entry.file failed', error);
reject(error as Error); reject(error as Error);
@@ -295,7 +310,7 @@ const useDocumentUploads = ({
} }
if (entry.isDirectory) { if (entry.isDirectory) {
const nextAncestors = entry.name ? [...ancestors, entry.name] : [...ancestors]; 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); const entries = await readAllEntries(reader);
for (const child of entries) { for (const child of entries) {
await walkEntry(child, nextAncestors); await walkEntry(child, nextAncestors);
@@ -506,7 +521,6 @@ const useDocumentUploads = ({
hasFiles, hasFiles,
defaultFolderName: DEFAULT_FOLDER_NAME, defaultFolderName: DEFAULT_FOLDER_NAME,
dragCounterRef, dragCounterRef,
dropOverlayState,
setDropOverlayState, setDropOverlayState,
}); });
@@ -57,6 +57,44 @@ const EntryType = Object.freeze({
const noop = () => {}; 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 { interface UseDocumentsWorkspaceOptions {
documentsViewMode?: string; documentsViewMode?: string;
documentsSortField?: string; documentsSortField?: string;
@@ -118,9 +156,31 @@ const useDocumentsWorkspace = ({
const routeFolderId = folderMatch?.params?.folderId || null; const routeFolderId = folderMatch?.params?.folderId || null;
const routeDocumentId = docMatch?.params?.documentId || null; const routeDocumentId = docMatch?.params?.documentId || null;
const previewDocumentId = routeDocumentId; const previewDocumentId = routeDocumentId;
const { status: appStatus, token, tenant, tenants: tenantOptions = [] } = appState; const {
const tenantName = tenant?.name || tenant?.slug || null; status: appStatus,
const currentTenantId = tenant?.id || null; 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 { status, setStatusMessage } = useDocumentsStore();
const handleApiReport = useCallback( const handleApiReport = useCallback(
({ message, variant }) => setStatusMessage(message, variant), ({ message, variant }) => setStatusMessage(message, variant),
@@ -172,9 +232,9 @@ const useDocumentsWorkspace = ({
documentsRouteMatch || documentsFolderRouteMatch || documentsDetailRouteMatch, documentsRouteMatch || documentsFolderRouteMatch || documentsDetailRouteMatch,
); );
const [draggedDocumentIds, setDraggedDocumentIds] = useState([]); const [draggedDocumentIds, setDraggedDocumentIds] = useState<DocumentId[]>([]);
const [draggedFolderId, setDraggedFolderId] = useState(null); const [draggedFolderId, setDraggedFolderId] = useState<FolderId | null>(null);
const [activePreviewId, setActivePreviewId] = useState(routeDocumentId || null); const [activePreviewId, setActivePreviewId] = useState<DocumentId | null>(routeDocumentId || null);
const shellRef = useRef(null); const shellRef = useRef(null);
const assetManagerRef = useRef(null); const assetManagerRef = useRef(null);
if (!assetManagerRef.current) { 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); const folderContentsRef = useRef(folderContents);
useEffect(() => { useEffect(() => {
folderContentsRef.current = folderContents; folderContentsRef.current = folderContents;
@@ -363,6 +425,17 @@ const useDocumentsWorkspace = ({
setActivePreviewId, 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) => { const getDocumentAsset = useCallback((doc, type) => {
if (!doc || !type) return null; if (!doc || !type) return null;
return getAssetFromVersion(doc.current_version || null, type); return getAssetFromVersion(doc.current_version || null, type);
@@ -688,12 +761,13 @@ const useDocumentsWorkspace = ({
const removeDocumentsFromCaches = useCallback( const removeDocumentsFromCaches = useCallback(
(documentIds) => { (documentIds: DocumentId[] | null | undefined) => {
if (!documentIds || documentIds.length === 0) { const safeIds = Array.isArray(documentIds) ? documentIds : [];
if (!safeIds.length) {
return; return;
} }
const idSet = new Set(documentIds); const idSet = new Set<DocumentId>(safeIds);
setDocuments((prev) => prev.filter((doc) => !idSet.has(doc.id))); setDocuments((prev) => prev.filter((doc) => !idSet.has(doc.id)));
setSearchResults((prev) => { setSearchResults((prev) => {
@@ -704,12 +778,12 @@ const useDocumentsWorkspace = ({
return filtered.length === prev.length ? prev : filtered; return filtered.length === prev.length ? prev : filtered;
}); });
setFolderContents((prev) => { setFolderContents((prev: Map<FolderId, FolderContentsEntry>) => {
if (!prev.size) { if (!prev.size) {
return prev; return prev;
} }
let changed = false; let changed = false;
const next = new Map(); const next = new Map<FolderId, FolderContentsEntry>();
prev.forEach((contents, key) => { prev.forEach((contents, key) => {
const docs = Array.isArray(contents?.documents) ? contents.documents : null; const docs = Array.isArray(contents?.documents) ? contents.documents : null;
if (!docs || docs.length === 0) { if (!docs || docs.length === 0) {
@@ -1177,7 +1251,6 @@ const useDocumentsWorkspace = ({
documents, documents,
searchResults, searchResults,
previewDocuments, previewDocuments,
focusedDocumentId,
selectionOrder, selectionOrder,
selectedDocumentIds, selectedDocumentIds,
documentLookup, documentLookup,
@@ -1188,8 +1261,7 @@ const useDocumentsWorkspace = ({
previewEntries, previewEntries,
previewDocumentId, previewDocumentId,
activePreviewId, activePreviewId,
openDocumentPreview, openDocumentPreview: openDocumentPreviewForDetail,
promoteSelectionOrder,
handleDocumentTitleUpdate, handleDocumentTitleUpdate,
handleDocumentIssuedUpdate, handleDocumentIssuedUpdate,
handleDocumentTagAdd, handleDocumentTagAdd,
@@ -1206,6 +1278,17 @@ const useDocumentsWorkspace = ({
tagLookupById, tagLookupById,
}); });
const inspectDocumentForDesk = useCallback(
(doc: DocumentLike | null) => {
const docId = doc?.id;
if (docId == null) {
return;
}
inspectDocument(docId);
},
[inspectDocument],
);
const handleEntryPointerCore = useEntryPointerCore({ const handleEntryPointerCore = useEntryPointerCore({
resolveDocumentRowKey, resolveDocumentRowKey,
resolveFolderRowKey, resolveFolderRowKey,
@@ -1426,7 +1509,7 @@ const useDocumentsWorkspace = ({
handleDocumentsViewModeChange, handleDocumentsViewModeChange,
handleDeskExit: handleDeskExitSafe, handleDeskExit: handleDeskExitSafe,
refreshCurrentFolder, refreshCurrentFolder,
inspectDocument, inspectDocument: inspectDocumentForDesk,
handleEntryPointerCore, handleEntryPointerCore,
promoteSelectionOrder, promoteSelectionOrder,
currentTenantId, currentTenantId,
+4 -2
View File
@@ -1,5 +1,7 @@
import { MutableRefObject, useEffect } from 'react'; import { MutableRefObject, useEffect } from 'react';
type FolderId = string | number | 'root' | null;
interface DropOverlayState { interface DropOverlayState {
active: boolean; active: boolean;
folderName: string | null; folderName: string | null;
@@ -9,8 +11,8 @@ interface UseFileDropOptions {
shellRef: MutableRefObject<HTMLElement | null>; shellRef: MutableRefObject<HTMLElement | null>;
token?: string | null; token?: string | null;
currentFolderName: string | null; currentFolderName: string | null;
selectedFolder: string | null; selectedFolder: FolderId;
handleFileDrop: (dataTransfer: DataTransfer, folderId: string | null) => Promise<void>; handleFileDrop: (dataTransfer: DataTransfer, folderId: FolderId) => Promise<void>;
hasFiles: (event: DragEvent) => boolean; hasFiles: (event: DragEvent) => boolean;
defaultFolderName: string; defaultFolderName: string;
dragCounterRef: MutableRefObject<number>; dragCounterRef: MutableRefObject<number>;
+128 -41
View File
@@ -1,4 +1,5 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { import {
DEFAULT_FOLDER_NAME, DEFAULT_FOLDER_NAME,
createRootNode, createRootNode,
@@ -9,6 +10,80 @@ import {
resolveFolderRowKey, resolveFolderRowKey,
} from '../../app/appLayoutUtils'; } 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 = ({ const useFolderTree = ({
initialSelectedFolder = 'root', initialSelectedFolder = 'root',
assetManager, assetManager,
@@ -20,15 +95,15 @@ const useFolderTree = ({
setDocuments, setDocuments,
setFolderContents, setFolderContents,
folderContentsRef, folderContentsRef,
}) => { }: UseFolderTreeOptions) => {
const [folderNodes, setFolderNodes] = useState(() => { const [folderNodes, setFolderNodes] = useState<Map<FolderId, FolderTreeNode>>(() => {
const rootNode = createRootNode(); const rootNode = createRootNode() as FolderTreeNode;
return new Map([[rootNode.id, rootNode]]); return new Map([[rootNode.id, rootNode]]);
}); });
const [selectedFolder, setSelectedFolder] = useState(initialSelectedFolder || 'root'); const [selectedFolder, setSelectedFolder] = useState<FolderId>(initialSelectedFolder || 'root');
const [currentFolder, setCurrentFolder] = useState(null); const [currentFolder, setCurrentFolder] = useState<FolderSummary | null>(null);
const [currentSubfolders, setCurrentSubfolders] = useState([]); const [currentSubfolders, setCurrentSubfolders] = useState<FolderSummary[]>([]);
const { const {
focusedDocumentId, focusedDocumentId,
@@ -40,8 +115,8 @@ const useFolderTree = ({
} = selectionHelpers; } = selectionHelpers;
const applySelectedFolder = useCallback( const applySelectedFolder = useCallback(
(folderId, contents) => { (folderId: FolderId, contents?: FolderContentsEntry | null) => {
const subfolders = contents?.subfolders ?? []; const subfolders = Array.isArray(contents?.subfolders) ? contents.subfolders : [];
const docs = assetManager.hydrateDocuments(contents?.documents ?? []); const docs = assetManager.hydrateDocuments(contents?.documents ?? []);
const folderInfo = contents?.folder ?? null; const folderInfo = contents?.folder ?? null;
@@ -50,12 +125,12 @@ const useFolderTree = ({
setCurrentFolder(folderInfo); setCurrentFolder(folderInfo);
const availableDocKeys = docs const availableDocKeys = docs
.map((doc) => resolveDocumentRowKey(doc.id)) .map((doc) => resolveDocumentRowKey(doc?.id as Identifier))
.filter(Boolean); .filter(Boolean);
const availableDocKeySet = new Set(availableDocKeys); const availableDocKeySet = new Set(availableDocKeys);
const availableFolderKeys = new Set( const availableFolderKeys = new Set(
subfolders subfolders
.map((folder) => resolveFolderRowKey(folder.id)) .map((folder) => resolveFolderRowKey(folder?.id as Identifier))
.filter(Boolean), .filter(Boolean),
); );
@@ -102,20 +177,20 @@ const useFolderTree = ({
], ],
); );
const expandFolderAncestors = useCallback((targetId) => { const expandFolderAncestors = useCallback((targetId: FolderId | null) => {
if (!targetId || targetId === 'root') { if (!targetId || targetId === 'root') {
setFolderNodes((prev) => { setFolderNodes((prev: Map<FolderId, FolderTreeNode>) => {
const root = prev.get('root'); const root = prev.get('root');
if (root?.expanded) return prev; if (root?.expanded) return prev;
const next = new Map(prev); const next = new Map<FolderId, FolderTreeNode>(prev);
next.set('root', { ...root, expanded: true }); next.set('root', { ...root, expanded: true });
return next; return next;
}); });
return; return;
} }
setFolderNodes((prev) => { setFolderNodes((prev: Map<FolderId, FolderTreeNode>) => {
const next = new Map(prev); const next = new Map<FolderId, FolderTreeNode>(prev);
let currentId = targetId; let currentId = targetId;
let guard = 0; let guard = 0;
while (currentId && guard < 32) { while (currentId && guard < 32) {
@@ -133,15 +208,21 @@ const useFolderTree = ({
const ensureFolderData = useCallback( const ensureFolderData = useCallback(
async ( async (
folderId, folderId: FolderId,
{ {
includeDocuments = true, includeDocuments = true,
prefetchDepth = 0, prefetchDepth = 0,
force = false, force = false,
sortField = documentsSortFieldRef.current, sortField = documentsSortFieldRef.current,
sortDirection = documentsSortDirectionRef.current, sortDirection = documentsSortDirectionRef.current,
}: {
includeDocuments?: boolean;
prefetchDepth?: number;
force?: boolean;
sortField?: string;
sortDirection?: string;
} = {}, } = {},
) => { ): Promise<FolderContentsEntry> => {
const requestTenantId = tenantIdRef.current; const requestTenantId = tenantIdRef.current;
const cached = folderContentsRef.current.get(folderId); const cached = folderContentsRef.current.get(folderId);
const cachedSortField = cached?.__sortField || documentsSortFieldRef.current; const cachedSortField = cached?.__sortField || documentsSortFieldRef.current;
@@ -168,7 +249,7 @@ const useFolderTree = ({
} }
const path = folderId === 'root' ? 'root' : folderId; const path = folderId === 'root' ? 'root' : folderId;
const params = {}; const params: Record<string, unknown> = {};
if (!includeDocuments) { if (!includeDocuments) {
params.include_documents = false; params.include_documents = false;
} else { } else {
@@ -176,10 +257,12 @@ const useFolderTree = ({
params.dir = sortDirection; params.dir = sortDirection;
} }
const requestConfig = Object.keys(params).length ? { params } : {}; 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 hydrated = assetManager.hydrateFolderContents(data);
const childFolders = Array.isArray(data.subfolders) ? data.subfolders : []; 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 = { const enriched = {
...hydrated, ...hydrated,
@@ -192,8 +275,8 @@ const useFolderTree = ({
return enriched; return enriched;
} }
setFolderNodes((prev) => { setFolderNodes((prev: Map<FolderId, FolderTreeNode>) => {
const next = new Map(prev); const next = new Map<FolderId, FolderTreeNode>(prev);
const existingNode = next.get(folderId) || { const existingNode = next.get(folderId) || {
id: folderId, id: folderId,
name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || 'Folder', name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || 'Folder',
@@ -215,7 +298,11 @@ const useFolderTree = ({
}); });
childFolders.forEach((child) => { 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 previousChildren = Array.isArray(childNode?.children) ? childNode.children : [];
const childHasChildren = (() => { const childHasChildren = (() => {
if (childNode?.loaded) { if (childNode?.loaded) {
@@ -235,10 +322,10 @@ const useFolderTree = ({
} }
return false; return false;
})(); })();
next.set(child.id, { next.set(childId, {
id: child.id, id: childId,
name: child.name, name: child.name,
parentId: child.parent_id ?? 'root', parentId: (child.parent_id ?? 'root') as FolderId,
children: previousChildren, children: previousChildren,
expanded: childNode?.expanded ?? false, expanded: childNode?.expanded ?? false,
loaded: childNode?.loaded ?? false, loaded: childNode?.loaded ?? false,
@@ -261,11 +348,11 @@ const useFolderTree = ({
); );
} }
setFolderContents((prev) => { setFolderContents((prev: Map<FolderId, FolderContentsEntry>) => {
if (tenantIdRef.current !== requestTenantId) { if (tenantIdRef.current !== requestTenantId) {
return prev; return prev;
} }
const next = new Map(prev); const next = new Map<FolderId, FolderContentsEntry>(prev);
if (includeDocuments) { if (includeDocuments) {
next.set(folderId, enriched); next.set(folderId, enriched);
} else { } else {
@@ -302,7 +389,7 @@ const useFolderTree = ({
); );
const ensureFolderAncestorsLoaded = useCallback( const ensureFolderAncestorsLoaded = useCallback(
async (targetId) => { async (targetId: FolderId | null) => {
if (!targetId || targetId === 'root') { if (!targetId || targetId === 'root') {
return; return;
} }
@@ -323,7 +410,7 @@ const useFolderTree = ({
); );
const isInvalidFolderDrop = useCallback( const isInvalidFolderDrop = useCallback(
(sourceId, targetId) => { (sourceId: FolderId | null, targetId: FolderId | null) => {
if (!sourceId) return false; if (!sourceId) return false;
if (!targetId || targetId === 'root') { if (!targetId || targetId === 'root') {
return false; return false;
@@ -349,9 +436,9 @@ const useFolderTree = ({
); );
const resetFolderTreeState = useCallback(() => { const resetFolderTreeState = useCallback(() => {
const rootNode = createRootNode(); const rootNode = createRootNode() as FolderTreeNode;
setFolderNodes(new Map([[rootNode.id, rootNode]])); setFolderNodes(new Map<FolderId, FolderTreeNode>([[rootNode.id, rootNode]]));
setFolderContents(new Map()); setFolderContents(new Map<FolderId, FolderContentsEntry>());
setSelectedFolder('root'); setSelectedFolder('root');
setCurrentFolder(null); setCurrentFolder(null);
setCurrentSubfolders([]); setCurrentSubfolders([]);
@@ -362,11 +449,11 @@ const useFolderTree = ({
return currentFolder.name; return currentFolder.name;
}, [selectedFolder, currentFolder]); }, [selectedFolder, currentFolder]);
const folderOptions = useMemo(() => { const folderOptions: FolderOption[] = useMemo(() => {
const cache = new Map(); const cache = new Map<FolderId, string>();
const computePath = (id) => { const computePath = (id: FolderId | null): string => {
if (cache.has(id)) { if (cache.has(id as FolderId)) {
return cache.get(id); return cache.get(id as FolderId) as string;
} }
if (!id || id === 'root') { if (!id || id === 'root') {
cache.set('root', DEFAULT_FOLDER_NAME); cache.set('root', DEFAULT_FOLDER_NAME);
@@ -376,7 +463,7 @@ const useFolderTree = ({
if (!node) { if (!node) {
return 'Folder'; return 'Folder';
} }
const parentId = node.parentId || 'root'; const parentId = (node.parentId || 'root') as FolderId;
const parentPath = computePath(parentId); const parentPath = computePath(parentId);
const name = node.name || 'Folder'; const name = node.name || 'Folder';
const fullPath = parentId === 'root' ? name : `${parentPath}/${name}`; const fullPath = parentId === 'root' ? name : `${parentPath}/${name}`;
@@ -384,7 +471,7 @@ const useFolderTree = ({
return fullPath; return fullPath;
}; };
const entries = []; const entries: FolderOption[] = [];
folderNodes.forEach((node, id) => { folderNodes.forEach((node, id) => {
if (!node) return; if (!node) return;
entries.push({ id, label: computePath(id) }); entries.push({ id, label: computePath(id) });
@@ -400,7 +487,7 @@ const useFolderTree = ({
}, [folderNodes]); }, [folderNodes]);
const folderLabelMap = useMemo(() => { const folderLabelMap = useMemo(() => {
const map = new Map(); const map = new Map<FolderId, string>();
folderOptions.forEach((option) => { folderOptions.forEach((option) => {
map.set(option.id, option.label); map.set(option.id, option.label);
}); });
@@ -4,7 +4,7 @@ import type { NavigateFunction } from 'react-router-dom';
interface ApiClient { interface ApiClient {
get: (path: string) => Promise<{ data: unknown }>; get: (path: string) => Promise<{ data: unknown }>;
post: (path: string, body?: unknown) => Promise<{ data: any }>; post: (path: string, body?: unknown) => Promise<{ data: any }>;
defaults: { headers: { common: Record<string, string> } }; defaults: { headers: { common: Record<string, unknown> } };
} }
interface TenantOption { interface TenantOption {
+1 -1
View File
@@ -16,7 +16,7 @@ if (!container) {
const root = createRoot(container); const root = createRoot(container);
root.render( root.render(
<AppStateProvider> <AppStateProvider>
<HashRouter hashType="hashbang"> <HashRouter>
<AppRouter /> <AppRouter />
</HashRouter> </HashRouter>
</AppStateProvider>, </AppStateProvider>,
+1 -1
View File
@@ -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') { if (!document || !hasOcr || typeof getDocumentAsset !== 'function') {
return ''; return '';
} }
+1 -1
View File
@@ -57,7 +57,7 @@ export const useViewerLayoutMode = (
measure(); measure();
if (!('ResizeObserver' in window)) { if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', measure); window.addEventListener('resize', measure);
return () => { return () => {
if (frame) { if (frame) {
+11 -3
View File
@@ -3,12 +3,20 @@ import { Navigate, useNavigate, useParams } from 'react-router-dom';
import { useAppShell } from '../appShellContext'; 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 DocumentViewerRoute: React.FC = () => {
const { const {
previewWorkspaceDocument, previewWorkspaceDocument,
ensurePreviewData, ensurePreviewData,
notifyApiError, notifyApiError,
} = useAppShell(); } = useAppShell() as DocumentViewerRouteContext;
const { documentId } = useParams(); const { documentId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -22,12 +30,12 @@ const DocumentViewerRoute: React.FC = () => {
const hydrate = async () => { const hydrate = async () => {
try { try {
await ensurePreviewData(documentId); await ensurePreviewData?.(documentId);
} catch (error) { } catch (error) {
if (cancelled) { if (cancelled) {
return; return;
} }
notifyApiError(error, 'Failed to open document preview.'); notifyApiError?.(error, 'Failed to open document preview.');
navigate('/documents', { replace: true }); navigate('/documents', { replace: true });
} }
}; };
+45 -16
View File
@@ -1,19 +1,45 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }) => { type Identifier = string | number;
const [capabilitySets, setCapabilitySets] = useState([]);
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 [capabilitySetsLoading, setCapabilitySetsLoading] = useState(false);
const [creatingCapabilitySet, setCreatingCapabilitySet] = useState(false); const [creatingCapabilitySet, setCreatingCapabilitySet] = useState(false);
const [savingCapabilitySetId, setSavingCapabilitySetId] = useState(null); const [savingCapabilitySetId, setSavingCapabilitySetId] = useState<Identifier | null>(null);
const [deletingCapabilitySetId, setDeletingCapabilitySetId] = useState(null); const [deletingCapabilitySetId, setDeletingCapabilitySetId] = useState<Identifier | null>(null);
const [supportsCapabilitySetLabels, setSupportsCapabilitySetLabels] = useState(false); const [supportsCapabilitySetLabels, setSupportsCapabilitySetLabels] = useState(false);
const applyCapabilitySets = useCallback((updater) => { const applyCapabilitySets = useCallback((updater: CapabilitySet[] | ((prev: CapabilitySet[]) => CapabilitySet[])) => {
setCapabilitySets((previous) => { setCapabilitySets((previous) => {
const base = Array.isArray(previous) ? [...previous] : []; const base = Array.isArray(previous) ? [...previous] : [];
const next = typeof updater === 'function' const next = typeof updater === 'function'
? updater(base) ? updater(base)
: (Array.isArray(updater) ? [...updater] : base); : Array.isArray(updater)
? [...updater]
: base;
const supportsLabels = next.some((item) => Object.prototype.hasOwnProperty.call(item || {}, 'label')); const supportsLabels = next.some((item) => Object.prototype.hasOwnProperty.call(item || {}, 'label'));
setSupportsCapabilitySetLabels(supportsLabels); setSupportsCapabilitySetLabels(supportsLabels);
return next; return next;
@@ -27,7 +53,7 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }) =>
} }
setCapabilitySetsLoading(true); setCapabilitySetsLoading(true);
try { try {
const { data } = await api.get('/capability-sets'); const { data } = await api.get<CapabilitySet[]>('/capability-sets');
applyCapabilitySets(Array.isArray(data) ? data : []); applyCapabilitySets(Array.isArray(data) ? data : []);
} catch (error) { } catch (error) {
notifyApiError?.(error, 'Failed to load capability sets.'); notifyApiError?.(error, 'Failed to load capability sets.');
@@ -45,7 +71,7 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }) =>
}, [applyCapabilitySets, refreshCapabilitySets, token]); }, [applyCapabilitySets, refreshCapabilitySets, token]);
const createCapabilitySet = useCallback( const createCapabilitySet = useCallback(
async ({ slug, label, capabilities } = {}) => { async ({ slug, label, capabilities }: { slug?: string; label?: string; capabilities?: string[] } = {}) => {
if (creatingCapabilitySet) { if (creatingCapabilitySet) {
return false; return false;
} }
@@ -55,7 +81,7 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }) =>
} }
setCreatingCapabilitySet(true); setCreatingCapabilitySet(true);
try { try {
const payload = { const payload: { slug?: string; label?: string; capabilities: string[] } = {
capabilities, capabilities,
}; };
const trimmedSlug = slug?.trim(); const trimmedSlug = slug?.trim();
@@ -67,12 +93,12 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }) =>
payload.label = trimmedLabel; payload.label = trimmedLabel;
} }
const { data } = await api.post('/capability-sets', payload); const { data } = await api.post<CapabilitySet>('/capability-sets', payload);
if (data) { if (data) {
applyCapabilitySets((previous) => { applyCapabilitySets((previous) => {
const next = previous.filter((entry) => entry?.id !== data.id); const next = previous.filter((entry) => entry?.id !== data.id);
next.push(data); next.push(data);
next.sort((a, b) => a.slug.localeCompare(b.slug)); next.sort((a, b) => (a.slug || '').localeCompare(b.slug || ''));
return next; return next;
}); });
} else { } else {
@@ -99,13 +125,16 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }) =>
); );
const updateCapabilitySet = useCallback( const updateCapabilitySet = useCallback(
async (capabilitySetId, { slug, label, capabilities } = {}) => { async (
capabilitySetId: Identifier | null,
{ slug, label, capabilities }: { slug?: string; label?: string; capabilities?: string[] } = {},
) => {
if (!capabilitySetId) { if (!capabilitySetId) {
return false; return false;
} }
setSavingCapabilitySetId(capabilitySetId); setSavingCapabilitySetId(capabilitySetId);
try { try {
const payload = {}; const payload: { slug?: string; label?: string; capabilities?: string[] } = {};
if (slug !== undefined) { if (slug !== undefined) {
const trimmed = slug?.trim(); const trimmed = slug?.trim();
if (trimmed) { if (trimmed) {
@@ -126,7 +155,7 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }) =>
payload.capabilities = capabilities; payload.capabilities = capabilities;
} }
const { data } = await api.patch(`/capability-sets/${capabilitySetId}`, payload); const { data } = await api.patch<CapabilitySet>(`/capability-sets/${capabilitySetId}`, payload);
if (data) { if (data) {
applyCapabilitySets((previous) => { applyCapabilitySets((previous) => {
let found = false; let found = false;
@@ -140,7 +169,7 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }) =>
if (!found) { if (!found) {
next.push(data); next.push(data);
} }
next.sort((a, b) => a.slug.localeCompare(b.slug)); next.sort((a, b) => (a.slug || '').localeCompare(b.slug || ''));
return next; return next;
}); });
} else { } else {
@@ -166,7 +195,7 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }) =>
); );
const deleteCapabilitySet = useCallback( const deleteCapabilitySet = useCallback(
async (capabilitySetId) => { async (capabilitySetId: Identifier | null) => {
if (!capabilitySetId) { if (!capabilitySetId) {
return false; return false;
} }
+4 -4
View File
@@ -58,13 +58,13 @@ interface PasskeyRegisterPayload {
nickname?: string; nickname?: string;
} }
type RegisterPasskeyFailureReason = 'unsupported' | 'busy' | 'cancelled' | 'error'; export type RegisterPasskeyFailureReason = 'unsupported' | 'busy' | 'cancelled' | 'error';
type RegisterPasskeyResult = export type RegisterPasskeyResult =
| { ok: true } | { ok: true }
| { ok: false; reason: RegisterPasskeyFailureReason; message?: string }; | { ok: false; reason: RegisterPasskeyFailureReason; message?: string };
type RevokePasskeyFailureReason = 'missing-id' | 'error'; export type RevokePasskeyFailureReason = 'missing-id' | 'error';
type RevokePasskeyResult = export type RevokePasskeyResult =
| { ok: true } | { ok: true }
| { ok: false; reason: RevokePasskeyFailureReason; message?: string }; | { ok: false; reason: RevokePasskeyFailureReason; message?: string };
+85 -5
View File
@@ -61,11 +61,21 @@
gap: 0.5rem; 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 { .panel-floating {
position: absolute; position: absolute;
top: calc(50% + 0.25rem); top: 0;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -45%);
background: color-mix(in oklch, var(--surface) 100%, transparent); background: color-mix(in oklch, var(--surface) 100%, transparent);
border: 1px solid color-mix(in oklch, var(--border) 95%, transparent); border: 1px solid color-mix(in oklch, var(--border) 95%, transparent);
padding: 0.45rem 0.85rem; padding: 0.45rem 0.85rem;
@@ -73,14 +83,28 @@
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 400; font-weight: 400;
color: var(--fg); 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; pointer-events: auto;
display: flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-wrap: nowrap; flex-wrap: wrap;
gap: 0.75rem; gap: 0.75rem;
z-index: 950000; 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 { .panel-floating__label {
@@ -128,6 +152,15 @@
pointer-events: auto; 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 { .panel-floating-actions .quick-add {
pointer-events: auto; pointer-events: auto;
} }
@@ -155,6 +188,53 @@
height: 1.35rem; 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 { .documents-panel .documents-scroll {
overflow-y: auto; overflow-y: auto;
background: transparent; background: transparent;
+1
View File
@@ -133,6 +133,7 @@
flex: 0 0 var(--sidebar-width); flex: 0 0 var(--sidebar-width);
background: var(--bg); background: var(--bg);
position: relative; position: relative;
z-index: 1000000;
} }
.sidebar--resizing { .sidebar--resizing {
+10 -6
View File
@@ -39,9 +39,9 @@ const BreadcrumbTrail = ({
() => (shouldTruncateFromStart ? normalized : normalized.slice().reverse()), () => (shouldTruncateFromStart ? normalized : normalized.slice().reverse()),
[normalized, shouldTruncateFromStart], [normalized, shouldTruncateFromStart],
); );
const containerRef = useRef(null); const containerRef = useRef<HTMLDivElement | null>(null);
const measurementRef = useRef(null); const measurementRef = useRef<HTMLDivElement | null>(null);
const ellipsisButtonRef = useRef(null); const ellipsisButtonRef = useRef<HTMLButtonElement | null>(null);
const [availableWidth, setAvailableWidth] = useState(null); const [availableWidth, setAvailableWidth] = useState(null);
const [startIndex, setStartIndex] = useState(0); const [startIndex, setStartIndex] = useState(0);
const measureRafRef = useRef(null); const measureRafRef = useRef(null);
@@ -139,13 +139,17 @@ const BreadcrumbTrail = ({
return; 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) { if (!entryNodes.length) {
return; return;
} }
const separatorNodes = Array.from(measurement.querySelectorAll('[data-item-type="separator"]')); const separatorNodes = Array.from(
const ellipsisNode = measurement.querySelector('[data-item-type="ellipsis"]'); 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 originalEntryDisplay = entryNodes.map((node) => node.style.display);
const originalSeparatorDisplay = separatorNodes.map((node) => node.style.display); const originalSeparatorDisplay = separatorNodes.map((node) => node.style.display);
+20 -11
View File
@@ -1,24 +1,30 @@
import { createAssetView, resolveDocumentAssetUrl } from '../asset_manager'; 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; const BASE_FETCH_OPTIONS = { start: 1, limit: 1 } as const;
interface DocumentLike { interface DocumentVersion extends DocumentVersionLike {
id?: string | number; download_path?: string | null;
current_version?: unknown;
} }
interface AssetLike { export interface DocumentLike extends AssetManagerDocumentLike {
id?: string | number; current_version?: DocumentVersion | null;
[key: string]: unknown;
} }
type EnsurePreviewData = (id: string | number) => Promise<DocumentLike | null | undefined>; export type AssetLike = AssetManagerAssetLike;
type GetDocumentAsset = (doc: DocumentLike, type: string) => AssetLike | null | undefined;
type EnsureAssetUrl = ( export type EnsurePreviewData = (id: string | number) => Promise<DocumentLike | null | undefined>;
export type EnsureAssetUrl = (
id: string | number, id: string | number,
asset: AssetLike, asset: AssetLike,
options?: { start?: number; limit?: number; force?: boolean }, options?: { start?: number; limit?: number; force?: boolean },
) => Promise<AssetLike | null | undefined>; ) => Promise<AssetLike | null | undefined>;
export type GetDocumentAsset = AssetManagerGetAsset;
interface ResolveOcrTextUrlOptions { interface ResolveOcrTextUrlOptions {
document: DocumentLike | null; document: DocumentLike | null;
@@ -62,10 +68,13 @@ export async function resolveOcrTextUrl({
const baseView = createAssetView(asset); const baseView = createAssetView(asset);
const hasUrl = Boolean(baseView.getPrimaryUrl()); const hasUrl = Boolean(baseView.getPrimaryUrl());
let entry = asset; let entry: AssetLike = asset;
if (ensureAssetUrl) { if (ensureAssetUrl) {
const ensureOptions = { ...BASE_FETCH_OPTIONS, force: !hasUrl }; 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); const ensuredView = createAssetView(entry);
+12 -5
View File
@@ -82,6 +82,13 @@ export const base64urlToUint8Array = (value?: string | null): Uint8Array => {
return bytes; 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 => { const toUint8Array = (input?: ArrayBuffer | ArrayBufferView | ArrayLike<number> | null): Uint8Array => {
if (!input) { if (!input) {
return new Uint8Array(); return new Uint8Array();
@@ -124,14 +131,14 @@ export const preparePublicKeyCreationOptions = (
const publicKey: PublicKeyCredentialCreationOptions = { ...challengeResponse.publicKey }; const publicKey: PublicKeyCredentialCreationOptions = { ...challengeResponse.publicKey };
if (publicKey.challenge && typeof publicKey.challenge === 'string') { if (publicKey.challenge && typeof publicKey.challenge === 'string') {
publicKey.challenge = base64urlToUint8Array(publicKey.challenge); publicKey.challenge = base64urlToBufferSource(publicKey.challenge);
} }
if (publicKey.user?.id) { if (publicKey.user?.id) {
publicKey.user = { publicKey.user = {
...publicKey.user, ...publicKey.user,
id: typeof publicKey.user.id === 'string' id: typeof publicKey.user.id === 'string'
? base64urlToUint8Array(publicKey.user.id) ? base64urlToBufferSource(publicKey.user.id)
: publicKey.user.id, : publicKey.user.id,
}; };
} }
@@ -140,7 +147,7 @@ export const preparePublicKeyCreationOptions = (
publicKey.excludeCredentials = publicKey.excludeCredentials.map((descriptor) => ({ publicKey.excludeCredentials = publicKey.excludeCredentials.map((descriptor) => ({
...descriptor, ...descriptor,
id: typeof descriptor.id === 'string' id: typeof descriptor.id === 'string'
? base64urlToUint8Array(descriptor.id) ? base64urlToBufferSource(descriptor.id)
: descriptor.id, : descriptor.id,
})); }));
} }
@@ -162,14 +169,14 @@ export const preparePublicKeyRequestOptions = (
const publicKey: PublicKeyCredentialRequestOptions = { ...challengeResponse.publicKey }; const publicKey: PublicKeyCredentialRequestOptions = { ...challengeResponse.publicKey };
if (publicKey.challenge && typeof publicKey.challenge === 'string') { if (publicKey.challenge && typeof publicKey.challenge === 'string') {
publicKey.challenge = base64urlToUint8Array(publicKey.challenge); publicKey.challenge = base64urlToBufferSource(publicKey.challenge);
} }
if (Array.isArray(publicKey.allowCredentials)) { if (Array.isArray(publicKey.allowCredentials)) {
publicKey.allowCredentials = publicKey.allowCredentials.map((descriptor) => ({ publicKey.allowCredentials = publicKey.allowCredentials.map((descriptor) => ({
...descriptor, ...descriptor,
id: typeof descriptor.id === 'string' id: typeof descriptor.id === 'string'
? base64urlToUint8Array(descriptor.id) ? base64urlToBufferSource(descriptor.id)
: descriptor.id, : descriptor.id,
})); }));
} }