fuck
This commit is contained in:
@@ -16,7 +16,7 @@ export const resolveApiPath = (path = '') => path;
|
||||
const makeRowKey = (type, id) =>
|
||||
id ? `${type}${ROW_KEY_SEPARATOR}${id}` : `${type}${ROW_KEY_SEPARATOR}`;
|
||||
|
||||
const normalizeRowKey = (key: string | number | null | undefined) => String(key ?? '');
|
||||
const normalizeRowKey = (key: string | number | null) => String(key ?? '');
|
||||
|
||||
const getRowType = (key) => normalizeRowKey(key).split(ROW_KEY_SEPARATOR, 1)[0] ?? '';
|
||||
|
||||
|
||||
@@ -25,11 +25,11 @@ type PreviewEntry = {
|
||||
canGoNext?: boolean;
|
||||
goPrev?: () => void;
|
||||
goNext?: () => void;
|
||||
} | null;
|
||||
};
|
||||
|
||||
interface AssetManagerLike {
|
||||
hydrateDetail: (payload: unknown) => { document?: DocumentLike } | null | undefined;
|
||||
hydrateDocument: (payload: unknown) => DocumentLike | null | undefined;
|
||||
hydrateDetail: (payload: unknown) => { document?: DocumentLike } | null;
|
||||
hydrateDocument: (payload: unknown) => DocumentLike | null;
|
||||
}
|
||||
|
||||
interface ApiClient {
|
||||
@@ -60,10 +60,10 @@ interface UseDocumentPreviewArgs {
|
||||
interface UseDocumentPreviewResult {
|
||||
previewEntries: Map<DocumentId, PreviewEntry>;
|
||||
previewDocuments: Map<DocumentId, DocumentLike>;
|
||||
ensurePreviewUrl: (documentId: DocumentId | null, options?: { force?: boolean }) => Promise<PreviewEntry | null>;
|
||||
ensurePreviewData: (documentId: DocumentId | null) => Promise<DocumentLike | null>;
|
||||
openDocumentPreview: (documentId: DocumentId | null, options?: { replace?: boolean }) => void;
|
||||
closeDocumentPreview: (folderId?: FolderId | null) => void;
|
||||
ensurePreviewUrl: (documentId: DocumentId, options?: { force?: boolean }) => Promise<PreviewEntry | null>;
|
||||
ensurePreviewData: (documentId: DocumentId) => Promise<DocumentLike | null>;
|
||||
openDocumentPreview: (documentId: DocumentId, options?: { replace?: boolean }) => void;
|
||||
closeDocumentPreview: (folderId?: FolderId) => void;
|
||||
resetPreviewState: () => void;
|
||||
removePreviewEntries: (ids: DocumentId[]) => void;
|
||||
}
|
||||
@@ -114,7 +114,7 @@ const useDocumentPreview = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const cachePreviewDocument = useCallback((doc: DocumentLike | null | undefined) => {
|
||||
const cachePreviewDocument = useCallback((doc: DocumentLike) => {
|
||||
if (!doc?.id) {
|
||||
return;
|
||||
}
|
||||
@@ -129,7 +129,7 @@ const useDocumentPreview = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeCachedPreviewDocument = useCallback((documentId?: DocumentId | null) => {
|
||||
const removeCachedPreviewDocument = useCallback((documentId: DocumentId) => {
|
||||
if (!documentId) {
|
||||
return;
|
||||
}
|
||||
@@ -144,7 +144,7 @@ const useDocumentPreview = ({
|
||||
}, []);
|
||||
|
||||
const ensurePreviewUrl = useCallback(
|
||||
async (documentId: DocumentId | null, { force = false }: { force?: boolean } = {}): Promise<PreviewEntry | null> => {
|
||||
async (documentId: DocumentId, { force = false }: { force?: boolean } = {}): Promise<PreviewEntry | null> => {
|
||||
if (!documentId) return null;
|
||||
|
||||
const existing = previewEntries.get(documentId) || null;
|
||||
@@ -194,7 +194,7 @@ const useDocumentPreview = ({
|
||||
);
|
||||
|
||||
const ensurePreviewData = useCallback(
|
||||
async (documentId: DocumentId | null): Promise<DocumentLike | null> => {
|
||||
async (documentId: DocumentId): Promise<DocumentLike | null> => {
|
||||
if (!documentId) return null;
|
||||
|
||||
const findInCache = () => {
|
||||
@@ -248,7 +248,7 @@ const useDocumentPreview = ({
|
||||
);
|
||||
|
||||
const openDocumentPreview = useCallback(
|
||||
(documentId: DocumentId | null, { replace = false }: { replace?: boolean } = {}) => {
|
||||
(documentId: DocumentId, { replace = false }: { replace?: boolean } = {}) => {
|
||||
if (!documentId) return;
|
||||
detailPanelControlRef.current?.close?.();
|
||||
previewReturnPathRef.current = `${locationPathname}${locationSearch}`;
|
||||
@@ -258,7 +258,7 @@ const useDocumentPreview = ({
|
||||
);
|
||||
|
||||
const closeDocumentPreview = useCallback(
|
||||
(folderId: FolderId | null = null) => {
|
||||
(folderId?: FolderId) => {
|
||||
const fallbackPath = previewReturnPathRef.current;
|
||||
previewReturnPathRef.current = null;
|
||||
|
||||
|
||||
@@ -11,11 +11,11 @@ interface SelectionEventLike {
|
||||
}
|
||||
|
||||
interface UseDocumentSelectionOptions {
|
||||
resolveDocumentRowKey: (id: DocumentId | null | undefined) => RowKey | null | undefined;
|
||||
resolveFolderRowKey: (id: DocumentId | null | undefined) => RowKey | null | undefined;
|
||||
resolveDocumentRowKey: (id: DocumentId | null) => RowKey | null;
|
||||
resolveFolderRowKey: (id: DocumentId | null) => RowKey | null;
|
||||
isDocumentRowKey: (key?: RowKey | null) => boolean;
|
||||
isFolderRowKey: (key?: RowKey | null) => boolean;
|
||||
getRowId: (key?: RowKey | null) => DocumentId | null | undefined;
|
||||
getRowId: (key?: RowKey | null) => DocumentId | null;
|
||||
initialEntries?: RowKey[];
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ export const useDocumentSelection = ({
|
||||
|
||||
const applySelection = useCallback(
|
||||
(
|
||||
rowKeys: Array<RowKey | null | undefined>,
|
||||
rowKeys: Array<RowKey | null>,
|
||||
{ anchor, interactedKeys = [] }: ApplySelectionOptions = {},
|
||||
) => {
|
||||
const visibleRowKeySet = visibleRowKeySetRef.current;
|
||||
@@ -101,7 +101,7 @@ export const useDocumentSelection = ({
|
||||
|
||||
(rowKeys || []).forEach((key) => {
|
||||
if (!key) return;
|
||||
let canonicalKey: RowKey | null | undefined = null;
|
||||
let canonicalKey: RowKey | null = null;
|
||||
if (visibleRowKeySet.has(key)) {
|
||||
canonicalKey = key;
|
||||
} else if (isDocumentRowKey(key)) {
|
||||
@@ -174,7 +174,7 @@ export const useDocumentSelection = ({
|
||||
}, [applySelection]);
|
||||
|
||||
const handleEntrySelection = useCallback(
|
||||
(rowKey: RowKey | null | undefined, event?: SelectionEventLike) => {
|
||||
(rowKey: RowKey | null, event?: SelectionEventLike) => {
|
||||
const visibleRowKeySet = visibleRowKeySetRef.current;
|
||||
const navigableRowKeys = navigableRowKeysRef.current;
|
||||
if (!rowKey || !visibleRowKeySet.has(rowKey)) {
|
||||
|
||||
@@ -9,8 +9,8 @@ interface SelectionEntry {
|
||||
}
|
||||
|
||||
interface WorkspaceSelectionOptions {
|
||||
resolveDocumentRowKey?: (id: string | number) => RowKey | null | undefined;
|
||||
resolveFolderRowKey?: (id: string | number) => RowKey | null | undefined;
|
||||
resolveDocumentRowKey?: (id: string | number) => RowKey | null;
|
||||
resolveFolderRowKey?: (id: string | number) => RowKey | null;
|
||||
isDocumentRowKey?: (key: RowKey | SelectionEntry) => boolean;
|
||||
isFolderRowKey?: (key: RowKey | SelectionEntry) => boolean;
|
||||
getRowId?: (key: RowKey | SelectionEntry) => string | number | null;
|
||||
|
||||
@@ -27,7 +27,7 @@ type EnsureAssetUrl = (
|
||||
options?: { start?: number; limit?: number; [key: string]: unknown },
|
||||
) => Promise<unknown>;
|
||||
|
||||
type GetDocumentAsset = (document: DocumentLike | null | undefined, assetType: string) => AssetLike | null | undefined;
|
||||
type GetDocumentAsset = (document: DocumentLike | null, assetType: string) => AssetLike | null;
|
||||
|
||||
interface NavigatorSnapshot {
|
||||
url: string | null;
|
||||
|
||||
@@ -30,7 +30,7 @@ import '../styles/workspace/workspace-cards.css';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
type TagLike = { id?: Identifier | null; label?: string; color?: string | null } | null | undefined;
|
||||
type TagLike = { id?: Identifier | null; label?: string; color?: string | null } | null;
|
||||
|
||||
export interface DeskDocument {
|
||||
id?: Identifier | null;
|
||||
@@ -126,7 +126,7 @@ interface DesktopWorkspaceProps {
|
||||
onAssignTagToDocument?: (...args: unknown[]) => void;
|
||||
ensureAssetUrl?: (...args: unknown[]) => Promise<unknown> | unknown;
|
||||
getDocumentAsset?: (...args: unknown[]) => unknown;
|
||||
activeTagIds?: Array<Identifier | null | undefined>;
|
||||
activeTagIds?: Array<Identifier | null>;
|
||||
selectedDocumentIds?: Identifier[];
|
||||
onClearSelection?: () => void;
|
||||
detailPanelOpen?: boolean;
|
||||
@@ -142,7 +142,7 @@ interface DesktopWorkspaceViewProps {
|
||||
handleCanvasDragOver: (event: React.DragEvent<HTMLDivElement>) => void;
|
||||
handleCanvasDragLeave: (event: React.DragEvent<HTMLDivElement>) => void;
|
||||
handleCanvasDrop: (event: React.DragEvent<HTMLDivElement>) => void;
|
||||
ensureDocumentSize: (doc: DeskDocument | null | undefined) => DocumentSizeInfo | null;
|
||||
ensureDocumentSize: (doc: DeskDocument | null) => DocumentSizeInfo | null;
|
||||
layoutSnapshot: Map<string, LayoutEntry>;
|
||||
layoutRef: React.MutableRefObject<Map<string, LayoutEntry>>;
|
||||
dragTransformsRef: React.MutableRefObject<Map<string, DragTransformOverride>>;
|
||||
@@ -176,15 +176,15 @@ interface DesktopWorkspaceViewProps {
|
||||
detailPanelOpen: boolean;
|
||||
onCloseDetailPanel?: DesktopWorkspaceProps['onCloseDetailPanel'];
|
||||
documentLookup: Map<string, DeskDocument>;
|
||||
resolveBaseMetrics: (doc: DeskDocument | null | undefined, cardWidth: number, cardHeight: number) => {
|
||||
resolveBaseMetrics: (doc: DeskDocument | null, cardWidth: number, cardHeight: number) => {
|
||||
baseWidth: number;
|
||||
baseHeight: number;
|
||||
baseScale: number;
|
||||
};
|
||||
bringToFront: (docId: Identifier | null | undefined) => void;
|
||||
bringToFront: (docId: Identifier | null) => void;
|
||||
setDraggingId: (value: string | null) => void;
|
||||
canvasSize: { width: number; height: number };
|
||||
openOverlayForDoc: (docId: Identifier | null | undefined, originInfo?: OverlayOriginHint | null) => void;
|
||||
openOverlayForDoc: (docId: Identifier | null, originInfo?: OverlayOriginHint | null) => void;
|
||||
recalcVisibleDocIds: () => void;
|
||||
dragSettings: DragSettings;
|
||||
onInspectDocument?: DesktopWorkspaceProps['onInspectDocument'];
|
||||
@@ -231,7 +231,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
|
||||
);
|
||||
const [docSizeVersion, setDocSizeVersion] = useState(0);
|
||||
const docSizeMapRef = useRef<Map<string, DocumentSizeInfo>>(new Map());
|
||||
const ensureDocumentSize = useCallback((doc: DeskDocument | null | undefined): DocumentSizeInfo | null => {
|
||||
const ensureDocumentSize = useCallback((doc: DeskDocument | null): DocumentSizeInfo | null => {
|
||||
if (!doc?.id) {
|
||||
return null;
|
||||
}
|
||||
@@ -312,7 +312,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
|
||||
const layoutRef = useRef<Map<string, LayoutEntry>>(layoutSnapshot);
|
||||
layoutRef.current = engine.layout as Map<string, LayoutEntry>;
|
||||
|
||||
const bringToFront = useCallback((docId: Identifier | null | undefined) => {
|
||||
const bringToFront = useCallback((docId: Identifier | null) => {
|
||||
engine.bringToFront(docId);
|
||||
}, [engine]);
|
||||
|
||||
@@ -425,7 +425,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
|
||||
}, [engine]);
|
||||
|
||||
const resolvePreviewDimensions = useCallback(
|
||||
(doc: DeskDocument | null | undefined): PreviewMetadataEntry | null => {
|
||||
(doc: DeskDocument | null): PreviewMetadataEntry | null => {
|
||||
if (!doc?.id) {
|
||||
return null;
|
||||
}
|
||||
@@ -581,7 +581,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
|
||||
}, [overlayDocId, documentLookup]);
|
||||
|
||||
const resolveBaseMetrics = useCallback(
|
||||
(doc: DeskDocument | null | undefined, cardWidth: number, cardHeight: number) => {
|
||||
(doc: DeskDocument | null, cardWidth: number, cardHeight: number) => {
|
||||
const previewDims = doc ? resolvePreviewDimensions(doc) : null;
|
||||
if (previewDims?.width && previewDims?.height) {
|
||||
const baseWidth = Math.max(previewDims.width, cardWidth);
|
||||
@@ -612,7 +612,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
|
||||
}, [draggingId, items, setDraggingId]);
|
||||
|
||||
const openOverlayForDoc = useCallback(
|
||||
(docId: Identifier | null | undefined, originInfo: OverlayOriginHint | null = null) => {
|
||||
(docId: Identifier | null, originInfo: OverlayOriginHint | null = null) => {
|
||||
if (!docId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export const preventAll = (event?: PreventableEvent | null): void => {
|
||||
type AnyFn = (...args: unknown[]) => unknown;
|
||||
|
||||
export const safeInvoke = <Fn extends AnyFn>(
|
||||
fn: Fn | null | undefined,
|
||||
fn: Fn | null,
|
||||
...args: Parameters<Fn>
|
||||
): ReturnType<Fn> | undefined =>
|
||||
(fn ? (fn(...args) as ReturnType<Fn>) : undefined);
|
||||
|
||||
@@ -18,11 +18,11 @@ interface PreviewMetadataEntry {
|
||||
height: number;
|
||||
}
|
||||
|
||||
type GetDocumentAsset = (doc: DocumentLike, type: string) => AssetLike | null | undefined;
|
||||
type EnsureAssetUrl = (docId: string | number, asset: AssetLike, options?: { start?: number; limit?: number }) => Promise<AssetLike | null | undefined>;
|
||||
type GetDocumentAsset = (doc: DocumentLike, type: string) => AssetLike | null;
|
||||
type EnsureAssetUrl = (docId: string | number, asset: AssetLike, options?: { start?: number; limit?: number }) => Promise<AssetLike | null>;
|
||||
|
||||
const usePreviewMetadata = (
|
||||
documents: DocumentLike[] | null | undefined,
|
||||
documents: DocumentLike[] | null,
|
||||
getDocumentAsset?: GetDocumentAsset,
|
||||
ensureAssetUrl?: EnsureAssetUrl,
|
||||
) => {
|
||||
@@ -50,7 +50,7 @@ const usePreviewMetadata = (
|
||||
let view = createAssetView(asset);
|
||||
let metadata = view.getPrimaryMetadata();
|
||||
|
||||
const hasDimensions = (meta: { width?: number | string; height?: number | string } | null | undefined) =>
|
||||
const hasDimensions = (meta: { width?: number | string; height?: number | string } | null) =>
|
||||
Number.isFinite(Number(meta?.width)) &&
|
||||
Number.isFinite(Number(meta?.height)) &&
|
||||
Number(meta.width) > 0 &&
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useCallback, useMemo } from 'react';
|
||||
type Identifier = string | number;
|
||||
|
||||
type DocumentEntry = { id?: Identifier } & Record<string, unknown>;
|
||||
type InspectTarget = DocumentEntry | Identifier | null | undefined;
|
||||
type InspectTarget = DocumentEntry | Identifier | null;
|
||||
|
||||
type WorkspaceViewMode = 'desk' | 'grid' | 'list' | string;
|
||||
|
||||
|
||||
@@ -59,10 +59,10 @@ interface DragGroupItemInternal extends EngineGroupItem {
|
||||
initialRotation?: number;
|
||||
}
|
||||
|
||||
type EnsureDocumentSizeFn = (doc: DocumentLike | null | undefined) => DocumentSizeInfo | null;
|
||||
type EnsureDocumentSizeFn = (doc: DocumentLike | null) => DocumentSizeInfo | null;
|
||||
|
||||
type ResolveBaseMetricsFn = (
|
||||
doc: DocumentLike | null | undefined,
|
||||
doc: DocumentLike | null,
|
||||
width: number,
|
||||
height: number,
|
||||
) => { baseWidth: number; baseHeight: number; baseScale: number };
|
||||
@@ -75,7 +75,7 @@ interface DragSettings {
|
||||
}
|
||||
|
||||
interface PointerDownOptions {
|
||||
stackDocIds?: Array<Identifier | null | undefined>;
|
||||
stackDocIds?: Array<Identifier | null>;
|
||||
stackSelectionApplied?: boolean;
|
||||
wasSelected?: boolean;
|
||||
modifierActive?: boolean;
|
||||
@@ -90,23 +90,23 @@ interface UseDocumentDragOptions {
|
||||
documentLookup: Map<string, DocumentLike>;
|
||||
ensureDocumentSize: EnsureDocumentSizeFn;
|
||||
resolveBaseMetrics: ResolveBaseMetricsFn;
|
||||
bringToFront: (docId: Identifier | null | undefined) => void;
|
||||
bringToFront: (docId: Identifier | null) => void;
|
||||
setDraggingId: (docKey: string | null) => void;
|
||||
canvasSize: { width: number; height: number };
|
||||
openOverlayForDoc?: (
|
||||
docId: Identifier | null | undefined,
|
||||
docId: Identifier | null,
|
||||
originInfo?: { rotation: number; scale: number; width: number; height: number },
|
||||
) => void;
|
||||
recalcVisibleDocIds: () => void;
|
||||
settings?: DragSettings;
|
||||
containerRef?: RefObject<HTMLElement>;
|
||||
onInspectDocument?: (docId: Identifier | null | undefined, event?: PointerEvent | ReactPointerEvent) => void;
|
||||
onInspectDocument?: (docId: Identifier | null, event?: PointerEvent | ReactPointerEvent) => void;
|
||||
onDocumentStackSelect?: (
|
||||
docIds: Identifier[],
|
||||
event: PointerEvent | ReactPointerEvent,
|
||||
options?: { replace?: boolean },
|
||||
) => void;
|
||||
selectedDocumentIds?: Array<Identifier | null | undefined>;
|
||||
selectedDocumentIds?: Array<Identifier | null>;
|
||||
markLayoutDirty?: () => void;
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
||||
});
|
||||
const dragStateRef = useRef<DragStateInternal | null>(null);
|
||||
|
||||
const setDragTransform = useCallback((docKey: Identifier | null | undefined, transform: DragTransform | null) => {
|
||||
const setDragTransform = useCallback((docKey: Identifier | null, transform: DragTransform | null) => {
|
||||
if (!docKey) {
|
||||
return;
|
||||
}
|
||||
@@ -248,7 +248,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
||||
map.clear();
|
||||
}, [dragTransformsRef]);
|
||||
|
||||
const commitActiveDragTransforms = useCallback((docIds: Array<Identifier | null | undefined> | null = null) => {
|
||||
const commitActiveDragTransforms = useCallback((docIds: Array<Identifier | null> | null = null) => {
|
||||
const map = dragTransformsRef?.current;
|
||||
if (!map || !map.size) {
|
||||
return;
|
||||
|
||||
@@ -107,7 +107,7 @@ type WorkspaceSubscriber = () => void;
|
||||
|
||||
type DeskDocument = { id?: string | number | null } & Record<string, unknown>;
|
||||
|
||||
type EnsureDocumentSize = (doc: DeskDocument) => DocumentSize | null | undefined;
|
||||
type EnsureDocumentSize = (doc: DeskDocument) => DocumentSize | null;
|
||||
|
||||
type ResolveBaseMetrics = () => BaseMetrics;
|
||||
|
||||
@@ -144,7 +144,7 @@ export const TORQUE_TO_ACCELERATION = 0.006;
|
||||
export const SETTLE_ANGULAR_VELOCITY = 1.2;
|
||||
|
||||
export const applyDomTransform = (
|
||||
node: HTMLElement | null | undefined,
|
||||
node: HTMLElement | null,
|
||||
{
|
||||
centerX,
|
||||
centerY,
|
||||
@@ -662,7 +662,7 @@ export class WorkspaceEngine {
|
||||
}
|
||||
}
|
||||
|
||||
setItems(items: DeskDocument[] | null | undefined): void {
|
||||
setItems(items: DeskDocument[] | null): void {
|
||||
const normalized = Array.isArray(items) ? items : [];
|
||||
this.items = normalized;
|
||||
const canGenerateLayoutImmediately =
|
||||
@@ -689,7 +689,7 @@ export class WorkspaceEngine {
|
||||
this.resolveBaseMetrics = fn;
|
||||
}
|
||||
|
||||
setItemRefs(ref: ItemRefs | null | undefined): void {
|
||||
setItemRefs(ref: ItemRefs | null): void {
|
||||
this.itemRefs = ref || { current: new Map() };
|
||||
}
|
||||
|
||||
@@ -778,7 +778,7 @@ export class WorkspaceEngine {
|
||||
|
||||
updateLayoutEntry(
|
||||
docId: string | number | null,
|
||||
updater: (previous: LayoutEntry | null) => LayoutEntry | null | undefined,
|
||||
updater: (previous: LayoutEntry | null) => LayoutEntry | null,
|
||||
): void {
|
||||
if (docId == null) {
|
||||
return;
|
||||
|
||||
@@ -54,7 +54,7 @@ interface UseDetailWorkspaceArgs {
|
||||
handleTagRemove?: (...args: unknown[]) => void;
|
||||
ensureAssetUrl?: EnsureAssetUrl;
|
||||
getDocumentAsset?: GetDocumentAsset;
|
||||
ensurePreviewData?: (docId: Identifier, options?: Record<string, unknown>) => Promise<DocumentLike | null | undefined>;
|
||||
ensurePreviewData?: (docId: Identifier, options?: Record<string, unknown>) => Promise<DocumentLike | null>;
|
||||
correspondents?: unknown[];
|
||||
handleCorrespondentAdd?: (...args: unknown[]) => void;
|
||||
handleCorrespondentRemove?: (...args: unknown[]) => void;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import DocumentSummarySection, { DocumentSummarySectionProps } from './DocumentSummarySection';
|
||||
import { buildDocumentMetadataItems, extractDocumentMetadataPayload } from './documentMetadata';
|
||||
import { describeDocumentSummary, extractDocumentMetadataPayload, type DocumentSummaryRow } from './documentSummary';
|
||||
|
||||
type PanelTab = { id: string; label: string; render: (context?: Record<string, unknown>) => ReactNode };
|
||||
|
||||
@@ -15,8 +15,8 @@ type ContentState =
|
||||
|
||||
export interface DocumentInfoPanelProps {
|
||||
document: DocumentSummarySectionProps['document'];
|
||||
summaryProps?: Omit<DocumentSummarySectionProps, 'document' | 'detailItems' | 'layout'>;
|
||||
metadataItems?: Array<{ label: string; value?: string }>;
|
||||
summaryProps?: Omit<DocumentSummarySectionProps, 'document' | 'layout'>;
|
||||
metadataItems?: DocumentSummaryRow[];
|
||||
metadataPayload?: Record<string, unknown>;
|
||||
metadataTabLabel?: string;
|
||||
detailsTabLabel?: string;
|
||||
@@ -76,7 +76,7 @@ const DocumentInfoPanel: React.FC<DocumentInfoPanelProps> = ({
|
||||
if (Array.isArray(metadataItemsProp) && metadataItemsProp.length) {
|
||||
return metadataItemsProp;
|
||||
}
|
||||
return buildDocumentMetadataItems(document);
|
||||
return describeDocumentSummary(document);
|
||||
}, [metadataItemsProp, document]);
|
||||
|
||||
const metadataPayload = useMemo(() => {
|
||||
@@ -149,18 +149,17 @@ const DocumentInfoPanel: React.FC<DocumentInfoPanelProps> = ({
|
||||
const renderSummarySection = useCallback(() => (
|
||||
<DocumentSummarySection
|
||||
document={document}
|
||||
detailItems={metadataItems}
|
||||
layout={summaryLayout}
|
||||
{...summaryProps}
|
||||
/>
|
||||
), [document, summaryLayout, summaryProps, metadataItems]);
|
||||
), [document, summaryLayout, summaryProps]);
|
||||
|
||||
const renderDetailsSection = useCallback(() => (
|
||||
<section className={`${base}__section`}>
|
||||
{metadataItems.length ? (
|
||||
<dl className={`${base}__section-list`}>
|
||||
{metadataItems.map(({ label, value }) => (
|
||||
<div className={`${base}__section-item`} key={label}>
|
||||
{metadataItems.map(({ key, label, value }) => (
|
||||
<div className={`${base}__section-item`} key={key || label}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value || '—'}</dd>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { EditIcon, IconX, PlusIcon } from '../ui/icons';
|
||||
import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './SelectionAssignmentMenu';
|
||||
import React, { useCallback, useEffect, useMemo, useState, type ReactNode, type FormEvent } from 'react';
|
||||
import { EditIcon, IconX, CheckIcon, PlusIcon } from '../ui/icons';
|
||||
import SelectionAssignmentMenu, {
|
||||
SelectionAssignmentMenuItem,
|
||||
type NormalizedSelectionAssignmentItem,
|
||||
} from './SelectionAssignmentMenu';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
import {
|
||||
formatDate,
|
||||
toDateInputValue,
|
||||
toIssuedTimestamp,
|
||||
} from '../utils/date';
|
||||
import { describeDocumentSummary } from './documentSummary';
|
||||
import { describeDocumentSummary, type DocumentSummaryRow } from './documentSummary';
|
||||
import { isPlainObject } from '../utils/typeGuards';
|
||||
|
||||
type Identifier = string | number;
|
||||
@@ -70,7 +72,14 @@ export interface DocumentSummarySectionProps {
|
||||
onUpdateTitle?: (docId: Identifier | undefined, title: string) => Promise<boolean> | boolean;
|
||||
onUpdateIssued?: (docId: Identifier | undefined, timestamp: number | null) => Promise<boolean> | boolean;
|
||||
layout?: 'default' | 'compact';
|
||||
detailItems?: Array<{ label?: string; value?: string }>;
|
||||
}
|
||||
|
||||
interface MetaItem {
|
||||
key: string;
|
||||
label: string;
|
||||
valueContent?: React.ReactNode | null;
|
||||
fallbackValue?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export const sortCorrespondents = (entries = []) =>
|
||||
@@ -122,7 +131,7 @@ const resolveOptionName = (source?: QuickAddOption | string | null): string => {
|
||||
return `${source}`.trim();
|
||||
};
|
||||
|
||||
const normalizeQuickAddOption = (option: QuickAddOption | string | null | undefined): QuickAddEntry | null => {
|
||||
const normalizeQuickAddOption = (option?: QuickAddOption | string | null): QuickAddEntry | null => {
|
||||
if (option == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -227,7 +236,7 @@ export const TagSection: React.FC<TagSectionProps> = ({
|
||||
}, [normalizedOptions, tags]);
|
||||
|
||||
const handleAssignmentSelect = useCallback(
|
||||
(item: SelectionAssignmentMenuItem | null) => {
|
||||
(item: NormalizedSelectionAssignmentItem) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
@@ -271,11 +280,7 @@ export const TagSection: React.FC<TagSectionProps> = ({
|
||||
showCounts={false}
|
||||
positionStrategy="fixed"
|
||||
triggerClassName="quick-add__chip quick-add__trigger"
|
||||
triggerContent={(
|
||||
<span className="quick-add__chip-label">
|
||||
<PlusIcon className="icon-inline" aria-hidden="true" /> Add tag
|
||||
</span>
|
||||
)}
|
||||
triggerContent={<PlusIcon className="icon-inline" aria-hidden="true" />}
|
||||
/>
|
||||
) : null}
|
||||
{!tags.length && !showQuickAdd ? <span className="tag-list__empty meta">{emptyMessage}</span> : null}
|
||||
@@ -356,7 +361,7 @@ export const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
|
||||
}, [normalizedOptions, entries]);
|
||||
|
||||
const handleAssignmentSelect = useCallback(
|
||||
(item: SelectionAssignmentMenuItem | null) => {
|
||||
(item: NormalizedSelectionAssignmentItem) => {
|
||||
if (!onAdd || !item) {
|
||||
return;
|
||||
}
|
||||
@@ -411,11 +416,7 @@ export const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
|
||||
showCounts={false}
|
||||
positionStrategy="fixed"
|
||||
triggerClassName="quick-add__chip quick-add__trigger"
|
||||
triggerContent={(
|
||||
<span className="quick-add__chip-label">
|
||||
<PlusIcon className="icon-inline" aria-hidden="true" /> Add correspondent
|
||||
</span>
|
||||
)}
|
||||
triggerContent={<PlusIcon className="icon-inline" aria-hidden="true" />}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -435,20 +436,9 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
|
||||
onUpdateTitle,
|
||||
onUpdateIssued,
|
||||
layout = 'default',
|
||||
detailItems = [],
|
||||
}) => {
|
||||
const isCompactLayout = layout === 'compact';
|
||||
const summary = useMemo(() => {
|
||||
if (!document) {
|
||||
return {
|
||||
title: '',
|
||||
originalName: '',
|
||||
sizeLabel: '—',
|
||||
pageCount: null,
|
||||
};
|
||||
}
|
||||
return describeDocumentSummary(document);
|
||||
}, [document]);
|
||||
const summaryRows = useMemo(() => describeDocumentSummary(document), [document]);
|
||||
const issuedDateLabel = useMemo(
|
||||
() => formatDate(document?.issued_at, { fallback: null }),
|
||||
[document?.issued_at],
|
||||
@@ -475,8 +465,8 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
|
||||
return sortCorrespondents(document?.correspondents || []);
|
||||
}, [correspondents, document?.correspondents]);
|
||||
|
||||
const metaRows = useMemo(() => {
|
||||
const rows: { key: string; label: string; value: string | null }[] = [];
|
||||
const extraSummaryRows = useMemo(() => {
|
||||
const rows: DocumentSummaryRow[] = [];
|
||||
const currentVersionNumber = document?.current_version?.version_number;
|
||||
if (Number.isFinite(currentVersionNumber)) {
|
||||
rows.push({
|
||||
@@ -485,17 +475,8 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
|
||||
value: `#${currentVersionNumber}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (summary.sizeLabel && summary.sizeLabel !== '—') {
|
||||
rows.push({ key: 'size', label: 'Size', value: summary.sizeLabel });
|
||||
}
|
||||
|
||||
if (Number.isFinite(summary.pageCount)) {
|
||||
rows.push({ key: 'pages', label: 'Pages', value: String(summary.pageCount) });
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [document?.current_version?.version_number, summary]);
|
||||
}, [document?.current_version?.version_number]);
|
||||
|
||||
const [titleDraft, setTitleDraft] = useState('');
|
||||
const [titleSaving, setTitleSaving] = useState(false);
|
||||
@@ -595,57 +576,60 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const TitleSection = () => (
|
||||
editableTitle && isTitleEditing ? (
|
||||
<form className="doc-title-edit" onSubmit={submitTitleEdit}>
|
||||
<input
|
||||
value={titleDraft}
|
||||
onChange={(event) => {
|
||||
setTitleDraft(event.target.value);
|
||||
if (titleError) {
|
||||
setTitleError(null);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelTitleEdit();
|
||||
}
|
||||
}}
|
||||
aria-label="Document title"
|
||||
autoFocus
|
||||
disabled={titleSaving}
|
||||
/>
|
||||
<button type="submit" disabled={titleSaving}>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={cancelTitleEdit}
|
||||
disabled={titleSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="doc-title-row__title">{summary.title}</h3>
|
||||
{editableTitle ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={startTitleEdit}
|
||||
aria-label="Edit title"
|
||||
title="Edit title"
|
||||
>
|
||||
<EditIcon className="icon-inline" />
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
const renderTitleEditForm = (extraClassName?: string) => (
|
||||
<form className={`doc-title-edit${extraClassName ? ` ${extraClassName}` : ''}`} onSubmit={submitTitleEdit}>
|
||||
<input
|
||||
value={titleDraft}
|
||||
onChange={(event) => {
|
||||
setTitleDraft(event.target.value);
|
||||
if (titleError) {
|
||||
setTitleError(null);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelTitleEdit();
|
||||
}
|
||||
}}
|
||||
aria-label="Document title"
|
||||
autoFocus
|
||||
disabled={titleSaving}
|
||||
/>
|
||||
<button type="submit" className="icon-button icon-button--accent" disabled={titleSaving} aria-label="Save title">
|
||||
<CheckIcon size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={cancelTitleEdit}
|
||||
disabled={titleSaving}
|
||||
aria-label="Cancel"
|
||||
>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
|
||||
const titleMetaDisplay = editableTitle && isTitleEditing
|
||||
? renderTitleEditForm('doc-title-edit--inline')
|
||||
: (
|
||||
<>
|
||||
<span className="detail-meta__value">{document?.title}</span>
|
||||
{editableTitle ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={startTitleEdit}
|
||||
aria-label="Edit title"
|
||||
title="Edit title"
|
||||
>
|
||||
<EditIcon className="icon-inline" />
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const issuedDisplay = editableIssued && isIssuedEditing ? (
|
||||
<form className="doc-issued-edit" onSubmit={submitIssuedEdit}>
|
||||
<input
|
||||
@@ -660,16 +644,17 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
|
||||
aria-label="Issued on"
|
||||
disabled={issuedSaving}
|
||||
/>
|
||||
<button type="submit" disabled={issuedSaving}>
|
||||
Save
|
||||
<button type="submit" className="icon-button icon-button--accent" disabled={issuedSaving} aria-label="Save issued date">
|
||||
<CheckIcon size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
className="icon-button"
|
||||
onClick={cancelIssuedEdit}
|
||||
disabled={issuedSaving}
|
||||
aria-label="Cancel"
|
||||
>
|
||||
Cancel
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
@@ -689,184 +674,98 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
|
||||
</>
|
||||
);
|
||||
|
||||
const metaItems = [
|
||||
{
|
||||
key: 'issued',
|
||||
label: 'Issued',
|
||||
valueContent: issuedDisplay,
|
||||
error: issuedError,
|
||||
},
|
||||
...metaRows.map((row) => ({
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
fallbackValue: row.value,
|
||||
})),
|
||||
];
|
||||
|
||||
const detailRows = Array.isArray(detailItems)
|
||||
? detailItems.map((item, index) => ({
|
||||
key: `detail-${item?.label || index}`,
|
||||
label: item?.label || '—',
|
||||
fallbackValue: item?.value,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const compactRows = [...metaItems, ...detailRows];
|
||||
|
||||
const renderTags = () => (
|
||||
<section className="document-summary__section document-summary__section--tags">
|
||||
<TagSection
|
||||
tags={resolvedTags}
|
||||
onRemove={
|
||||
onTagRemove
|
||||
? (tag) => onTagRemove(document.id, tag.id)
|
||||
: undefined
|
||||
}
|
||||
onAdd={
|
||||
onTagAdd
|
||||
? ({ value, option }) => onTagAdd(document, value, { option })
|
||||
: undefined
|
||||
}
|
||||
datalistOptions={tagOptions}
|
||||
className="document-summary__tags"
|
||||
/>
|
||||
</section>
|
||||
const tagsValueContent = (
|
||||
<TagSection
|
||||
tags={resolvedTags}
|
||||
onRemove={
|
||||
onTagRemove
|
||||
? (tag) => onTagRemove(document.id, tag.id)
|
||||
: undefined
|
||||
}
|
||||
onAdd={
|
||||
onTagAdd
|
||||
? ({ value, option }) => onTagAdd(document, value, { option })
|
||||
: undefined
|
||||
}
|
||||
datalistOptions={tagOptions}
|
||||
className="document-summary__tags"
|
||||
/>
|
||||
);
|
||||
|
||||
const renderCorrespondents = () => (
|
||||
<section className="document-summary__section document-summary__section--correspondents">
|
||||
<CorrespondentSection
|
||||
entries={resolvedCorrespondents}
|
||||
onRemove={
|
||||
onCorrespondentRemove
|
||||
? (entry) =>
|
||||
onCorrespondentRemove({
|
||||
documentId: document.id,
|
||||
correspondentId: entry.id,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
onAdd={
|
||||
onCorrespondentAdd
|
||||
? ({ name, option }) =>
|
||||
onCorrespondentAdd({
|
||||
document,
|
||||
name,
|
||||
option,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
showCount
|
||||
datalistOptions={correspondentOptions}
|
||||
className="document-summary__correspondents"
|
||||
/>
|
||||
</section>
|
||||
const correspondentsValueContent = (
|
||||
<CorrespondentSection
|
||||
entries={resolvedCorrespondents}
|
||||
onRemove={
|
||||
onCorrespondentRemove
|
||||
? (entry) =>
|
||||
onCorrespondentRemove({
|
||||
documentId: document.id,
|
||||
correspondentId: entry.id,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
onAdd={
|
||||
onCorrespondentAdd
|
||||
? ({ name, option }) =>
|
||||
onCorrespondentAdd({
|
||||
document,
|
||||
name,
|
||||
option,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
showCount
|
||||
datalistOptions={correspondentOptions}
|
||||
className="document-summary__correspondents"
|
||||
/>
|
||||
);
|
||||
|
||||
if (isCompactLayout) {
|
||||
return (
|
||||
<div className="document-summary document-summary--compact">
|
||||
<section className="document-summary__section document-summary__title-row">
|
||||
<TitleSection />
|
||||
</section>
|
||||
{titleError ? <div className="status-inline error">{titleError}</div> : null}
|
||||
{renderTags()}
|
||||
{renderCorrespondents()}
|
||||
{compactRows.length ? (
|
||||
<section className="document-summary__section document-summary__meta document-summary__meta--compact">
|
||||
<dl className="document-summary__details-list document-summary__details-list--meta">
|
||||
{compactRows.map((item) => (
|
||||
<div key={item.key} className="document-summary__details-row">
|
||||
<dt>{item.label}</dt>
|
||||
<dd>
|
||||
{item.valueContent != null && item.valueContent !== ''
|
||||
? item.valueContent
|
||||
: item.fallbackValue || '—'}
|
||||
</dd>
|
||||
{item.error ? <div className="status-inline error">{item.error}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const summaryRowOverrides = useMemo(
|
||||
() => ({
|
||||
title: { valueContent: titleMetaDisplay, error: titleError },
|
||||
issued: { valueContent: issuedDisplay, error: issuedError },
|
||||
tags: { valueContent: tagsValueContent },
|
||||
correspondents: { valueContent: correspondentsValueContent },
|
||||
}),
|
||||
[titleMetaDisplay, titleError, issuedDisplay, issuedError, tagsValueContent, correspondentsValueContent],
|
||||
);
|
||||
|
||||
const baseRows: MetaItem[] = useMemo(
|
||||
() => [...summaryRows, ...extraSummaryRows].map((row) => {
|
||||
const overrides = summaryRowOverrides[row.key] || {};
|
||||
return {
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
valueContent: overrides.valueContent ?? null,
|
||||
fallbackValue: overrides.valueContent ? row.value : row.value,
|
||||
error: overrides.error ?? null,
|
||||
};
|
||||
}),
|
||||
[summaryRows, extraSummaryRows, summaryRowOverrides],
|
||||
);
|
||||
|
||||
const allRows = baseRows;
|
||||
const summaryClass = `document-summary${isCompactLayout ? ' document-summary--compact' : ''}`;
|
||||
const sectionClass = `document-summary__section document-summary__meta${isCompactLayout ? ' document-summary__meta--compact' : ''}`;
|
||||
const listClass = `document-summary__details-list${isCompactLayout ? ' document-summary__details-list--meta' : ''}`;
|
||||
|
||||
return (
|
||||
<div className="document-summary">
|
||||
<div className="doc-title-row">
|
||||
<div className="doc-title-row__primary">
|
||||
{editableTitle && isTitleEditing ? (
|
||||
<form className="doc-title-edit" onSubmit={submitTitleEdit}>
|
||||
<input
|
||||
value={titleDraft}
|
||||
onChange={(event) => {
|
||||
setTitleDraft(event.target.value);
|
||||
if (titleError) {
|
||||
setTitleError(null);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelTitleEdit();
|
||||
}
|
||||
}}
|
||||
aria-label="Document title"
|
||||
autoFocus
|
||||
disabled={titleSaving}
|
||||
/>
|
||||
<button type="submit" disabled={titleSaving}>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={cancelTitleEdit}
|
||||
disabled={titleSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="doc-title-row__title">{summary.title}</h3>
|
||||
{editableTitle ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={startTitleEdit}
|
||||
aria-label="Edit title"
|
||||
title="Edit title"
|
||||
>
|
||||
<EditIcon className="icon-inline" />
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{titleError ? <div className="status-inline error">{titleError}</div> : null}
|
||||
|
||||
<div className="detail-meta">
|
||||
<div className="detail-meta__row">
|
||||
<span className="detail-meta__label">Issued:</span>
|
||||
{issuedDisplay}
|
||||
</div>
|
||||
{issuedError ? <div className="status-inline error">{issuedError}</div> : null}
|
||||
|
||||
{metaRows.map((row) => (
|
||||
<div key={row.key} className="detail-meta__row">
|
||||
<span className="detail-meta__label">{row.label}:</span>
|
||||
<span className="detail-meta__value">{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{renderTags()}
|
||||
{renderCorrespondents()}
|
||||
<div className={summaryClass}>
|
||||
<section className={sectionClass}>
|
||||
<dl className={listClass}>
|
||||
{allRows.map((item) => (
|
||||
<div key={item.key} className="document-summary__details-row">
|
||||
<dt>{item.label}</dt>
|
||||
<dd>
|
||||
{item.valueContent != null && item.valueContent !== ''
|
||||
? item.valueContent
|
||||
: item.fallbackValue || '—'}
|
||||
</dd>
|
||||
{item.error ? <div className="status-inline error">{item.error}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -77,10 +77,10 @@ interface DocumentsGridProps {
|
||||
getDocumentAsset?: (...args: any[]) => unknown;
|
||||
gridIconSize?: number;
|
||||
tagLookupById?: Map<Identifier, DocumentTag> | null;
|
||||
onTagClick?: (tagId?: Identifier | null) => void;
|
||||
onTagClick?: (tagId: Identifier) => void;
|
||||
scrollRef?: RefObject<HTMLElement | null>;
|
||||
onCorrespondentClick?: (correspondentId?: Identifier | null) => void;
|
||||
activeCorrespondentIdSet?: Set<Identifier | null> | null;
|
||||
onCorrespondentClick?: (correspondentId: Identifier) => void;
|
||||
activeCorrespondentIdSet?: Set<Identifier> | null;
|
||||
onDocumentRename?: (docId: Identifier, title: string) => Promise<boolean> | boolean;
|
||||
}
|
||||
|
||||
@@ -434,20 +434,26 @@ const DocumentsGrid: React.FC<DocumentsGridProps> = ({
|
||||
</div>
|
||||
{visibleTags.length > 0 && (
|
||||
<div className="document-card__tags">
|
||||
{visibleTags.map((tag) => {
|
||||
{visibleTags.map((tag, index) => {
|
||||
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
||||
const style = getTagColorStyle(colorSource);
|
||||
const tagId = tag?.id ?? null;
|
||||
const clickable = tagId != null && typeof onTagClick === 'function';
|
||||
const key = tagId ?? `${doc.id}-tag-${index}`;
|
||||
return (
|
||||
<span
|
||||
key={tag.id}
|
||||
key={key}
|
||||
className="badge tag-chip"
|
||||
style={style || undefined}
|
||||
title={tag.label}
|
||||
role="button"
|
||||
onClick={(event) => {
|
||||
role={clickable ? 'button' : undefined}
|
||||
onClick={clickable ? (event) => {
|
||||
event.stopPropagation();
|
||||
onTagClick?.(tag.id);
|
||||
}}
|
||||
if (tagId == null) {
|
||||
return;
|
||||
}
|
||||
onTagClick?.(tagId);
|
||||
} : undefined}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.stopPropagation();
|
||||
@@ -463,13 +469,16 @@ const DocumentsGrid: React.FC<DocumentsGridProps> = ({
|
||||
onDragEnd={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
onKeyDown={clickable ? (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onTagClick?.(tag.id);
|
||||
if (tagId == null) {
|
||||
return;
|
||||
}
|
||||
onTagClick?.(tagId);
|
||||
}
|
||||
}}
|
||||
} : undefined}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
|
||||
@@ -82,9 +82,9 @@ export interface DocumentsListProps {
|
||||
onDocumentTagDrop?: (event: DragEvent<HTMLTableRowElement>, documentId: Identifier) => void;
|
||||
onDocumentRename?: (documentId: Identifier, nextTitle: string) => Promise<boolean> | boolean;
|
||||
tagLookupById?: Map<Identifier, DocumentTag> | null;
|
||||
onTagClick?: (tagId?: Identifier | null) => void;
|
||||
onCorrespondentClick?: (correspondentId?: Identifier | null) => void;
|
||||
activeCorrespondentIdSet?: Set<Identifier | null> | null;
|
||||
onTagClick?: (tagId: Identifier) => void;
|
||||
onCorrespondentClick?: (correspondentId: Identifier) => void;
|
||||
activeCorrespondentIdSet?: Set<Identifier> | null;
|
||||
scrollRef?: RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
@@ -456,20 +456,24 @@ const DocumentsList: React.FC<DocumentsListProps> = ({
|
||||
</div>
|
||||
{(doc.tags || []).length > 0 && (
|
||||
<div className="doc-name__tags">
|
||||
{(doc.tags || []).map((tag) => {
|
||||
{(doc.tags || []).map((tag, index) => {
|
||||
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
||||
const style = getTagColorStyle(colorSource);
|
||||
const tagId = tag?.id ?? null;
|
||||
const clickable = tagId != null && typeof onTagClick === 'function';
|
||||
const key = tagId ?? `${doc.id}-tag-${index}`;
|
||||
return (
|
||||
<span
|
||||
key={tag.id}
|
||||
key={key}
|
||||
className="badge tag-chip"
|
||||
style={style || undefined}
|
||||
title={tag.label}
|
||||
role="button"
|
||||
onClick={(event) => {
|
||||
role={clickable ? 'button' : undefined}
|
||||
onClick={clickable ? (event) => {
|
||||
event.stopPropagation();
|
||||
onTagClick?.(tag.id);
|
||||
}}
|
||||
if (tagId == null) return;
|
||||
onTagClick?.(tagId);
|
||||
} : undefined}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.stopPropagation();
|
||||
@@ -485,13 +489,16 @@ const DocumentsList: React.FC<DocumentsListProps> = ({
|
||||
onDragEnd={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
onKeyDown={clickable ? (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onTagClick?.(tag.id);
|
||||
if (tagId == null) {
|
||||
return;
|
||||
}
|
||||
onTagClick?.(tagId);
|
||||
}
|
||||
}}
|
||||
} : undefined}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface SelectionAssignmentMenuProps {
|
||||
items?: SelectionAssignmentMenuItem[];
|
||||
placeholder?: string;
|
||||
emptyMessage?: string;
|
||||
createLabel?: string | null;
|
||||
createLabel?: string;
|
||||
onToggle?: (item: NormalizedSelectionAssignmentItem) => Promise<void> | void;
|
||||
onCreate?: (value: string) => Promise<void> | void;
|
||||
disabled?: boolean;
|
||||
@@ -82,7 +82,7 @@ const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
|
||||
items = [],
|
||||
placeholder = 'Search…',
|
||||
emptyMessage = 'No entries',
|
||||
createLabel = null,
|
||||
createLabel = 'Add',
|
||||
onToggle,
|
||||
onCreate,
|
||||
disabled = false,
|
||||
@@ -91,8 +91,8 @@ const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
|
||||
triggerClassName = 'quick-add__chip quick-add__trigger panel-floating-actions__trigger',
|
||||
showStateIndicators = true,
|
||||
showCounts = true,
|
||||
onOpenMenu = null,
|
||||
renderItemLabel = null,
|
||||
onOpenMenu,
|
||||
renderItemLabel,
|
||||
positionStrategy = 'absolute',
|
||||
}) => {
|
||||
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
@@ -162,7 +162,7 @@ const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (item: NormalizedSelectionAssignmentItem) => {
|
||||
if (!item || !onToggle) {
|
||||
if (!onToggle) {
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
@@ -265,8 +265,8 @@ const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
|
||||
type="submit"
|
||||
className="icon-button selection-assignment__add"
|
||||
disabled={!canSubmitCreate}
|
||||
aria-label={createLabel || 'Add'}
|
||||
title={createLabel || 'Add'}
|
||||
aria-label={createLabel}
|
||||
title={createLabel}
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
@@ -12,18 +12,18 @@ export type DocumentLike = OcrDocumentLike;
|
||||
|
||||
const asyncFalse = async () => false;
|
||||
|
||||
const resolveDocumentDownloadHref = (document: DocumentLike | null | undefined, resolveApiPath?: ResolveApiPath | null): string | null => {
|
||||
const resolveDocumentDownloadHref = (document?: DocumentLike | null, resolveApiPath?: ResolveApiPath | null): string | null => {
|
||||
if (!document || !resolveApiPath) {
|
||||
return null;
|
||||
}
|
||||
const downloadPath = (document.current_version as { download_path?: string | null } | null | undefined)?.download_path;
|
||||
const downloadPath = (document.current_version as { download_path?: string | null } | null)?.download_path;
|
||||
if (!downloadPath) {
|
||||
return null;
|
||||
}
|
||||
return resolveApiPath(downloadPath);
|
||||
};
|
||||
|
||||
const hasDocumentOcrAsset = (document: DocumentLike | null | undefined, getDocumentAsset?: GetDocumentAsset | null): boolean => {
|
||||
const hasDocumentOcrAsset = (document?: DocumentLike | null, getDocumentAsset?: GetDocumentAsset | null): boolean => {
|
||||
if (!document || !getDocumentAsset) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { formatDateTime } from '../utils/date';
|
||||
|
||||
interface DocumentVersionMetadata {
|
||||
checksum?: string | null;
|
||||
}
|
||||
|
||||
interface DocumentMetadata {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DocumentLike {
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
filename?: string | null;
|
||||
original_name?: string | null;
|
||||
content_type?: string | null;
|
||||
metadata?: DocumentMetadata | null;
|
||||
current_version?: DocumentVersionMetadata | null;
|
||||
}
|
||||
|
||||
export interface DocumentMetadataItem {
|
||||
label: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
export const buildDocumentMetadataItems = (document?: DocumentLike | null): DocumentMetadataItem[] => {
|
||||
if (!document) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const metadata = document.current_version || {};
|
||||
|
||||
return [
|
||||
{ label: 'Created at', value: formatDateTime(document.created_at) },
|
||||
{ label: 'Updated at', value: formatDateTime(document.updated_at) },
|
||||
{
|
||||
label: 'Filename',
|
||||
value: document.filename ?? null,
|
||||
},
|
||||
{
|
||||
label: 'Original filename',
|
||||
value: document.original_name ?? null,
|
||||
},
|
||||
{
|
||||
label: 'SHA-256 checksum',
|
||||
value: metadata.checksum ?? null,
|
||||
},
|
||||
{
|
||||
label: 'Content type',
|
||||
value: document.content_type ?? null,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const extractDocumentMetadataPayload = (document?: DocumentLike | null): DocumentMetadata | null => {
|
||||
if (!document?.metadata) {
|
||||
return null;
|
||||
}
|
||||
const keys = Object.keys(document.metadata);
|
||||
if (!keys.length) {
|
||||
return null;
|
||||
}
|
||||
return document.metadata;
|
||||
};
|
||||
|
||||
export default buildDocumentMetadataItems;
|
||||
@@ -1,13 +1,13 @@
|
||||
import { formatFileSize } from '../utils/format';
|
||||
import { formatDateTime as defaultFormatDateTime } from '../utils/date';
|
||||
|
||||
interface DocumentMetadata {
|
||||
interface DocumentPageMetadata {
|
||||
page_count?: number | string | null;
|
||||
}
|
||||
|
||||
interface DocumentVersion {
|
||||
size_bytes?: number | string | null;
|
||||
metadata?: DocumentMetadata | null;
|
||||
metadata?: DocumentPageMetadata | null;
|
||||
}
|
||||
|
||||
interface TagEntry {
|
||||
@@ -35,31 +35,18 @@ interface DescribeSummaryOptions {
|
||||
formatDateTime?: typeof defaultFormatDateTime;
|
||||
}
|
||||
|
||||
export type DocumentSummaryRowType = 'text' | 'editable-title' | 'editable-issued' | 'tags' | 'correspondents';
|
||||
|
||||
export interface DocumentSummaryRow {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string | null;
|
||||
kind?: DocumentSummaryRowType;
|
||||
}
|
||||
|
||||
export interface DocumentSummary {
|
||||
title: string | undefined;
|
||||
originalName: string | null;
|
||||
mimeTypeLabel: string;
|
||||
sizeLabel: string;
|
||||
createdAtLabel: string;
|
||||
issuedLabel: string;
|
||||
updatedAtLabel: string;
|
||||
pageCount: number | null;
|
||||
pageCountLabel: string;
|
||||
folderLabel: string | null;
|
||||
tags: TagEntry[];
|
||||
correspondents: CorrespondentEntry[];
|
||||
tagsSummary: string;
|
||||
correspondentsSummary: string;
|
||||
summaryRows: DocumentSummaryRow[];
|
||||
}
|
||||
export type DocumentSummary = DocumentSummaryRow[];
|
||||
|
||||
const coercePageCount = (metadata?: DocumentMetadata | null): number | null => {
|
||||
const coercePageCount = (metadata?: DocumentPageMetadata | null): number | null => {
|
||||
const raw = metadata?.page_count;
|
||||
if (raw == null || raw === '') {
|
||||
return null;
|
||||
@@ -71,86 +58,61 @@ const coercePageCount = (metadata?: DocumentMetadata | null): number | null => {
|
||||
const sanitizeArray = <T>(entries?: Array<T | null> | null): T[] =>
|
||||
Array.isArray(entries) ? entries.filter(Boolean) as T[] : [];
|
||||
|
||||
interface DocumentMetadataPayload {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MetadataDocumentLike {
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
filename?: string | null;
|
||||
original_name?: string | null;
|
||||
content_type?: string | null;
|
||||
metadata?: DocumentMetadataPayload | null;
|
||||
current_version?: { checksum?: string | null } | null;
|
||||
}
|
||||
|
||||
export const describeDocumentSummary = (document?: SummaryDocument | null, options: DescribeSummaryOptions = {}): DocumentSummary => {
|
||||
const {
|
||||
formatDateTime = defaultFormatDateTime,
|
||||
} = options;
|
||||
|
||||
if (!document) {
|
||||
return {
|
||||
title: '',
|
||||
originalName: null,
|
||||
mimeTypeLabel: '—',
|
||||
sizeLabel: '—',
|
||||
createdAtLabel: '—',
|
||||
issuedLabel: '—',
|
||||
updatedAtLabel: '—',
|
||||
pageCount: null,
|
||||
pageCountLabel: '—',
|
||||
folderLabel: null,
|
||||
tags: [],
|
||||
correspondents: [],
|
||||
tagsSummary: '—',
|
||||
correspondentsSummary: '—',
|
||||
summaryRows: [],
|
||||
};
|
||||
}
|
||||
|
||||
const originalName = document.original_name;
|
||||
const mimeTypeLabel = document.content_type || 'Unknown';
|
||||
|
||||
const sizeBytes = Number(document.current_version?.size_bytes);
|
||||
const formatDateLabel = (value?: string | null) => formatDateTime(value) || '—';
|
||||
const doc = document ?? {};
|
||||
const sizeBytes = Number(doc.current_version?.size_bytes);
|
||||
const sizeLabel = Number.isFinite(sizeBytes) && sizeBytes > 0 ? formatFileSize(sizeBytes) : '—';
|
||||
|
||||
const metadata = document.current_version?.metadata || null;
|
||||
const metadata = doc.current_version?.metadata || null;
|
||||
const pageCount = coercePageCount(metadata);
|
||||
const pageCountLabel = Number.isFinite(pageCount) ? String(pageCount) : '—';
|
||||
|
||||
const createdAtLabel = formatDateTime(document.created_at);
|
||||
const issuedLabel = formatDateTime(document.issued_at);
|
||||
const updatedAtLabel = formatDateTime(document.updated_at);
|
||||
|
||||
const folderLabel = document.folder_path ?? null;
|
||||
const displayFolderLabel = folderLabel ?? 'Documents';
|
||||
|
||||
const tags = sanitizeArray<TagEntry>(document.tags);
|
||||
const correspondents = sanitizeArray<CorrespondentEntry>(document.correspondents);
|
||||
|
||||
const tags = sanitizeArray<TagEntry>(doc.tags);
|
||||
const correspondents = sanitizeArray<CorrespondentEntry>(doc.correspondents);
|
||||
const tagLabels = tags.map((tag) => tag.label).filter(Boolean) as string[];
|
||||
const correspondentLabels = correspondents.map((entry) => entry.name).filter(Boolean) as string[];
|
||||
|
||||
const tagsSummary = tagLabels.length ? tagLabels.join(', ') : '—';
|
||||
const correspondentsSummary = correspondentLabels.length
|
||||
? correspondentLabels.join(', ')
|
||||
: '—';
|
||||
|
||||
const summaryRows: DocumentSummaryRow[] = [
|
||||
{ key: 'created', label: 'Created', value: createdAtLabel },
|
||||
const correspondentsSummary = correspondentLabels.length ? correspondentLabels.join(', ') : '—';
|
||||
return [
|
||||
{ key: 'title', label: 'Title', value: doc.title ?? null, kind: 'editable-title' },
|
||||
{ key: 'tags', label: 'Tags', value: tagsSummary, kind: 'tags' },
|
||||
{ key: 'correspondents', label: 'Correspondents', value: correspondentsSummary, kind: 'correspondents' },
|
||||
{ key: 'issued', label: 'Issued', value: formatDateLabel(doc.issued_at), kind: 'editable-issued' },
|
||||
{ key: 'created', label: 'Created at', value: formatDateLabel(doc.created_at) },
|
||||
{ key: 'updated', label: 'Updated at', value: formatDateLabel(doc.updated_at) },
|
||||
{ key: 'size', label: 'Size', value: sizeLabel },
|
||||
{ key: 'type', label: 'Type', value: mimeTypeLabel },
|
||||
{ key: 'issued', label: 'Issued', value: issuedLabel },
|
||||
{ key: 'content-type', label: 'Content type', value: doc.content_type || 'Unknown' },
|
||||
{ key: 'pages', label: 'Pages', value: pageCountLabel },
|
||||
{ key: 'updated', label: 'Updated', value: updatedAtLabel },
|
||||
{ key: 'folder', label: 'Folder', value: displayFolderLabel },
|
||||
{ key: 'tags', label: 'Tags', value: tagsSummary },
|
||||
{ key: 'correspondents', label: 'Correspondents', value: correspondentsSummary },
|
||||
{ key: 'filename', label: 'Filename', value: doc.filename },
|
||||
{ key: 'original-filename', label: 'Original filename', value: doc.original_name },
|
||||
{ key: 'checksum', label: 'SHA-256 checksum', value: doc.current_version?.checksum },
|
||||
];
|
||||
};
|
||||
|
||||
return {
|
||||
title: document.title,
|
||||
originalName,
|
||||
mimeTypeLabel,
|
||||
sizeLabel,
|
||||
createdAtLabel,
|
||||
issuedLabel,
|
||||
updatedAtLabel,
|
||||
pageCount,
|
||||
pageCountLabel,
|
||||
folderLabel,
|
||||
tags,
|
||||
correspondents,
|
||||
tagsSummary,
|
||||
correspondentsSummary,
|
||||
summaryRows,
|
||||
};
|
||||
export const extractDocumentMetadataPayload = (document?: MetadataDocumentLike | null): DocumentMetadataPayload | null => {
|
||||
if (!document?.metadata) {
|
||||
return null;
|
||||
}
|
||||
const keys = Object.keys(document.metadata);
|
||||
if (!keys.length) {
|
||||
return null;
|
||||
}
|
||||
return document.metadata;
|
||||
};
|
||||
|
||||
@@ -20,8 +20,8 @@ interface UseDocumentsSelectionOptions {
|
||||
showingSearchResults: boolean;
|
||||
currentSubfolders: FolderEntry[];
|
||||
visibleDocuments: DocumentEntry[];
|
||||
resolveFolderRowKey: (id: string | number) => string | null | undefined;
|
||||
resolveDocumentRowKey: (id: string | number) => string | null | undefined;
|
||||
resolveFolderRowKey: (id: string | number) => string | null;
|
||||
resolveDocumentRowKey: (id: string | number) => string | null;
|
||||
configureSelectionEnvironment: (config: { visibleRowKeySet: Set<string>; navigableRowKeys: string[] }) => void;
|
||||
visibleRowKeySet: Set<string>;
|
||||
selectedEntries: string[];
|
||||
|
||||
@@ -12,8 +12,8 @@ type FocusableInput = (HTMLInputElement | HTMLTextAreaElement) & {
|
||||
};
|
||||
|
||||
type InlineRenameOptions<TEntity> = {
|
||||
getCurrentValue?: (entity: TEntity) => string | null | undefined;
|
||||
getEntityId?: (entity: TEntity) => string | number | null | undefined;
|
||||
getCurrentValue?: (entity: TEntity) => string | null;
|
||||
getEntityId?: (entity: TEntity) => string | number | null;
|
||||
};
|
||||
|
||||
type InlineRenameHandler = (
|
||||
@@ -25,9 +25,9 @@ type InlineRenameReturn<TEntity> = {
|
||||
editingId: string | number | null;
|
||||
draftValue: string;
|
||||
setDraftValue: Dispatch<SetStateAction<string>>;
|
||||
beginEditing: (entity: TEntity | null | undefined, event?: SyntheticEvent | Event) => void;
|
||||
beginEditing: (entity?: TEntity | null, event?: SyntheticEvent | Event) => void;
|
||||
cancelEditing: (event?: SyntheticEvent | Event) => void;
|
||||
submitEditing: (entity: TEntity | null | undefined) => Promise<boolean>;
|
||||
submitEditing: (entity?: TEntity | null) => Promise<boolean>;
|
||||
savingId: string | number | null;
|
||||
attachInputRef: (node: FocusableInput | null) => void;
|
||||
};
|
||||
@@ -50,16 +50,14 @@ const focusInput = (node: FocusableInput | null) => {
|
||||
|
||||
const identity = (value: unknown) => value as string;
|
||||
|
||||
const defaultGetEntityId = <T,>(entity: T) =>
|
||||
(entity as { id?: string | number } | null | undefined)?.id ?? null;
|
||||
const defaultGetEntityId = <T,>(entity?: T | null) =>
|
||||
(entity as { id?: string | number } | null)?.id ?? null;
|
||||
|
||||
const useInlineRename = <TEntity,>(
|
||||
onRename?: InlineRenameHandler,
|
||||
{
|
||||
getCurrentValue = identity as (entity: TEntity) => string | null | undefined,
|
||||
getEntityId = defaultGetEntityId as (
|
||||
entity: TEntity,
|
||||
) => string | number | null | undefined,
|
||||
getCurrentValue = identity as (entity: TEntity) => string | null,
|
||||
getEntityId = defaultGetEntityId as (entity: TEntity) => string | number | null,
|
||||
}: InlineRenameOptions<TEntity> = {},
|
||||
): InlineRenameReturn<TEntity> => {
|
||||
const [editingId, setEditingId] = useState<string | number | null>(null);
|
||||
@@ -75,7 +73,7 @@ const useInlineRename = <TEntity,>(
|
||||
}, []);
|
||||
|
||||
const beginEditing = useCallback(
|
||||
(entity: TEntity | null | undefined, event?: SyntheticEvent | Event) => {
|
||||
(entity?: TEntity | null, event?: SyntheticEvent | Event) => {
|
||||
if (!entity) {
|
||||
return;
|
||||
}
|
||||
@@ -107,7 +105,7 @@ const useInlineRename = <TEntity,>(
|
||||
);
|
||||
|
||||
const submitEditing = useCallback(
|
||||
async (entity: TEntity | null | undefined) => {
|
||||
async (entity?: TEntity | null) => {
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ interface UseAuthManagerArgs {
|
||||
}
|
||||
|
||||
interface UseAuthManagerResult {
|
||||
tokenRef: MutableRefObject<string | null | undefined>;
|
||||
tokenRef: MutableRefObject<string | null>;
|
||||
refreshAccessToken: () => Promise<string>;
|
||||
handleLogout: () => Promise<void>;
|
||||
}
|
||||
@@ -57,7 +57,7 @@ const useAuthManager = ({
|
||||
setStatusMessage,
|
||||
setLoading,
|
||||
}: UseAuthManagerArgs): UseAuthManagerResult => {
|
||||
const tokenRef = useRef<string | null | undefined>(token);
|
||||
const tokenRef = useRef<string | null>(token);
|
||||
const refreshPromiseRef = useRef<Promise<string> | null>(null);
|
||||
const initialRefreshAttemptedRef = useRef(Boolean(token));
|
||||
|
||||
|
||||
@@ -47,8 +47,8 @@ const useCorrespondents = ({
|
||||
}, [apiClient, notifyApiError, tenantIdRef]);
|
||||
|
||||
const handleCorrespondentUpdate = useCallback(
|
||||
async (correspondentId: string | number | null | undefined, changes: { name?: string }) => {
|
||||
if (!correspondentId) {
|
||||
async (correspondentId: string | number, changes: { name?: string }) => {
|
||||
if (correspondentId == null) {
|
||||
throw new Error('Missing correspondent identifier.');
|
||||
}
|
||||
|
||||
@@ -100,8 +100,8 @@ const useCorrespondents = ({
|
||||
);
|
||||
|
||||
const handleCorrespondentDelete = useCallback(
|
||||
async (correspondentId: string | number | null | undefined) => {
|
||||
if (!correspondentId) {
|
||||
async (correspondentId: string | number) => {
|
||||
if (correspondentId == null) {
|
||||
throw new Error('Missing correspondent identifier.');
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,12 @@ interface CorrespondentOption {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
interface UseDocumentCorrespondentActionsArgs {
|
||||
apiClient: ApiClient;
|
||||
correspondents: CorrespondentOption[];
|
||||
handleCorrespondentCreate: (payload: { name: string }) => Promise<CorrespondentOption | null | undefined>;
|
||||
handleCorrespondentCreate: (payload: { name: string }) => Promise<CorrespondentOption | null>;
|
||||
refreshCurrentFolder: () => Promise<void>;
|
||||
notifyApiError: (error: unknown, fallback: string) => void;
|
||||
setStatusMessage: (message: string, variant?: string) => void;
|
||||
@@ -41,10 +43,10 @@ const useDocumentCorrespondentActions = ({
|
||||
|
||||
const handleDocumentCorrespondentAttach = useCallback(
|
||||
async (
|
||||
{ documentId, correspondentId }: { documentId?: string | number | null; correspondentId?: string | number | null },
|
||||
{ documentId, correspondentId }: { documentId: Identifier; correspondentId: Identifier },
|
||||
{ notify = true, refresh = true }: { notify?: boolean; refresh?: boolean } = {},
|
||||
) => {
|
||||
if (!documentId || !correspondentId) {
|
||||
if (documentId == null || correspondentId == null) {
|
||||
throw new Error('Missing document or correspondent.');
|
||||
}
|
||||
try {
|
||||
@@ -70,10 +72,10 @@ const useDocumentCorrespondentActions = ({
|
||||
|
||||
const handleCorrespondentRemove = useCallback(
|
||||
async (
|
||||
{ documentId, correspondentId }: { documentId?: string | number | null; correspondentId?: string | number | null },
|
||||
{ documentId, correspondentId }: { documentId: Identifier; correspondentId: Identifier },
|
||||
{ notify = true, refresh = true }: { notify?: boolean; refresh?: boolean } = {},
|
||||
) => {
|
||||
if (!documentId || !correspondentId) {
|
||||
if (documentId == null || correspondentId == null) {
|
||||
throw new Error('Missing document or correspondent.');
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -30,8 +30,8 @@ interface UseDocumentDragHandlersOptions {
|
||||
documentLookup: Map<Identifier, DocumentLike>;
|
||||
setDraggedDocumentIds: (ids: Identifier[] | []) => void;
|
||||
setDraggedFolderId: (id: FolderIdentifier | null) => void;
|
||||
resolveDocumentRowKey: (id: Identifier) => string | null | undefined;
|
||||
resolveFolderRowKey: (id: FolderIdentifier) => string | null | undefined;
|
||||
resolveDocumentRowKey: (id: Identifier) => string | null;
|
||||
resolveFolderRowKey: (id: FolderIdentifier) => string | null;
|
||||
documentsViewMode: string;
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ const useDocumentDragHandlers = ({
|
||||
);
|
||||
|
||||
const handleDocumentDragStart = useCallback(
|
||||
(event: DragEvent<HTMLElement>, documentOrId: DocumentLike | Identifier | null | undefined) => {
|
||||
(event: DragEvent<HTMLElement>, documentOrId: DocumentLike | Identifier | null) => {
|
||||
const documentId: Identifier | null = Object(documentOrId) === documentOrId
|
||||
? (documentOrId as DocumentLike)?.id ?? null
|
||||
: (documentOrId as Identifier | null);
|
||||
|
||||
@@ -10,8 +10,8 @@ type NullableFolderId = FolderId | null;
|
||||
type StatusLevel = 'success' | 'error' | 'info' | string;
|
||||
|
||||
type DocumentCacheMapper = (
|
||||
doc: DocumentLike | null | undefined,
|
||||
) => DocumentLike | null | undefined;
|
||||
doc: DocumentLike | null,
|
||||
) => DocumentLike | null;
|
||||
|
||||
type MapDocumentCaches = (mapper: DocumentCacheMapper) => void;
|
||||
|
||||
@@ -140,7 +140,7 @@ interface UseDocumentMutationsArgs {
|
||||
tags: Tag[];
|
||||
refreshTags: () => Promise<void>;
|
||||
tagManager: TagManager;
|
||||
extractDocumentFromResponse?: (payload: unknown) => DocumentLike | null | undefined;
|
||||
extractDocumentFromResponse?: (payload: unknown) => DocumentLike | null;
|
||||
}
|
||||
|
||||
interface UseDocumentMutationsResult {
|
||||
|
||||
@@ -11,7 +11,7 @@ interface FolderContentsEntry {
|
||||
}
|
||||
|
||||
interface UseDocumentsOptions {
|
||||
setSearchResults: Dispatch<SetStateAction<DocumentLike[] | null | undefined>>;
|
||||
setSearchResults: Dispatch<SetStateAction<DocumentLike[] | null>>;
|
||||
setFolderContents: Dispatch<SetStateAction<Map<string, FolderContentsEntry>>>;
|
||||
}
|
||||
|
||||
|
||||
@@ -757,13 +757,12 @@ const useDocumentsWorkspace = ({
|
||||
|
||||
|
||||
const removeDocumentsFromCaches = useCallback(
|
||||
(documentIds: DocumentId[] | null | undefined) => {
|
||||
const safeIds = Array.isArray(documentIds) ? documentIds : [];
|
||||
if (!safeIds.length) {
|
||||
(documentIds: DocumentId[]) => {
|
||||
if (!documentIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idSet = new Set<DocumentId>(safeIds);
|
||||
const idSet = new Set<DocumentId>(documentIds);
|
||||
|
||||
setDocuments((prev) => prev.filter((doc) => !idSet.has(doc.id)));
|
||||
setSearchResults((prev) => {
|
||||
@@ -1275,7 +1274,7 @@ const useDocumentsWorkspace = ({
|
||||
});
|
||||
|
||||
const inspectDocumentForDesk = useCallback(
|
||||
(docOrId: DocumentLike | Identifier | null | undefined) => {
|
||||
(docOrId?: DocumentLike | Identifier | null) => {
|
||||
if (docOrId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ const useFolderTreeActions = ({
|
||||
setCreatingFolder,
|
||||
}: UseFolderTreeActionsOptions) => {
|
||||
const moveFolder = useCallback(
|
||||
async (folderId: FolderKey, targetFolderId: FolderKey | null | undefined) => {
|
||||
async (folderId: FolderKey, targetFolderId: FolderKey | null) => {
|
||||
const node = folderNodes.get(folderId);
|
||||
if (!node) {
|
||||
setStatusMessage('Folder metadata unavailable. Try refreshing.', 'error');
|
||||
@@ -214,7 +214,7 @@ const useFolderTreeActions = ({
|
||||
);
|
||||
|
||||
const loadFolder = useCallback(
|
||||
async (folderId: FolderKey | null | undefined, { showLoading = true, preserveSearch = false }: LoadFolderOptions = {}) => {
|
||||
async (folderId: FolderKey | null, { showLoading = true, preserveSearch = false }: LoadFolderOptions = {}) => {
|
||||
const targetId = folderId || 'root';
|
||||
setSelectedFolder(targetId);
|
||||
await ensureFolderAncestorsLoaded(targetId);
|
||||
@@ -256,7 +256,7 @@ const useFolderTreeActions = ({
|
||||
);
|
||||
|
||||
const selectFolder = useCallback(
|
||||
async (folderId: FolderKey | null | undefined, { replace = false, immediate = false }: SelectFolderOptions = {}) => {
|
||||
async (folderId: FolderKey | null, { replace = false, immediate = false }: SelectFolderOptions = {}) => {
|
||||
const targetId = folderId && folderId !== 'root' ? folderId : 'root';
|
||||
|
||||
await ensureFolderAncestorsLoaded(targetId);
|
||||
|
||||
@@ -56,8 +56,8 @@ const useTags = ({
|
||||
}, [apiClient, notifyApiError, tenantIdRef]);
|
||||
|
||||
const handleTagUpdate = useCallback(
|
||||
async (tagId: string | number | null | undefined, changes: { label?: string; color?: string | null }) => {
|
||||
if (!tagId) {
|
||||
async (tagId: string | number, changes: { label?: string; color?: string | null }) => {
|
||||
if (tagId == null) {
|
||||
throw new Error('Missing tag identifier.');
|
||||
}
|
||||
|
||||
@@ -104,8 +104,8 @@ const useTags = ({
|
||||
);
|
||||
|
||||
const handleTagDelete = useCallback(
|
||||
async (tagId: string | number | null | undefined) => {
|
||||
if (!tagId) {
|
||||
async (tagId: string | number) => {
|
||||
if (tagId == null) {
|
||||
throw new Error('Missing tag identifier.');
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ type EnsureAssetUrl = (
|
||||
options?: { start?: number; limit?: number; [key: string]: unknown },
|
||||
) => Promise<unknown>;
|
||||
|
||||
type GetAsset = (document: DocumentLike, assetType: string) => AssetLike | null | undefined;
|
||||
type GetAsset = (document: DocumentLike, assetType: string) => AssetLike | null;
|
||||
|
||||
interface AssetViewLike {
|
||||
getCardinality: () => number;
|
||||
@@ -41,7 +41,7 @@ interface AssetViewLike {
|
||||
}
|
||||
|
||||
interface UseAssetNavigatorOptions {
|
||||
document: DocumentLike | null | undefined;
|
||||
document?: DocumentLike | null;
|
||||
assetType: string;
|
||||
ensureAssetUrl?: EnsureAssetUrl;
|
||||
getAsset?: GetAsset;
|
||||
@@ -50,7 +50,7 @@ interface UseAssetNavigatorOptions {
|
||||
}
|
||||
|
||||
interface AssetNavigatorReturn {
|
||||
document: DocumentLike | null | undefined;
|
||||
document: DocumentLike | null;
|
||||
documentId: Identifier | null;
|
||||
asset: AssetLike | null;
|
||||
assetType: string;
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
sortCorrespondents,
|
||||
} from '../documents/DocumentSummarySection';
|
||||
import type { DocumentSummarySectionProps } from '../documents/DocumentSummarySection';
|
||||
import { extractDocumentMetadataPayload } from '../documents/documentMetadata';
|
||||
import { extractDocumentMetadataPayload } from '../documents/documentSummary';
|
||||
import { createDocumentActionState } from '../documents/documentActions';
|
||||
import { resolveDocumentAssetUrl } from '../asset_manager';
|
||||
import PanelHeader from '../ui/PanelHeader';
|
||||
@@ -56,8 +56,8 @@ interface DocumentViewerPanelProps extends DocumentSummarySectionProps {
|
||||
} | null;
|
||||
hydrateDocument?: (doc: DocumentLike | null) => DocumentLike | null;
|
||||
ensureAssetUrl?: (docId: string | number, asset: AssetLike, options?: { start?: number; limit?: number }) => Promise<unknown>;
|
||||
getDocumentAsset?: (doc: DocumentLike | null, type: string) => AssetLike | null | undefined;
|
||||
ensurePreviewData?: (docId: string | number, options?: { signal?: AbortSignal }) => Promise<DocumentLike | null | undefined>;
|
||||
getDocumentAsset?: (doc: DocumentLike | null, type: string) => AssetLike | null;
|
||||
ensurePreviewData?: (docId: string | number, options?: { signal?: AbortSignal }) => Promise<DocumentLike | null>;
|
||||
resolveApiPath?: (path: string) => string;
|
||||
notifyApiError?: (error: unknown, fallbackMessage?: string) => void;
|
||||
sidebarToggle?: ReactNode;
|
||||
|
||||
@@ -30,11 +30,11 @@ interface CapabilityDropdownProps {
|
||||
}
|
||||
|
||||
const isCapabilityOption = (
|
||||
option: CapabilityDropdownOption | CapabilityValue | null | undefined,
|
||||
option: CapabilityDropdownOption | CapabilityValue | null,
|
||||
): option is CapabilityDropdownOption => isPlainObject(option);
|
||||
|
||||
const resolveCapabilityValue = (
|
||||
option: CapabilityDropdownOption | CapabilityValue | null | undefined,
|
||||
option: CapabilityDropdownOption | CapabilityValue | null,
|
||||
): CapabilityValue | null => {
|
||||
if (option == null) {
|
||||
return null;
|
||||
@@ -109,10 +109,7 @@ const CapabilityDropdown = ({
|
||||
};
|
||||
}, [close, isOpen]);
|
||||
|
||||
const handleOptionClick = useCallback((value: CapabilityValue | null) => {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
const handleOptionClick = useCallback((value: CapabilityValue) => {
|
||||
if (selectedValues.includes(value)) {
|
||||
onDeselect?.(value);
|
||||
} else {
|
||||
|
||||
@@ -29,12 +29,12 @@ interface CapabilitySetMutationPayload {
|
||||
}
|
||||
|
||||
type RefreshHandler = (() => void | Promise<void>) | undefined;
|
||||
type CreateCapabilitySetHandler = (payload: CapabilitySetMutationPayload) => Promise<boolean | CapabilitySet | void | null | undefined> | boolean | CapabilitySet | void | null | undefined;
|
||||
type CreateCapabilitySetHandler = (payload: CapabilitySetMutationPayload) => Promise<boolean | CapabilitySet | void | null> | boolean | CapabilitySet | void | null;
|
||||
type UpdateCapabilitySetHandler = (
|
||||
id: CapabilitySetId,
|
||||
payload: CapabilitySetMutationPayload,
|
||||
) => Promise<boolean | CapabilitySet | void | null | undefined> | boolean | CapabilitySet | void | null | undefined;
|
||||
type DeleteCapabilitySetHandler = (id: CapabilitySetId) => Promise<boolean | void | null | undefined> | boolean | void | null | undefined;
|
||||
) => Promise<boolean | CapabilitySet | void | null> | boolean | CapabilitySet | void | null;
|
||||
type DeleteCapabilitySetHandler = (id: CapabilitySetId) => Promise<boolean | void | null> | boolean | void | null;
|
||||
|
||||
interface CapabilitySetsSectionProps {
|
||||
capabilitySets?: CapabilitySet[];
|
||||
@@ -53,7 +53,7 @@ interface CapabilitySetsSectionProps {
|
||||
onDeleteCapabilitySet?: DeleteCapabilitySetHandler;
|
||||
}
|
||||
|
||||
type CapabilityOptionInput = CapabilityDropdownOption | CapabilityValue | null | undefined;
|
||||
type CapabilityOptionInput = CapabilityDropdownOption | CapabilityValue | null;
|
||||
|
||||
const isCapabilityOption = (option: CapabilityOptionInput): option is CapabilityDropdownOption =>
|
||||
isPlainObject(option);
|
||||
@@ -142,7 +142,7 @@ const CapabilitySetsSection: React.FC<CapabilitySetsSectionProps> = ({
|
||||
return order;
|
||||
}, [capabilitySelectionOptions]);
|
||||
|
||||
const sortCapabilityValues = useCallback((values: CapabilityValue[] | null | undefined) => {
|
||||
const sortCapabilityValues = useCallback((values: CapabilityValue[] | null) => {
|
||||
if (!Array.isArray(values)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ interface UsePasskeysResult {
|
||||
refreshPasskeys: () => Promise<void>;
|
||||
registerPasskey: (options?: { nickname?: string }) => Promise<RegisterPasskeyResult>;
|
||||
revokePasskey: (
|
||||
passkeyId: string | number | null | undefined,
|
||||
passkeyId: string | number,
|
||||
reason?: string,
|
||||
) => Promise<RevokePasskeyResult>;
|
||||
}
|
||||
@@ -193,10 +193,10 @@ const usePasskeys = ({ api, notifyApiError, setStatusMessage, token }: UsePasske
|
||||
|
||||
const revokePasskey = useCallback(
|
||||
async (
|
||||
passkeyId: string | number | null | undefined,
|
||||
passkeyId: string | number,
|
||||
reason?: string,
|
||||
): Promise<RevokePasskeyResult> => {
|
||||
if (!passkeyId) {
|
||||
if (passkeyId == null) {
|
||||
return { ok: false, reason: 'missing-id' };
|
||||
}
|
||||
setRevokingPasskeyId(passkeyId);
|
||||
|
||||
@@ -31,6 +31,34 @@ import { useSidebarContext } from './SidebarContext';
|
||||
import { usePanelManager, usePanelResizeBindings } from '../app/PanelManagerContext';
|
||||
import type { StatusMessage } from '../hooks/documents/store/useDocumentsStore';
|
||||
|
||||
interface CommunityLink {
|
||||
label: string;
|
||||
href: string;
|
||||
title: string;
|
||||
Icon: typeof GithubIcon;
|
||||
}
|
||||
|
||||
const COMMUNITY_LINKS: CommunityLink[] = [
|
||||
{
|
||||
label: 'GitHub',
|
||||
href: 'https://github.com/papercrate-dms/papercrate',
|
||||
title: 'Open Papercrate on GitHub',
|
||||
Icon: GithubIcon,
|
||||
},
|
||||
{
|
||||
label: 'Matrix',
|
||||
href: 'https://matrix.to/#/#papercrate:matrix.org',
|
||||
title: 'Join the Papercrate Matrix room',
|
||||
Icon: MatrixIcon,
|
||||
},
|
||||
{
|
||||
label: 'Website',
|
||||
href: 'https://papercrate.org',
|
||||
title: 'Visit papercrate.org',
|
||||
Icon: WorldIcon,
|
||||
},
|
||||
];
|
||||
|
||||
type Identifier = string | number;
|
||||
type FolderIdentifier = Identifier | 'root';
|
||||
|
||||
@@ -98,11 +126,11 @@ interface SidebarProps {
|
||||
creatingFolder?: boolean;
|
||||
tags?: TagEntry[];
|
||||
untaggedFilterId?: Identifier | null;
|
||||
activeTagIds?: Array<Identifier | null | undefined>;
|
||||
onToggleTagFilter?: (tagId: Identifier | null | undefined) => void;
|
||||
activeTagIds?: Array<Identifier | null>;
|
||||
onToggleTagFilter?: (tagId: Identifier | null) => void;
|
||||
correspondents?: CorrespondentEntry[];
|
||||
activeCorrespondentIds?: Array<Identifier | null | undefined>;
|
||||
onToggleCorrespondentFilter?: (correspondentId: Identifier | null | undefined) => void;
|
||||
activeCorrespondentIds?: Array<Identifier | null>;
|
||||
onToggleCorrespondentFilter?: (correspondentId: Identifier | null) => void;
|
||||
onManageTags?: () => void;
|
||||
onManageCorrespondents?: () => void;
|
||||
onCreateTag?: (label: string) => Promise<void> | void;
|
||||
@@ -338,17 +366,17 @@ const Sidebar: React.FC<SidebarProps> = ({
|
||||
.sort((a, b) => a.name!.localeCompare(b.name!, undefined, { sensitivity: 'base' }));
|
||||
}, [correspondents]);
|
||||
const activeCorrespondentSet = useMemo(
|
||||
() => new Set<Identifier | null | undefined>(activeCorrespondentIds || []),
|
||||
() => new Set<Identifier>(activeCorrespondentIds || []),
|
||||
[activeCorrespondentIds],
|
||||
);
|
||||
const handleToggleTag = useCallback(
|
||||
(tagId: Identifier | null | undefined) => {
|
||||
(tagId: Identifier | null) => {
|
||||
onToggleTagFilter?.(tagId);
|
||||
},
|
||||
[onToggleTagFilter],
|
||||
);
|
||||
const activeTagSet = useMemo(
|
||||
() => new Set<Identifier | null | undefined>(activeTagIds || []),
|
||||
() => new Set<Identifier | null>(activeTagIds || []),
|
||||
[activeTagIds],
|
||||
);
|
||||
const untaggedActive = untaggedFilterId ? activeTagSet.has(untaggedFilterId) : false;
|
||||
@@ -566,6 +594,26 @@ const Sidebar: React.FC<SidebarProps> = ({
|
||||
)
|
||||
: null;
|
||||
|
||||
const communityMenuFooter = COMMUNITY_LINKS.length
|
||||
? (
|
||||
<div className="menu__footer">
|
||||
{COMMUNITY_LINKS.map(({ href, label, title, Icon }) => (
|
||||
<a
|
||||
key={href}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="menu__footer-link"
|
||||
title={title}
|
||||
>
|
||||
<Icon size={16} stroke={1.5} />
|
||||
<span>{label}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
: null;
|
||||
|
||||
const handleSearchInputChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onSearchChange?.(event.target.value);
|
||||
@@ -596,7 +644,7 @@ const Sidebar: React.FC<SidebarProps> = ({
|
||||
offset: 6,
|
||||
}) as FloatingMenuControls;
|
||||
const showTenantList = tenants.length > 1;
|
||||
const menuClassName = `menu${!showTenantList && !themeMenuSection ? ' menu--simple' : ''}`;
|
||||
const menuClassName = `menu${!showTenantList && !themeMenuSection && !communityMenuFooter ? ' menu--simple' : ''}`;
|
||||
|
||||
const toggleTenantMenu = useCallback(() => {
|
||||
if (!tenantMenuOpen && tenants.length === 0 && onSelectTenant) {
|
||||
@@ -735,6 +783,7 @@ const Sidebar: React.FC<SidebarProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
{themeMenuSection}
|
||||
{communityMenuFooter}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
@@ -981,38 +1030,6 @@ const Sidebar: React.FC<SidebarProps> = ({
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sidebar__footer">
|
||||
<a
|
||||
className="sidebar__footer-link"
|
||||
href="https://github.com/papercrate-dms/papercrate"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="Open Papercrate on GitHub"
|
||||
>
|
||||
<GithubIcon size={16} stroke={1.5} />
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<a
|
||||
className="sidebar__footer-link"
|
||||
href="https://matrix.to/#/#papercrate:matrix.org"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="Join the Papercrate Matrix room"
|
||||
>
|
||||
<MatrixIcon size={16} stroke={1.5} />
|
||||
<span>Matrix</span>
|
||||
</a>
|
||||
<a
|
||||
className="sidebar__footer-link"
|
||||
href="https://papercrate.org"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="Visit papercrate.org"
|
||||
>
|
||||
<WorldIcon size={16} stroke={1.5} />
|
||||
<span>Website</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -77,6 +77,15 @@ button.danger:hover:not([disabled]) {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.icon-button--accent {
|
||||
color: var(--on-accent);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.icon-button--accent:hover:not([disabled]) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.icon-button.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
@@ -96,4 +105,3 @@ button.danger:hover:not([disabled]) {
|
||||
color: var(--danger);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
--surface-ink-soft: color-mix(in oklch, var(--fg) 8%, transparent);
|
||||
|
||||
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: clamp(13px, 0.5vw + 12px, 15px);
|
||||
font-size: 100%;
|
||||
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
|
||||
--detail-panel-width: calc(100vw / 3);
|
||||
|
||||
@@ -603,7 +603,12 @@
|
||||
|
||||
.tag-list.document-summary__tags,
|
||||
.correspondent-list.document-summary__correspondents {
|
||||
margin-top: 0.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin: 0;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.document-summary__details {
|
||||
@@ -614,26 +619,40 @@
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.document-summary__details-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.document-summary__details-row dt {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
word-break: break-word;
|
||||
white-space: nowrap;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.document-summary__details-row dd {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
word-break: break-word;
|
||||
text-align: right;
|
||||
flex: 1 1 auto;
|
||||
display: inline-flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.document-summary__details-row .icon-button {
|
||||
margin: -0.25rem 0;
|
||||
}
|
||||
|
||||
.detail-panel .doc-title-edit,
|
||||
@@ -644,10 +663,40 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.document-summary__details-row .doc-title-edit,
|
||||
.document-summary__details-row .doc-title-edit--inline {
|
||||
width: auto;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.doc-title-edit--inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.doc-title-edit--inline input {
|
||||
width: auto;
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.detail-panel .doc-title-edit input,
|
||||
.document-summary .doc-title-edit input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.3rem 0.55rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.detail-panel .doc-title-edit input:focus-visible,
|
||||
.document-summary .doc-title-edit input:focus-visible {
|
||||
outline: 2px solid var(--selection-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.status-inline {
|
||||
@@ -918,14 +967,7 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.detail-panel dt {
|
||||
font-weight: 600;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.detail-panel dd {
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
.
|
||||
|
||||
.detail-panel .tag-list,
|
||||
.document-summary .tag-list,
|
||||
|
||||
@@ -249,9 +249,7 @@
|
||||
}
|
||||
|
||||
.detail-panel .document-viewer__section-item dd {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.document-viewer__section-placeholder {
|
||||
|
||||
@@ -188,48 +188,6 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar__footer {
|
||||
border-top: 1px solid var(--border-muted, var(--border));
|
||||
background: var(--bg);
|
||||
padding: 0.6rem 0.85rem;
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.sidebar__footer-link {
|
||||
display: inline-flex;
|
||||
flex: 1 1 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.83rem;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
padding: 0.2rem 0.35rem;
|
||||
border-radius: 999px;
|
||||
transition:
|
||||
color 0.12s ease,
|
||||
background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.sidebar__footer-link:visited {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.sidebar__footer-link:hover,
|
||||
.sidebar__footer-link:focus-visible {
|
||||
color: var(--sidebar-fg, var(--fg));
|
||||
background-color: color-mix(in srgb, var(--surface-overlay) 20%, transparent);
|
||||
}
|
||||
|
||||
.sidebar__footer-link .icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar__title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
@@ -497,6 +455,40 @@
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.menu__footer {
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 0.35rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: nowrap;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.menu__footer-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
text-decoration: none;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.menu__footer-link:visited {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.menu__footer-link:hover,
|
||||
.menu__footer-link:focus-visible {
|
||||
background: var(--sidebar-hover-bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.menu__button .menu__check-slot {
|
||||
width: 1rem;
|
||||
display: inline-flex;
|
||||
@@ -556,14 +548,6 @@
|
||||
background: var(--sidebar-hover-bg);
|
||||
}
|
||||
|
||||
.sidebar__footer .sidebar-section {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.sidebar__footer .sidebar-section__actions {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.sidebar-section:first-of-type,
|
||||
.sidebar-section--folders {
|
||||
margin-top: 0;
|
||||
|
||||
@@ -18,7 +18,7 @@ class TagManager {
|
||||
this.colorGenerator = colorGenerator;
|
||||
}
|
||||
|
||||
normalizeLabel(label: string | null | undefined): string {
|
||||
normalizeLabel(label?: string | null): string {
|
||||
return label?.trim?.() || '';
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ interface NormalizedOption {
|
||||
index: number;
|
||||
}
|
||||
|
||||
const normalizeOption = (option: QuickAddOption | null | undefined, index: number): NormalizedOption | null => {
|
||||
const normalizeOption = (option: QuickAddOption | null, index: number): NormalizedOption | null => {
|
||||
if (option == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ const rgbToHsl = ({ r, g, b }: RgbColor) => {
|
||||
return { h: hue, s: clamp01(saturation), l: clamp01(lightness) };
|
||||
};
|
||||
|
||||
export const hexToRgb = (input: string | null | undefined): (RgbColor & { hex: string }) | null => {
|
||||
export const hexToRgb = (input?: string): (RgbColor & { hex: string }) | null => {
|
||||
if (!input) return null;
|
||||
const match = HEX_COLOR_PATTERN.exec(input.trim());
|
||||
if (!match) return null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const ensureDate = (value: string | number | Date | null | undefined): Date | null => {
|
||||
const ensureDate = (value: string | number | Date | null): Date | null => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ interface FormatOptions {
|
||||
options?: Intl.DateTimeFormatOptions;
|
||||
}
|
||||
|
||||
export const formatDate = (value: string | number | Date | null | undefined, { fallback = '—', locale, options }: FormatOptions = {}): string => {
|
||||
export const formatDate = (value: string | number | Date | null, { fallback = '—', locale, options }: FormatOptions = {}): string => {
|
||||
const date = ensureDate(value);
|
||||
if (!date) {
|
||||
return fallback;
|
||||
@@ -20,7 +20,7 @@ export const formatDate = (value: string | number | Date | null | undefined, { f
|
||||
return date.toLocaleDateString(locale, options);
|
||||
};
|
||||
|
||||
export const formatDateTime = (value: string | number | Date | null | undefined, { fallback = '—', locale, options }: FormatOptions = {}): string => {
|
||||
export const formatDateTime = (value: string | number | Date | null, { fallback = '—', locale, options }: FormatOptions = {}): string => {
|
||||
const date = ensureDate(value);
|
||||
if (!date) {
|
||||
return fallback;
|
||||
@@ -28,7 +28,7 @@ export const formatDateTime = (value: string | number | Date | null | undefined,
|
||||
return date.toLocaleString(locale, options);
|
||||
};
|
||||
|
||||
export const toDateInputValue = (value: string | number | Date | null | undefined): string => {
|
||||
export const toDateInputValue = (value: string | number | Date | null): string => {
|
||||
const date = ensureDate(value);
|
||||
if (!date) {
|
||||
return '';
|
||||
@@ -38,7 +38,7 @@ export const toDateInputValue = (value: string | number | Date | null | undefine
|
||||
return localDate.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
export const toIssuedTimestamp = (dateString: string | null | undefined, fallback: string | number | Date | null | undefined): string | null => {
|
||||
export const toIssuedTimestamp = (dateString: string | null, fallback: string | number | Date | null): string | null => {
|
||||
if (!dateString) {
|
||||
return null;
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export const toIssuedTimestamp = (dateString: string | null | undefined, fallbac
|
||||
return Number.isNaN(candidate.getTime()) ? null : candidate.toISOString();
|
||||
};
|
||||
|
||||
export const parseDateValue = (value: string | number | Date | null | undefined): Date | null => ensureDate(value);
|
||||
export const parseDateValue = (value: string | number | Date | null): Date | null => ensureDate(value);
|
||||
|
||||
export default {
|
||||
formatDate,
|
||||
|
||||
@@ -18,12 +18,12 @@ export interface DocumentLike extends AssetManagerDocumentLike {
|
||||
|
||||
export type AssetLike = AssetManagerAssetLike;
|
||||
|
||||
export type EnsurePreviewData = (id: string | number) => Promise<DocumentLike | null | undefined>;
|
||||
export type EnsurePreviewData = (id: string | number) => Promise<DocumentLike | null>;
|
||||
export type EnsureAssetUrl = (
|
||||
id: string | number,
|
||||
asset: AssetLike,
|
||||
options?: { start?: number; limit?: number; force?: boolean },
|
||||
) => Promise<AssetLike | null | undefined>;
|
||||
) => Promise<AssetLike | null>;
|
||||
export type GetDocumentAsset = AssetManagerGetAsset;
|
||||
|
||||
interface ResolveOcrTextUrlOptions {
|
||||
@@ -33,7 +33,7 @@ interface ResolveOcrTextUrlOptions {
|
||||
ensureAssetUrl?: EnsureAssetUrl;
|
||||
}
|
||||
|
||||
const pickAsset = (doc: DocumentLike | null | undefined, getDocumentAsset?: GetDocumentAsset): AssetLike | null => {
|
||||
const pickAsset = (doc?: DocumentLike | null, getDocumentAsset?: GetDocumentAsset): AssetLike | null => {
|
||||
if (!doc || !getDocumentAsset) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user