refactor: introduce dedicated identifier types for improved clarity and type safety
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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[]>([]);
|
||||||
|
|
||||||
|
|||||||
@@ -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,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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> </th>
|
<th> </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,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[];
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 = ({
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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 = ({
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user