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