This commit is contained in:
2025-11-15 04:11:22 +01:00
parent bd3eab8b40
commit d0379c57ce
47 changed files with 558 additions and 703 deletions
+1 -1
View File
@@ -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] ?? '';
+13 -13
View File
@@ -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;
+6 -6
View File
@@ -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)) {
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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;
+11 -11
View File
@@ -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;
}
+1 -1
View File
@@ -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;
+9 -9
View File
@@ -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;
+5 -5
View File
@@ -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;
+1 -1
View File
@@ -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;
+7 -8
View File
@@ -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>
+167 -268
View File
@@ -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>
);
};
+21 -12
View File
@@ -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>
+19 -12
View File
@@ -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>
+3 -3
View File
@@ -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;
+49 -87
View File
@@ -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[];
+10 -12
View File
@@ -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 {
+1 -1
View File
@@ -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);
+4 -4
View File
@@ -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.');
}
+3 -3
View File
@@ -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;
+3 -3
View File
@@ -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 [];
}
+3 -3
View File
@@ -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);
+57 -40
View File
@@ -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>
);
};
+9 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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);
+57 -15
View File
@@ -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,
-2
View File
@@ -249,9 +249,7 @@
}
.detail-panel .document-viewer__section-item dd {
margin: 0;
font-size: 0.9rem;
font-weight: 500;
}
.document-viewer__section-placeholder {
+34 -50
View File
@@ -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;
+1 -1
View File
@@ -18,7 +18,7 @@ class TagManager {
this.colorGenerator = colorGenerator;
}
normalizeLabel(label: string | null | undefined): string {
normalizeLabel(label?: string | null): string {
return label?.trim?.() || '';
}
+1 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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;
+6 -6
View File
@@ -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,
+3 -3
View File
@@ -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;
}