refactor: introduce dedicated identifier types for improved clarity and type safety

This commit is contained in:
2025-11-24 23:47:36 +01:00
parent 22f0c040a2
commit 70e7bda87e
79 changed files with 1567 additions and 1572 deletions
+1 -2
View File
@@ -11,8 +11,7 @@ import { DocumentsHeaderBreadcrumb } from '../documents/panel/DocumentsPanelHead
import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext';
import { PanelManagerProvider, usePanelManager } from './PanelManagerContext';
import Sidebar from '../sidebar/Sidebar';
type Identifier = string | number;
import type { Identifier } from '../types/identifiers';
type EnsureAssetUrl = (
docId: Identifier,
+10 -10
View File
@@ -28,7 +28,7 @@ interface StatusMessage {
}
interface TenantOption {
id?: string | number | null;
id?: string | null;
name?: string | null;
}
@@ -321,7 +321,7 @@ const LoginRoute: React.FC = () => {
const payload: {
magic_token: string;
username?: string;
preferred_tenant_id?: string | number;
preferred_tenant_id?: string;
} = {
magic_token: magicToken,
};
@@ -520,14 +520,14 @@ const LoginRoute: React.FC = () => {
onCancelSelection={handleCancelSelection}
selectingTenantId={selectingTenantId}
onPasskeyLogin={handlePasskeyLogin}
passkeySupported={passkeySupported}
passkeyLoading={passkeyLoading}
onSignup={handleSignup}
signupSupported={signupSupported}
signupLoading={signupLoading}
magicLoginPending={magicLoginPending}
initialUsername={magicLoginParams.username || ''}
/>
passkeySupported={passkeySupported}
passkeyLoading={passkeyLoading}
onSignup={handleSignup}
signupSupported={signupSupported}
signupLoading={signupLoading}
magicLoginPending={magicLoginPending}
initialUsername={magicLoginParams.username || ''}
/>
</div>
);
};
+4 -4
View File
@@ -15,12 +15,12 @@ import PanelHeader from '../ui/PanelHeader';
type UploadStatus = 'pending' | 'uploading' | 'success' | 'duplicate' | 'error' | (string & {});
interface UploadQueueItem {
id: string | number;
id: string;
name: string;
status: UploadStatus;
error?: string | null;
document?: { id?: string | number; title?: string };
conflictDocumentId?: string | number;
document?: { id?: string; title?: string };
conflictDocumentId?: string;
}
interface UploadQueueOverlayProps {
@@ -191,7 +191,7 @@ const UploadQueueOverlay = ({ queue = [], onClearQueue }: UploadQueueOverlayProp
{item.error}
</span>
) : (
<span>{meta.label}</span>
<span>{meta.label}</span>
)}
</div>
</div>
+4 -2
View File
@@ -1,13 +1,15 @@
import type { DocumentId, FolderId } from '../types/identifiers';
// Entry key utilities for workspace selection
// Entry keys are strings in the format "document:id" or "folder:id"
const ENTRY_KEY_SEPARATOR = ':';
// Create entry key strings
export const createDocumentEntryKey = (documentId: string | number): string =>
export const createDocumentEntryKey = (documentId: DocumentId): string =>
`document${ENTRY_KEY_SEPARATOR}${documentId}`;
export const createFolderEntryKey = (folderId: string | number): string =>
export const createFolderEntryKey = (folderId: FolderId): string =>
`folder${ENTRY_KEY_SEPARATOR}${folderId}`;
// Type guards for entry key strings
+5 -5
View File
@@ -1,19 +1,19 @@
import { useCallback, useEffect, useRef, useState } from 'react';
export interface DetailDocument {
id?: string | number;
id?: string;
[key: string]: unknown;
}
interface UseDetailPanelOptions {
documentLookup: Map<string | number, DetailDocument>;
documentLookup: Map<string, DetailDocument>;
orderedSelectedDocuments: DetailDocument[];
}
interface OpenDetailPanelArgs {
documentId?: string | number;
documentId?: string;
document?: DetailDocument | null;
documentIds?: Array<string | number>;
documentIds?: Array<string>;
documents?: DetailDocument[];
}
@@ -22,7 +22,7 @@ export const useDetailPanel = ({
orderedSelectedDocuments,
}: UseDetailPanelOptions) => {
const [detailPanelOpen, setDetailPanelOpen] = useState(false);
const [detailPanelDocId, setDetailPanelDocId] = useState<string | number | null>(null);
const [detailPanelDocId, setDetailPanelDocId] = useState<string | null>(null);
const [detailPanelDocument, setDetailPanelDocument] = useState<DetailDocument | null>(null);
const latestOrderedDocsRef = useRef<DetailDocument[]>([]);
+2 -2
View File
@@ -5,8 +5,8 @@ import type {
SetStateAction,
} from 'react';
import { fetchDocument } from '../lib/apiClient';
import type { DocumentId } from '../types/identifiers';
type DocumentId = string | number;
type FolderId = DocumentId | 'root';
type DocumentLike = {
@@ -102,7 +102,7 @@ const useDocumentPreview = ({
async (documentId: DocumentId, { force = false }: { force?: boolean } = {}): Promise<DocumentLink | null> => {
if (!documentId) return null;
const existing = documentLinks.get(documentId);
const existing = documentLinks.get(documentId);
const now = Date.now();
const expiresAt = existing?.expiresAt ?? null;
if (!force && existing && (!expiresAt || expiresAt > now)) {
+6 -7
View File
@@ -6,8 +6,7 @@ import {
isFolderEntry,
getEntryId,
} from './entryKey';
type DocumentId = string | number;
import type { DocumentId } from '../types/identifiers';
interface SelectionEventLike {
shiftKey?: boolean;
@@ -21,7 +20,7 @@ interface UseDocumentSelectionOptions {
}
interface ApplySelectionOptions {
anchor?: string;
anchor: string | null;
interactedKeys?: string[];
}
@@ -35,8 +34,8 @@ export const useDocumentSelection = ({
const selectionOrderRef = useRef<string[]>(initialEntries);
const selectionAnchorRef = useRef<string | null>(null);
const selectionInitializedRef = useRef(false);
const [focusedDocumentId, setFocusedDocumentId] = useState<DocumentId | undefined>(undefined);
const [focusedRowKey, setFocusedRowKey] = useState<string | undefined>(undefined);
const [focusedDocumentId, setFocusedDocumentId] = useState<DocumentId | null>(null);
const [focusedRowKey, setFocusedRowKey] = useState<string | null>(null);
const visibleRowKeySetRef = useRef<Set<string>>(new Set());
const navigableRowKeysRef = useRef<string[]>([]);
@@ -90,7 +89,7 @@ export const useDocumentSelection = ({
const applySelection = useCallback(
(
rowKeys: Array<string | null>,
{ anchor, interactedKeys = [] }: ApplySelectionOptions = {},
{ anchor = null, interactedKeys = [] }: ApplySelectionOptions = { anchor: null, interactedKeys: [] },
) => {
const visibleRowKeySet = visibleRowKeySetRef.current;
const unique: string[] = [];
@@ -117,7 +116,7 @@ export const useDocumentSelection = ({
}
});
let resolvedAnchor = anchor;
let resolvedAnchor: string | null = anchor ?? null;
if (resolvedAnchor && !unique.includes(resolvedAnchor)) {
resolvedAnchor = null;
}
+1 -2
View File
@@ -2,8 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { TAG_FILTER_UNTAGGED } from './workspaceUtils';
import { listDocuments } from '../lib/apiClient';
type Identifier = string | number;
import type { Identifier } from '../types/identifiers';
type DocumentLike = { id?: Identifier } & Record<string, unknown>;
+2 -2
View File
@@ -9,13 +9,13 @@ const TAGS_MODAL = 'tags';
const CORRESPONDENTS_MODAL = 'correspondents';
interface TagRecord {
id?: string | number;
id?: string;
label?: string;
[key: string]: unknown;
}
interface CorrespondentRecord {
id?: string | number;
id?: string;
name?: string;
[key: string]: unknown;
}
+4 -4
View File
@@ -10,8 +10,8 @@ interface SelectionEntry {
}
interface WorkspaceSelectionOptions {
onDocumentActivate?: (id: string | number) => void;
onInspectFolder?: (id: string | number) => void;
onDocumentActivate?: (id: string) => void;
onInspectFolder?: (id: string) => void;
}
const identity = <T,>(value: T) => value;
@@ -71,7 +71,7 @@ export const useWorkspaceSelection = ({
);
const inspectDocument = useCallback(
(documentId?: string | number) => {
(documentId?: string) => {
if (!documentId) return;
onDocumentActivate(documentId);
},
@@ -79,7 +79,7 @@ export const useWorkspaceSelection = ({
);
const inspectFolder = useCallback(
(folderId?: string | number) => {
(folderId?: string) => {
if (!folderId) return;
onInspectFolder(folderId);
},
+1 -2
View File
@@ -5,8 +5,7 @@ import DocumentsPanel from '../documents/panel/DocumentsPanel';
import DocumentViewerPanel from '../preview/DocumentViewerPanel';
import { usePanelManager } from './PanelManagerContext';
import { FolderManagerProvider } from '../folders/FolderManagerContext';
type Identifier = string | number;
import type { Identifier } from '../types/identifiers';
type EnsureAssetUrl = (docId: Identifier, asset: unknown, options?: Record<string, unknown>) => Promise<unknown> | void;
type EnsurePreviewData = (docId: Identifier, options?: Record<string, unknown>) => Promise<unknown>;
+1 -1
View File
@@ -1,4 +1,4 @@
export type Identifier = string | number;
import type { Identifier } from './types/identifiers';
type Nullable<T> = T | null;
@@ -7,7 +7,7 @@ interface CorrespondentUsage {
}
export interface CorrespondentEntry {
id?: string | number;
id?: string;
name?: string;
usage?: CorrespondentUsage;
[key: string]: unknown;
@@ -17,8 +17,8 @@ export interface CorrespondentsPanelProps {
correspondents?: CorrespondentEntry[];
onRefresh?: () => void | Promise<void>;
onCreate: (payload: { name: string }) => Promise<CorrespondentEntry | void>;
onUpdate: (id: string | number, payload: { name: string }) => Promise<void>;
onDelete: (id: string | number) => Promise<void>;
onUpdate: (id: string, payload: { name: string }) => Promise<void>;
onDelete: (id: string) => Promise<void>;
onNotify?: (message: string, variant?: string) => void;
}
@@ -30,12 +30,12 @@ function CorrespondentsPanel({
onDelete,
onNotify,
}: CorrespondentsPanelProps) {
const [editingId, setEditingId] = useState<string | number | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [draftName, setDraftName] = useState('');
const [createName, setCreateName] = useState('');
const [saving, setSaving] = useState(false);
const [creating, setCreating] = useState(false);
const [deletingId, setDeletingId] = useState<string | number | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const startEdit = useCallback((correspondent: CorrespondentEntry) => {
setEditingId(correspondent.id);
+9 -8
View File
@@ -3,17 +3,18 @@ import DesktopPreviewCard from './DesktopPreviewCard';
import { resolveCorrespondents } from '../documents/correspondents';
import { getTagColorStyle } from '../utils/colors';
import { preventAll } from './events';
import type { DocumentId } from '../types/identifiers';
type DocumentLike = {
id?: string | number;
id?: string;
title?: string;
tags?: Array<{ id?: string | number; label?: string; color?: string | null }>;
tags?: Array<{ id?: string; label?: string; color?: string | null }>;
[key: string]: unknown;
};
interface PendingRemovalTag {
docId?: string | number;
tagId?: string | number;
docId?: string;
tagId?: string;
}
interface DesktopDocumentCardProps {
@@ -30,10 +31,10 @@ interface DesktopDocumentCardProps {
getDocumentAsset?: (...args: any[]) => unknown;
handleNavigatorSnapshot?: (...args: any[]) => void;
cardPointerHandlers?: React.HTMLAttributes<HTMLDivElement>;
onDocumentActivate?: (id: string | number) => void;
onTagDragEnter?: (event: React.DragEvent<HTMLDivElement>, docId: string | number) => void;
onTagDragOver?: (event: React.DragEvent<HTMLDivElement>, docId: string | number) => void;
onTagDragLeave?: (event: React.DragEvent<HTMLDivElement>, docId: string | number) => void;
onDocumentActivate?: (id: string) => void;
onTagDragEnter?: (event: React.DragEvent<HTMLDivElement>, docId: DocumentId) => void;
onTagDragOver?: (event: React.DragEvent<HTMLDivElement>, docId: DocumentId) => void;
onTagDragLeave?: (event: React.DragEvent<HTMLDivElement>, docId: DocumentId) => void;
onTagDrop?: (event: React.DragEvent<HTMLDivElement>, doc: DocumentLike) => void;
onDocTagPointerDown?: (event: React.PointerEvent<HTMLElement>, doc: DocumentLike, tag: any) => void;
onDocTagDragStart?: (event: React.DragEvent<HTMLElement>, doc: DocumentLike, tag: any) => void;
+2 -3
View File
@@ -1,8 +1,7 @@
import { useEffect } from 'react';
import type { JSX } from 'react';
import { useAssetNavigator } from '../hooks/useAssetNavigator';
type Identifier = string | number;
import type { Identifier } from '../types/identifiers';
interface DocumentLike {
id?: Identifier;
@@ -21,7 +20,7 @@ interface AssetLike {
type EnsureAssetUrl = (
documentId: Identifier,
asset: AssetLike,
options?: { force?: boolean; [key: string]: unknown },
options?: { force?: boolean;[key: string]: unknown },
) => Promise<unknown>;
type GetDocumentAsset = (document: DocumentLike | null, assetType: string) => AssetLike | null;
+7 -8
View File
@@ -29,8 +29,7 @@ import '../styles/workspace/workspace-layout.css';
import '../styles/workspace/workspace-items.css';
import '../styles/workspace/workspace-cards.css';
import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext';
type Identifier = string | number;
import type { DocumentId, Identifier } from '../types/identifiers';
type TagLike = { id?: Identifier | null; label?: string; color?: string | null } | null;
type DocumentLinkLike = { url?: string | null; mimeType?: string | null };
@@ -79,7 +78,7 @@ interface DocumentSizeInfo {
}
interface PreviewMetadataEntry {
docId: string;
docId: DocumentId;
width: number;
height: number;
}
@@ -225,7 +224,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
const syntheticEvent = event || ({
metaKey: true,
ctrlKey: true,
preventDefault: () => {},
preventDefault: () => { },
} as unknown as PointerEvent);
docIds.forEach((id) => {
const key = getDocRowKey(id);
@@ -354,7 +353,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
engine.recalcVisibleDocIds();
}, [engine]);
const setDraggingId = useCallback((value: string | number | null) => {
const setDraggingId = useCallback((value: string | null) => {
engine.setDraggingId(value);
}, [engine]);
@@ -686,7 +685,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
},
[resolvePreviewDimensions],
);
useEffect(() => {
if (draggingId && !items.some((doc) => String(doc.id) === draggingId)) {
@@ -1072,8 +1071,8 @@ function DesktopWorkspaceView({
const dragging = docKey ? draggingId === docKey : false;
const docTagKeys = Array.isArray(doc?.tags)
? doc.tags
.map((tag) => (tag?.id != null ? String(tag.id) : null))
.filter((id): id is string => Boolean(id))
.map((tag) => (tag?.id != null ? String(tag.id) : null))
.filter((id): id is string => Boolean(id))
: [];
const matchesFilter =
activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key));
+30 -6
View File
@@ -1,3 +1,6 @@
import type { DocumentId } from '../types/identifiers';
type TenantId = import('../types/identifiers').TenantId;
const DB_NAME = 'papercrate_desk';
const DB_VERSION = 1;
const LAYOUT_STORE = 'layouts';
@@ -107,9 +110,9 @@ const withStore = async <T>(mode: TransactionMode, handler: (store: IDBObjectSto
};
interface LayoutRecord {
tenantId: string | number;
viewId: string | number;
documentId: string | number;
tenantId: TenantId;
viewId: string;
documentId: DocumentId;
centerX?: number;
centerY?: number;
rotation?: number;
@@ -117,7 +120,13 @@ interface LayoutRecord {
updatedAt?: number;
}
export const fetchLayoutRecords = async ({ tenantId, viewId }: { tenantId?: string | number; viewId?: string | number }): Promise<LayoutRecord[]> => {
export const fetchLayoutRecords = async ({
tenantId,
viewId,
}: {
tenantId?: TenantId;
viewId?: string;
}): Promise<LayoutRecord[]> => {
if (!tenantId || !viewId) {
return [];
}
@@ -133,7 +142,22 @@ export const fetchLayoutRecords = async ({ tenantId, viewId }: { tenantId?: stri
}
};
export const upsertLayoutRecords = async ({ tenantId, viewId, entries }: { tenantId?: string | number; viewId?: string | number; entries?: Array<{ documentId?: string | number; centerX?: number; centerY?: number; rotation?: number; zIndex?: number; updatedAt?: number }> }) => {
export const upsertLayoutRecords = async ({
tenantId,
viewId,
entries,
}: {
tenantId?: TenantId;
viewId?: string;
entries?: Array<{
documentId?: DocumentId;
centerX?: number;
centerY?: number;
rotation?: number;
zIndex?: number;
updatedAt?: number;
}>;
}) => {
if (!tenantId || !viewId || !Array.isArray(entries) || !entries.length) {
return;
}
@@ -162,7 +186,7 @@ export const upsertLayoutRecords = async ({ tenantId, viewId, entries }: { tenan
}
};
export const deleteTenantLayouts = async (tenantId?: string | number) => {
export const deleteTenantLayouts = async (tenantId?: TenantId) => {
if (!tenantId) {
return;
}
@@ -1,23 +1,24 @@
import { useEffect, useState } from 'react';
import type { DocumentId } from '../../types/identifiers';
interface DocumentLike {
id?: string | number;
id?: string;
current_version?: unknown;
tags?: unknown;
}
interface AssetLike {
id?: string | number;
id?: string;
[key: string]: unknown;
}
interface PreviewMetadataEntry {
docId: string;
docId: DocumentId;
width: number;
height: number;
}
type GetDocumentAsset = (doc: DocumentLike, type: string) => AssetLike | null;
type EnsureAssetUrl = (docId: string | number, asset: AssetLike, options?: { force?: boolean }) => Promise<AssetLike | null>;
type EnsureAssetUrl = (docId: DocumentId, asset: AssetLike, options?: { force?: boolean }) => Promise<AssetLike | null>;
const usePreviewMetadata = (
documents: DocumentLike[] | null,
+4 -3
View File
@@ -1,4 +1,5 @@
import { safeInvoke } from '../events';
import type { DocumentId } from '../../types/identifiers';
export const CLICK_ACTIONS = {
selectSingle: 'selectSingle',
@@ -25,9 +26,9 @@ export const LONG_PRESS_DURATION_MS = 450;
export const withinThreshold = (dx: number, dy: number, thresholdSquared: number): boolean => (dx * dx + dy * dy) <= thresholdSquared;
interface PointerIntentArgs {
doc: { id: string | number };
doc: { id: string };
entryDescriptor: unknown;
selectedDocumentIds: Array<string | number>;
selectedDocumentIds: Array<string>;
metaKey: boolean;
pointerButton?: number;
pointerType?: string;
@@ -35,7 +36,7 @@ interface PointerIntentArgs {
}
export interface PointerIntent {
docId: string | number;
docId: DocumentId;
entryDescriptor: unknown;
pointerType?: string;
pointerButton?: number;
+178 -179
View File
@@ -19,8 +19,7 @@ import {
applyDomTransform,
type WorkspaceEngine,
} from './workspaceEngine';
type Identifier = string | number;
import type { DocumentId, Identifier } from '../types/identifiers';
interface DocumentLike {
id?: Identifier | null;
@@ -121,7 +120,7 @@ interface UseDocumentDragOptions {
type EngineInertiaState = Parameters<WorkspaceEngine['startInertiaAnimation']>[1];
interface DragStateInternal extends EngineDragState {
docId: string;
docId: DocumentId;
docKey: string;
pointerId: number;
originCenterX: number;
@@ -208,15 +207,15 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
bringToFront,
setDraggingId,
canvasSize,
openOverlayForDoc,
recalcVisibleDocIds,
settings,
containerRef: providedContainerRef,
onDocumentActivate,
onDocumentStackSelect,
selectedDocumentIds = [],
markLayoutDirty,
} = options;
openOverlayForDoc,
recalcVisibleDocIds,
settings,
containerRef: providedContainerRef,
onDocumentActivate,
onDocumentStackSelect,
selectedDocumentIds = [],
markLayoutDirty,
} = options;
const fallbackContainerRef = useRef<HTMLElement | null>(null);
const containerRef = providedContainerRef ?? fallbackContainerRef;
@@ -237,7 +236,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
const tapHandler = usePointerTap<DragTapMetadata>({
delay: 220,
onSingle: () => {},
onSingle: () => { },
onDouble: ({ data, event }) => {
if (!data?.docId) {
return;
@@ -281,8 +280,8 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
}
const keys = Array.isArray(docIds) && docIds.length
? docIds
.map((id) => (id != null ? String(id) : null))
.filter((value): value is string => Boolean(value))
.map((id) => (id != null ? String(id) : null))
.filter((value): value is string => Boolean(value))
: Array.from(map.keys());
keys.forEach((key) => {
const transform = map.get(key);
@@ -350,8 +349,8 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
const stackDocIdsOptionRaw = options?.stackDocIds;
const stackDocIdsOption = Array.isArray(stackDocIdsOptionRaw)
? stackDocIdsOptionRaw
.map((value) => (value != null ? String(value) : null))
.filter((value): value is string => Boolean(value))
.map((value) => (value != null ? String(value) : null))
.filter((value): value is string => Boolean(value))
: null;
const stackSelectionAppliedInitial = Boolean(options?.stackSelectionApplied);
const wasSelectedAtPointerDown = Boolean(options?.wasSelected);
@@ -361,8 +360,8 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
let selectionIds: string[] = Array.isArray(selectedDocumentIds)
? selectedDocumentIds
.map((id) => (id != null ? String(id) : null))
.filter((id): id is string => Boolean(id))
.map((id) => (id != null ? String(id) : null))
.filter((id): id is string => Boolean(id))
: [];
if (!stackDocIdsOption && !pointerModifierActive && !wasSelectedAtPointerDown) {
@@ -399,7 +398,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
if (isGroupDrag) {
selectionIds.forEach((id) => {
if (id !== docKey) {
engine?.cancelInertiaAnimation?.(id);
engine?.cancelInertiaAnimation?.(id);
}
});
}
@@ -491,7 +490,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
offsetY: baseOffsetY,
targetRotation: initialRotation,
displayRotation: initialRotation,
} satisfies DragGroupItemInternal;
});
@@ -569,28 +568,28 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
setDraggingId(docKey);
if (isGroupDrag) {
groupItems.forEach((item) => {
if (item.docId === docKey) {
return;
}
const node = itemRefs.current.get(item.docId);
if (node) {
item.displayRotation = item.initialRotation;
const itemEntry = layoutRef.current.get(item.docId) || null;
applyDomTransform(node, {
centerX: item.currentCenterX,
centerY: item.currentCenterY,
width: item.width,
height: item.height,
rotation: item.displayRotation ?? 0,
scale: 1,
zIndex: itemEntry?.z,
});
}
});
}
}, [
if (isGroupDrag) {
groupItems.forEach((item) => {
if (item.docId === docKey) {
return;
}
const node = itemRefs.current.get(item.docId);
if (node) {
item.displayRotation = item.initialRotation;
const itemEntry = layoutRef.current.get(item.docId) || null;
applyDomTransform(node, {
centerX: item.currentCenterX,
centerY: item.currentCenterY,
width: item.width,
height: item.height,
rotation: item.displayRotation ?? 0,
scale: 1,
zIndex: itemEntry?.z,
});
}
});
}
}, [
bringToFront,
canvasPadding,
containerRef,
@@ -674,147 +673,147 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
state.rotation = state.restRotation + state.dynamicRotation;
};
if (state.isGroup) {
const containerRect = containerRef.current?.getBoundingClientRect?.();
if (containerRect) {
state.containerRectLeft = containerRect.left;
state.containerRectTop = containerRect.top;
}
const pointerCanvasX = event.clientX - state.containerRectLeft;
const pointerCanvasY = event.clientY - state.containerRectTop;
const deltaX = event.clientX - state.startX;
const deltaY = event.clientY - state.startY;
if (!state.moved) {
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
if (distanceSquared < DRAG_HYSTERESIS_SQUARED) {
return;
if (state.isGroup) {
const containerRect = containerRef.current?.getBoundingClientRect?.();
if (containerRect) {
state.containerRectLeft = containerRect.left;
state.containerRectTop = containerRect.top;
}
state.moved = true;
if (
!state.stackSelectionApplied
&& Array.isArray(state.stackDocIds)
&& state.stackDocIds.length > 0
) {
safeInvoke(onDocumentStackSelect, state.stackDocIds as Identifier[], event, {
replace: state.stackReplace,
});
state.stackSelectionApplied = true;
}
if (!state.groupElevated) {
const layout = layoutRef.current;
const sortedGroup = state.activeDocIds
.filter((id) => id !== state.docKey)
.sort((a, b) => {
const aZ = layout.get(a)?.z ?? 0;
const bZ = layout.get(b)?.z ?? 0;
return aZ - bZ;
const pointerCanvasX = event.clientX - state.containerRectLeft;
const pointerCanvasY = event.clientY - state.containerRectTop;
const deltaX = event.clientX - state.startX;
const deltaY = event.clientY - state.startY;
if (!state.moved) {
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
if (distanceSquared < DRAG_HYSTERESIS_SQUARED) {
return;
}
state.moved = true;
if (
!state.stackSelectionApplied
&& Array.isArray(state.stackDocIds)
&& state.stackDocIds.length > 0
) {
safeInvoke(onDocumentStackSelect, state.stackDocIds as Identifier[], event, {
replace: state.stackReplace,
});
state.stackSelectionApplied = true;
}
if (!state.groupElevated) {
const layout = layoutRef.current;
const sortedGroup = state.activeDocIds
.filter((id) => id !== state.docKey)
.sort((a, b) => {
const aZ = layout.get(a)?.z ?? 0;
const bZ = layout.get(b)?.z ?? 0;
return aZ - bZ;
});
sortedGroup.forEach((id) => bringToFront(id));
bringToFront(state.docKey);
state.groupElevated = true;
sortedGroup.forEach((id) => bringToFront(id));
bringToFront(state.docKey);
state.groupElevated = true;
}
}
const docWidth = state.width;
const docHeight = state.height;
const halfWidth = docWidth / 2;
const halfHeight = docHeight / 2;
const canvasWidth = canvasSize.width || defaultCanvasWidth;
const canvasHeight = canvasSize.height || defaultCanvasHeight;
const minCenterX = canvasPadding + halfWidth;
const maxCenterX = Math.max(minCenterX, canvasWidth - canvasPadding - halfWidth);
const minCenterY = canvasPadding + halfHeight;
const maxCenterY = Math.max(minCenterY, canvasHeight - canvasPadding - halfHeight);
const desiredCenterX = pointerCanvasX - state.localPointerOffsetX;
const desiredCenterY = pointerCanvasY - state.localPointerOffsetY;
const centerX = clamp(desiredCenterX, minCenterX, maxCenterX);
const centerY = clamp(desiredCenterY, minCenterY, maxCenterY);
state.currentCenterX = centerX;
state.currentCenterY = centerY;
state.groupItems.forEach((item) => {
const isPrimary = item.docId === state.docKey;
if (isPrimary) {
item.currentCenterX = centerX;
item.currentCenterY = centerY;
item.offsetX = item.baseOffsetX ?? 0;
item.offsetY = item.baseOffsetY ?? 0;
item.displayRotation = state.rotation ?? item.displayRotation ?? 0;
} else {
const decay = 0.82;
const currentOffsetX = (item.offsetX ?? item.baseOffsetX ?? 0) * decay;
const currentOffsetY = (item.offsetY ?? item.baseOffsetY ?? 0) * decay;
item.offsetX = Math.abs(currentOffsetX) < 0.5 ? 0 : currentOffsetX;
item.offsetY = Math.abs(currentOffsetY) < 0.5 ? 0 : currentOffsetY;
const targetX = centerX + item.offsetX;
const targetY = centerY + item.offsetY;
const smoothing = 0.18;
item.currentCenterX += (targetX - item.currentCenterX) * smoothing;
item.currentCenterY += (targetY - item.currentCenterY) * smoothing;
const halfW = item.width / 2;
const halfH = item.height / 2;
const minX = canvasPadding + halfW;
const maxX = Math.max(minX, canvasWidth - canvasPadding - halfW);
const minY = canvasPadding + halfH;
const maxY = Math.max(minY, canvasHeight - canvasPadding - halfH);
item.currentCenterX = clamp(item.currentCenterX, minX, maxX);
item.currentCenterY = clamp(item.currentCenterY, minY, maxY);
const rotationBlend = 0.16;
item.displayRotation += (item.targetRotation - item.displayRotation) * rotationBlend;
}
const entry = layoutRef.current.get(item.docId) || null;
const payload = {
centerX: item.currentCenterX,
centerY: item.currentCenterY,
rotation: item.displayRotation ?? 0,
width: item.width,
height: item.height,
scale: isPrimary ? state.dragScale || 1 : 1,
zIndex: entry?.z,
};
setDragTransform(item.docId, payload);
const node = itemRefs.current.get(item.docId);
applyDomTransform(node, payload);
});
const currentTimestampGroup =
(Number.isFinite(event?.timeStamp))
? event.timeStamp
: performance?.now
? performance.now()
: Date.now();
const previousTimestampGroup = state.lastTimestamp ?? currentTimestampGroup;
let dtGroup = (currentTimestampGroup - previousTimestampGroup) / 1000;
if (!Number.isFinite(dtGroup) || dtGroup <= 0) {
dtGroup = MIN_TIMESTEP;
}
dtGroup = clamp(dtGroup, MIN_TIMESTEP, MAX_TIMESTEP);
state.lastClientX = event.clientX;
state.lastClientY = event.clientY;
state.lastTimestamp = currentTimestampGroup;
updatePointerAngularVelocity(pointerCanvasX, pointerCanvasY, centerX, centerY, dtGroup);
applyDynamicRotation(dtGroup, 0.96);
state.groupItems.forEach((item) => {
if (item.docId === state.docKey) {
item.displayRotation = state.rotation ?? item.displayRotation ?? 0;
}
});
return;
}
const docWidth = state.width;
const docHeight = state.height;
const halfWidth = docWidth / 2;
const halfHeight = docHeight / 2;
const canvasWidth = canvasSize.width || defaultCanvasWidth;
const canvasHeight = canvasSize.height || defaultCanvasHeight;
const minCenterX = canvasPadding + halfWidth;
const maxCenterX = Math.max(minCenterX, canvasWidth - canvasPadding - halfWidth);
const minCenterY = canvasPadding + halfHeight;
const maxCenterY = Math.max(minCenterY, canvasHeight - canvasPadding - halfHeight);
const desiredCenterX = pointerCanvasX - state.localPointerOffsetX;
const desiredCenterY = pointerCanvasY - state.localPointerOffsetY;
const centerX = clamp(desiredCenterX, minCenterX, maxCenterX);
const centerY = clamp(desiredCenterY, minCenterY, maxCenterY);
state.currentCenterX = centerX;
state.currentCenterY = centerY;
state.groupItems.forEach((item) => {
const isPrimary = item.docId === state.docKey;
if (isPrimary) {
item.currentCenterX = centerX;
item.currentCenterY = centerY;
item.offsetX = item.baseOffsetX ?? 0;
item.offsetY = item.baseOffsetY ?? 0;
item.displayRotation = state.rotation ?? item.displayRotation ?? 0;
} else {
const decay = 0.82;
const currentOffsetX = (item.offsetX ?? item.baseOffsetX ?? 0) * decay;
const currentOffsetY = (item.offsetY ?? item.baseOffsetY ?? 0) * decay;
item.offsetX = Math.abs(currentOffsetX) < 0.5 ? 0 : currentOffsetX;
item.offsetY = Math.abs(currentOffsetY) < 0.5 ? 0 : currentOffsetY;
const targetX = centerX + item.offsetX;
const targetY = centerY + item.offsetY;
const smoothing = 0.18;
item.currentCenterX += (targetX - item.currentCenterX) * smoothing;
item.currentCenterY += (targetY - item.currentCenterY) * smoothing;
const halfW = item.width / 2;
const halfH = item.height / 2;
const minX = canvasPadding + halfW;
const maxX = Math.max(minX, canvasWidth - canvasPadding - halfW);
const minY = canvasPadding + halfH;
const maxY = Math.max(minY, canvasHeight - canvasPadding - halfH);
item.currentCenterX = clamp(item.currentCenterX, minX, maxX);
item.currentCenterY = clamp(item.currentCenterY, minY, maxY);
const rotationBlend = 0.16;
item.displayRotation += (item.targetRotation - item.displayRotation) * rotationBlend;
}
const entry = layoutRef.current.get(item.docId) || null;
const payload = {
centerX: item.currentCenterX,
centerY: item.currentCenterY,
rotation: item.displayRotation ?? 0,
width: item.width,
height: item.height,
scale: isPrimary ? state.dragScale || 1 : 1,
zIndex: entry?.z,
};
setDragTransform(item.docId, payload);
const node = itemRefs.current.get(item.docId);
applyDomTransform(node, payload);
});
const currentTimestampGroup =
(Number.isFinite(event?.timeStamp))
? event.timeStamp
: performance?.now
? performance.now()
: Date.now();
const previousTimestampGroup = state.lastTimestamp ?? currentTimestampGroup;
let dtGroup = (currentTimestampGroup - previousTimestampGroup) / 1000;
if (!Number.isFinite(dtGroup) || dtGroup <= 0) {
dtGroup = MIN_TIMESTEP;
}
dtGroup = clamp(dtGroup, MIN_TIMESTEP, MAX_TIMESTEP);
state.lastClientX = event.clientX;
state.lastClientY = event.clientY;
state.lastTimestamp = currentTimestampGroup;
updatePointerAngularVelocity(pointerCanvasX, pointerCanvasY, centerX, centerY, dtGroup);
applyDynamicRotation(dtGroup, 0.96);
state.groupItems.forEach((item) => {
if (item.docId === state.docKey) {
item.displayRotation = state.rotation ?? item.displayRotation ?? 0;
}
});
return;
}
if (state.locked) {
return;
}
+18 -47
View File
@@ -1,7 +1,7 @@
import { clamp, formatTransform } from '../utils/math';
import { fetchLayoutRecords, upsertLayoutRecords } from './db';
type DocumentId = string;
import type { DocumentId } from '../types/identifiers';
type TenantId = import('../types/identifiers').TenantId;
interface Point {
x: number;
@@ -63,7 +63,7 @@ interface BaseMetrics {
}
interface DragGroupItem {
docId?: string | number | null;
docId?: string | null;
width: number;
height: number;
currentCenterX?: number;
@@ -80,7 +80,7 @@ interface DragState {
}
interface InertiaSimulationState {
docId: string;
docId: DocumentId;
restRotation: number;
rotation: number;
dynamicRotation: number;
@@ -106,7 +106,7 @@ interface WorkspaceSnapshot {
type WorkspaceSubscriber = () => void;
type DeskDocument = { id?: string | number | null } & Record<string, unknown>;
type DeskDocument = { id?: string | null } & Record<string, unknown>;
type EnsureDocumentSize = (doc: DeskDocument) => DocumentSize | null;
@@ -226,7 +226,7 @@ export const clampCardDimensions = (width: number, height: number): CardDimensio
};
};
export const computeFallbackCardSize = (docId: string | number): CardDimensions | null => {
export const computeFallbackCardSize = (docId: DocumentId): CardDimensions | null => {
const baseSeed = seededRandom(`${docId}:fallback-size`);
const aspectSeed = seededRandom(`${docId}:fallback-aspect`);
@@ -259,7 +259,7 @@ function randomRangeFromSeed(seedKey: string, min: number, max: number): number
return min + seed * span;
}
function buildKey(docId: string | number, suffix: string): string {
function buildKey(docId: DocumentId, suffix: string): string {
return `${docId}::${suffix}`;
}
@@ -525,63 +525,34 @@ const generateInitialLayout = (
export class WorkspaceEngine {
allowLayoutPersistence: boolean;
tenantId: string | null;
tenantId: TenantId | null;
viewId: string | null;
layout: Map<DocumentId, LayoutEntry>;
layoutSnapshot: Map<DocumentId, LayoutEntry>;
persistedLayout: Map<DocumentId, LayoutEntry>;
layoutDirty: boolean;
zCounter: number;
canvasSize: { width: number; height: number };
visibleDocIds: Set<DocumentId>;
draggingId: string | null;
tagDropTargetId: string | null;
pendingTagDocId: string | null;
pendingRemovalTag: unknown;
dragInProgress: boolean;
activeDragDocIds: Set<DocumentId>;
pendingSnapshotSync: boolean;
pendingPersistSync: boolean;
persistDebounceId: number | null;
items: DeskDocument[];
documentLookup: Map<string, DeskDocument>;
ensureDocumentSize: EnsureDocumentSize;
resolveBaseMetrics: ResolveBaseMetrics;
snapshotCache: WorkspaceSnapshot;
subscribers: Set<WorkspaceSubscriber>;
loadingPersisted: boolean;
pendingPersistence: unknown;
itemRefs: ItemRefs;
inertiaAnimations: Map<string, InertiaSimulationState>;
initialLoadDone: boolean;
constructor({
@@ -716,7 +687,7 @@ export class WorkspaceEngine {
this.emit();
}
setDraggingId(docId: string | number | null): void {
setDraggingId(docId: DocumentId | null): void {
const normalized = docId != null ? String(docId) : null;
if (this.draggingId === normalized) {
return;
@@ -725,7 +696,7 @@ export class WorkspaceEngine {
this.emit();
}
beginDrag(docIds: Array<string | number | null> = []): void {
beginDrag(docIds: Array<string | null> = []): void {
this.dragInProgress = true;
if (Array.isArray(docIds)) {
this.activeDragDocIds = new Set(docIds.map((id) => (id != null ? String(id) : null)).filter(Boolean));
@@ -749,7 +720,7 @@ export class WorkspaceEngine {
}
}
setTagDropTargetId(docId: string | number | null): void {
setTagDropTargetId(docId: DocumentId | null): void {
const normalized = docId != null ? String(docId) : null;
if (this.tagDropTargetId === normalized) {
return;
@@ -758,7 +729,7 @@ export class WorkspaceEngine {
this.emit();
}
setPendingTagDocId(docId: string | number | null): void {
setPendingTagDocId(docId: DocumentId | null): void {
const normalized = docId != null ? String(docId) : null;
if (this.pendingTagDocId === normalized) {
return;
@@ -779,7 +750,7 @@ export class WorkspaceEngine {
this.layoutDirty = true;
}
getLayout(docId: string | number | null): LayoutEntry | null {
getLayout(docId: DocumentId | null): LayoutEntry | null {
if (docId == null) {
return null;
}
@@ -788,7 +759,7 @@ export class WorkspaceEngine {
}
updateLayoutEntry(
docId: string | number | null,
docId: DocumentId | null,
updater: (previous: LayoutEntry | null) => LayoutEntry | null,
): void {
if (docId == null) {
@@ -807,7 +778,7 @@ export class WorkspaceEngine {
this.persistLayoutSnapshot();
}
bringToFront(docId: string | number | null): void {
bringToFront(docId: DocumentId | null): void {
if (docId == null) {
return;
}
@@ -825,7 +796,7 @@ export class WorkspaceEngine {
}
applyTransform(
docId: string | number | null,
docId: DocumentId | null,
centerX: number,
centerY: number,
width: number,
@@ -896,7 +867,7 @@ export class WorkspaceEngine {
this.persistLayoutSnapshot();
}
cancelInertiaAnimation(docId: string | number | null): void {
cancelInertiaAnimation(docId: DocumentId | null): void {
const key = docId != null ? String(docId) : null;
if (!key) {
return;
@@ -995,7 +966,7 @@ export class WorkspaceEngine {
return isSettled;
}
startInertiaAnimation(docId: string | number | null, baseState: InertiaSimulationState): void {
startInertiaAnimation(docId: DocumentId | null, baseState: InertiaSimulationState): void {
const raf = window.requestAnimationFrame;
if (!raf) {
return;
+33 -33
View File
@@ -4,7 +4,7 @@ import { clamp } from '../utils/math';
import PdfViewer from '../preview/PdfViewer';
type DocumentLike = {
id?: string | number;
id?: string;
title?: string;
mime_type?: string | null;
[key: string]: unknown;
@@ -43,7 +43,7 @@ const determineDisplayKind = (entry?: DocumentLink | null): DisplayKind => {
return 'image';
};
const noop = () => {};
const noop = () => { };
const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
open = false,
@@ -214,22 +214,22 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
const key = event.key;
if (key === ' ' || key === 'Space' || key === 'Spacebar') {
const target = event.target;
if (target instanceof HTMLElement) {
const tag = target.tagName ? target.tagName.toLowerCase() : '';
if (
target.isContentEditable
|| tag === 'input'
|| tag === 'textarea'
|| tag === 'select'
) {
return;
const target = event.target;
if (target instanceof HTMLElement) {
const tag = target.tagName ? target.tagName.toLowerCase() : '';
if (
target.isContentEditable
|| tag === 'input'
|| tag === 'textarea'
|| tag === 'select'
) {
return;
}
}
event.preventDefault();
onClose();
return;
}
event.preventDefault();
onClose();
return;
}
if (key === 'Escape') {
event.preventDefault();
@@ -291,19 +291,19 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
const contentStyle: CSSProperties = isNativeScale
? {
cursor: 'zoom-out',
width: naturalSize.width ? `${naturalSize.width}px` : 'auto',
height: naturalSize.height ? `${naturalSize.height}px` : 'auto',
maxWidth: 'none',
maxHeight: 'none',
touchAction: 'manipulation',
}
cursor: 'zoom-out',
width: naturalSize.width ? `${naturalSize.width}px` : 'auto',
height: naturalSize.height ? `${naturalSize.height}px` : 'auto',
maxWidth: 'none',
maxHeight: 'none',
touchAction: 'manipulation',
}
: {
cursor: 'zoom-in',
maxWidth: '95vw',
maxHeight: '95vh',
touchAction: 'manipulation',
};
cursor: 'zoom-in',
maxWidth: '95vw',
maxHeight: '95vh',
touchAction: 'manipulation',
};
if (!shouldRender) {
return null;
@@ -323,11 +323,11 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
>
<div
className={stageClassName}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<div
className={containerClassName}
+1 -2
View File
@@ -6,8 +6,7 @@ import { DEFAULT_FOLDER_NAME } from '../app/workspaceUtils';
import { getEntryId, isDocumentEntry } from '../app/entryKey';
import type { DocumentInfoPanelProps } from '../documents/DocumentInfoPanel';
import type { EnsureAssetUrl, GetDocumentAsset } from '../utils/ocr';
type Identifier = string | number;
import type { Identifier } from '../types/identifiers';
interface DocumentLike {
id?: Identifier;
@@ -3,15 +3,15 @@ import React from 'react';
const NBSP = String.fromCharCode(160);
export interface CorrespondentLinkEntry {
id?: string | number | null;
id?: string | null;
name?: string | null;
key?: string;
}
interface CorrespondentLinksProps {
correspondents?: CorrespondentLinkEntry[];
activeCorrespondentIdSet?: Set<string | number>;
onCorrespondentClick?: (id: string | number) => void;
activeCorrespondentIdSet?: Set<string>;
onCorrespondentClick?: (id: string) => void;
}
const CorrespondentLinks: React.FC<CorrespondentLinksProps> = ({
@@ -23,7 +23,7 @@ const CorrespondentLinks: React.FC<CorrespondentLinksProps> = ({
return null;
}
const activeSet = activeCorrespondentIdSet || new Set<string | number>();
const activeSet = activeCorrespondentIdSet || new Set<string>();
const handleClick = (event: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>, correspondent: CorrespondentLinkEntry) => {
if (!onCorrespondentClick || correspondent.id == null) {
return;
+6 -6
View File
@@ -36,7 +36,7 @@ export interface DocumentInfoPanelProps {
activeTab?: string;
onTabChange?: (tabId: string) => void;
defaultTabId?: string;
resetKey?: string | number | null;
resetKey?: string | null;
classNamePrefix?: string;
hideTabNavWhenSingle?: boolean;
summaryPlacement?: 'inline' | 'tabs';
@@ -204,11 +204,11 @@ const DocumentInfoPanel: React.FC<DocumentInfoPanelProps> = ({
const summaryNode = summaryInline
? (
<>
{renderSummarySection()}
{renderDetailsSection()}
</>
)
<>
{renderSummarySection()}
{renderDetailsSection()}
</>
)
: null;
const visibleTabs = useMemo(() => {
@@ -14,11 +14,10 @@ import {
import { describeDocumentSummary, type DocumentSummaryRow } from './documentSummary';
import { useFolderManager } from '../folders/FolderManagerContext';
type Identifier = string | number;
import type { FolderId, Identifier, TagId } from '../types/identifiers';
interface TagEntry {
id?: Identifier;
id?: TagId;
label?: string;
color?: string | null;
}
@@ -33,7 +32,7 @@ interface DocumentLike {
id?: Identifier;
title?: string;
issued_at?: string | null;
folder_id?: string | null;
folder_id?: FolderId | null;
current_version?: { version_number?: number } | null;
tags?: TagEntry[];
correspondents?: CorrespondentEntry[];
@@ -64,17 +63,17 @@ interface CorrespondentSectionProps {
export interface DocumentSummarySectionProps {
document?: DocumentLike | null;
tagLookupById?: Map<Identifier, TagEntry>;
tagLookupById?: Map<TagId, TagEntry>;
tagOptions?: SelectionAssignmentMenuItem[];
onTagAdd?: (doc: DocumentLike, value: string, context?: { option?: unknown }) => void;
onTagRemove?: (docId: Identifier | undefined, tagId: Identifier | undefined) => void;
onTagRemove?: (docId: Identifier | undefined, tagId: TagId | undefined) => void;
correspondents?: CorrespondentEntry[];
correspondentOptions?: SelectionAssignmentMenuItem[];
onCorrespondentAdd?: (payload: { document: DocumentLike; name: string; option?: unknown }) => void;
onCorrespondentRemove?: (payload: { documentId: Identifier | undefined; correspondentId: Identifier | undefined }) => void;
onUpdateTitle?: (docId: Identifier | undefined, title: string) => Promise<boolean> | boolean;
onUpdateIssued?: (docId: Identifier | undefined, timestamp: number | null) => Promise<boolean> | boolean;
onFolderNavigate?: (folderId: string | null) => void;
onFolderNavigate?: (folderId: FolderId | null) => void;
layout?: 'default' | 'compact';
}
@@ -17,7 +17,7 @@ const DEFAULT_THUMBNAIL_SIZE = 48;
// Detect when an element becomes visible within a scroll container so we can delay loading.
const useLazyVisibility = (
rootRef: MutableRefObject<Element | null> | null,
resetKey?: string | number | null,
resetKey?: string | null,
) => {
const targetRef = useRef<HTMLDivElement | null>(null);
const [isVisible, setIsVisible] = useState(false);
+319 -320
View File
@@ -8,8 +8,7 @@ import { resolveCorrespondents } from './correspondents';
import { writeTagTransferData } from './tagTransfer';
import useInlineRename from './useInlineRename';
import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext';
export type Identifier = string | number;
import type { Identifier } from '../types/identifiers';
export interface FolderLike {
id?: Identifier | 'root';
@@ -153,347 +152,347 @@ const DocumentsGrid: React.FC<DocumentsGridProps> = ({
const totalSelectionCount = documentSelectionCount + folderSelectionCount;
return (
<div
className="documents-grid"
role="list"
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
onClick={(event) => {
if (event.target === event.currentTarget) {
<div
className="documents-grid"
role="list"
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
onClick={(event) => {
if (event.target === event.currentTarget) {
clearSelection();
}
}}
>
{entries.map((entry) => {
if (entry.type === 'folder') {
const folder = entry.folder;
if (!folder) {
return null;
}
const canDragFolder = folder.id !== 'root';
const isDraggingFolder = draggedFolderId === folder.id;
const isSelectedFolder = selectedFolderIdsSet?.has(folder.id);
const classes = ['document-card', 'folder-card'];
if (isDraggingFolder) classes.push('is-dragging');
if (isSelectedFolder) classes.push('selected');
const canRenameFolder = Boolean(onFolderRename) && folder.id !== 'root';
const isFolderEditing = editingFolderId === folder.id;
const folderDraftValue = isFolderEditing ? folderDraft : folder.name;
const trimmedFolderDraft = isFolderEditing ? folderDraft.trim() : '';
const isFolderSaving = savingFolderId === folder.id;
const canSubmitFolder =
isFolderEditing && trimmedFolderDraft.length > 0 && trimmedFolderDraft !== folder.name;
const allowInlineFolderEdit = canRenameFolder && isSelectedFolder && totalSelectionCount === 1;
}}
>
{entries.map((entry) => {
if (entry.type === 'folder') {
const folder = entry.folder;
if (!folder) {
return null;
}
const canDragFolder = folder.id !== 'root';
const isDraggingFolder = draggedFolderId === folder.id;
const isSelectedFolder = selectedFolderIdsSet?.has(folder.id);
const classes = ['document-card', 'folder-card'];
if (isDraggingFolder) classes.push('is-dragging');
if (isSelectedFolder) classes.push('selected');
const canRenameFolder = Boolean(onFolderRename) && folder.id !== 'root';
const isFolderEditing = editingFolderId === folder.id;
const folderDraftValue = isFolderEditing ? folderDraft : folder.name;
const trimmedFolderDraft = isFolderEditing ? folderDraft.trim() : '';
const isFolderSaving = savingFolderId === folder.id;
const canSubmitFolder =
isFolderEditing && trimmedFolderDraft.length > 0 && trimmedFolderDraft !== folder.name;
const allowInlineFolderEdit = canRenameFolder && isSelectedFolder && totalSelectionCount === 1;
return (
<div
key={entry.key}
className={classes.join(' ')}
role="listitem"
id={`folder-card-${folder.id}`}
draggable={canDragFolder}
onClick={(event) => onFolderClick?.(folder, event)}
onDoubleClick={(event) => {
event.preventDefault();
onFolderSelect?.(folder.id);
}}
onDragOver={(event) => onFolderDragOver?.(event, folder.id)}
onDragLeave={onFolderDragLeave}
onDrop={(event) => onFolderDrop?.(event, folder.id)}
onDragStart={(event) => {
if (canDragFolder) {
onFolderDragStart?.(event, folder.id);
}
}}
onDragEnd={(event) => {
if (canDragFolder) {
onFolderDragEnd?.(event);
}
}}
>
<div className="folder-card__icon">
<FolderIcon className="folder-card__icon-svg" size={gridIconSize} />
</div>
<div className="folder-card__meta">
{isFolderEditing ? (
<div className="folder-card__edit doc-title-edit">
<input
type="text"
ref={attachFolderInputRef}
value={folderDraftValue}
onChange={(event) => setFolderDraft(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
return (
<div
key={entry.key}
className={classes.join(' ')}
role="listitem"
id={`folder-card-${folder.id}`}
draggable={canDragFolder}
onClick={(event) => onFolderClick?.(folder, event)}
onDoubleClick={(event) => {
event.preventDefault();
onFolderSelect?.(folder.id);
}}
onDragOver={(event) => onFolderDragOver?.(event, folder.id)}
onDragLeave={onFolderDragLeave}
onDrop={(event) => onFolderDrop?.(event, folder.id)}
onDragStart={(event) => {
if (canDragFolder) {
onFolderDragStart?.(event, folder.id);
}
}}
onDragEnd={(event) => {
if (canDragFolder) {
onFolderDragEnd?.(event);
}
}}
>
<div className="folder-card__icon">
<FolderIcon className="folder-card__icon-svg" size={gridIconSize} />
</div>
<div className="folder-card__meta">
{isFolderEditing ? (
<div className="folder-card__edit doc-title-edit">
<input
type="text"
ref={attachFolderInputRef}
value={folderDraftValue}
onChange={(event) => setFolderDraft(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
submitFolderEditing(folder);
} else if (event.key === 'Escape') {
event.preventDefault();
cancelFolderEditing(event);
}
}}
onBlur={(event) => {
const nextFocus = event.relatedTarget;
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
cancelFolderEditing();
}
}}
/>
<button
type="button"
className="icon-button"
aria-label="Save name"
title="Save name"
disabled={!canSubmitFolder || isFolderSaving}
onClick={(event) => {
event.stopPropagation();
submitFolderEditing(folder);
} else if (event.key === 'Escape') {
event.preventDefault();
cancelFolderEditing(event);
}
}}
onBlur={(event) => {
const nextFocus = event.relatedTarget;
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
cancelFolderEditing();
}
}}
/>
<button
type="button"
className="icon-button"
aria-label="Save name"
title="Save name"
disabled={!canSubmitFolder || isFolderSaving}
onClick={(event) => {
event.stopPropagation();
submitFolderEditing(folder);
}}
>
<CheckIcon />
</button>
<button
type="button"
className="icon-button"
aria-label="Cancel"
title="Cancel"
onClick={(event) => cancelFolderEditing(event)}
>
<CloseIcon />
</button>
</div>
) : (
<div className="folder-card__label-row">
<span
className="folder-card__name"
title={folder.name}
role={allowInlineFolderEdit ? 'button' : undefined}
tabIndex={allowInlineFolderEdit ? 0 : undefined}
onClick={(event) => {
if (!allowInlineFolderEdit) {
return;
}
event.preventDefault();
event.stopPropagation();
beginFolderEditing(folder);
}}
onKeyDown={(event) => {
if (!allowInlineFolderEdit) {
return;
}
if (event.key === 'Enter') {
}}
>
<CheckIcon />
</button>
<button
type="button"
className="icon-button"
aria-label="Cancel"
title="Cancel"
onClick={(event) => cancelFolderEditing(event)}
>
<CloseIcon />
</button>
</div>
) : (
<div className="folder-card__label-row">
<span
className="folder-card__name"
title={folder.name}
role={allowInlineFolderEdit ? 'button' : undefined}
tabIndex={allowInlineFolderEdit ? 0 : undefined}
onClick={(event) => {
if (!allowInlineFolderEdit) {
return;
}
event.preventDefault();
event.stopPropagation();
beginFolderEditing(folder);
}
}}
>
{folder.name}
</span>
</div>
)}
}}
onKeyDown={(event) => {
if (!allowInlineFolderEdit) {
return;
}
if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
beginFolderEditing(folder);
}
}}
>
{folder.name}
</span>
</div>
)}
</div>
</div>
</div>
);
}
);
}
const doc = entry.document;
if (!doc) {
return null;
}
const doc = entry.document;
if (!doc) {
return null;
}
const isSelected = selectedDocumentIdsSet?.has(doc.id);
const isDraggingDoc = draggingDocumentIdsSet?.has(doc.id);
const tagList = Array.isArray(doc.tags) ? doc.tags : [];
const visibleTags = tagList.slice(0, 3);
const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0;
const correspondents = resolveCorrespondents(doc);
const cardClasses = ['document-card', 'document'];
if (isSelected) cardClasses.push('selected');
if (isDraggingDoc) cardClasses.push('is-dragging');
const isEditingDoc = editingDocumentId === doc.id;
const documentDraftValue = isEditingDoc ? documentDraft : doc.title;
const trimmedDocumentDraft = isEditingDoc ? documentDraft.trim() : '';
const isDocumentSaving = savingDocumentId === doc.id;
const canSubmitDocument =
isEditingDoc && trimmedDocumentDraft.length > 0 && trimmedDocumentDraft !== doc.title;
const allowInlineDocumentEdit = onDocumentRename && isSelected && totalSelectionCount === 1;
return (
<div
key={entry.key}
className={cardClasses.join(' ')}
role="listitem"
id={`document-card-${doc.id}`}
data-doc-id={doc.id}
onClick={(event) => onDocumentClick?.(doc, event)}
onDoubleClick={(event) => onDocumentActivate?.(doc, event)}
draggable
onDragStart={(event) => onDocumentDragStart?.(event, doc)}
onDragEnd={(event) => onDocumentDragEnd?.(event)}
onDragOver={(event) => onDocumentTagDragOver?.(event)}
onDragOverCapture={(event) => onDocumentTagDragOver?.(event)}
onDragLeave={onDocumentTagDragLeave}
onDragLeaveCapture={onDocumentTagDragLeave}
onDrop={(event) => onDocumentTagDrop?.(event, doc.id)}
onDropCapture={(event) => onDocumentTagDrop?.(event, doc.id)}
>
<DocumentThumbnailImage
document={doc}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
alt={`Thumbnail for ${doc.title}`}
maxSize={gridIconSize}
scrollRootRef={scrollRef}
/>
<div className="document-card__meta">
<div className="document-card__title" title={doc.title}>
{correspondents.length > 0 ? (
<span className="doc-correspondents">
<CorrespondentLinks
correspondents={correspondents}
activeCorrespondentIdSet={activeCorrespondentIdSet}
onCorrespondentClick={onCorrespondentClick}
/>
</span>
) : null}
{isEditingDoc ? (
<div className="document-card__title-edit doc-title-edit">
<input
type="text"
ref={attachDocumentInputRef}
value={documentDraftValue}
onChange={(event) => setDocumentDraft(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
const isSelected = selectedDocumentIdsSet?.has(doc.id);
const isDraggingDoc = draggingDocumentIdsSet?.has(doc.id);
const tagList = Array.isArray(doc.tags) ? doc.tags : [];
const visibleTags = tagList.slice(0, 3);
const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0;
const correspondents = resolveCorrespondents(doc);
const cardClasses = ['document-card', 'document'];
if (isSelected) cardClasses.push('selected');
if (isDraggingDoc) cardClasses.push('is-dragging');
const isEditingDoc = editingDocumentId === doc.id;
const documentDraftValue = isEditingDoc ? documentDraft : doc.title;
const trimmedDocumentDraft = isEditingDoc ? documentDraft.trim() : '';
const isDocumentSaving = savingDocumentId === doc.id;
const canSubmitDocument =
isEditingDoc && trimmedDocumentDraft.length > 0 && trimmedDocumentDraft !== doc.title;
const allowInlineDocumentEdit = onDocumentRename && isSelected && totalSelectionCount === 1;
return (
<div
key={entry.key}
className={cardClasses.join(' ')}
role="listitem"
id={`document-card-${doc.id}`}
data-doc-id={doc.id}
onClick={(event) => onDocumentClick?.(doc, event)}
onDoubleClick={(event) => onDocumentActivate?.(doc, event)}
draggable
onDragStart={(event) => onDocumentDragStart?.(event, doc)}
onDragEnd={(event) => onDocumentDragEnd?.(event)}
onDragOver={(event) => onDocumentTagDragOver?.(event)}
onDragOverCapture={(event) => onDocumentTagDragOver?.(event)}
onDragLeave={onDocumentTagDragLeave}
onDragLeaveCapture={onDocumentTagDragLeave}
onDrop={(event) => onDocumentTagDrop?.(event, doc.id)}
onDropCapture={(event) => onDocumentTagDrop?.(event, doc.id)}
>
<DocumentThumbnailImage
document={doc}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
alt={`Thumbnail for ${doc.title}`}
maxSize={gridIconSize}
scrollRootRef={scrollRef}
/>
<div className="document-card__meta">
<div className="document-card__title" title={doc.title}>
{correspondents.length > 0 ? (
<span className="doc-correspondents">
<CorrespondentLinks
correspondents={correspondents}
activeCorrespondentIdSet={activeCorrespondentIdSet}
onCorrespondentClick={onCorrespondentClick}
/>
</span>
) : null}
{isEditingDoc ? (
<div className="document-card__title-edit doc-title-edit">
<input
type="text"
ref={attachDocumentInputRef}
value={documentDraftValue}
onChange={(event) => setDocumentDraft(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
submitDocumentEditing(doc);
} else if (event.key === 'Escape') {
event.preventDefault();
cancelDocumentEditing(event);
}
}}
onBlur={(event) => {
const nextFocus = event.relatedTarget;
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
cancelDocumentEditing();
}
}}
/>
<button
type="button"
className="icon-button"
aria-label="Save title"
title="Save title"
disabled={!canSubmitDocument || isDocumentSaving}
onClick={(event) => {
event.stopPropagation();
submitDocumentEditing(doc);
} else if (event.key === 'Escape') {
event.preventDefault();
cancelDocumentEditing(event);
}
}}
onBlur={(event) => {
const nextFocus = event.relatedTarget;
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
cancelDocumentEditing();
}
}}
/>
<button
type="button"
className="icon-button"
aria-label="Save title"
title="Save title"
disabled={!canSubmitDocument || isDocumentSaving}
onClick={(event) => {
event.stopPropagation();
submitDocumentEditing(doc);
}}
>
<CheckIcon />
</button>
<button
type="button"
className="icon-button"
aria-label="Cancel"
title="Cancel"
onClick={(event) => cancelDocumentEditing(event)}
>
<CloseIcon />
</button>
</div>
) : (
<div className="document-card__title-row">
<span
className="document-card__title-badge"
role={allowInlineDocumentEdit ? 'button' : undefined}
tabIndex={allowInlineDocumentEdit ? 0 : undefined}
onClick={(event) => {
if (!allowInlineDocumentEdit) {
return;
}
event.preventDefault();
event.stopPropagation();
beginDocumentEditing(doc);
}}
onKeyDown={(event) => {
if (!allowInlineDocumentEdit) {
return;
}
if (event.key === 'Enter') {
}}
>
<CheckIcon />
</button>
<button
type="button"
className="icon-button"
aria-label="Cancel"
title="Cancel"
onClick={(event) => cancelDocumentEditing(event)}
>
<CloseIcon />
</button>
</div>
) : (
<div className="document-card__title-row">
<span
className="document-card__title-badge"
role={allowInlineDocumentEdit ? 'button' : undefined}
tabIndex={allowInlineDocumentEdit ? 0 : undefined}
onClick={(event) => {
if (!allowInlineDocumentEdit) {
return;
}
event.preventDefault();
event.stopPropagation();
beginDocumentEditing(doc);
}
}}
>
{doc.title}
</span>
</div>
)}
</div>
{visibleTags.length > 0 && (
<div className="document-card__tags">
{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={key}
className="badge tag-chip"
style={style || undefined}
title={tag.label}
role={clickable ? 'button' : undefined}
onClick={clickable ? (event) => {
event.stopPropagation();
if (tagId == null) {
}}
onKeyDown={(event) => {
if (!allowInlineDocumentEdit) {
return;
}
onTagClick?.(tagId);
} : undefined}
draggable
onDragStart={(event) => {
event.stopPropagation();
try {
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'copyMove';
}
} catch (error) {
console.warn('[documents] Failed to configure drag effect', error);
}
writeTagTransferData(event.dataTransfer, tag, doc.id);
}}
onDragEnd={(event) => {
event.stopPropagation();
}}
onKeyDown={clickable ? (event) => {
if (event.key === 'Enter' || event.key === ' ') {
if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
beginDocumentEditing(doc);
}
}}
>
{doc.title}
</span>
</div>
)}
</div>
{visibleTags.length > 0 && (
<div className="document-card__tags">
{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={key}
className="badge tag-chip"
style={style || undefined}
title={tag.label}
role={clickable ? 'button' : undefined}
onClick={clickable ? (event) => {
event.stopPropagation();
if (tagId == null) {
return;
}
onTagClick?.(tagId);
}
} : undefined}
>
{tag.label}
</span>
);
})}
{remainingTagCount > 0 && (
<span className="badge tag-chip tag-chip--more">+{remainingTagCount}</span>
)}
</div>
)}
} : undefined}
draggable
onDragStart={(event) => {
event.stopPropagation();
try {
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'copyMove';
}
} catch (error) {
console.warn('[documents] Failed to configure drag effect', error);
}
writeTagTransferData(event.dataTransfer, tag, doc.id);
}}
onDragEnd={(event) => {
event.stopPropagation();
}}
onKeyDown={clickable ? (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
if (tagId == null) {
return;
}
onTagClick?.(tagId);
}
} : undefined}
>
{tag.label}
</span>
);
})}
{remainingTagCount > 0 && (
<span className="badge tag-chip tag-chip--more">+{remainingTagCount}</span>
)}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
);
})}
</div>
);
};
+336 -337
View File
@@ -9,8 +9,7 @@ import { resolveCorrespondents } from './correspondents';
import { writeTagTransferData } from './tagTransfer';
import useInlineRename from './useInlineRename';
import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext';
export type Identifier = string | number;
import type { Identifier } from '../types/identifiers';
export interface FolderLike {
id?: Identifier | 'root';
@@ -160,356 +159,356 @@ const DocumentsList: React.FC<DocumentsListProps> = ({
const totalSelectionCount = documentSelectionCount + folderSelectionCount;
return (
<table aria-multiselectable="true">
<thead
onClick={() => {
clearSelection();
}}
>
<tr>
<th>&nbsp;</th>
<th>Name</th>
<th>Issued</th>
<th>Added</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => {
if (entry.type === 'folder') {
const folder = entry.folder;
if (!folder) {
return null;
}
const canDragFolder = folder.id !== 'root';
const isDraggingFolder = draggedFolderId === folder.id;
const isSelectedFolder = selectedFolderIdsSet?.has(folder.id);
const canRenameFolder = Boolean(onFolderRename) && folder.id !== 'root';
const isFolderEditing = editingFolderId === folder.id;
const folderDraftValue = isFolderEditing ? folderDraft : folder.name;
const trimmedFolderDraft = isFolderEditing ? folderDraft.trim() : '';
const isFolderSaving = savingFolderId === folder.id;
const canSubmitFolder =
isFolderEditing && trimmedFolderDraft.length > 0 && trimmedFolderDraft !== folder.name;
const allowInlineFolderEdit = canRenameFolder && isSelectedFolder && totalSelectionCount === 1;
<table aria-multiselectable="true">
<thead
onClick={() => {
clearSelection();
}}
>
<tr>
<th>&nbsp;</th>
<th>Name</th>
<th>Issued</th>
<th>Added</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => {
if (entry.type === 'folder') {
const folder = entry.folder;
if (!folder) {
return null;
}
const canDragFolder = folder.id !== 'root';
const isDraggingFolder = draggedFolderId === folder.id;
const isSelectedFolder = selectedFolderIdsSet?.has(folder.id);
const canRenameFolder = Boolean(onFolderRename) && folder.id !== 'root';
const isFolderEditing = editingFolderId === folder.id;
const folderDraftValue = isFolderEditing ? folderDraft : folder.name;
const trimmedFolderDraft = isFolderEditing ? folderDraft.trim() : '';
const isFolderSaving = savingFolderId === folder.id;
const canSubmitFolder =
isFolderEditing && trimmedFolderDraft.length > 0 && trimmedFolderDraft !== folder.name;
const allowInlineFolderEdit = canRenameFolder && isSelectedFolder && totalSelectionCount === 1;
return (
<tr
key={entry.key}
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${isSelectedFolder ? ' selected' : ''}`}
id={`folder-row-${folder.id}`}
onClick={(event) => onFolderClick?.(folder, event)}
onDoubleClick={(event) => {
event.preventDefault();
onFolderSelect?.(folder.id);
}}
onDragOver={(event) => onFolderDragOver?.(event, folder.id)}
onDragLeave={onFolderDragLeave}
onDrop={(event) => onFolderDrop?.(event, folder.id)}
draggable={canDragFolder}
onDragStart={(event) => {
if (canDragFolder) {
onFolderDragStart?.(event, folder.id);
}
}}
onDragEnd={(event) => {
if (canDragFolder) {
onFolderDragEnd?.(event);
}
}}
>
<td className="thumb-cell">
<div className="thumb-icon">
<FolderIcon className="thumb-icon__image" size={32} />
</div>
</td>
<td className="doc-list__name">
<div className="doc-list__name-content">
<span className="doc-name__title">
<span className="doc-name__primary">
{isFolderEditing ? (
<span className="doc-title-edit">
<input
type="text"
ref={attachFolderInputRef}
value={folderDraftValue}
onChange={(event) => setFolderDraft(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
return (
<tr
key={entry.key}
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${isSelectedFolder ? ' selected' : ''}`}
id={`folder-row-${folder.id}`}
onClick={(event) => onFolderClick?.(folder, event)}
onDoubleClick={(event) => {
event.preventDefault();
onFolderSelect?.(folder.id);
}}
onDragOver={(event) => onFolderDragOver?.(event, folder.id)}
onDragLeave={onFolderDragLeave}
onDrop={(event) => onFolderDrop?.(event, folder.id)}
draggable={canDragFolder}
onDragStart={(event) => {
if (canDragFolder) {
onFolderDragStart?.(event, folder.id);
}
}}
onDragEnd={(event) => {
if (canDragFolder) {
onFolderDragEnd?.(event);
}
}}
>
<td className="thumb-cell">
<div className="thumb-icon">
<FolderIcon className="thumb-icon__image" size={32} />
</div>
</td>
<td className="doc-list__name">
<div className="doc-list__name-content">
<span className="doc-name__title">
<span className="doc-name__primary">
{isFolderEditing ? (
<span className="doc-title-edit">
<input
type="text"
ref={attachFolderInputRef}
value={folderDraftValue}
onChange={(event) => setFolderDraft(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
submitFolderEditing(folder);
} else if (event.key === 'Escape') {
event.preventDefault();
cancelFolderEditing(event);
}
}}
onBlur={(event) => {
const nextFocus = event.relatedTarget;
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
cancelFolderEditing();
}
}}
/>
<button
type="button"
className="icon-button"
aria-label="Save name"
title="Save name"
disabled={!canSubmitFolder || isFolderSaving}
onClick={(event) => {
event.stopPropagation();
submitFolderEditing(folder);
} else if (event.key === 'Escape') {
event.preventDefault();
}}
>
<CheckIcon />
</button>
<button
type="button"
className="icon-button"
aria-label="Cancel"
title="Cancel"
onClick={(event) => {
cancelFolderEditing(event);
}
}}
onBlur={(event) => {
const nextFocus = event.relatedTarget;
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
cancelFolderEditing();
}
}}
/>
<button
type="button"
className="icon-button"
aria-label="Save name"
title="Save name"
disabled={!canSubmitFolder || isFolderSaving}
}}
>
<CloseIcon />
</button>
</span>
) : (
<span
className="doc-name__primary-text"
role={allowInlineFolderEdit ? 'button' : undefined}
tabIndex={allowInlineFolderEdit ? 0 : undefined}
onClick={(event) => {
event.stopPropagation();
submitFolderEditing(folder);
}}
>
<CheckIcon />
</button>
<button
type="button"
className="icon-button"
aria-label="Cancel"
title="Cancel"
onClick={(event) => {
cancelFolderEditing(event);
}}
>
<CloseIcon />
</button>
</span>
) : (
<span
className="doc-name__primary-text"
role={allowInlineFolderEdit ? 'button' : undefined}
tabIndex={allowInlineFolderEdit ? 0 : undefined}
onClick={(event) => {
if (!allowInlineFolderEdit) {
return;
}
event.preventDefault();
event.stopPropagation();
beginFolderEditing(folder);
}}
onKeyDown={(event) => {
if (!allowInlineFolderEdit) {
return;
}
if (event.key === 'Enter') {
if (!allowInlineFolderEdit) {
return;
}
event.preventDefault();
event.stopPropagation();
beginFolderEditing(folder);
}
}}
>
{folder.name}
</span>
)}
</span>
</span>
</div>
</td>
<td></td>
<td></td>
</tr>
);
}
const doc = entry.document;
if (!doc) {
return null;
}
const isSelected = selectedDocumentIdsSet?.has(doc.id);
const isDraggingDoc = draggingDocumentIdsSet?.has(doc.id);
const rowClasses = ['document'];
if (isSelected) rowClasses.push('selected');
if (isDraggingDoc) rowClasses.push('is-dragging');
const correspondents = resolveCorrespondents(doc);
const isEditingDoc = editingDocumentId === doc.id;
const documentDraftValue = isEditingDoc ? documentDraft : doc.title;
const trimmedDocumentDraft = isEditingDoc ? documentDraft.trim() : '';
const isDocumentSaving = savingDocumentId === doc.id;
const canSubmitDocument =
isEditingDoc && trimmedDocumentDraft.length > 0 && trimmedDocumentDraft !== doc.title;
const allowInlineDocumentEdit =
onDocumentRename && isSelected && totalSelectionCount === 1;
const issuedLabel = formatDate(doc.issued_at);
const addedLabel = formatDate(doc.created_at || doc.uploaded_at);
return (
<tr
key={entry.key}
className={rowClasses.join(' ')}
id={`document-row-${doc.id}`}
data-doc-id={doc.id}
onClick={(event) => onDocumentClick?.(doc, event)}
onDoubleClick={(event) => onDocumentActivate?.(doc, event)}
draggable
onDragStart={(event) => onDocumentDragStart?.(event, doc)}
onDragEnd={(event) => onDocumentDragEnd?.(event)}
onDragOver={onDocumentTagDragOver}
onDragLeave={onDocumentTagDragLeave}
onDrop={(event) => onDocumentTagDrop?.(event, doc.id)}
>
<td className="thumb-cell">
<DocumentThumbnailImage
document={doc}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
alt={`Thumbnail for ${doc.title}`}
scrollRootRef={scrollRef}
/>
</td>
<td className="doc-list__name">
<div className="doc-name">
<div className="doc-list__name-content">
<span className="doc-name__title">
{correspondents.length > 0 ? (
<span className="doc-correspondents">
<CorrespondentLinks
correspondents={correspondents}
activeCorrespondentIdSet={activeCorrespondentIdSet}
onCorrespondentClick={onCorrespondentClick}
/>
}}
onKeyDown={(event) => {
if (!allowInlineFolderEdit) {
return;
}
if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
beginFolderEditing(folder);
}
}}
>
{folder.name}
</span>
)}
</span>
) : null}
<span className="doc-name__primary">
{isEditingDoc ? (
<span className="doc-title-edit">
<input
type="text"
ref={attachDocumentInputRef}
value={documentDraftValue}
onChange={(event) => setDocumentDraft(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
submitDocumentEditing(doc);
} else if (event.key === 'Escape') {
event.preventDefault();
cancelDocumentEditing(event);
}
}}
onBlur={(event) => {
const nextFocus = event.relatedTarget;
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
cancelDocumentEditing();
}
}}
/>
<button
type="button"
className="icon-button"
aria-label="Save title"
title="Save title"
disabled={!canSubmitDocument || isDocumentSaving}
onClick={(event) => {
event.stopPropagation();
submitDocumentEditing(doc);
}}
>
<CheckIcon />
</button>
<button
type="button"
className="icon-button"
aria-label="Cancel"
title="Cancel"
onClick={(event) => {
cancelDocumentEditing(event);
}}
>
<CloseIcon />
</button>
</span>
</div>
</td>
<td></td>
<td></td>
</tr>
);
}
const doc = entry.document;
if (!doc) {
return null;
}
const isSelected = selectedDocumentIdsSet?.has(doc.id);
const isDraggingDoc = draggingDocumentIdsSet?.has(doc.id);
const rowClasses = ['document'];
if (isSelected) rowClasses.push('selected');
if (isDraggingDoc) rowClasses.push('is-dragging');
const correspondents = resolveCorrespondents(doc);
const isEditingDoc = editingDocumentId === doc.id;
const documentDraftValue = isEditingDoc ? documentDraft : doc.title;
const trimmedDocumentDraft = isEditingDoc ? documentDraft.trim() : '';
const isDocumentSaving = savingDocumentId === doc.id;
const canSubmitDocument =
isEditingDoc && trimmedDocumentDraft.length > 0 && trimmedDocumentDraft !== doc.title;
const allowInlineDocumentEdit =
onDocumentRename && isSelected && totalSelectionCount === 1;
const issuedLabel = formatDate(doc.issued_at);
const addedLabel = formatDate(doc.created_at || doc.uploaded_at);
return (
<tr
key={entry.key}
className={rowClasses.join(' ')}
id={`document-row-${doc.id}`}
data-doc-id={doc.id}
onClick={(event) => onDocumentClick?.(doc, event)}
onDoubleClick={(event) => onDocumentActivate?.(doc, event)}
draggable
onDragStart={(event) => onDocumentDragStart?.(event, doc)}
onDragEnd={(event) => onDocumentDragEnd?.(event)}
onDragOver={onDocumentTagDragOver}
onDragLeave={onDocumentTagDragLeave}
onDrop={(event) => onDocumentTagDrop?.(event, doc.id)}
>
<td className="thumb-cell">
<DocumentThumbnailImage
document={doc}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
alt={`Thumbnail for ${doc.title}`}
scrollRootRef={scrollRef}
/>
</td>
<td className="doc-list__name">
<div className="doc-name">
<div className="doc-list__name-content">
<span className="doc-name__title">
{correspondents.length > 0 ? (
<span className="doc-correspondents">
<CorrespondentLinks
correspondents={correspondents}
activeCorrespondentIdSet={activeCorrespondentIdSet}
onCorrespondentClick={onCorrespondentClick}
/>
</span>
) : (
<span
className="doc-name__primary-text"
role={allowInlineDocumentEdit ? 'button' : undefined}
tabIndex={allowInlineDocumentEdit ? 0 : undefined}
onClick={(event) => {
if (!allowInlineDocumentEdit) {
return;
}
event.preventDefault();
event.stopPropagation();
beginDocumentEditing(doc);
}}
onKeyDown={(event) => {
if (!allowInlineDocumentEdit) {
return;
}
if (event.key === 'Enter') {
) : null}
<span className="doc-name__primary">
{isEditingDoc ? (
<span className="doc-title-edit">
<input
type="text"
ref={attachDocumentInputRef}
value={documentDraftValue}
onChange={(event) => setDocumentDraft(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
submitDocumentEditing(doc);
} else if (event.key === 'Escape') {
event.preventDefault();
cancelDocumentEditing(event);
}
}}
onBlur={(event) => {
const nextFocus = event.relatedTarget;
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
cancelDocumentEditing();
}
}}
/>
<button
type="button"
className="icon-button"
aria-label="Save title"
title="Save title"
disabled={!canSubmitDocument || isDocumentSaving}
onClick={(event) => {
event.stopPropagation();
submitDocumentEditing(doc);
}}
>
<CheckIcon />
</button>
<button
type="button"
className="icon-button"
aria-label="Cancel"
title="Cancel"
onClick={(event) => {
cancelDocumentEditing(event);
}}
>
<CloseIcon />
</button>
</span>
) : (
<span
className="doc-name__primary-text"
role={allowInlineDocumentEdit ? 'button' : undefined}
tabIndex={allowInlineDocumentEdit ? 0 : undefined}
onClick={(event) => {
if (!allowInlineDocumentEdit) {
return;
}
event.preventDefault();
event.stopPropagation();
beginDocumentEditing(doc);
}
}}
>
{doc.title}
</span>
)}
</span>
</span>
</div>
{(doc.tags || []).length > 0 && (
<div className="doc-name__tags">
{(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={key}
className="badge tag-chip"
style={style || undefined}
title={tag.label}
role={clickable ? 'button' : undefined}
onClick={clickable ? (event) => {
event.stopPropagation();
if (tagId == null) return;
onTagClick?.(tagId);
} : undefined}
draggable
onDragStart={(event) => {
event.stopPropagation();
try {
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'copyMove';
}
} catch (error) {
console.warn('[documents] Failed to configure drag effect', error);
}
writeTagTransferData(event.dataTransfer, tag, doc.id);
}}
onDragEnd={(event) => {
event.stopPropagation();
}}
onKeyDown={clickable ? (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
if (tagId == null) {
}}
onKeyDown={(event) => {
if (!allowInlineDocumentEdit) {
return;
}
onTagClick?.(tagId);
}
} : undefined}
>
{tag.label}
</span>
);
})}
if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
beginDocumentEditing(doc);
}
}}
>
{doc.title}
</span>
)}
</span>
</span>
</div>
)}
</div>
</td>
<td>{issuedLabel}</td>
<td>{addedLabel}</td>
</tr>
);
})}
</tbody>
</table>
{(doc.tags || []).length > 0 && (
<div className="doc-name__tags">
{(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={key}
className="badge tag-chip"
style={style || undefined}
title={tag.label}
role={clickable ? 'button' : undefined}
onClick={clickable ? (event) => {
event.stopPropagation();
if (tagId == null) return;
onTagClick?.(tagId);
} : undefined}
draggable
onDragStart={(event) => {
event.stopPropagation();
try {
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'copyMove';
}
} catch (error) {
console.warn('[documents] Failed to configure drag effect', error);
}
writeTagTransferData(event.dataTransfer, tag, doc.id);
}}
onDragEnd={(event) => {
event.stopPropagation();
}}
onKeyDown={clickable ? (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
if (tagId == null) {
return;
}
onTagClick?.(tagId);
}
} : undefined}
>
{tag.label}
</span>
);
})}
</div>
)}
</div>
</td>
<td>{issuedLabel}</td>
<td>{addedLabel}</td>
</tr>
);
})}
</tbody>
</table>
);
};
+1 -2
View File
@@ -1,6 +1,5 @@
import { shallowEqual } from 'react-redux';
type DocumentId = string | number;
import type { DocumentId } from '../types/identifiers';
export type ManagedDocument = { id?: DocumentId | null } & Record<string, unknown>;
@@ -5,18 +5,18 @@ import { CheckIcon, CircleDashedCheckIcon, PlusIcon } from '../ui/icons';
export type AssignmentState = 'all' | 'partial' | 'none';
export interface SelectionAssignmentMenuItem {
id?: string | number;
id?: string;
label?: string;
state?: AssignmentState;
count?: number | null;
total?: number | null;
color?: string | null;
value?: string | number;
value?: string;
payload?: unknown;
}
export interface NormalizedSelectionAssignmentItem {
id: string | number;
id: string;
label: string;
state: AssignmentState;
count: number | null;
@@ -105,7 +105,7 @@ const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
const inputRef = useRef<HTMLInputElement | null>(null);
const [query, setQuery] = useState('');
const [pending, setPending] = useState(false);
const [sortSnapshot, setSortSnapshot] = useState<Array<string | number> | null>(null);
const [sortSnapshot, setSortSnapshot] = useState<Array<string> | null>(null);
const {
isOpen,
@@ -175,10 +175,10 @@ const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
const orderedItems = useMemo(() => {
if (freezeSortOnOpen && sortSnapshot && sortByState) {
const itemMap = new Map<string | number, NormalizedSelectionAssignmentItem>(
const itemMap = new Map<string, NormalizedSelectionAssignmentItem>(
sortedByStateItems.map((item) => [item.id, item]),
);
const seen = new Set<string | number>();
const seen = new Set<string>();
const fromSnapshot = sortSnapshot
.map((id) => {
const entry = itemMap.get(id);
@@ -12,10 +12,10 @@ import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './Selectio
import SelectionSummary from './SelectionSummary';
import { useAppState } from '../app/appState';
import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext';
import type { DocumentId } from '../types/identifiers';
const ROOT_FOLDER_LABEL = 'Documents';
type DocumentId = string | number;
type NullableDocumentId = DocumentId | null;
type SelectedIdList = NullableDocumentId[] | null;
@@ -145,7 +145,7 @@ const buildTagAssignments = (
return [];
}
const map = new Map<string | number, {
const map = new Map<string, {
id?: DocumentId;
label: string;
color: string | null;
@@ -209,7 +209,7 @@ const buildCorrespondentAssignments = (
return [];
}
const map = new Map<string | number, {
const map = new Map<string, {
id?: DocumentId;
label: string;
count: number;
@@ -491,7 +491,7 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
const value = isRecord(candidate)
? (candidate?.id ?? candidate?.value ?? null)
: candidate;
if (!value && value !== 0) {
if (!value) {
return;
}
await onMoveDocumentsToFolder(documentIdList, value as DocumentId);
@@ -1,10 +1,9 @@
import React, { createContext, useContext } from 'react';
type Identifier = string | number;
import type { Identifier } from '../../types/identifiers';
export interface DocumentsFilterValue {
query: string;
searchResultIds: Array<string | number> | null;
searchResultIds: Array<string> | null;
searchLoading: boolean;
includeDescendants: boolean;
activeTagIds: Identifier[];
+4 -4
View File
@@ -1,5 +1,5 @@
export interface CorrespondentReference {
id?: string | number | null;
id?: string | null;
name?: string | null;
key?: string;
}
@@ -9,9 +9,9 @@ export interface DocumentLike {
}
export interface ResolvedCorrespondent {
id?: string | number | null;
id?: string | null;
name: string;
key: string | number;
key: string;
}
export const resolveCorrespondents = (doc?: DocumentLike | null): ResolvedCorrespondent[] => {
@@ -19,7 +19,7 @@ export const resolveCorrespondents = (doc?: DocumentLike | null): ResolvedCorres
return [];
}
const seen = new Set<string | number>();
const seen = new Set<string>();
const results: ResolvedCorrespondent[] = [];
doc.correspondents.forEach((entry = {}, index) => {
@@ -1,7 +1,6 @@
import { useCallback } from 'react';
import { assignCorrespondentsBulk } from '../../lib/apiClient';
export type Identifier = string | number;
import type { Identifier } from '../../types/identifiers';
type BulkAssignmentResponse = {
assigned?: number;
@@ -62,35 +61,35 @@ const useBulkDocumentActions = ({
if (!target?.id) {
setStatusMessage('Unable to resolve correspondent.', 'error');
return;
}
}
const response: BulkAssignmentResponse = await assignCorrespondentsBulk<BulkAssignmentResponse>({
document_ids: targets,
assignments: [
{
correspondent_id: target.id,
},
],
action: 'add',
});
const { assigned = 0, removed = 0 } = response;
if (updateDocumentCaches && target.id) {
targets.forEach((docId) => {
updateDocumentCaches(docId, (doc) => {
if (!doc) return doc;
const current = Array.isArray((doc as any).correspondents) ? (doc as any).correspondents : [];
if (current.some((entry: any) => entry?.id === target.id)) {
return doc;
}
return {
...(doc as any),
correspondents: [...current, { id: target.id, name: (target as any).name }],
};
});
const response: BulkAssignmentResponse = await assignCorrespondentsBulk<BulkAssignmentResponse>({
document_ids: targets,
assignments: [
{
correspondent_id: target.id,
},
],
action: 'add',
});
}
const { assigned = 0, removed = 0 } = response;
if (updateDocumentCaches && target.id) {
targets.forEach((docId) => {
updateDocumentCaches(docId, (doc) => {
if (!doc) return doc;
const current = Array.isArray((doc as any).correspondents) ? (doc as any).correspondents : [];
if (current.some((entry: any) => entry?.id === target.id)) {
return doc;
}
return {
...(doc as any),
correspondents: [...current, { id: target.id, name: (target as any).name }],
};
});
});
}
const assignedSuffix = assigned === 1 ? '' : 's';
if (removed > 0) {
const removedSuffix = removed === 1 ? '' : 's';
@@ -105,18 +104,18 @@ const useBulkDocumentActions = ({
);
}
if (input) {
input.value = '';
}
},
[
correspondentLookupByName,
handleCorrespondentCreate,
resolveTargetDocumentIds,
setStatusMessage,
updateDocumentCaches,
],
);
if (input) {
input.value = '';
}
},
[
correspondentLookupByName,
handleCorrespondentCreate,
resolveTargetDocumentIds,
setStatusMessage,
updateDocumentCaches,
],
);
const handleBulkCorrespondentRemove = useCallback(
async ({ assignments = [], documentIds }: { assignments?: CorrespondentAssignment[]; documentIds?: Identifier[] }) => {
@@ -132,33 +131,33 @@ const useBulkDocumentActions = ({
return;
}
const normalizedAssignments = assignments.map((entry) => ({
correspondent_id: entry.correspondent_id,
}));
const normalizedAssignments = assignments.map((entry) => ({
correspondent_id: entry.correspondent_id,
}));
const response: BulkAssignmentResponse = await assignCorrespondentsBulk<BulkAssignmentResponse>({
document_ids: targets,
assignments: normalizedAssignments,
action: 'remove',
});
const { assigned = 0, removed = 0 } = response;
if (updateDocumentCaches) {
targets.forEach((docId) => {
updateDocumentCaches(docId, (doc) => {
if (!doc || !Array.isArray((doc as any).correspondents)) {
return doc;
}
const filtered = (doc as any).correspondents.filter(
(entry: any) =>
entry && !normalizedAssignments.some((assignment) => assignment.correspondent_id === entry.id),
);
return filtered.length === (doc as any).correspondents.length
? doc
: { ...(doc as any), correspondents: filtered };
});
const response: BulkAssignmentResponse = await assignCorrespondentsBulk<BulkAssignmentResponse>({
document_ids: targets,
assignments: normalizedAssignments,
action: 'remove',
});
}
const { assigned = 0, removed = 0 } = response;
if (updateDocumentCaches) {
targets.forEach((docId) => {
updateDocumentCaches(docId, (doc) => {
if (!doc || !Array.isArray((doc as any).correspondents)) {
return doc;
}
const filtered = (doc as any).correspondents.filter(
(entry: any) =>
entry && !normalizedAssignments.some((assignment) => assignment.correspondent_id === entry.id),
);
return filtered.length === (doc as any).correspondents.length
? doc
: { ...(doc as any), correspondents: filtered };
});
});
}
if (removed > 0) {
const removedSuffix = removed === 1 ? '' : 's';
@@ -169,12 +168,12 @@ const useBulkDocumentActions = ({
} else if (assigned > 0) {
const assignedSuffix = assigned === 1 ? '' : 's';
setStatusMessage(`Correspondent updated ${assigned} link${assignedSuffix}.`, 'info');
} else {
setStatusMessage('No correspondents changed.', 'info');
}
},
[resolveTargetDocumentIds, setStatusMessage, updateDocumentCaches],
);
} else {
setStatusMessage('No correspondents changed.', 'info');
}
},
[resolveTargetDocumentIds, setStatusMessage, updateDocumentCaches],
);
const handleDeleteSelection = useCallback(async () => {
const docIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds : [];
@@ -1,7 +1,6 @@
import { useMemo } from 'react';
import type { WorkspaceSelectionValue } from '../../app/WorkspaceSelectionContext';
type Identifier = string | number;
import type { Identifier } from '../../types/identifiers';
interface DocumentLinkLike {
url?: string | null;
@@ -96,8 +95,8 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
searchLoading,
tagLookupById,
activeCorrespondentFilters,
ensureAssetUrl,
getDocumentAsset,
ensureAssetUrl,
getDocumentAsset,
handleDocumentTagDrop,
documentsViewMode,
documentsSortField,
@@ -1,20 +1,21 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { createDocumentEntryKey, createFolderEntryKey, isFolderEntry } from '../../app/entryKey';
import type { DocumentId, FolderId } from '../../types/identifiers';
interface FolderEntry {
id: string | number;
id: FolderId;
[key: string]: unknown;
}
interface DocumentEntry {
id: string | number;
id: DocumentId;
[key: string]: unknown;
}
interface NavigableRow {
key: string;
type: 'folder' | 'document';
id: string | number;
id: FolderId | DocumentId;
}
interface UseDocumentsSelectionOptions {
@@ -25,19 +26,19 @@ interface UseDocumentsSelectionOptions {
visibleRowKeySet: Set<string>;
selectedEntries: string[];
selectionAnchorRef: { current: string | null };
promoteSelectionOrderRaw: (id: string | number) => void;
setFocusedDocumentId: (id: string | number | null) => void;
setActivePreviewId: (id: string | number | null) => void;
promoteSelectionOrderRaw: (id: DocumentId) => void;
setFocusedDocumentId: (id: DocumentId | null) => void;
setActivePreviewId: (id: DocumentId | null) => void;
clearSelection: () => void;
focusedDocumentId: string | number | null;
focusedDocumentId: DocumentId | null;
setFocusedRowKey: (value: string | null | ((current: string | null) => string | null)) => void;
focusedRowKey: string | null;
}
const useDocumentsSelection = ({
showingSearchResults,
currentSubfolders,
visibleDocuments,
currentSubfolders = [],
visibleDocuments = [],
configureSelectionEnvironment,
visibleRowKeySet,
selectedEntries,
@@ -82,7 +83,7 @@ const useDocumentsSelection = ({
}, [configureSelectionEnvironment, visibleRowKeySet, navigableRowKeys]);
const promoteSelectionOrder = useCallback(
(docId: string | number | null) => {
(docId: DocumentId | null) => {
if (!docId) return;
promoteSelectionOrderRaw(docId);
const rowKey = createDocumentEntryKey(docId);
@@ -99,7 +100,7 @@ const useDocumentsSelection = ({
clearSelection();
}, [clearSelection]);
const prevFocusedDocIdRef = useRef<string | number | null>(focusedDocumentId);
const prevFocusedDocIdRef = useRef<DocumentId | null>(focusedDocumentId);
useEffect(() => {
const previous = prevFocusedDocIdRef.current;
if (previous === focusedDocumentId) {
@@ -109,7 +110,7 @@ const useDocumentsSelection = ({
if (focusedDocumentId) {
setFocusedRowKey(createDocumentEntryKey(focusedDocumentId));
} else {
setFocusedRowKey((current) => (isFolderEntry(current) ? current : null));
setFocusedRowKey((current) => (current && isFolderEntry(current) ? current : null));
}
}, [focusedDocumentId, setFocusedRowKey]);
@@ -18,6 +18,7 @@ import DocumentsPanelHeader, {
import { SelectionFloatingPanel } from '../SelectionFloatingActions';
import { createDocumentsTableHeaderActions } from './DocumentsToolbar';
import { useDocumentsFilter } from '../context/DocumentsFilterContext';
import type { Identifier } from '../../types/identifiers';
const DEFAULT_GRID_ICON_SIZE = 144;
@@ -73,7 +74,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
documentLinks,
ensureDownloadUrl,
deskWorkspaceProps = null,
onRefresh = () => {},
onRefresh = () => { },
sortField,
sortDirection,
onSortFieldChange,
@@ -108,8 +109,8 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
() =>
Array.isArray(searchResultIds)
? searchResultIds
.map((id) => documentLookup?.get?.(id) || null)
.filter((doc): doc is Record<string, unknown> => Boolean(doc))
.map((id) => documentLookup?.get?.(id) || null)
.filter((doc): doc is Record<string, unknown> => Boolean(doc))
: null,
[searchResultIds, documentLookup],
);
@@ -256,8 +257,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
const isGridView = viewMode === 'grid';
const isDeskView = viewMode === 'desk';
type Identifier = string | number;
type ZoomSource = { url: string; alt?: string | null; mimeType?: string | null };
type ZoomSource = { url: string; alt?: string | null; mimeType?: string | null };
const [previewDocId, setPreviewDocId] = useState<Identifier | null>(null);
@@ -499,7 +499,7 @@ type ZoomSource = { url: string; alt?: string | null; mimeType?: string | null }
setFocusedRowKey(targetRow.key);
handleEntrySelection(targetRow.key, {
shiftKey,
preventDefault: () => {},
preventDefault: () => { },
});
},
[
@@ -2,8 +2,7 @@ import React from 'react';
import type { ReactNode } from 'react';
import PanelHeader from '../../ui/PanelHeader';
import BreadcrumbTrail from '../../ui/BreadcrumbTrail';
type Identifier = string | number;
import type { Identifier } from '../../types/identifiers';
export interface DocumentsHeaderBreadcrumb {
id?: Identifier;
@@ -40,12 +39,12 @@ const DocumentsPanelHeader: React.FC<DocumentsPanelHeaderProps> = ({
const lastIndex = breadcrumbEntries.length - 1;
const trailEntries = breadcrumbEntries.length
? breadcrumbEntries.map((crumb, index) => ({
id: crumb.id ?? index,
label: crumb.name ?? crumb.label ?? crumb.title ?? '',
onClick: index < lastIndex && onBreadcrumbClick
? () => onBreadcrumbClick(crumb)
: null,
}))
id: crumb.id ?? index,
label: crumb.name ?? crumb.label ?? crumb.title ?? '',
onClick: index < lastIndex && onBreadcrumbClick
? () => onBreadcrumbClick(crumb)
: null,
}))
: [{ id: 'current-location', label: header.title }];
const headerTitle = (
+15 -6
View File
@@ -1,14 +1,16 @@
import type { DocumentId, TagId } from '../types/identifiers';
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
const TAG_TEXT_MIME_TYPE = 'text/plain';
interface TagPayload {
id: string | number;
id: TagId;
label: string;
sourceDocId: string | number | null;
sourceDocId: DocumentId | null;
}
interface TagLike {
id?: string | number;
id?: TagId;
label?: string | null;
}
@@ -21,7 +23,10 @@ const serializePayload = (payload: TagPayload): string | null => {
}
};
export const createTagTransferPayload = (tag?: TagLike | null, sourceDocId: string | number | null = null): TagPayload | null => {
export const createTagTransferPayload = (
tag?: TagLike | null,
sourceDocId: DocumentId | null = null,
): TagPayload | null => {
if (!tag || tag.id == null) {
return null;
}
@@ -33,7 +38,11 @@ export const createTagTransferPayload = (tag?: TagLike | null, sourceDocId: stri
};
};
export const writeTagTransferData = (dataTransfer: DataTransfer | null, tag: TagLike, sourceDocId: string | number | null = null): void => {
export const writeTagTransferData = (
dataTransfer: DataTransfer | null,
tag: TagLike,
sourceDocId: DocumentId | null = null,
): void => {
if (!dataTransfer) {
return;
}
@@ -122,7 +131,7 @@ export const isTagTransferEvent = (event?: DragEventLike | null): boolean => {
if (!types) {
return false;
}
const typeList = Array.isArray(types) ? [...types] : Array.from(types);
const typeList = Array.isArray(types) ? [...types] : Array.from(types);
return TAG_MIME_TYPES.some((type) => typeList.includes(type));
};
+3 -3
View File
@@ -20,7 +20,7 @@ export const isPrimaryPointerEvent = (event?: PointerEventLike | null): boolean
export type EntryType = 'document' | 'folder';
export interface WorkspaceEntry {
id: string | number;
id: string;
key?: string;
type: EntryType;
[key: string]: unknown;
@@ -28,7 +28,7 @@ export interface WorkspaceEntry {
interface UseEntryPointerOptions {
onSelectEntry?: (entry: WorkspaceEntry, event?: PointerEventLike | null, metadata?: EntryPointerMetadata) => void;
onDocumentActivate?: (id: string | number, metadata?: EntryPointerMetadata) => void;
onDocumentActivate?: (id: string, metadata?: EntryPointerMetadata) => void;
}
export interface EntryPointerMetadata {
@@ -36,7 +36,7 @@ export interface EntryPointerMetadata {
primaryClick: boolean;
rowKey: string;
type: EntryType;
id: string | number;
id: string;
}
export const useEntryPointer = ({
+8 -8
View File
@@ -13,22 +13,22 @@ type FocusableInput = (HTMLInputElement | HTMLTextAreaElement) & {
type InlineRenameOptions<TEntity> = {
getCurrentValue?: (entity: TEntity) => string | null;
getEntityId?: (entity: TEntity) => string | number | null;
getEntityId?: (entity: TEntity) => string | null;
};
type InlineRenameHandler = (
id: string | number,
id: string,
value: string,
) => boolean | void | Promise<boolean | void>;
type InlineRenameReturn<TEntity> = {
editingId: string | number | null;
editingId: string | null;
draftValue: string;
setDraftValue: Dispatch<SetStateAction<string>>;
beginEditing: (entity?: TEntity | null, event?: SyntheticEvent | Event) => void;
cancelEditing: (event?: SyntheticEvent | Event) => void;
submitEditing: (entity?: TEntity | null) => Promise<boolean>;
savingId: string | number | null;
savingId: string | null;
attachInputRef: (node: FocusableInput | null) => void;
};
@@ -51,18 +51,18 @@ const focusInput = (node: FocusableInput | null) => {
const identity = (value: unknown) => value as string;
const defaultGetEntityId = <T,>(entity?: T | null) =>
(entity as { id?: string | number } | null)?.id ?? null;
(entity as { id?: string } | null)?.id ?? null;
const useInlineRename = <TEntity,>(
onRename?: InlineRenameHandler,
{
getCurrentValue = identity as (entity: TEntity) => string | null,
getEntityId = defaultGetEntityId as (entity: TEntity) => string | number | null,
getEntityId = defaultGetEntityId as (entity: TEntity) => string | null,
}: InlineRenameOptions<TEntity> = {},
): InlineRenameReturn<TEntity> => {
const [editingId, setEditingId] = useState<string | number | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [draftValue, setDraftValue] = useState('');
const [savingId, setSavingId] = useState<string | number | null>(null);
const [savingId, setSavingId] = useState<string | null>(null);
const inputRef = useRef<FocusableInput | null>(null);
const resetState = useCallback(() => {
@@ -17,7 +17,7 @@ const FolderManagerContext = createContext<FolderManager>(defaultManager);
interface FolderManagerProviderProps {
folderNodes?: Map<string | 'root', { name?: string | null }>;
ensureFolderData?: (folderId: string | 'root', options?: { force?: boolean; includeDocuments?: boolean }) => Promise<void>;
ensureFolderData?: (folderId: FolderId | 'root', options?: { force?: boolean; includeDocuments?: boolean }) => Promise<void>;
children: ReactNode;
}
@@ -8,7 +8,7 @@ type ApiClient = {
};
interface CorrespondentEntry {
id?: string | number;
id?: string;
name?: string;
[key: string]: unknown;
}
@@ -17,7 +17,7 @@ interface UseCorrespondentsOptions {
apiClient: ApiClient;
notifyApiError: (error: unknown, fallback: string) => void;
setStatusMessage: (message: string, variant?: string) => void;
tenantIdRef: MutableRefObject<string | number | null>;
tenantIdRef: MutableRefObject<string | null>;
mapDocumentCaches?: (mapper: (doc: any) => any) => void;
}
@@ -47,7 +47,7 @@ const useCorrespondents = ({
}, [apiClient, notifyApiError, tenantIdRef]);
const handleCorrespondentUpdate = useCallback(
async (correspondentId: string | number, changes: { name?: string }) => {
async (correspondentId: string, changes: { name?: string }) => {
if (correspondentId == null) {
throw new Error('Missing correspondent identifier.');
}
@@ -100,7 +100,7 @@ const useCorrespondents = ({
);
const handleCorrespondentDelete = useCallback(
async (correspondentId: string | number) => {
async (correspondentId: string) => {
if (correspondentId == null) {
throw new Error('Missing correspondent identifier.');
}
@@ -1,4 +1,5 @@
import { useCallback, useMemo } from 'react';
import type { Identifier } from '../../types/identifiers';
type ApiClient = {
@@ -7,13 +8,11 @@ type ApiClient = {
};
interface CorrespondentOption {
id?: string | number;
id?: string;
name?: string;
[key: string]: unknown;
}
type Identifier = string | number;
interface UseDocumentCorrespondentActionsArgs {
apiClient: ApiClient;
correspondents: CorrespondentOption[];
@@ -135,7 +134,7 @@ const useDocumentCorrespondentActions = ({
};
const handleCorrespondentAdd = useCallback(
async ({ document, name, input = null, option = null }: { document?: { id?: string | number }; name?: string; input?: HTMLInputElement | null; option?: CorrespondentOption | string | null }) => {
async ({ document, name, input = null, option = null }: { document?: { id?: string }; name?: string; input?: HTMLInputElement | null; option?: CorrespondentOption | string | null }) => {
if (!document?.id) {
throw new Error('Missing document for correspondent assignment.');
}
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import type { DragEvent } from 'react';
import { createDocumentEntryKey, createFolderEntryKey } from '../../app/entryKey';
import type { FolderId, Identifier } from '../../types/identifiers';
type Identifier = string | number;
type FolderIdentifier = string | 'root';
type FolderIdentifier = FolderId | 'root';
type FolderInput = FolderIdentifier | number;
interface DocumentLike {
@@ -14,7 +14,7 @@ interface DocumentLike {
type ApplySelectionFn = (
keys: string[],
options?: { anchor?: string | null; interactedKeys?: string[] },
options?: { anchor: string | null; interactedKeys?: string[] },
) => void;
type HandleEntrySelectionFn = (
@@ -13,9 +13,9 @@ import {
trashDocument,
updateDocument,
} from '../../lib/apiClient';
import type { DocumentId, FolderId as FolderIdentifier } from '../../types/identifiers';
type DocumentId = string | number;
type FolderId = DocumentId | 'root';
type FolderId = FolderIdentifier | 'root';
type NullableFolderId = FolderId | null;
type StatusLevel = 'success' | 'error' | 'info' | string;
@@ -1,6 +1,6 @@
import { useCallback } from 'react';
type Identifier = string | number;
import type { Identifier } from '../../types/identifiers';
interface TagRecord {
id?: Identifier;
@@ -79,59 +79,59 @@ const useDocumentTagging = ({
}
try {
if (action === 'add') {
const createdIds: Identifier[] = [];
const createdTags: TagRecord[] = [];
for (const label of normalized) {
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
if (!tag) {
const payload = tagManager.buildPayload({ label });
const response = await apiClient.post<{ id?: Identifier; label: string }>('/tags', payload);
tag = 'data' in response ? response.data : response;
await refreshTags();
if (action === 'add') {
const createdIds: Identifier[] = [];
const createdTags: TagRecord[] = [];
for (const label of normalized) {
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
if (!tag) {
const payload = tagManager.buildPayload({ label });
const response = await apiClient.post<{ id?: Identifier; label: string }>('/tags', payload);
tag = 'data' in response ? response.data : response;
await refreshTags();
}
createdIds.push(tag.id);
createdTags.push(tag);
}
createdIds.push(tag.id);
createdTags.push(tag);
}
tagIds = Array.from(new Set(createdIds));
tagIds = Array.from(new Set(createdIds));
if (updateDocumentCaches) {
const tagById = new Map<Identifier, TagRecord>();
tags.forEach((tag) => {
if (tag?.id != null) {
tagById.set(tag.id, tag);
}
});
createdTags.forEach((tag) => {
if (tag?.id != null) {
tagById.set(tag.id, tag);
}
});
targetDocumentIds.forEach((docId) => {
tagIds.forEach((tagId) => {
const cachedTag = tagById.get(tagId);
if (!cachedTag) {
return;
if (updateDocumentCaches) {
const tagById = new Map<Identifier, TagRecord>();
tags.forEach((tag) => {
if (tag?.id != null) {
tagById.set(tag.id, tag);
}
updateDocumentCaches(docId, (doc) => {
if (!doc) {
return doc;
});
createdTags.forEach((tag) => {
if (tag?.id != null) {
tagById.set(tag.id, tag);
}
});
targetDocumentIds.forEach((docId) => {
tagIds.forEach((tagId) => {
const cachedTag = tagById.get(tagId);
if (!cachedTag) {
return;
}
const currentTags = Array.isArray((doc as any).tags) ? (doc as any).tags : [];
if (currentTags.some((entry: any) => entry?.id === tagId)) {
return doc;
}
return {
...(doc as any),
tags: [...currentTags, { ...cachedTag }],
};
updateDocumentCaches(docId, (doc) => {
if (!doc) {
return doc;
}
const currentTags = Array.isArray((doc as any).tags) ? (doc as any).tags : [];
if (currentTags.some((entry: any) => entry?.id === tagId)) {
return doc;
}
return {
...(doc as any),
tags: [...currentTags, { ...cachedTag }],
};
});
});
});
});
}
}
}
tagIds = Array.from(new Set(tagIds));
tagIds = Array.from(new Set(tagIds));
if (!tagIds.length) {
return { ok: false, reason: 'no-tags' };
@@ -205,8 +205,7 @@ const useDocumentTagging = ({
if (result?.ok) {
const { tagCount, docsCount } = result;
setStatusMessage(
`Assigned ${tagCount} tag${tagCount === 1 ? '' : 's'} to ${docsCount} document${
docsCount === 1 ? '' : 's'
`Assigned ${tagCount} tag${tagCount === 1 ? '' : 's'} to ${docsCount} document${docsCount === 1 ? '' : 's'
}.`,
'success',
);
@@ -3,8 +3,8 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import useFileDrop from './useFileDrop';
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/workspaceUtils';
import { fetchDocument } from '../../lib/apiClient';
import type { Identifier } from '../../types/identifiers';
type Identifier = string | number;
type FolderId = Identifier | 'root' | null;
type FileEntry = {
+1 -2
View File
@@ -7,8 +7,7 @@ import {
useState,
} from 'react';
import DocumentsManager from '../../documents/DocumentsManager';
type DocumentId = string | number;
import type { DocumentId } from '../../types/identifiers';
interface DocumentLike {
id?: DocumentId;
@@ -52,6 +52,7 @@ import useWorkspaceTaxonomies from './useWorkspaceTaxonomies';
import useWorkspaceBreadcrumbs from './useWorkspaceBreadcrumbs';
import useWorkspaceDeskProps from './useWorkspaceDeskProps';
import useWorkspaceSelectionSync from './useWorkspaceSelectionSync';
import type { DocumentId, FolderId as FolderIdentifier, Identifier } from '../../types/identifiers';
const EntryType = Object.freeze({
document: 'document',
@@ -60,9 +61,7 @@ const EntryType = Object.freeze({
const noop = () => { };
type Identifier = string | number;
type DocumentId = Identifier;
type FolderId = Identifier | 'root';
type FolderId = FolderIdentifier | 'root';
interface DocumentLike {
id?: DocumentId | null;
+1 -1
View File
@@ -1,6 +1,6 @@
import { MutableRefObject, useEffect } from 'react';
type FolderId = string | number | 'root' | null;
type FolderId = string | 'root' | null;
interface DropOverlayState {
active: boolean;
+10 -8
View File
@@ -8,9 +8,9 @@ import {
createDocumentEntryKey,
createFolderEntryKey,
} from '../../app/entryKey';
import type { FolderId as FolderIdentifier, Identifier } from '../../types/identifiers';
type Identifier = string | number;
type FolderId = Identifier | 'root';
type FolderId = FolderIdentifier | 'root';
interface DocumentLike {
id?: Identifier | null;
@@ -126,8 +126,8 @@ const useFolderTree = ({
.filter(Boolean),
);
let nextDocKeys = [];
let mergedSelection = [];
let nextDocKeys: string[] = [];
let mergedSelection: string[] = [];
setSelectedEntries((previous) => {
const previousFolderKeys = previous
@@ -140,9 +140,11 @@ const useFolderTree = ({
});
const nextFocus = (() => {
const currentFocusedKey = createDocumentEntryKey(focusedDocumentId);
if (currentFocusedKey && availableDocKeySet.has(currentFocusedKey)) {
return focusedDocumentId;
if (focusedDocumentId) {
const currentFocusedKey = createDocumentEntryKey(focusedDocumentId);
if (currentFocusedKey && availableDocKeySet.has(currentFocusedKey)) {
return focusedDocumentId;
}
}
if (nextDocKeys.length) {
const lastDocKey = nextDocKeys[nextDocKeys.length - 1];
@@ -172,7 +174,7 @@ const useFolderTree = ({
if (!targetId || targetId === 'root') {
setFolderNodes((prev: Map<FolderId, FolderTreeNode>) => {
const root = prev.get('root');
if (root?.expanded) return prev;
if (!root || root.expanded) return prev;
const next = new Map<FolderId, FolderTreeNode>(prev);
next.set('root', { ...root, expanded: true });
return next;
@@ -7,8 +7,8 @@ import {
moveFolder as moveFolderRequest,
renameFolder as renameFolderRequest,
} from '../../lib/apiClient';
import type { FolderId } from '../../types/identifiers';
type FolderId = string | number;
type FolderKey = FolderId | 'root';
interface FolderNode {
+6 -5
View File
@@ -1,4 +1,5 @@
import { MutableRefObject, useCallback, useState } from 'react';
import type { TagId, TenantId } from '../../types/identifiers';
type ApiClient = {
get: (path: string) => Promise<{ data: unknown }>
@@ -12,7 +13,7 @@ interface TagManagerInterface {
}
interface TagEntry {
id?: string | number;
id?: TagId;
label?: string;
color?: string | null;
[key: string]: unknown;
@@ -23,8 +24,8 @@ interface UseTagsOptions {
notifyApiError: (error: unknown, fallback: string) => void;
setStatusMessage: (message: string, variant?: string) => void;
tagManager: TagManagerInterface;
tenantIdRef: MutableRefObject<string | number | null>;
setActiveTagFilters: (updater: (prev: Array<string | number>) => Array<string | number>) => void;
tenantIdRef: MutableRefObject<TenantId | null>;
setActiveTagFilters: (updater: (prev: Array<TagId>) => Array<TagId>) => void;
mapDocumentCaches?: (mapper: (doc: any) => any) => void;
}
@@ -56,7 +57,7 @@ const useTags = ({
}, [apiClient, notifyApiError, tenantIdRef]);
const handleTagUpdate = useCallback(
async (tagId: string | number, changes: { label?: string; color?: string | null }) => {
async (tagId: TagId, changes: { label?: string; color?: string | null }) => {
if (tagId == null) {
throw new Error('Missing tag identifier.');
}
@@ -104,7 +105,7 @@ const useTags = ({
);
const handleTagDelete = useCallback(
async (tagId: string | number) => {
async (tagId: TagId) => {
if (tagId == null) {
throw new Error('Missing tag identifier.');
}
@@ -1,5 +1,6 @@
import { MutableRefObject, useCallback } from 'react';
import type { NavigateFunction } from 'react-router-dom';
import type { FolderId, TenantId } from '../../types/identifiers';
interface ApiClient {
get: (path: string) => Promise<{ data: unknown }>;
@@ -8,24 +9,24 @@ interface ApiClient {
}
interface TenantOption {
id?: string | number;
id?: TenantId;
name?: string;
}
interface UseTenantManagerOptions {
apiClient: ApiClient;
appDispatch: (action: any) => void;
currentTenantId: string | number | null;
currentTenantId: TenantId | null;
resetWorkspaceState: () => void;
setStatusMessage: (message: string, variant?: string) => void;
notifyApiError: (error: unknown, message: string) => void;
refreshTags: () => Promise<void>;
refreshCorrespondents: () => Promise<void>;
loadFolder: (folderId: string, options?: { preserveSearch?: boolean }) => Promise<void>;
loadFolder: (folderId: FolderId, options?: { preserveSearch?: boolean }) => Promise<void>;
handleDocumentsViewModeChange: (mode: string) => void;
navigate: NavigateFunction;
tokenRef?: MutableRefObject<string | null>;
tenantIdRef?: MutableRefObject<string | number | null>;
tenantIdRef?: MutableRefObject<TenantId | null>;
}
const useTenantManager = ({
@@ -1,8 +1,8 @@
import React, { useEffect, useMemo } from 'react';
import { DEFAULT_FOLDER_NAME } from '../../app/workspaceUtils';
import type { FolderId as FolderIdentifier } from '../../types/identifiers';
type Identifier = string | number;
type FolderId = Identifier | 'root';
type FolderId = FolderIdentifier | 'root';
interface UseWorkspaceBreadcrumbsArgs {
selectedFolder: FolderId | null;
@@ -1,11 +1,10 @@
import { useCallback, useMemo } from 'react';
import type { MutableRefObject } from 'react';
import { createDocumentEntryKey } from '../../app/entryKey';
type Identifier = string | number;
import type { Identifier } from '../../types/identifiers';
interface ApplySelectionFn {
(keys: string[], options?: { anchor?: string | null; interactedKeys?: string[] }): unknown;
(keys: string[], options?: { anchor: string | null; interactedKeys?: string[] }): unknown;
}
interface UseWorkspaceDeskPropsArgs {
@@ -17,8 +16,8 @@ interface UseWorkspaceDeskPropsArgs {
applySelection: ApplySelectionFn;
showingSearchResults: boolean;
searchQuery: string;
activeTagFilters: Array<string | number>;
activeCorrespondentFilters: Array<string | number>;
activeTagFilters: Array<string>;
activeCorrespondentFilters: Array<string>;
selectedFolder: Identifier | 'root' | null;
promoteSelectionOrder: () => void;
handleDocumentTagDrop: (docId: Identifier, tagId: Identifier) => Promise<void> | void;
@@ -1,14 +1,13 @@
import { useEffect } from 'react';
import type { MutableRefObject } from 'react';
type Identifier = string | number;
import type { Identifier } from '../../types/identifiers';
interface UseWorkspaceSelectionSyncArgs {
showingSearchResults: boolean;
searchQuery: string;
setSelectedEntries: (entries: Array<string | number>) => void;
setSelectionOrder: (order: Array<string | number>) => void;
selectionOrderRef: MutableRefObject<Array<string | number>>;
setSelectedEntries: (entries: Array<string>) => void;
setSelectionOrder: (order: Array<string>) => void;
selectionOrderRef: MutableRefObject<Array<string>>;
selectionAnchorRef: MutableRefObject<Identifier | string | null>;
setFocusedDocumentId: (id: Identifier | null) => void;
selectedDocumentIds: Identifier[];
@@ -5,8 +5,7 @@ import TagManager from '../../tag_manager';
import useCorrespondents from './useCorrespondents';
import useDocumentCorrespondentActions from './useDocumentCorrespondentActions';
import useTags from './useTags';
type Identifier = string | number;
import type { Identifier } from '../../types/identifiers';
interface UseWorkspaceTaxonomiesArgs {
apiClient: any;
+3 -4
View File
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { resolveAssetUrl } from '../asset_manager';
type Identifier = string | number;
import type { Identifier } from '../types/identifiers';
type DocumentLike = {
id?: Identifier;
@@ -27,7 +26,7 @@ type AssetLike = {
type EnsureAssetUrl = (
documentId: Identifier,
asset: AssetLike,
options?: { force?: boolean; [key: string]: unknown },
options?: { force?: boolean;[key: string]: unknown },
) => Promise<unknown>;
type GetAsset = (document: DocumentLike, assetType: string) => AssetLike | null;
@@ -92,7 +91,7 @@ export const useAssetNavigator = ({
let cancelled = false;
setIsLoading(true);
ensureAssetUrl(documentId, asset, { force: true })
.catch(() => {})
.catch(() => { })
.finally(() => {
if (!cancelled) {
setIsLoading(false);
+2 -1
View File
@@ -1,6 +1,7 @@
// Types aligned with OpenAPI schemas for common endpoints.
import type { Identifier } from '../types/identifiers';
export type Identifier = string | number;
export type { Identifier };
export interface DownloadLink {
url: string;
+77 -77
View File
@@ -12,7 +12,7 @@ const StatusBanner: React.FC<StatusBannerProps> = ({ status }) => {
};
interface TenantOption {
id?: string | number;
id?: string;
name?: string;
}
@@ -25,7 +25,7 @@ interface LoginViewProps {
tenantSelection?: TenantSelectionState | null;
onSelectTenant?: (tenant: TenantOption) => void;
onCancelSelection?: () => void;
selectingTenantId?: string | number | null;
selectingTenantId?: string | null;
onPasskeyLogin?: (username: string) => void;
onSignup?: (username: string) => void;
passkeySupported?: boolean;
@@ -88,88 +88,88 @@ const LoginView: React.FC<LoginViewProps> = ({
<img
src={loginLogoSrc}
alt="Papercrate logo"
width={72}
height={72}
decoding="async"
loading="lazy"
/>
<h1>Papercrate</h1>
</div>
<div className="login-card">
{hasTenantSelection ? (
<div className="login-card__selection">
<p>Select a tenant to finish signing in.</p>
<div className="login-card__tenant-list">
{tenantSelection?.tenants?.map((tenant) => (
<button
key={tenant.id}
type="button"
onClick={() => onSelectTenant?.(tenant)}
disabled={Boolean(selectingTenantId)}
className={
selectingTenantId === tenant.id
? 'login-card__tenant-button is-loading'
: 'login-card__tenant-button'
}
>
{tenant.name}
</button>
))}
</div>
<button
type="button"
className="login-card__back-button"
onClick={() => onCancelSelection?.()}
disabled={Boolean(selectingTenantId)}
>
Use a different account
</button>
width={72}
height={72}
decoding="async"
loading="lazy"
/>
<h1>Papercrate</h1>
</div>
) : (
<>
<p>Use your registered passkey to sign in or create a new account.</p>
<form className="login-card__fields" onSubmit={handleSubmit}>
<label htmlFor="username">Username</label>
<input
id="username"
name="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="Username"
autoComplete="username"
disabled={passkeyLoading || magicLoginPending}
required
/>
{passkeySupported ? (
<div className="login-card">
{hasTenantSelection ? (
<div className="login-card__selection">
<p>Select a tenant to finish signing in.</p>
<div className="login-card__tenant-list">
{tenantSelection?.tenants?.map((tenant) => (
<button
key={tenant.id}
type="button"
onClick={() => onSelectTenant?.(tenant)}
disabled={Boolean(selectingTenantId)}
className={
selectingTenantId === tenant.id
? 'login-card__tenant-button is-loading'
: 'login-card__tenant-button'
}
>
{tenant.name}
</button>
))}
</div>
<button
type="submit"
className="login-card__passkey-button"
disabled={passkeyLoading || magicLoginPending || !username.trim()}
type="button"
className="login-card__back-button"
onClick={() => onCancelSelection?.()}
disabled={Boolean(selectingTenantId)}
>
{passkeyLoading ? 'Signing in…' : 'Sign in with passkey'}
Use a different account
</button>
) : (
<p className="settings-empty">Passkeys are not supported in this browser.</p>
)}
</form>
{signupSupported ? (
<button
type="button"
className="login-card__signup-button"
onClick={handleSignupClick}
disabled={signupLoading || magicLoginPending || !username.trim()}
>
{signupLoading ? 'Creating account…' : 'Create account with passkey'}
</button>
) : null}
</>
)}
<StatusBanner status={status} />
</div>
) : (
<>
<p>Use your registered passkey to sign in or create a new account.</p>
<form className="login-card__fields" onSubmit={handleSubmit}>
<label htmlFor="username">Username</label>
<input
id="username"
name="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="Username"
autoComplete="username"
disabled={passkeyLoading || magicLoginPending}
required
/>
{passkeySupported ? (
<button
type="submit"
className="login-card__passkey-button"
disabled={passkeyLoading || magicLoginPending || !username.trim()}
>
{passkeyLoading ? 'Signing in…' : 'Sign in with passkey'}
</button>
) : (
<p className="settings-empty">Passkeys are not supported in this browser.</p>
)}
</form>
{signupSupported ? (
<button
type="button"
className="login-card__signup-button"
onClick={handleSignupClick}
disabled={signupLoading || magicLoginPending || !username.trim()}
>
{signupLoading ? 'Creating account…' : 'Create account with passkey'}
</button>
) : null}
</>
)}
<StatusBanner status={status} />
</div>
</div>
</div>
</div>
</div>
);
};
@@ -5,7 +5,7 @@ import { DownloadIcon } from '../ui/icons';
import PdfViewer from './PdfViewer';
interface DocumentLike {
id?: string | number;
id?: string;
title?: string;
mime_type?: string;
filename?: string;
@@ -40,7 +40,7 @@ interface DocumentViewerLayoutProps {
summaryProps?: Record<string, unknown>;
metadataPayload?: unknown;
contentTabConfig?: ContentTabConfig | null;
resetKey?: string | number | null;
resetKey?: string | null;
classNamePrefix?: string;
defaultTabId?: string;
infoPanelProps?: Record<string, unknown>;
@@ -211,12 +211,12 @@ const DocumentViewerLayout = ({
const stackedLeadingTabs = useMemo(() => (
isStacked
? [
{
id: 'preview',
label: 'Preview',
render: () => renderViewportPane(),
},
]
{
id: 'preview',
label: 'Preview',
render: () => renderViewportPane(),
},
]
: []
), [isStacked, renderViewportPane]);
+10 -9
View File
@@ -28,14 +28,15 @@ import BreadcrumbTrail from '../ui/BreadcrumbTrail';
import DocumentViewerLayout from './DocumentViewerLayout';
import useViewerLayoutMode from './useViewerLayoutMode';
import { usePanelResizeBindings } from '../app/PanelManagerContext';
import type { DocumentId, FolderId } from '../types/identifiers';
interface DocumentLike {
id?: string | number;
id?: DocumentId;
title?: string;
mime_type?: string | null;
issued_at?: string | null;
folder_id?: string | null;
correspondents?: Array<{ id?: string | number; name?: string }>;
folder_id?: FolderId | null;
correspondents?: Array<{ id?: string; name?: string }>;
current_version?: {
version_number?: number;
download?: { url?: string | null; expires_at?: number } | null;
@@ -51,7 +52,7 @@ interface DocumentLike {
}
interface AssetLike {
id?: string | number;
id?: string;
url?: string | null;
metadata?: Record<string, unknown> | null;
[key: string]: unknown;
@@ -59,16 +60,16 @@ interface AssetLike {
interface DocumentViewerPanelProps extends DocumentSummarySectionProps {
document: DocumentLike | null;
ensureAssetUrl?: (docId: string | number, asset: AssetLike, options?: { force?: boolean }) => Promise<unknown>;
ensureAssetUrl?: (docId: DocumentId, asset: AssetLike, options?: { force?: boolean }) => Promise<unknown>;
getDocumentAsset?: (doc: DocumentLike | null, type: string) => AssetLike | null;
ensurePreviewData?: (docId: string | number, options?: { signal?: AbortSignal }) => Promise<DocumentLike | null>;
ensurePreviewData?: (docId: DocumentId, options?: { signal?: AbortSignal }) => Promise<DocumentLike | null>;
notifyApiError?: (error: unknown, fallbackMessage?: string) => void;
sidebarToggle?: ReactNode;
onClosePanel?: () => void;
resolveFolderPath?: (doc: DocumentLike | null) => Array<{ id?: string | number; name?: string }>;
resolveFolderPath?: (doc: DocumentLike | null) => Array<{ id?: string; name?: string }>;
variant?: 'viewer' | 'sidebar';
onCollapsePanel?: () => void;
onMaximizePanel?: (args: { documentIds: Array<string | number> }) => void;
onMaximizePanel?: (args: { documentIds: Array<string> }) => void;
}
@@ -163,7 +164,7 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
}, [document, getDocumentAsset]);
const navigateToFolder = useCallback(
(folderId) => {
(folderId: FolderId | null) => {
const target = folderId == null
? '/documents'
: `/documents/folder/${folderId}`;
+1 -2
View File
@@ -2,8 +2,7 @@ import React, { useEffect } from 'react';
import { Navigate, useNavigate, useParams } from 'react-router-dom';
import { useAppShell } from '../appShellContext';
type Identifier = string | number;
import type { Identifier } from '../types/identifiers';
interface DocumentViewerRouteContext {
previewWorkspaceDocument?: { id?: Identifier } | null;
@@ -7,9 +7,7 @@ import {
} from 'react';
import type { JSX } from 'react';
import { CheckIcon, ChevronDownIcon } from '../../ui/icons';
type CapabilityValue = string | number;
import type { CapabilityValue } from '../../types/identifiers';
export interface CapabilityDropdownOption {
value?: CapabilityValue | null;
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ChangeEvent, FormEvent } from 'react';
type Identifier = string | number;
import type { Identifier } from '../../types/identifiers';
interface ApiTokenEntry {
id?: Identifier;
@@ -125,21 +124,21 @@ const ApiTokensSection = ({
const capabilitySelectionOptions = useMemo<CapabilitySelectionOption[]>(() => (
Array.isArray(capabilities)
? capabilities.map((capability) => {
const capabilityText = `${capability ?? ''}`;
if (!capabilityText.includes(':')) {
return { value: capability, label: capabilityText };
}
const [namespace, action] = capabilityText.split(':');
if (!namespace || !action) {
return { value: capability, label: capabilityText };
}
const formattedNamespace = `${namespace.charAt(0).toUpperCase()}${namespace.slice(1)}`;
const formattedAction = action.replace(/_/g, ' ');
return {
value: capability,
label: `${formattedNamespace}: ${formattedAction}`,
};
})
const capabilityText = `${capability ?? ''}`;
if (!capabilityText.includes(':')) {
return { value: capability, label: capabilityText };
}
const [namespace, action] = capabilityText.split(':');
if (!namespace || !action) {
return { value: capability, label: capabilityText };
}
const formattedNamespace = `${namespace.charAt(0).toUpperCase()}${namespace.slice(1)}`;
const formattedAction = action.replace(/_/g, ' ');
return {
value: capability,
label: `${formattedNamespace}: ${formattedAction}`,
};
})
: []
), [capabilities]);
@@ -8,10 +8,7 @@ import React, {
import type { SettingsSectionConfig } from '../SettingsModal';
import { IconX } from '../../ui/icons';
import CapabilityDropdown, { CapabilityDropdownOption } from '../components/CapabilityDropdown';
type CapabilityValue = string | number;
type CapabilitySetId = string | number;
import type { CapabilitySetId, CapabilityValue } from '../../types/identifiers';
interface CapabilitySet {
id: CapabilitySetId;
@@ -18,10 +18,10 @@ interface PasskeysSectionProps {
passkeysSupported?: boolean | null;
passkeysLoading?: boolean;
registeringPasskey?: boolean;
revokingPasskeyId?: string | number | null;
revokingPasskeyId?: string | null;
onRefreshPasskeys?: () => void | Promise<void>;
onRegisterPasskey?: (args: { nickname?: string }) => Promise<RegisterPasskeyResult | undefined>;
onRevokePasskey?: (id: string | number, reason?: string) => Promise<void>;
onRevokePasskey?: (id: string, reason?: string) => Promise<void>;
}
const PasskeysSection = ({
+11 -10
View File
@@ -6,6 +6,7 @@ import {
regenerateApiToken,
type ApiTokenRecord,
} from '../lib/apiClient';
import type { ApiTokenId, CapabilitySetId } from '../types/identifiers';
interface ApiTokensResponse {
token_info?: ApiTokenRecord;
@@ -15,7 +16,7 @@ interface ApiTokensResponse {
interface CreateTokenArgs {
label?: string;
expires_at?: string;
capability_set_id?: string | number;
capability_set_id?: CapabilitySetId;
}
interface UseApiTokensArgs {
@@ -28,13 +29,13 @@ interface UseApiTokensResult {
tokens: ApiTokenRecord[];
loading: boolean;
creating: boolean;
deletingId: string | number | null;
regeneratingId: string | number | null;
deletingId: ApiTokenId | null;
regeneratingId: ApiTokenId | null;
createdSecret: string | null;
refresh: () => Promise<void>;
create: (args?: CreateTokenArgs) => Promise<ApiTokensResponse | false>;
revoke: (tokenId?: string | number | null) => Promise<boolean>;
regenerate: (tokenId?: string | number | null) => Promise<boolean>;
revoke: (tokenId?: ApiTokenId | null) => Promise<boolean>;
regenerate: (tokenId?: ApiTokenId | null) => Promise<boolean>;
dismissSecret: () => void;
}
@@ -42,8 +43,8 @@ const useApiTokens = ({ notifyApiError, setStatusMessage, token }: UseApiTokensA
const [tokens, setTokens] = useState<ApiTokenRecord[]>([]);
const [loading] = useState(false);
const [creating, setCreating] = useState(false);
const [deletingId, setDeletingId] = useState<string | number | null>(null);
const [regeneratingId, setRegeneratingId] = useState<string | number | null>(null);
const [deletingId, setDeletingId] = useState<ApiTokenId | null>(null);
const [regeneratingId, setRegeneratingId] = useState<ApiTokenId | null>(null);
const [createdSecret, setCreatedSecret] = useState<string | null>(null);
const refresh = useCallback(async () => {
@@ -65,7 +66,7 @@ const useApiTokens = ({ notifyApiError, setStatusMessage, token }: UseApiTokensA
}
setCreating(true);
try {
const payload: { capability_set_id: string | number; label?: string; expires_at?: string } = { capability_set_id };
const payload: { capability_set_id: CapabilitySetId; label?: string; expires_at?: string } = { capability_set_id };
if (label) {
payload.label = label;
}
@@ -100,7 +101,7 @@ const useApiTokens = ({ notifyApiError, setStatusMessage, token }: UseApiTokensA
);
const revoke = useCallback(
async (tokenId?: string | number | null) => {
async (tokenId?: string | null) => {
if (!tokenId) {
return false;
}
@@ -121,7 +122,7 @@ const useApiTokens = ({ notifyApiError, setStatusMessage, token }: UseApiTokensA
);
const regenerate = useCallback(
async (tokenId?: string | number | null) => {
async (tokenId?: string | null) => {
if (!tokenId) {
return false;
}
+1 -2
View File
@@ -5,8 +5,7 @@ import {
listCapabilitySets,
updateCapabilitySet as updateCapabilitySetRequest,
} from '../lib/apiClient';
type Identifier = string | number;
import type { Identifier } from '../types/identifiers';
interface CapabilitySet {
id?: Identifier;
+6 -5
View File
@@ -12,6 +12,7 @@ import {
listPasskeys,
startPasskeyRegistration,
} from '../lib/apiClient';
import type { PasskeyId } from '../types/identifiers';
type StatusMessageFn = (message: string, variant?: string) => void;
type NotifyApiErrorFn = (error: unknown, message: string) => void;
@@ -27,7 +28,7 @@ type ApiError = {
};
export interface PasskeyRecord {
id?: string | number;
id?: PasskeyId;
nickname?: string;
created_at?: string;
createdAt?: string;
@@ -79,11 +80,11 @@ interface UsePasskeysResult {
passkeysSupported: boolean | null;
passkeysLoading: boolean;
registeringPasskey: boolean;
revokingPasskeyId: string | number | null;
revokingPasskeyId: PasskeyId | null;
refreshPasskeys: () => Promise<void>;
registerPasskey: (options?: { nickname?: string }) => Promise<RegisterPasskeyResult>;
revokePasskey: (
passkeyId: string | number,
passkeyId: PasskeyId,
reason?: string,
) => Promise<RevokePasskeyResult>;
}
@@ -93,7 +94,7 @@ const usePasskeys = ({ notifyApiError, setStatusMessage, token }: UsePasskeysArg
const [passkeysSupported, setPasskeysSupported] = useState<boolean | null>(null);
const [passkeysLoading, setPasskeysLoading] = useState(false);
const [registeringPasskey, setRegisteringPasskey] = useState(false);
const [revokingPasskeyId, setRevokingPasskeyId] = useState<string | number | null>(null);
const [revokingPasskeyId, setRevokingPasskeyId] = useState<PasskeyId | null>(null);
const refreshPasskeys = useCallback(async (): Promise<void> => {
if (!token) {
@@ -191,7 +192,7 @@ const usePasskeys = ({ notifyApiError, setStatusMessage, token }: UsePasskeysArg
const revokePasskey = useCallback(
async (
passkeyId: string | number,
passkeyId: PasskeyId,
reason?: string,
): Promise<RevokePasskeyResult> => {
if (passkeyId == null) {
+159 -160
View File
@@ -31,6 +31,7 @@ import { getTagColorStyle } from '../utils/colors';
import { useSidebarContext } from './SidebarContext';
import { usePanelManager, usePanelResizeBindings } from '../app/PanelManagerContext';
import type { StatusMessage } from '../hooks/documents/store/useDocumentsStore';
import type { Identifier } from '../types/identifiers';
interface CommunityLink {
label: string;
@@ -60,7 +61,6 @@ const COMMUNITY_LINKS: CommunityLink[] = [
},
];
type Identifier = string | number;
type FolderIdentifier = Identifier | 'root';
interface FolderTreeNode {
@@ -528,94 +528,94 @@ const Sidebar: React.FC<SidebarProps> = ({
const themeMenuSection =
neutralHue != null
? (
<div className="menu__section">
<div className="menu__heading menu__heading--with-actions">
<span>Theme</span>
<div className="menu__heading-actions">
<button
type="button"
className="icon-button"
onClick={handleThemeModeToggle}
aria-label={`Switch theme (next: ${nextThemeLabel})`}
title={`Theme: ${themeModeLabel} (next: ${nextThemeLabel})`}
>
{themeModeIcon}
</button>
<button
type="button"
className="icon-button"
onClick={handleThemeAdjustmentsReset}
aria-label="Reset theme adjustments"
title="Reset theme adjustments"
>
<RestoreIcon size={16} />
</button>
</div>
<div className="menu__section">
<div className="menu__heading menu__heading--with-actions">
<span>Theme</span>
<div className="menu__heading-actions">
<button
type="button"
className="icon-button"
onClick={handleThemeModeToggle}
aria-label={`Switch theme (next: ${nextThemeLabel})`}
title={`Theme: ${themeModeLabel} (next: ${nextThemeLabel})`}
>
{themeModeIcon}
</button>
<button
type="button"
className="icon-button"
onClick={handleThemeAdjustmentsReset}
aria-label="Reset theme adjustments"
title="Reset theme adjustments"
>
<RestoreIcon size={16} />
</button>
</div>
<label className="menu__slider" htmlFor={neutralHueInputId}>
<span className="menu__slider-label">Hue</span>
<input
id={neutralHueInputId}
type="range"
min="0"
max="360"
step="1"
value={neutralHue}
onChange={(event) => handleNeutralHueChange(event.target.value)}
onDoubleClick={resetNeutralHue}
/>
<span className="menu__slider-value">{neutralHue}°</span>
</label>
<label className="menu__slider" htmlFor={neutralChromaInputId}>
<span className="menu__slider-label">Chroma</span>
<input
id={neutralChromaInputId}
type="range"
min="0"
max="1"
step="0.01"
value={neutralChroma}
onChange={(event) => handleNeutralChromaChange(event.target.value)}
onDoubleClick={resetNeutralChroma}
/>
<span className="menu__slider-value">{formatSliderValue(neutralChroma)}</span>
</label>
<label className="menu__slider" htmlFor={neutralContrastInputId}>
<span className="menu__slider-label">Contrast</span>
<input
id={neutralContrastInputId}
type="range"
min="0"
max="1"
step="0.01"
value={neutralContrast}
onChange={(event) => handleNeutralContrastChange(event.target.value)}
onDoubleClick={resetNeutralContrast}
/>
<span className="menu__slider-value">{formatSliderValue(neutralContrast)}</span>
</label>
</div>
)
<label className="menu__slider" htmlFor={neutralHueInputId}>
<span className="menu__slider-label">Hue</span>
<input
id={neutralHueInputId}
type="range"
min="0"
max="360"
step="1"
value={neutralHue}
onChange={(event) => handleNeutralHueChange(event.target.value)}
onDoubleClick={resetNeutralHue}
/>
<span className="menu__slider-value">{neutralHue}°</span>
</label>
<label className="menu__slider" htmlFor={neutralChromaInputId}>
<span className="menu__slider-label">Chroma</span>
<input
id={neutralChromaInputId}
type="range"
min="0"
max="1"
step="0.01"
value={neutralChroma}
onChange={(event) => handleNeutralChromaChange(event.target.value)}
onDoubleClick={resetNeutralChroma}
/>
<span className="menu__slider-value">{formatSliderValue(neutralChroma)}</span>
</label>
<label className="menu__slider" htmlFor={neutralContrastInputId}>
<span className="menu__slider-label">Contrast</span>
<input
id={neutralContrastInputId}
type="range"
min="0"
max="1"
step="0.01"
value={neutralContrast}
onChange={(event) => handleNeutralContrastChange(event.target.value)}
onDoubleClick={resetNeutralContrast}
/>
<span className="menu__slider-value">{formatSliderValue(neutralContrast)}</span>
</label>
</div>
)
: 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>
)
<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(
@@ -736,62 +736,62 @@ const Sidebar: React.FC<SidebarProps> = ({
}
const tenantMenuContent = tenantMenuOpen && tenantMenuStyle
? createPortal(
<div
className={menuClassName}
ref={tenantMenuRef}
role="menu"
aria-label="Account menu"
style={tenantMenuStyle}
>
{showTenantList ? (
<div className="menu__section">
<div className="menu__heading">Switch tenant</div>
<div className="menu__wrapper">
{tenants.map((tenant) => {
const tenantId = tenant?.id || null;
const isActive = tenantId === activeTenantId;
const tenantLabel = tenant?.name || tenantId || 'Tenant';
return (
<button
key={tenantId ?? tenantLabel}
type="button"
className={`menu__button${isActive ? ' active' : ''}`}
onClick={() => handleTenantSelect(tenant)}
role="menuitem"
>
<span className="menu__check-slot">
{isActive ? <CheckIcon size={16} /> : null}
</span>
<span className="menu__label">{tenantLabel}</span>
</button>
);
})}
</div>
</div>
) : null}
<div className="menu__section">
<div className="menu__wrapper">
<button type="button" className="menu__button" onClick={handleSettingsFromMenu}>
<SettingsIcon size={16} />
Settings
</button>
<button
type="button"
className="menu__button menu__button--danger"
onClick={handleLogoutFromMenu}
>
<LogoutIcon size={16} />
Log out
</button>
</div>
? createPortal(
<div
className={menuClassName}
ref={tenantMenuRef}
role="menu"
aria-label="Account menu"
style={tenantMenuStyle}
>
{showTenantList ? (
<div className="menu__section">
<div className="menu__heading">Switch tenant</div>
<div className="menu__wrapper">
{tenants.map((tenant) => {
const tenantId = tenant?.id || null;
const isActive = tenantId === activeTenantId;
const tenantLabel = tenant?.name || tenantId || 'Tenant';
return (
<button
key={tenantId ?? tenantLabel}
type="button"
className={`menu__button${isActive ? ' active' : ''}`}
onClick={() => handleTenantSelect(tenant)}
role="menuitem"
>
<span className="menu__check-slot">
{isActive ? <CheckIcon size={16} /> : null}
</span>
<span className="menu__label">{tenantLabel}</span>
</button>
);
})}
</div>
{themeMenuSection}
{communityMenuFooter}
</div>,
document.body,
)
: null;
</div>
) : null}
<div className="menu__section">
<div className="menu__wrapper">
<button type="button" className="menu__button" onClick={handleSettingsFromMenu}>
<SettingsIcon size={16} />
Settings
</button>
<button
type="button"
className="menu__button menu__button--danger"
onClick={handleLogoutFromMenu}
>
<LogoutIcon size={16} />
Log out
</button>
</div>
</div>
{themeMenuSection}
{communityMenuFooter}
</div>,
document.body,
)
: null;
return (
<aside className={sidebarClassNames.join(' ')} ref={sidebarRef} style={sidebarStyle}>
@@ -925,9 +925,8 @@ const Sidebar: React.FC<SidebarProps> = ({
</div>
</div>
<div
className={`sidebar-tag-cloud${
activeTagSet.size ? ' sidebar-tag-cloud--has-active' : ''
}`}
className={`sidebar-tag-cloud${activeTagSet.size ? ' sidebar-tag-cloud--has-active' : ''
}`}
role="list"
>
{untaggedFilterId ? (
@@ -955,25 +954,25 @@ const Sidebar: React.FC<SidebarProps> = ({
style={style || undefined}
onClick={() => handleToggleTag(tag.id)}
aria-pressed={isActive}
draggable
onDragStart={(event) => {
try {
const payload = JSON.stringify({
id: tag.id,
label: tag.label,
color: tag.color || null,
});
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData('application/x-papercrate-tag', payload);
event.dataTransfer.setData('text/papercrate-tag', payload);
} catch (error) {
console.warn('[sidebar] Failed to set tag drag payload', error);
}
}}
>
{tag.label}
</button>
);
draggable
onDragStart={(event) => {
try {
const payload = JSON.stringify({
id: tag.id,
label: tag.label,
color: tag.color || null,
});
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData('application/x-papercrate-tag', payload);
event.dataTransfer.setData('text/papercrate-tag', payload);
} catch (error) {
console.warn('[sidebar] Failed to set tag drag payload', error);
}
}}
>
{tag.label}
</button>
);
})}
</div>
</div>
+1 -2
View File
@@ -2,8 +2,7 @@ import { useMemo } from 'react';
import type { DragEvent } from 'react';
import { TAG_FILTER_UNTAGGED } from '../app/workspaceUtils';
import type { StatusMessage } from '../hooks/documents/store/useDocumentsStore';
type Identifier = string | number;
import type { Identifier } from '../types/identifiers';
interface FolderTreeNode {
id: Identifier;
+11
View File
@@ -0,0 +1,11 @@
// Common string-based identifiers used across the app.
export type Identifier = string;
export type DocumentId = Identifier;
export type FolderId = Identifier;
export type CapabilitySetId = Identifier;
export type CapabilityValue = Identifier;
export type TenantId = Identifier;
export type TagId = Identifier;
export type ApiTokenId = Identifier;
export type PasskeyId = Identifier;
+3 -3
View File
@@ -4,10 +4,10 @@ import { PlusIcon } from './icons';
import useFloatingMenu from './useFloatingMenu';
type QuickAddOption = string | number | { id?: string | number; label?: string; name?: string;[key: string]: unknown };
type QuickAddOption = string | { id?: string; label?: string; name?: string;[key: string]: unknown };
interface NormalizedOption {
id?: string | number;
id?: string;
label: string;
original: QuickAddOption;
index: number;
@@ -47,7 +47,7 @@ interface FloatingMenuState {
updatePosition: () => void;
}
type CSSVarStyle = CSSProperties & Record<string, string | number>;
type CSSVarStyle = CSSProperties & Record<string, string>;
export interface QuickAddMenuProps {
onSelectOption?: (value: QuickAddOption, normalized: NormalizedOption) => Promise<void> | void;
+6 -6
View File
@@ -1,4 +1,4 @@
const ensureDate = (value: string | number | Date | null): Date | null => {
const ensureDate = (value: string | 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, { fallback = '—', locale, options }: FormatOptions = {}): string => {
export const formatDate = (value: string | 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, { fallback = '
return date.toLocaleDateString(locale, options);
};
export const formatDateTime = (value: string | number | Date | null, { fallback = '—', locale, options }: FormatOptions = {}): string => {
export const formatDateTime = (value: string | 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, { fallback
return date.toLocaleString(locale, options);
};
export const toDateInputValue = (value: string | number | Date | null): string => {
export const toDateInputValue = (value: string | Date | null): string => {
const date = ensureDate(value);
if (!date) {
return '';
@@ -38,7 +38,7 @@ export const toDateInputValue = (value: string | number | Date | null): string =
return localDate.toISOString().slice(0, 10);
};
export const toIssuedTimestamp = (dateString: string | null, fallback: string | number | Date | null): string | null => {
export const toIssuedTimestamp = (dateString: string | null, fallback: string | Date | null): string | null => {
if (!dateString) {
return null;
}
@@ -52,7 +52,7 @@ export const toIssuedTimestamp = (dateString: string | null, fallback: string |
return Number.isNaN(candidate.getTime()) ? null : candidate.toISOString();
};
export const parseDateValue = (value: string | number | Date | null): Date | null => ensureDate(value);
export const parseDateValue = (value: string | Date | null): Date | null => ensureDate(value);
export default {
formatDate,
+2 -2
View File
@@ -12,9 +12,9 @@ export interface DocumentLike extends AssetManagerDocumentLike {
export type AssetLike = AssetManagerAssetLike;
export type EnsurePreviewData = (id: string | number) => Promise<DocumentLike | null>;
export type EnsurePreviewData = (id: string) => Promise<DocumentLike | null>;
export type EnsureAssetUrl = (
id: string | number,
id: string,
asset: AssetLike,
options?: { force?: boolean },
) => Promise<AssetLike | null>;