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 type { ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAppShell } from '../appShellContext';
import DocumentsLayout from './DocumentsLayout';
@@ -8,7 +9,45 @@ import BreadcrumbTrail from '../ui/BreadcrumbTrail';
import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext';
import { PanelManagerProvider, usePanelManager } from './PanelManagerContext';
type Breadcrumb = { id?: string | number; name?: string; label?: string; title?: string };
type Identifier = string | number;
type Breadcrumb = { id?: Identifier; name?: string; label?: string; title?: string };
type EnsureAssetUrl = (
docId: Identifier,
asset: unknown,
options?: Record<string, unknown>,
) => Promise<unknown> | void;
type EnsurePreviewData = (docId: Identifier, options?: Record<string, unknown>) => Promise<unknown>;
type GetDocumentAsset = (document: unknown, assetType: string) => unknown;
type ResolveApiPath = (path: string) => string;
type NotifyApiError = (error: unknown, fallbackMessage?: string) => void;
interface DocumentsTableProps {
breadcrumbs?: Breadcrumb[] | null;
[key: string]: unknown;
}
interface DocumentsRouteAppShell {
sidebarProps?: Record<string, unknown> | null;
documentsTableProps?: DocumentsTableProps | null;
detailPanelProps?: Record<string, unknown> | null;
detailPanelOpen?: boolean;
documentsViewMode?: string;
deskWorkspaceProps?: Record<string, unknown> | null;
openTagsModal?: () => void;
openCorrespondentsModal?: () => void;
previewWorkspaceDocument?: unknown;
previewWorkspaceEntry?: unknown;
previewDocumentId?: Identifier | null;
closeDocumentPreview?: () => void;
ensurePreviewData?: EnsurePreviewData;
resolveApiPath?: ResolveApiPath;
ensureAssetUrl?: EnsureAssetUrl;
getDocumentAsset?: GetDocumentAsset;
notifyApiError?: NotifyApiError;
}
const DocumentsRouteContent: React.FC = () => {
const {
@@ -29,7 +68,7 @@ const DocumentsRouteContent: React.FC = () => {
ensureAssetUrl,
getDocumentAsset,
notifyApiError,
} = useAppShell();
} = useAppShell() as DocumentsRouteAppShell;
const navigate = useNavigate();
const { collapsed: sidebarCollapsed } = useSidebarContext();
const {
@@ -37,18 +76,25 @@ const DocumentsRouteContent: React.FC = () => {
expandSidebar,
} = usePanelManager();
const safeSidebarProps = useMemo<Record<string, unknown>>(
() => (sidebarProps && typeof sidebarProps === 'object' ? sidebarProps : {}),
[sidebarProps],
);
const sidebarPropsWithActions = useMemo(
() => ({
...sidebarProps,
...safeSidebarProps,
onManageTags: openTagsModal,
onManageCorrespondents: openCorrespondentsModal,
}),
[sidebarProps, openTagsModal, openCorrespondentsModal],
[safeSidebarProps, openTagsModal, openCorrespondentsModal],
);
const sidebarHidden = sidebarCollapsed || sidebarSuppressed;
const breadcrumbs = documentsTableProps?.breadcrumbs || null;
const breadcrumbs = Array.isArray(documentsTableProps?.breadcrumbs)
? documentsTableProps?.breadcrumbs
: null;
const parentBreadcrumb = useMemo(() => {
if (!Array.isArray(breadcrumbs) || breadcrumbs.length <= 1) {
return null;
@@ -114,11 +160,14 @@ const DocumentsRouteContent: React.FC = () => {
const variant = surface.variant || 'documents';
const surfaceDetail = (surface as { detail?: ReactNode }).detail || null;
const hasDetail = Boolean(surfaceDetail);
const mainContentClass = `main-content main-content--${variant}${
surface.detail ? ' main-content--has-detail' : ''
hasDetail ? ' main-content--has-detail' : ''
}`;
const bodyClass = `main-content__body main-content__body--${variant}${
surface.detail ? ' main-content__body--has-detail' : ''
hasDetail ? ' main-content__body--has-detail' : ''
}`;
const header = surface.header || null;
@@ -153,26 +202,30 @@ const DocumentsRouteContent: React.FC = () => {
<DocumentsLayout sidebarProps={sidebarPropsWithActions}>
<div className={mainContentClass}>
{header ? (
<div className="main-content__header-wrapper">
<>
<div className="main-content__header-wrapper">
<PanelHeader
className="main-content__header"
leading={header.leading}
title={headerTitle}
titleTag="h2"
actions={header.actions}
/>
</div>
{(header.selectionLabel || header.floatingActions) ? (
<div className="panel-floating" aria-live="polite" aria-atomic="true">
{header.selectionLabel ? (
<span className="panel-floating__label">{header.selectionLabel}</span>
) : null}
{header.floatingActions || null}
<div className="panel-floating-region" aria-live="polite" aria-atomic="true">
<div className="panel-floating">
{header.selectionLabel ? (
<span className="panel-floating__label">{header.selectionLabel}</span>
) : null}
{header.floatingActions || null}
</div>
</div>
) : null}
<PanelHeader
className="main-content__header"
leading={header.leading}
title={headerTitle}
titleTag="h2"
actions={header.actions}
/>
</div>
</>
) : null}
<div className={bodyClass}>{surface.content}</div>
{surface.detail || null}
{surfaceDetail}
</div>
</DocumentsLayout>
);
+17 -3
View File
@@ -221,13 +221,18 @@ const LoginRoute: React.FC = () => {
const publicKey = preparePublicKeyRequestOptions({ publicKey: publicKeyOptions });
setStatusMessage('Confirm the passkey prompt to continue.', 'info');
const assertion = await navigator.credentials.get({ publicKey });
const assertion = await navigator.credentials.get({ publicKey }) as PublicKeyCredential | null;
if (!assertion) {
setStatusMessage('Passkey login cancelled.', 'info');
return;
}
if (!(assertion instanceof PublicKeyCredential)) {
setStatusMessage('Unexpected credential response.', 'error');
return;
}
const serialized = serializeAuthenticationCredential(assertion);
const finishPayload = {
challengeId,
@@ -303,7 +308,11 @@ const LoginRoute: React.FC = () => {
setStatusMessage('Signing you in…', 'info');
try {
const payload = {
const payload: {
magic_token: string;
username?: string;
preferred_tenant_id?: string | number;
} = {
magic_token: magicToken,
};
if (magicUsername) {
@@ -419,13 +428,18 @@ const LoginRoute: React.FC = () => {
const publicKey = preparePublicKeyCreationOptions({ publicKey: publicKeyOptions });
setStatusMessage('Confirm the passkey prompt to finish creating your account.', 'info');
const credential = await navigator.credentials.create({ publicKey });
const credential = await navigator.credentials.create({ publicKey }) as PublicKeyCredential | null;
if (!credential) {
setStatusMessage('Signup cancelled.', 'info');
return;
}
if (!(credential instanceof PublicKeyCredential)) {
setStatusMessage('Unexpected credential response.', 'error');
return;
}
const serialized = serializeRegistrationCredential(credential);
const finishPayload = {
signup_token: signupToken,
+37 -7
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import TagsPanel from '../tags/TagsPanel';
import CorrespondentsPanel from '../correspondents/CorrespondentsPanel';
import CorrespondentsPanel, { CorrespondentsPanelProps } from '../correspondents/CorrespondentsPanel';
import PanelHeader from '../ui/PanelHeader';
const TAGS_MODAL = 'tags';
@@ -135,6 +135,36 @@ export const useManagementModals = ({
tags,
]);
const handleCorrespondentCreateSafe = useCallback<CorrespondentsPanelProps['onCreate']>(
async (payload) => {
if (typeof onCorrespondentCreate !== 'function') {
return undefined;
}
return onCorrespondentCreate(payload) ?? undefined;
},
[onCorrespondentCreate],
);
const handleCorrespondentUpdateSafe = useCallback<CorrespondentsPanelProps['onUpdate']>(
async (id, payload) => {
if (typeof onCorrespondentUpdate !== 'function') {
return;
}
await onCorrespondentUpdate(id, payload);
},
[onCorrespondentUpdate],
);
const handleCorrespondentDeleteSafe = useCallback<CorrespondentsPanelProps['onDelete']>(
async (id) => {
if (typeof onCorrespondentDelete !== 'function') {
return;
}
await onCorrespondentDelete(id);
},
[onCorrespondentDelete],
);
const correspondentsModal = useMemo(() => {
if (activeModal !== CORRESPONDENTS_MODAL) {
return null;
@@ -167,9 +197,9 @@ export const useManagementModals = ({
<CorrespondentsPanel
correspondents={correspondents}
onRefresh={refreshCorrespondents}
onCreate={onCorrespondentCreate}
onUpdate={onCorrespondentUpdate}
onDelete={onCorrespondentDelete}
onCreate={handleCorrespondentCreateSafe}
onUpdate={handleCorrespondentUpdateSafe}
onDelete={handleCorrespondentDeleteSafe}
onNotify={setStatusMessage}
/>
</div>
@@ -180,9 +210,9 @@ export const useManagementModals = ({
activeModal,
closeActiveModal,
correspondents,
onCorrespondentCreate,
onCorrespondentDelete,
onCorrespondentUpdate,
handleCorrespondentCreateSafe,
handleCorrespondentDeleteSafe,
handleCorrespondentUpdateSafe,
refreshCorrespondents,
setStatusMessage,
]);
-2
View File
@@ -140,7 +140,6 @@ export const useWorkspaceSurface = ({
onUpdateTitle,
onUpdateIssued,
resolveFolderPath,
onFolderNavigate,
} = detailExtras;
return createDocumentViewerSurface({
document: previewWorkspaceDocument,
@@ -162,7 +161,6 @@ export const useWorkspaceSurface = ({
onUpdateTitle,
onUpdateIssued,
resolveFolderPath,
onFolderNavigate,
});
}, [
showPreviewWorkspace,
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 462 KiB

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