Merge remote-tracking branch 'ui/ui' into dev
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
ALTER TABLE tenant.document_tags
|
||||||
|
DROP CONSTRAINT IF EXISTS document_tags_assigned_by_fkey,
|
||||||
|
ADD CONSTRAINT document_tags_assigned_by_fkey
|
||||||
|
FOREIGN KEY (assigned_by)
|
||||||
|
REFERENCES shared.users (id)
|
||||||
|
ON DELETE NO ACTION;
|
||||||
|
|
||||||
|
ALTER TABLE tenant.document_correspondents
|
||||||
|
DROP CONSTRAINT IF EXISTS document_correspondents_assigned_by_fkey,
|
||||||
|
ADD CONSTRAINT document_correspondents_assigned_by_fkey
|
||||||
|
FOREIGN KEY (assigned_by)
|
||||||
|
REFERENCES shared.users (id)
|
||||||
|
ON DELETE NO ACTION;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
ALTER TABLE tenant.document_tags
|
||||||
|
DROP CONSTRAINT IF EXISTS document_tags_assigned_by_fkey,
|
||||||
|
ADD CONSTRAINT document_tags_assigned_by_fkey
|
||||||
|
FOREIGN KEY (assigned_by)
|
||||||
|
REFERENCES shared.users (id)
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE tenant.document_correspondents
|
||||||
|
DROP CONSTRAINT IF EXISTS document_correspondents_assigned_by_fkey,
|
||||||
|
ADD CONSTRAINT document_correspondents_assigned_by_fkey
|
||||||
|
FOREIGN KEY (assigned_by)
|
||||||
|
REFERENCES shared.users (id)
|
||||||
|
ON DELETE SET NULL;
|
||||||
+11
-2
@@ -12,6 +12,8 @@ pub mod sql_types {
|
|||||||
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
|
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
|
||||||
#[diesel(postgres_type(name = "api_capability"))]
|
#[diesel(postgres_type(name = "api_capability"))]
|
||||||
pub struct ApiCapability;
|
pub struct ApiCapability;
|
||||||
|
#[diesel(postgres_type(name = "api_capability"))]
|
||||||
|
pub struct ApiCapability;
|
||||||
}
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
@@ -205,6 +207,7 @@ diesel::table! {
|
|||||||
created_at -> Timestamptz,
|
created_at -> Timestamptz,
|
||||||
updated_at -> Timestamptz,
|
updated_at -> Timestamptz,
|
||||||
capability_set_id -> Nullable<Uuid>,
|
capability_set_id -> Nullable<Uuid>,
|
||||||
|
capability_set_id -> Nullable<Uuid>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,24 +313,31 @@ diesel::joinable!(documents -> folders (folder_id));
|
|||||||
diesel::joinable!(documents -> tenants (tenant_id));
|
diesel::joinable!(documents -> tenants (tenant_id));
|
||||||
diesel::joinable!(folders -> tenants (tenant_id));
|
diesel::joinable!(folders -> tenants (tenant_id));
|
||||||
diesel::joinable!(jobs -> tenants (tenant_id));
|
diesel::joinable!(jobs -> tenants (tenant_id));
|
||||||
|
diesel::joinable!(magic_tokens -> users (user_id));
|
||||||
diesel::joinable!(user_sessions -> tenants (tenant_id));
|
diesel::joinable!(user_sessions -> tenants (tenant_id));
|
||||||
diesel::joinable!(user_sessions -> users (user_id));
|
diesel::joinable!(user_sessions -> users (user_id));
|
||||||
diesel::joinable!(tags -> tenants (tenant_id));
|
diesel::joinable!(tags -> tenants (tenant_id));
|
||||||
diesel::joinable!(user_memberships -> capability_sets (capability_set_id));
|
diesel::joinable!(user_memberships -> capability_sets (capability_set_id));
|
||||||
|
diesel::joinable!(user_memberships -> capability_sets (capability_set_id));
|
||||||
diesel::joinable!(user_memberships -> tenants (tenant_id));
|
diesel::joinable!(user_memberships -> tenants (tenant_id));
|
||||||
diesel::joinable!(user_memberships -> users (user_id));
|
diesel::joinable!(user_memberships -> users (user_id));
|
||||||
diesel::joinable!(user_passkeys -> users (user_id));
|
diesel::joinable!(user_passkeys -> users (user_id));
|
||||||
diesel::joinable!(webauthn_challenges -> users (user_id));
|
diesel::joinable!(webauthn_challenges -> users (user_id));
|
||||||
|
diesel::joinable!(api_tokens -> capability_sets (capability_set_id));
|
||||||
diesel::joinable!(api_tokens -> tenants (tenant_id));
|
diesel::joinable!(api_tokens -> tenants (tenant_id));
|
||||||
diesel::joinable!(api_tokens -> capability_sets (capability_set_id));
|
diesel::joinable!(api_tokens -> capability_sets (capability_set_id));
|
||||||
diesel::joinable!(api_tokens -> users (user_id));
|
diesel::joinable!(api_tokens -> users (user_id));
|
||||||
|
|
||||||
diesel::allow_tables_to_appear_in_same_query!(
|
diesel::allow_tables_to_appear_in_same_query!(
|
||||||
|
api_tokens,
|
||||||
|
capability_set_capabilities,
|
||||||
|
capability_sets,
|
||||||
correspondents,
|
correspondents,
|
||||||
capability_set_capabilities,
|
capability_set_capabilities,
|
||||||
capability_sets,
|
capability_sets,
|
||||||
document_asset_objects,
|
document_asset_objects,
|
||||||
document_assets,
|
document_assets,
|
||||||
|
document_assets_v2,
|
||||||
document_correspondents,
|
document_correspondents,
|
||||||
document_tags,
|
document_tags,
|
||||||
document_versions,
|
document_versions,
|
||||||
@@ -335,12 +345,11 @@ diesel::allow_tables_to_appear_in_same_query!(
|
|||||||
folders,
|
folders,
|
||||||
jobs,
|
jobs,
|
||||||
magic_tokens,
|
magic_tokens,
|
||||||
user_sessions,
|
|
||||||
tags,
|
tags,
|
||||||
tenants,
|
tenants,
|
||||||
user_memberships,
|
user_memberships,
|
||||||
user_passkeys,
|
user_passkeys,
|
||||||
|
user_sessions,
|
||||||
users,
|
users,
|
||||||
webauthn_challenges,
|
webauthn_challenges,
|
||||||
api_tokens,
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Desktop Workspace Interaction Spec
|
||||||
|
|
||||||
|
The desktop workspace should apply the following selection and drag behaviours:
|
||||||
|
|
||||||
|
- **Click on a non-selected card**: clear any existing selection, then select the clicked card only.
|
||||||
|
- **Click on a selected card**: keep the selection and open the detail panel for that card (no selection change).
|
||||||
|
- **Drag on a non-selected card**: clear the selection, select the dragged card, then drag that single card.
|
||||||
|
- **Drag on a selected card**: drag the entire current selection without altering which cards are selected.
|
||||||
|
- **Cmd/Ctrl + click on a non-selected card**: add that card to the existing selection.
|
||||||
|
- **Cmd/Ctrl + click on a selected card**: expand the selection by adding the stack of cards beneath the clicked card.
|
||||||
|
- **Cmd/Ctrl + drag on a non-selected card**: replace the current selection with the entire stack beneath the pointer, then drag that stack.
|
||||||
|
- **Cmd/Ctrl + drag on a selected card**: replace the current selection with the stack beneath the pointer, then drag that stack.
|
||||||
|
- **Touch long-press**: behaves like a stack-select gesture, expanding the selection to the stack under the pressed card without requiring modifier keys.
|
||||||
|
|
||||||
|
These rules ensure the selection model remains predictable while supporting stack-aware gestures unique to the desktop workspace.
|
||||||
+6
-3
@@ -1,6 +1,9 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1.6
|
||||||
|
|
||||||
FROM node:20-alpine AS build
|
ARG NODE_IMAGE=node:20-alpine
|
||||||
|
ARG NGINX_IMAGE=nginx:alpine
|
||||||
|
|
||||||
|
FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
@@ -9,7 +12,7 @@ RUN npm ci --no-audit --no-fund
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM ${NGINX_IMAGE}
|
||||||
WORKDIR /usr/share/nginx/html
|
WORKDIR /usr/share/nginx/html
|
||||||
|
|
||||||
COPY --from=build /app/dist ./
|
COPY --from=build /app/dist ./
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "webpack serve --mode development --open",
|
"dev": "webpack serve --mode development --open",
|
||||||
"build": "webpack --mode production",
|
"build": "webpack --mode production",
|
||||||
"lint": "eslint src --ext .js,.jsx"
|
"lint": "eslint src --ext .js,.jsx",
|
||||||
|
"test:engine": "node --test tests/workspaceEngine.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+26
-5021
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAppShell } from '../appShellContext';
|
import { useAppShell } from '../appShellContext';
|
||||||
import DocumentsLayout from './DocumentsLayout';
|
import DocumentsLayout from './DocumentsLayout';
|
||||||
@@ -21,7 +21,6 @@ const DocumentsRouteContent = () => {
|
|||||||
previewWorkspaceEntry,
|
previewWorkspaceEntry,
|
||||||
previewDocumentId,
|
previewDocumentId,
|
||||||
closeDocumentPreview,
|
closeDocumentPreview,
|
||||||
handleThumbnailRegeneration,
|
|
||||||
ensurePreviewData,
|
ensurePreviewData,
|
||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
@@ -84,12 +83,18 @@ const DocumentsRouteContent = () => {
|
|||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
notifyApiError,
|
notifyApiError,
|
||||||
handleThumbnailRegeneration,
|
|
||||||
closeDocumentPreview,
|
closeDocumentPreview,
|
||||||
parentBreadcrumb,
|
parentBreadcrumb,
|
||||||
onNavigateParent: handleNavigateParent,
|
onNavigateParent: handleNavigateParent,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.classList.add('has-main-content');
|
||||||
|
return () => {
|
||||||
|
document.body.classList.remove('has-main-content');
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!surface) {
|
if (!surface) {
|
||||||
return (
|
return (
|
||||||
<DocumentsLayout sidebarProps={sidebarPropsWithActions}>
|
<DocumentsLayout sidebarProps={sidebarPropsWithActions}>
|
||||||
@@ -141,6 +146,15 @@ const DocumentsRouteContent = () => {
|
|||||||
<DocumentsLayout sidebarProps={sidebarPropsWithActions}>
|
<DocumentsLayout sidebarProps={sidebarPropsWithActions}>
|
||||||
<div className={mainContentClass}>
|
<div className={mainContentClass}>
|
||||||
{header ? (
|
{header ? (
|
||||||
|
<div className="main-content__header-wrapper">
|
||||||
|
{(header.selectionLabel || header.floatingActions) ? (
|
||||||
|
<div className="panel-floating" aria-live="polite" aria-atomic="true">
|
||||||
|
{header.selectionLabel ? (
|
||||||
|
<span className="panel-floating__label">{header.selectionLabel}</span>
|
||||||
|
) : null}
|
||||||
|
{header.floatingActions || null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
className="main-content__header"
|
className="main-content__header"
|
||||||
leading={header.leading}
|
leading={header.leading}
|
||||||
@@ -148,6 +162,7 @@ const DocumentsRouteContent = () => {
|
|||||||
titleTag="h2"
|
titleTag="h2"
|
||||||
actions={header.actions}
|
actions={header.actions}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className={bodyClass}>{surface.content}</div>
|
<div className={bodyClass}>{surface.content}</div>
|
||||||
{surface.detail || null}
|
{surface.detail || null}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import SettingsModal from '../settings/SettingsModal';
|
import SettingsModal from '../settings/SettingsModal';
|
||||||
import { useAppShell } from '../appShellContext';
|
import { useAppShell } from '../appShellContext';
|
||||||
import useApiTokens from '../settings/useApiTokens';
|
import useApiTokens from '../settings/useApiTokens';
|
||||||
|
import useCapabilitySets from '../settings/useCapabilitySets';
|
||||||
|
import useCapabilities from '../settings/useCapabilities';
|
||||||
import { api } from './appState';
|
import { api } from './appState';
|
||||||
|
|
||||||
const SettingsRoute = () => {
|
const SettingsRoute = () => {
|
||||||
@@ -23,24 +25,49 @@ const SettingsRoute = () => {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
tokens,
|
tokens,
|
||||||
loading,
|
loading: tokensLoading,
|
||||||
creating,
|
creating: creatingToken,
|
||||||
deletingId,
|
deletingId,
|
||||||
regeneratingId,
|
regeneratingId,
|
||||||
updatingId,
|
|
||||||
createdSecret,
|
createdSecret,
|
||||||
refresh,
|
refresh: refreshTokens,
|
||||||
create,
|
create: createToken,
|
||||||
revoke,
|
revoke: revokeToken,
|
||||||
regenerate,
|
regenerate: regenerateToken,
|
||||||
updateCapabilities,
|
|
||||||
dismissSecret,
|
dismissSecret,
|
||||||
} = useApiTokens({ api, token, notifyApiError, setStatusMessage });
|
} = useApiTokens({ api, token, notifyApiError, setStatusMessage });
|
||||||
|
|
||||||
|
const {
|
||||||
|
capabilitySets,
|
||||||
|
capabilitySetsLoading,
|
||||||
|
creatingCapabilitySet,
|
||||||
|
savingCapabilitySetId,
|
||||||
|
deletingCapabilitySetId,
|
||||||
|
supportsCapabilitySetLabels,
|
||||||
|
refreshCapabilitySets,
|
||||||
|
createCapabilitySet,
|
||||||
|
updateCapabilitySet,
|
||||||
|
deleteCapabilitySet,
|
||||||
|
} = useCapabilitySets({ api, token, notifyApiError, setStatusMessage });
|
||||||
|
|
||||||
|
const {
|
||||||
|
capabilities,
|
||||||
|
capabilitiesLoading,
|
||||||
|
refreshCapabilities,
|
||||||
|
} = useCapabilities({ api, notifyApiError, token });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refreshTokens();
|
||||||
|
refreshCapabilitySets();
|
||||||
|
refreshCapabilities();
|
||||||
refreshPasskeys();
|
refreshPasskeys();
|
||||||
}, [refresh, refreshPasskeys]);
|
}, [refreshTokens, refreshCapabilitySets, refreshCapabilities, refreshPasskeys]);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
refreshTokens();
|
||||||
|
refreshCapabilitySets();
|
||||||
|
refreshCapabilities();
|
||||||
|
}, [refreshTokens, refreshCapabilitySets, refreshCapabilities]);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
dismissSecret();
|
dismissSecret();
|
||||||
@@ -67,18 +94,29 @@ const SettingsRoute = () => {
|
|||||||
open
|
open
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
tokens={tokens}
|
tokens={tokens}
|
||||||
loading={loading}
|
loading={tokensLoading}
|
||||||
creating={creating}
|
creating={creatingToken}
|
||||||
deletingId={deletingId}
|
deletingId={deletingId}
|
||||||
regeneratingId={regeneratingId}
|
regeneratingId={regeneratingId}
|
||||||
updatingId={updatingId}
|
onRefresh={handleRefresh}
|
||||||
onRefresh={refresh}
|
onCreate={createToken}
|
||||||
onCreate={create}
|
onDelete={revokeToken}
|
||||||
onDelete={revoke}
|
onRegenerate={regenerateToken}
|
||||||
onRegenerate={regenerate}
|
|
||||||
onUpdateCapabilities={updateCapabilities}
|
|
||||||
createdToken={createdSecret}
|
createdToken={createdSecret}
|
||||||
onDismissCreatedToken={dismissSecret}
|
onDismissCreatedToken={dismissSecret}
|
||||||
|
capabilitySets={capabilitySets}
|
||||||
|
capabilitySetsLoading={capabilitySetsLoading}
|
||||||
|
creatingCapabilitySet={creatingCapabilitySet}
|
||||||
|
savingCapabilitySetId={savingCapabilitySetId}
|
||||||
|
deletingCapabilitySetId={deletingCapabilitySetId}
|
||||||
|
supportsCapabilitySetLabels={supportsCapabilitySetLabels}
|
||||||
|
onRefreshCapabilitySets={refreshCapabilitySets}
|
||||||
|
capabilities={capabilities}
|
||||||
|
capabilitiesLoading={capabilitiesLoading}
|
||||||
|
onRefreshCapabilities={refreshCapabilities}
|
||||||
|
onCreateCapabilitySet={createCapabilitySet}
|
||||||
|
onUpdateCapabilitySet={updateCapabilitySet}
|
||||||
|
onDeleteCapabilitySet={deleteCapabilitySet}
|
||||||
passkeys={passkeys}
|
passkeys={passkeys}
|
||||||
passkeysSupported={passkeysSupported}
|
passkeysSupported={passkeysSupported}
|
||||||
passkeysLoading={passkeysLoading}
|
passkeysLoading={passkeysLoading}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { createAssetView } from '../asset_manager';
|
||||||
|
|
||||||
|
export const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early
|
||||||
|
export const DEFAULT_FOLDER_NAME = 'Documents';
|
||||||
|
export const DEFAULT_SORT_FIELD = 'title';
|
||||||
|
export const DEFAULT_SORT_DIRECTION = 'asc';
|
||||||
|
export const SORT_FIELD_VALUES = ['title', 'issued_at', 'created_at', 'updated_at'];
|
||||||
|
export const TAG_FILTER_UNTAGGED = '__UNTAGGED__';
|
||||||
|
|
||||||
|
const ROW_KEY_SEPARATOR = ':';
|
||||||
|
const DOCUMENT_ROW_PREFIX = 'document';
|
||||||
|
const FOLDER_ROW_PREFIX = 'folder';
|
||||||
|
|
||||||
|
export const resolveApiPath = (path = '') => path;
|
||||||
|
|
||||||
|
const makeRowKey = (type, id) =>
|
||||||
|
id ? `${type}${ROW_KEY_SEPARATOR}${id}` : `${type}${ROW_KEY_SEPARATOR}`;
|
||||||
|
|
||||||
|
const getRowType = (key) => (typeof key === 'string' ? key.split(ROW_KEY_SEPARATOR, 1)[0] : '');
|
||||||
|
|
||||||
|
export const getRowId = (key) => {
|
||||||
|
if (typeof key !== 'string') return '';
|
||||||
|
const separatorIndex = key.indexOf(ROW_KEY_SEPARATOR);
|
||||||
|
if (separatorIndex === -1) return key;
|
||||||
|
return key.slice(separatorIndex + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isDocumentRowKey = (key) => getRowType(key) === DOCUMENT_ROW_PREFIX;
|
||||||
|
export const isFolderRowKey = (key) => getRowType(key) === FOLDER_ROW_PREFIX;
|
||||||
|
|
||||||
|
export const resolveDocumentRowKey = (documentId) =>
|
||||||
|
documentId ? makeRowKey(DOCUMENT_ROW_PREFIX, documentId) : null;
|
||||||
|
|
||||||
|
export const resolveFolderRowKey = (folderId) =>
|
||||||
|
folderId ? makeRowKey(FOLDER_ROW_PREFIX, folderId) : null;
|
||||||
|
|
||||||
|
export const hasFiles = (event) =>
|
||||||
|
Array.from(event.dataTransfer?.types || []).includes('Files');
|
||||||
|
|
||||||
|
const isAssetEquivalent = (lhs, rhs) => {
|
||||||
|
if (!lhs || !rhs) return false;
|
||||||
|
const lhsView = createAssetView(lhs);
|
||||||
|
const rhsView = createAssetView(rhs);
|
||||||
|
const lhsPrimaryMetadata = lhsView.getPrimaryMetadata() || lhs?.metadata;
|
||||||
|
const rhsPrimaryMetadata = rhsView.getPrimaryMetadata() || rhs?.metadata;
|
||||||
|
const lhsCardinality = lhsView.getCardinality() || lhs?.cardinality || null;
|
||||||
|
const rhsCardinality = rhsView.getCardinality() || rhs?.cardinality || null;
|
||||||
|
const lhsObjects = lhsView.getObjects();
|
||||||
|
const rhsObjects = rhsView.getObjects();
|
||||||
|
const objectsComparable =
|
||||||
|
lhsObjects.length === rhsObjects.length
|
||||||
|
&& lhsObjects.every((entry, index) => {
|
||||||
|
const other = rhsObjects[index];
|
||||||
|
if (!other) return false;
|
||||||
|
if (entry.ordinal !== other.ordinal) return false;
|
||||||
|
if (entry.url && other.url && entry.url === other.url) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!entry.url && !other.url) {
|
||||||
|
return JSON.stringify(entry.metadata || null) === JSON.stringify(other.metadata || null);
|
||||||
|
}
|
||||||
|
return entry.url === other.url;
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
lhs.id === rhs.id
|
||||||
|
&& lhs.url === rhs.url
|
||||||
|
&& lhsPrimaryMetadata?.width === rhsPrimaryMetadata?.width
|
||||||
|
&& lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height
|
||||||
|
&& lhs.mime_type === rhs.mime_type
|
||||||
|
&& lhs.asset_type === rhs.asset_type
|
||||||
|
&& lhs.updated_at === rhs.updated_at
|
||||||
|
&& lhsCardinality === rhsCardinality
|
||||||
|
&& objectsComparable
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeAssetIntoGroup = (group, assetData) => {
|
||||||
|
if (!assetData || !assetData.asset_type) {
|
||||||
|
if (Array.isArray(group)) {
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
return group || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(group) || !group) {
|
||||||
|
const list = Array.isArray(group) ? group : [];
|
||||||
|
const index = list.findIndex((item) => item?.id === assetData.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
const existing = list[index];
|
||||||
|
if (isAssetEquivalent(existing, assetData)) {
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
const next = list.slice();
|
||||||
|
next[index] = { ...existing, ...assetData };
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return list.concat({ ...assetData });
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = assetData.asset_type;
|
||||||
|
const previous = group?.[key];
|
||||||
|
if (previous && isAssetEquivalent(previous, assetData)) {
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = { ...(group || {}) };
|
||||||
|
next[key] = { ...(previous || {}), ...assetData };
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mergeAssetIntoDocument = (doc, assetData) => {
|
||||||
|
if (!doc) return doc;
|
||||||
|
const existingGroup = doc.current_version?.assets || null;
|
||||||
|
const nextGroup = mergeAssetIntoGroup(existingGroup, assetData);
|
||||||
|
if (nextGroup === existingGroup) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
const updatedCurrentVersion = doc.current_version
|
||||||
|
? { ...doc.current_version, assets: nextGroup }
|
||||||
|
: { assets: nextGroup };
|
||||||
|
return { ...doc, current_version: updatedCurrentVersion };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createRootNode = () => ({
|
||||||
|
id: 'root',
|
||||||
|
name: DEFAULT_FOLDER_NAME,
|
||||||
|
parentId: null,
|
||||||
|
children: [],
|
||||||
|
expanded: true,
|
||||||
|
loaded: false,
|
||||||
|
hasChildren: false,
|
||||||
|
});
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
import React, { useContext, useEffect, useMemo, useReducer } from 'react';
|
import React, { useContext, useEffect, useMemo, useReducer } from 'react';
|
||||||
import axios from 'axios';
|
import api from '../lib/api';
|
||||||
|
|
||||||
const api = axios.create({
|
|
||||||
baseURL: '/api',
|
|
||||||
withCredentials: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const storage = typeof window !== 'undefined' ? window.sessionStorage : undefined;
|
const storage = typeof window !== 'undefined' ? window.sessionStorage : undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -3,147 +3,74 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
export const useDetailPanel = ({
|
export const useDetailPanel = ({
|
||||||
documentLookup,
|
documentLookup,
|
||||||
orderedSelectedDocuments,
|
orderedSelectedDocuments,
|
||||||
selectionOrder,
|
|
||||||
documentsViewMode,
|
|
||||||
getRowId,
|
|
||||||
isDocumentRowKey,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [detailPanelOpen, setDetailPanelOpen] = useState(false);
|
const [detailPanelOpen, setDetailPanelOpen] = useState(false);
|
||||||
const [detailPanelDocIds, setDetailPanelDocIds] = useState([]);
|
const [detailPanelDocId, setDetailPanelDocId] = useState(null);
|
||||||
const [detailPanelDocs, setDetailPanelDocs] = useState([]);
|
const [detailPanelDocument, setDetailPanelDocument] = useState(null);
|
||||||
const lastScrolledDetailDocRef = useRef(null);
|
|
||||||
const latestOrderedDocsRef = useRef([]);
|
const latestOrderedDocsRef = useRef([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
latestOrderedDocsRef.current = orderedSelectedDocuments;
|
latestOrderedDocsRef.current = orderedSelectedDocuments;
|
||||||
if (detailPanelOpen && orderedSelectedDocuments.length) {
|
if (detailPanelOpen && orderedSelectedDocuments.length) {
|
||||||
const snapshotIds = orderedSelectedDocuments
|
const nextDoc = orderedSelectedDocuments[orderedSelectedDocuments.length - 1];
|
||||||
.map((doc) => doc?.id)
|
if (nextDoc?.id) {
|
||||||
.filter((id) => typeof id === 'string' || typeof id === 'number');
|
setDetailPanelDocId(nextDoc.id);
|
||||||
if (snapshotIds.length) {
|
setDetailPanelDocument(nextDoc);
|
||||||
setDetailPanelDocIds(snapshotIds);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [orderedSelectedDocuments, detailPanelOpen]);
|
}, [orderedSelectedDocuments, detailPanelOpen]);
|
||||||
|
|
||||||
const resolveDocsForIds = useCallback(
|
|
||||||
(ids, fallbackDocs = []) => {
|
|
||||||
if (!ids?.length) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const fallbackMap = new Map((fallbackDocs || []).map((doc) => [doc?.id, doc]));
|
|
||||||
return ids
|
|
||||||
.map((id) => documentLookup.get(id) || fallbackMap.get(id) || null)
|
|
||||||
.filter(Boolean);
|
|
||||||
},
|
|
||||||
[documentLookup],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!detailPanelDocIds.length) {
|
|
||||||
setDetailPanelDocs((prev) => (prev.length ? [] : prev));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setDetailPanelDocs((prevDocs) => {
|
|
||||||
const resolved = resolveDocsForIds(detailPanelDocIds, prevDocs);
|
|
||||||
if (resolved.length === prevDocs.length && resolved.every((doc, index) => doc === prevDocs[index])) {
|
|
||||||
return prevDocs;
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
});
|
|
||||||
}, [detailPanelDocIds, resolveDocsForIds]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!detailPanelDocId) {
|
||||||
if (!detailPanelOpen) {
|
if (!detailPanelOpen) {
|
||||||
lastScrolledDetailDocRef.current = null;
|
setDetailPanelDocument(null);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const resolved = documentLookup.get(detailPanelDocId);
|
||||||
if (!orderedSelectedDocuments.length) {
|
if (resolved && resolved !== detailPanelDocument) {
|
||||||
lastScrolledDetailDocRef.current = null;
|
setDetailPanelDocument(resolved);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}, [detailPanelDocId, documentLookup, detailPanelDocument, detailPanelOpen]);
|
||||||
let lastSelectedId = null;
|
|
||||||
for (let index = selectionOrder.length - 1; index >= 0; index -= 1) {
|
|
||||||
const key = selectionOrder[index];
|
|
||||||
if (isDocumentRowKey(key)) {
|
|
||||||
lastSelectedId = getRowId(key);
|
|
||||||
if (lastSelectedId) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!lastSelectedId && orderedSelectedDocuments.length) {
|
|
||||||
const fallbackDoc = orderedSelectedDocuments[orderedSelectedDocuments.length - 1];
|
|
||||||
lastSelectedId = fallbackDoc?.id || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!lastSelectedId || lastScrolledDetailDocRef.current === lastSelectedId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof document === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetElement =
|
|
||||||
document.getElementById(`document-row-${lastSelectedId}`)
|
|
||||||
|| document.getElementById(`document-card-${lastSelectedId}`);
|
|
||||||
|
|
||||||
if (!targetElement) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastScrolledDetailDocRef.current = lastSelectedId;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
targetElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
detailPanelOpen,
|
|
||||||
orderedSelectedDocuments,
|
|
||||||
selectionOrder,
|
|
||||||
documentsViewMode,
|
|
||||||
getRowId,
|
|
||||||
isDocumentRowKey,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const detailPanelSelectedDocuments = detailPanelDocs;
|
|
||||||
|
|
||||||
const openDetailPanel = useCallback(
|
const openDetailPanel = useCallback(
|
||||||
({ documentIds: explicitIds, documents: explicitDocs } = {}) => {
|
({ documentId, document, documentIds, documents } = {}) => {
|
||||||
let sourceDocs = Array.isArray(explicitDocs) ? explicitDocs : null;
|
let targetDoc = document || null;
|
||||||
let snapshotIds = Array.isArray(explicitIds)
|
let targetId = documentId ?? document?.id ?? null;
|
||||||
? explicitIds.filter((id) => typeof id === 'string' || typeof id === 'number')
|
|
||||||
|
if (!targetDoc && Array.isArray(documents) && documents.length) {
|
||||||
|
targetDoc = documents[documents.length - 1];
|
||||||
|
targetId = targetDoc?.id ?? targetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetDoc && Array.isArray(documentIds) && documentIds.length) {
|
||||||
|
targetId = documentIds[documentIds.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetDoc && targetId != null) {
|
||||||
|
targetDoc = documentLookup.get(String(targetId)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetDoc) {
|
||||||
|
const fallbackDocs = latestOrderedDocsRef.current;
|
||||||
|
const fallbackDoc = Array.isArray(fallbackDocs) && fallbackDocs.length
|
||||||
|
? fallbackDocs[fallbackDocs.length - 1]
|
||||||
: null;
|
: null;
|
||||||
|
if (fallbackDoc) {
|
||||||
if (!snapshotIds?.length) {
|
targetDoc = fallbackDoc;
|
||||||
if (!sourceDocs || !sourceDocs.length) {
|
targetId = fallbackDoc.id;
|
||||||
sourceDocs = latestOrderedDocsRef.current;
|
|
||||||
}
|
}
|
||||||
snapshotIds = Array.isArray(sourceDocs)
|
|
||||||
? sourceDocs
|
|
||||||
.map((doc) => doc?.id)
|
|
||||||
.filter((id) => typeof id === 'string' || typeof id === 'number')
|
|
||||||
: [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueIds = [];
|
if (!targetDoc && targetId == null) {
|
||||||
snapshotIds.forEach((id) => {
|
return;
|
||||||
if (!uniqueIds.includes(id)) {
|
|
||||||
uniqueIds.push(id);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const resolvedDocs = resolveDocsForIds(uniqueIds, sourceDocs || latestOrderedDocsRef.current);
|
setDetailPanelDocId(targetDoc?.id || targetId || null);
|
||||||
|
setDetailPanelDocument(targetDoc || null);
|
||||||
setDetailPanelDocIds(uniqueIds);
|
setDetailPanelOpen(Boolean(targetDoc || targetId));
|
||||||
setDetailPanelDocs(resolvedDocs);
|
|
||||||
setDetailPanelOpen(true);
|
|
||||||
},
|
},
|
||||||
[resolveDocsForIds],
|
[documentLookup],
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeDetailPanel = useCallback(() => {
|
const closeDetailPanel = useCallback(() => {
|
||||||
@@ -152,7 +79,7 @@ export const useDetailPanel = ({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
detailPanelOpen,
|
detailPanelOpen,
|
||||||
detailPanelSelectedDocuments,
|
detailPanelDocument,
|
||||||
openDetailPanel,
|
openDetailPanel,
|
||||||
closeDetailPanel,
|
closeDetailPanel,
|
||||||
setDetailPanelOpen,
|
setDetailPanelOpen,
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const useDocumentPreview = ({
|
||||||
|
routeDocumentId,
|
||||||
|
documents,
|
||||||
|
searchResults,
|
||||||
|
setDocuments,
|
||||||
|
selectedFolder,
|
||||||
|
assetManager,
|
||||||
|
api,
|
||||||
|
resolveApiPath,
|
||||||
|
notifyApiError,
|
||||||
|
navigate,
|
||||||
|
locationPathname,
|
||||||
|
locationSearch,
|
||||||
|
detailPanelControlRef,
|
||||||
|
setActivePreviewId,
|
||||||
|
}) => {
|
||||||
|
const [previewEntries, setPreviewEntries] = useState(() => new Map());
|
||||||
|
const previewInflightRef = useRef(new Map());
|
||||||
|
const previewReturnPathRef = useRef(null);
|
||||||
|
|
||||||
|
const resetPreviewState = useCallback(() => {
|
||||||
|
setPreviewEntries(() => new Map());
|
||||||
|
previewInflightRef.current = new Map();
|
||||||
|
previewReturnPathRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removePreviewEntries = useCallback((ids) => {
|
||||||
|
if (!Array.isArray(ids) || ids.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPreviewEntries((prev) => {
|
||||||
|
if (!prev.size) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
let changed = false;
|
||||||
|
const next = new Map(prev);
|
||||||
|
ids.forEach((id) => {
|
||||||
|
if (next.delete(id)) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
previewInflightRef.current.delete(id);
|
||||||
|
});
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const ensurePreviewUrl = useCallback(
|
||||||
|
async (documentId, { force = false } = {}) => {
|
||||||
|
if (!documentId) return null;
|
||||||
|
|
||||||
|
const existing = previewEntries.get(documentId) || null;
|
||||||
|
const now = Date.now();
|
||||||
|
const expiresAt = typeof existing?.expiresAt === 'number' ? existing.expiresAt : null;
|
||||||
|
if (!force && existing && (!expiresAt || expiresAt > now)) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force && previewInflightRef.current.has(documentId)) {
|
||||||
|
return previewInflightRef.current.get(documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = (async () => {
|
||||||
|
try {
|
||||||
|
const docResponse = await api.get(`/documents/${documentId}`);
|
||||||
|
const downloadPath = docResponse.data?.document?.current_version?.download_path;
|
||||||
|
if (!downloadPath || !resolveApiPath) {
|
||||||
|
throw new Error('Document missing download path');
|
||||||
|
}
|
||||||
|
|
||||||
|
const href = resolveApiPath(downloadPath);
|
||||||
|
const entry = {
|
||||||
|
url: href,
|
||||||
|
contentType: docResponse.data?.document?.current_version?.version?.content_type || null,
|
||||||
|
filename: docResponse.data?.document?.filename,
|
||||||
|
expiresAt: Date.now() + 5 * 60 * 1000,
|
||||||
|
};
|
||||||
|
setPreviewEntries((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(documentId, entry);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return entry;
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError(error, 'Unable to fetch document preview.');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
previewInflightRef.current.delete(documentId);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
previewInflightRef.current.set(documentId, request);
|
||||||
|
return request;
|
||||||
|
},
|
||||||
|
[previewEntries, api, resolveApiPath, notifyApiError, setPreviewEntries],
|
||||||
|
);
|
||||||
|
|
||||||
|
const ensurePreviewData = useCallback(
|
||||||
|
async (documentId) => {
|
||||||
|
if (!documentId) return null;
|
||||||
|
|
||||||
|
const findInCache = () => {
|
||||||
|
const pool = searchResults ?? documents;
|
||||||
|
return pool.find((item) => item.id === documentId) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let doc = findInCache();
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
const { data } = await api.get(`/documents/${documentId}`);
|
||||||
|
const hydratedDetail = assetManager.hydrateDetail(data);
|
||||||
|
const fetched = hydratedDetail?.document || data.document || data;
|
||||||
|
doc = fetched ? assetManager.hydrateDocument(fetched) : null;
|
||||||
|
if (!doc) {
|
||||||
|
throw new Error('Document metadata unavailable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setDocuments((prev) => {
|
||||||
|
if (prev.some((item) => item.id === doc.id)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return [doc, ...prev];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!previewReturnPathRef.current) {
|
||||||
|
const fallbackFolderId = doc?.folder_id || 'root';
|
||||||
|
previewReturnPathRef.current =
|
||||||
|
fallbackFolderId === 'root' ? '/documents' : `/documents/folder/${fallbackFolderId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensurePreviewUrl(documentId, { force: false });
|
||||||
|
setActivePreviewId(documentId);
|
||||||
|
return doc;
|
||||||
|
},
|
||||||
|
[
|
||||||
|
searchResults,
|
||||||
|
documents,
|
||||||
|
assetManager,
|
||||||
|
setDocuments,
|
||||||
|
ensurePreviewUrl,
|
||||||
|
setActivePreviewId,
|
||||||
|
api,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openDocumentPreview = useCallback(
|
||||||
|
(documentId, { replace = false } = {}) => {
|
||||||
|
if (!documentId) return;
|
||||||
|
detailPanelControlRef.current.close();
|
||||||
|
previewReturnPathRef.current = `${locationPathname}${locationSearch}`;
|
||||||
|
navigate(`/documents/${documentId}`, { replace });
|
||||||
|
},
|
||||||
|
[navigate, locationPathname, locationSearch, detailPanelControlRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeDocumentPreview = useCallback(
|
||||||
|
(folderId = null) => {
|
||||||
|
const fallbackPath = previewReturnPathRef.current;
|
||||||
|
previewReturnPathRef.current = null;
|
||||||
|
|
||||||
|
if (fallbackPath) {
|
||||||
|
navigate(fallbackPath, { replace: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetId = folderId || selectedFolder || 'root';
|
||||||
|
const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
|
||||||
|
navigate(path, { replace: false });
|
||||||
|
},
|
||||||
|
[navigate, selectedFolder],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!routeDocumentId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeDocumentPreview();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [routeDocumentId, closeDocumentPreview]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!routeDocumentId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
ensurePreviewData(routeDocumentId).catch((error) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifyApiError(error, 'Failed to open document preview.');
|
||||||
|
closeDocumentPreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [routeDocumentId, ensurePreviewData, notifyApiError, closeDocumentPreview]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
previewEntries,
|
||||||
|
ensurePreviewUrl,
|
||||||
|
ensurePreviewData,
|
||||||
|
openDocumentPreview,
|
||||||
|
closeDocumentPreview,
|
||||||
|
resetPreviewState,
|
||||||
|
removePreviewEntries,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDocumentPreview;
|
||||||
@@ -143,7 +143,7 @@ export const useDocumentSelection = ({
|
|||||||
applySelection([], { anchor: null, interactedKeys: [] });
|
applySelection([], { anchor: null, interactedKeys: [] });
|
||||||
}, [applySelection]);
|
}, [applySelection]);
|
||||||
|
|
||||||
const handleRowSelection = useCallback(
|
const handleEntrySelection = useCallback(
|
||||||
(rowKey, event) => {
|
(rowKey, event) => {
|
||||||
const visibleRowKeySet = visibleRowKeySetRef.current;
|
const visibleRowKeySet = visibleRowKeySetRef.current;
|
||||||
const navigableRowKeys = navigableRowKeysRef.current;
|
const navigableRowKeys = navigableRowKeysRef.current;
|
||||||
@@ -241,7 +241,7 @@ export const useDocumentSelection = ({
|
|||||||
setFocusedRowKey,
|
setFocusedRowKey,
|
||||||
applySelection,
|
applySelection,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
handleRowSelection,
|
handleEntrySelection,
|
||||||
promoteSelectionOrder,
|
promoteSelectionOrder,
|
||||||
configureSelectionEnvironment,
|
configureSelectionEnvironment,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
DEFAULT_SORT_DIRECTION,
|
||||||
|
DEFAULT_SORT_FIELD,
|
||||||
|
SORT_FIELD_VALUES,
|
||||||
|
} from './appLayoutUtils';
|
||||||
|
|
||||||
|
const VIEW_MODE_STORAGE_KEY = 'papercrate_view_mode';
|
||||||
|
const SORT_FIELD_STORAGE_KEY = 'papercrate_sort_field';
|
||||||
|
const SORT_DIRECTION_STORAGE_KEY = 'papercrate_sort_direction';
|
||||||
|
const INCLUDE_DESCENDANTS_STORAGE_KEY = 'papercrate_include_descendants';
|
||||||
|
|
||||||
|
const readSessionStorage = (key) => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return window.sessionStorage.getItem(key);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[session-storage] failed to read ${key}`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeSessionStorage = (key, value) => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem(key, value);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[session-storage] failed to persist ${key}`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDocumentsPreferences = () => {
|
||||||
|
const [documentsViewMode, setDocumentsViewModeState] = useState(() => {
|
||||||
|
const stored = readSessionStorage(VIEW_MODE_STORAGE_KEY);
|
||||||
|
if (stored === 'grid' || stored === 'desk') {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
return 'list';
|
||||||
|
});
|
||||||
|
|
||||||
|
const [deskHelpOpen, setDeskHelpOpen] = useState(false);
|
||||||
|
const lastNonDeskViewRef = useRef(documentsViewMode === 'desk' ? 'list' : documentsViewMode);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (documentsViewMode !== 'desk') {
|
||||||
|
lastNonDeskViewRef.current = documentsViewMode;
|
||||||
|
} else if (deskHelpOpen) {
|
||||||
|
setDeskHelpOpen(false);
|
||||||
|
}
|
||||||
|
}, [documentsViewMode, deskHelpOpen]);
|
||||||
|
|
||||||
|
const setDocumentsViewMode = useCallback((mode) => {
|
||||||
|
const next = mode === 'grid' ? 'grid' : mode === 'desk' ? 'desk' : 'list';
|
||||||
|
setDocumentsViewModeState((previous) => {
|
||||||
|
if (next !== previous) {
|
||||||
|
writeSessionStorage(VIEW_MODE_STORAGE_KEY, next);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeskExit = useCallback(() => {
|
||||||
|
const fallback = lastNonDeskViewRef.current && lastNonDeskViewRef.current !== 'desk'
|
||||||
|
? lastNonDeskViewRef.current
|
||||||
|
: 'list';
|
||||||
|
setDocumentsViewMode(fallback);
|
||||||
|
}, [setDocumentsViewMode]);
|
||||||
|
|
||||||
|
const [documentsSortField, setDocumentsSortField] = useState(() => {
|
||||||
|
const stored = readSessionStorage(SORT_FIELD_STORAGE_KEY);
|
||||||
|
return SORT_FIELD_VALUES.includes(stored) ? stored : DEFAULT_SORT_FIELD;
|
||||||
|
});
|
||||||
|
const documentsSortFieldRef = useRef(documentsSortField);
|
||||||
|
useEffect(() => {
|
||||||
|
documentsSortFieldRef.current = documentsSortField;
|
||||||
|
writeSessionStorage(SORT_FIELD_STORAGE_KEY, documentsSortField);
|
||||||
|
}, [documentsSortField]);
|
||||||
|
|
||||||
|
const [documentsSortDirection, setDocumentsSortDirection] = useState(() => {
|
||||||
|
const stored = readSessionStorage(SORT_DIRECTION_STORAGE_KEY);
|
||||||
|
return stored === 'desc' || stored === 'asc' ? stored : DEFAULT_SORT_DIRECTION;
|
||||||
|
});
|
||||||
|
const documentsSortDirectionRef = useRef(documentsSortDirection);
|
||||||
|
useEffect(() => {
|
||||||
|
documentsSortDirectionRef.current = documentsSortDirection;
|
||||||
|
writeSessionStorage(SORT_DIRECTION_STORAGE_KEY, documentsSortDirection);
|
||||||
|
}, [documentsSortDirection]);
|
||||||
|
|
||||||
|
const handleDocumentsSortFieldChange = useCallback((field) => {
|
||||||
|
const nextField = SORT_FIELD_VALUES.includes(field) ? field : DEFAULT_SORT_FIELD;
|
||||||
|
setDocumentsSortField((previous) => (previous === nextField ? previous : nextField));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDocumentsSortDirectionToggle = useCallback(() => {
|
||||||
|
setDocumentsSortDirection((previous) => (previous === 'asc' ? 'desc' : 'asc'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [searchIncludeDescendants, setSearchIncludeDescendants] = useState(() => {
|
||||||
|
const stored = readSessionStorage(INCLUDE_DESCENDANTS_STORAGE_KEY);
|
||||||
|
if (stored === 'true') return true;
|
||||||
|
if (stored === 'false') return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
writeSessionStorage(
|
||||||
|
INCLUDE_DESCENDANTS_STORAGE_KEY,
|
||||||
|
searchIncludeDescendants ? 'true' : 'false',
|
||||||
|
);
|
||||||
|
}, [searchIncludeDescendants]);
|
||||||
|
|
||||||
|
const toggleSearchIncludeDescendants = useCallback(() => {
|
||||||
|
setSearchIncludeDescendants((previous) => !previous);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sortRefreshReadyRef = useRef(false);
|
||||||
|
|
||||||
|
return {
|
||||||
|
documentsViewMode,
|
||||||
|
handleDocumentsViewModeChange: setDocumentsViewMode,
|
||||||
|
handleDeskExit,
|
||||||
|
deskHelpOpen,
|
||||||
|
setDeskHelpOpen,
|
||||||
|
documentsSortField,
|
||||||
|
documentsSortDirection,
|
||||||
|
documentsSortFieldRef,
|
||||||
|
documentsSortDirectionRef,
|
||||||
|
handleDocumentsSortFieldChange,
|
||||||
|
handleDocumentsSortDirectionToggle,
|
||||||
|
searchIncludeDescendants,
|
||||||
|
setSearchIncludeDescendants,
|
||||||
|
toggleSearchIncludeDescendants,
|
||||||
|
sortRefreshReadyRef,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
TAG_FILTER_UNTAGGED,
|
||||||
|
resolveDocumentRowKey,
|
||||||
|
isDocumentRowKey,
|
||||||
|
} from './appLayoutUtils';
|
||||||
|
|
||||||
|
const useDocumentsSearch = ({
|
||||||
|
api,
|
||||||
|
assetManager,
|
||||||
|
token,
|
||||||
|
selectedFolder,
|
||||||
|
navigate,
|
||||||
|
locationPathname,
|
||||||
|
isDocumentsRoute,
|
||||||
|
selectionHelpers,
|
||||||
|
searchIncludeDescendants,
|
||||||
|
documentsSortField,
|
||||||
|
documentsSortDirection,
|
||||||
|
notifyApiError,
|
||||||
|
setLoading,
|
||||||
|
setSearchIncludeDescendants,
|
||||||
|
}) => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [activeTagFilters, setActiveTagFilters] = useState([]);
|
||||||
|
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]);
|
||||||
|
const [searchResults, setSearchResults] = useState(null);
|
||||||
|
const [searchLoading, setSearchLoading] = useState(false);
|
||||||
|
|
||||||
|
const toggleTagFilter = useCallback((tagId) => {
|
||||||
|
if (!tagId) return;
|
||||||
|
setActiveTagFilters((previous) => {
|
||||||
|
if (tagId === TAG_FILTER_UNTAGGED) {
|
||||||
|
return previous.includes(TAG_FILTER_UNTAGGED) ? [] : [TAG_FILTER_UNTAGGED];
|
||||||
|
}
|
||||||
|
const sanitized = previous.filter((id) => id !== TAG_FILTER_UNTAGGED);
|
||||||
|
if (sanitized.includes(tagId)) {
|
||||||
|
return sanitized.filter((id) => id !== tagId);
|
||||||
|
}
|
||||||
|
return sanitized.concat([tagId]);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleCorrespondentFilter = useCallback((correspondentId) => {
|
||||||
|
setActiveCorrespondentFilters((previous) => {
|
||||||
|
if (!correspondentId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return previous.includes(correspondentId) ? [] : [correspondentId];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isFilterActive = useMemo(
|
||||||
|
() =>
|
||||||
|
searchQuery.trim().length > 0
|
||||||
|
|| activeTagFilters.length > 0
|
||||||
|
|| activeCorrespondentFilters.length > 0,
|
||||||
|
[searchQuery, activeTagFilters, activeCorrespondentFilters],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearFilters = useCallback(() => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setActiveTagFilters([]);
|
||||||
|
setActiveCorrespondentFilters([]);
|
||||||
|
setSearchLoading(false);
|
||||||
|
setSearchIncludeDescendants(true);
|
||||||
|
}, [
|
||||||
|
setSearchIncludeDescendants,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback((value) => {
|
||||||
|
setSearchQuery(value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSearchSubmit = useCallback(() => {
|
||||||
|
if (!navigate) return;
|
||||||
|
const targetFolder = selectedFolder && selectedFolder !== 'root' ? selectedFolder : 'root';
|
||||||
|
const targetPath = targetFolder === 'root' ? '/documents' : `/documents/folder/${targetFolder}`;
|
||||||
|
if (!isDocumentsRoute || locationPathname !== targetPath) {
|
||||||
|
navigate(targetPath, { replace: false });
|
||||||
|
}
|
||||||
|
}, [navigate, selectedFolder, isDocumentsRoute, locationPathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return undefined;
|
||||||
|
|
||||||
|
if (!isFilterActive) {
|
||||||
|
setSearchResults(null);
|
||||||
|
setSearchLoading(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let started = false;
|
||||||
|
setSearchLoading(true);
|
||||||
|
|
||||||
|
const debounce = setTimeout(async () => {
|
||||||
|
started = true;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = {};
|
||||||
|
const trimmedQuery = searchQuery.trim();
|
||||||
|
if (trimmedQuery.length) {
|
||||||
|
params.query = trimmedQuery;
|
||||||
|
}
|
||||||
|
if (activeTagFilters.length) {
|
||||||
|
const onlyUntagged = activeTagFilters.length === 1
|
||||||
|
&& activeTagFilters[0] === TAG_FILTER_UNTAGGED;
|
||||||
|
if (onlyUntagged) {
|
||||||
|
params.tags = 'none';
|
||||||
|
} else {
|
||||||
|
const tagIds = activeTagFilters.filter((id) => id !== TAG_FILTER_UNTAGGED);
|
||||||
|
if (tagIds.length) {
|
||||||
|
params.tags = tagIds.join(',');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (activeCorrespondentFilters.length) {
|
||||||
|
params.correspondents = activeCorrespondentFilters.join(',');
|
||||||
|
}
|
||||||
|
const folderIdentifier = selectedFolder === 'root' ? null : selectedFolder;
|
||||||
|
if (folderIdentifier) {
|
||||||
|
params.folder_id = folderIdentifier;
|
||||||
|
}
|
||||||
|
if (!searchIncludeDescendants) {
|
||||||
|
params.include_descendants = false;
|
||||||
|
}
|
||||||
|
if (documentsSortField) {
|
||||||
|
params.sort = documentsSortField;
|
||||||
|
}
|
||||||
|
if (documentsSortDirection) {
|
||||||
|
params.dir = documentsSortDirection;
|
||||||
|
}
|
||||||
|
const { data } = await api.get('/documents', { params });
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const results = assetManager.hydrateDocuments(data || []);
|
||||||
|
setSearchResults(results);
|
||||||
|
|
||||||
|
if (!results.length) {
|
||||||
|
setSearchLoading(false);
|
||||||
|
selectionHelpers.setSelectedEntries([]);
|
||||||
|
selectionHelpers.setFocusedDocumentId(null);
|
||||||
|
selectionHelpers.selectionOrderRef.current = [];
|
||||||
|
selectionHelpers.setSelectionOrder([]);
|
||||||
|
selectionHelpers.selectionAnchorRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultKeys = results
|
||||||
|
.map((doc) => resolveDocumentRowKey(doc.id))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
let targetKey = null;
|
||||||
|
let nextSelectionKeys = [];
|
||||||
|
|
||||||
|
selectionHelpers.setSelectedEntries((previous) => {
|
||||||
|
const previousDocKeys = previous.filter(isDocumentRowKey);
|
||||||
|
const filtered = previousDocKeys.filter((key) => resultKeys.includes(key));
|
||||||
|
if (filtered.length) {
|
||||||
|
targetKey = filtered[filtered.length - 1];
|
||||||
|
nextSelectionKeys = filtered;
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
targetKey = null;
|
||||||
|
nextSelectionKeys = [];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
selectionHelpers.selectionOrderRef.current = nextSelectionKeys;
|
||||||
|
selectionHelpers.setSelectionOrder(nextSelectionKeys);
|
||||||
|
|
||||||
|
selectionHelpers.setFocusedDocumentId((previous) => {
|
||||||
|
if (previous && resultKeys.includes(resolveDocumentRowKey(previous))) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
selectionHelpers.selectionAnchorRef.current = targetKey;
|
||||||
|
} catch (error) {
|
||||||
|
if (cancelled) return;
|
||||||
|
notifyApiError(error, 'Search failed. Please try again.');
|
||||||
|
setSearchResults(null);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled && started) {
|
||||||
|
setLoading(false);
|
||||||
|
setSearchLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(debounce);
|
||||||
|
if (started) {
|
||||||
|
setLoading(false);
|
||||||
|
setSearchLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
api,
|
||||||
|
token,
|
||||||
|
isFilterActive,
|
||||||
|
searchQuery,
|
||||||
|
activeTagFilters,
|
||||||
|
activeCorrespondentFilters,
|
||||||
|
searchIncludeDescendants,
|
||||||
|
documentsSortField,
|
||||||
|
documentsSortDirection,
|
||||||
|
selectedFolder,
|
||||||
|
notifyApiError,
|
||||||
|
assetManager,
|
||||||
|
selectionHelpers,
|
||||||
|
setLoading,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchQuery,
|
||||||
|
setSearchQuery,
|
||||||
|
searchResults,
|
||||||
|
setSearchResults,
|
||||||
|
searchLoading,
|
||||||
|
setSearchLoading,
|
||||||
|
activeTagFilters,
|
||||||
|
setActiveTagFilters,
|
||||||
|
activeCorrespondentFilters,
|
||||||
|
setActiveCorrespondentFilters,
|
||||||
|
toggleTagFilter,
|
||||||
|
toggleCorrespondentFilter,
|
||||||
|
isFilterActive,
|
||||||
|
clearFilters,
|
||||||
|
handleSearchChange,
|
||||||
|
handleSearchSubmit,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDocumentsSearch;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useEntryPointerHandler as useEntryPointerCore, isPointerModifierEvent, isPrimaryPointerEvent } from '../documents/useEntryPointer';
|
||||||
|
|
||||||
|
export const useEntryPointer = ({
|
||||||
|
resolveDocumentRowKey,
|
||||||
|
resolveFolderRowKey,
|
||||||
|
onSelectDocument,
|
||||||
|
onInspectDocument,
|
||||||
|
onSelectFolder,
|
||||||
|
}) => {
|
||||||
|
const coreHandler = useEntryPointerCore({
|
||||||
|
resolveDocumentRowKey,
|
||||||
|
resolveFolderRowKey,
|
||||||
|
onSelectDocument: (documentId, event, meta) => {
|
||||||
|
const { modifierClick, primaryClick, rowKey } = meta;
|
||||||
|
onSelectDocument(documentId, event, { modifierClick, primaryClick, rowKey });
|
||||||
|
if (!modifierClick && primaryClick && typeof onInspectDocument === 'function') {
|
||||||
|
onInspectDocument(documentId, meta);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSelectFolder,
|
||||||
|
});
|
||||||
|
|
||||||
|
return useCallback((entry, event) => {
|
||||||
|
if (!entry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entry.type !== 'document') {
|
||||||
|
coreHandler(entry, event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modifierClick = isPointerModifierEvent(event);
|
||||||
|
const primaryClick = isPrimaryPointerEvent(event);
|
||||||
|
|
||||||
|
onSelectDocument(entry.id, event, {
|
||||||
|
modifierClick,
|
||||||
|
primaryClick,
|
||||||
|
rowKey: entry.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!modifierClick && primaryClick) {
|
||||||
|
onInspectDocument?.(entry.id, { modifierClick, primaryClick, rowKey: entry.key });
|
||||||
|
}
|
||||||
|
}, [coreHandler, onInspectDocument, onSelectDocument]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useEntryPointer;
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useDocumentSelection } from './useDocumentSelection';
|
||||||
|
|
||||||
|
const identity = (value) => value;
|
||||||
|
|
||||||
|
export const useWorkspaceSelection = ({
|
||||||
|
resolveDocumentRowKey,
|
||||||
|
resolveFolderRowKey,
|
||||||
|
isDocumentRowKey,
|
||||||
|
isFolderRowKey,
|
||||||
|
getRowId,
|
||||||
|
onInspectDocument = identity,
|
||||||
|
onInspectFolder = identity,
|
||||||
|
} = {}) => {
|
||||||
|
const selection = useDocumentSelection({
|
||||||
|
resolveDocumentRowKey,
|
||||||
|
resolveFolderRowKey,
|
||||||
|
isDocumentRowKey,
|
||||||
|
isFolderRowKey,
|
||||||
|
getRowId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
selectedEntries,
|
||||||
|
setSelectedEntries,
|
||||||
|
selectionOrder,
|
||||||
|
setSelectionOrder,
|
||||||
|
selectionOrderRef,
|
||||||
|
selectionAnchorRef,
|
||||||
|
selectionInitializedRef,
|
||||||
|
focusedDocumentId,
|
||||||
|
setFocusedDocumentId,
|
||||||
|
focusedRowKey,
|
||||||
|
setFocusedRowKey,
|
||||||
|
applySelection,
|
||||||
|
clearSelection,
|
||||||
|
handleEntrySelection,
|
||||||
|
promoteSelectionOrder,
|
||||||
|
configureSelectionEnvironment,
|
||||||
|
} = selection;
|
||||||
|
|
||||||
|
const selectedDocumentIds = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedEntries
|
||||||
|
.filter((entry) => isDocumentRowKey(entry))
|
||||||
|
.map((entry) => getRowId(entry))
|
||||||
|
.filter(Boolean),
|
||||||
|
[selectedEntries, isDocumentRowKey, getRowId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedFolderIds = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedEntries
|
||||||
|
.filter((entry) => isFolderRowKey(entry))
|
||||||
|
.map((entry) => getRowId(entry))
|
||||||
|
.filter(Boolean),
|
||||||
|
[selectedEntries, isFolderRowKey, getRowId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectEntry = useCallback(
|
||||||
|
(entry, event) => {
|
||||||
|
const rowKey = typeof entry === 'string' ? entry : entry?.rowKey;
|
||||||
|
if (!rowKey) return;
|
||||||
|
handleEntrySelection(rowKey, event);
|
||||||
|
},
|
||||||
|
[handleEntrySelection],
|
||||||
|
);
|
||||||
|
|
||||||
|
const inspectDocument = useCallback(
|
||||||
|
(documentId) => {
|
||||||
|
if (!documentId) return;
|
||||||
|
onInspectDocument(documentId);
|
||||||
|
},
|
||||||
|
[onInspectDocument],
|
||||||
|
);
|
||||||
|
|
||||||
|
const inspectFolder = useCallback(
|
||||||
|
(folderId) => {
|
||||||
|
if (!folderId) return;
|
||||||
|
onInspectFolder(folderId);
|
||||||
|
},
|
||||||
|
[onInspectFolder],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedEntries,
|
||||||
|
selectedDocumentIds,
|
||||||
|
selectedFolderIds,
|
||||||
|
selectionOrder,
|
||||||
|
selectionOrderRef,
|
||||||
|
selectionAnchorRef,
|
||||||
|
selectionInitializedRef,
|
||||||
|
focusedDocumentId,
|
||||||
|
setFocusedDocumentId,
|
||||||
|
focusedRowKey,
|
||||||
|
setFocusedRowKey,
|
||||||
|
applySelection,
|
||||||
|
clearSelection,
|
||||||
|
handleEntrySelection: selectEntry,
|
||||||
|
promoteSelectionOrder,
|
||||||
|
configureSelectionEnvironment,
|
||||||
|
setSelectedEntries,
|
||||||
|
setSelectionOrder,
|
||||||
|
inspectDocument,
|
||||||
|
inspectFolder,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useWorkspaceSelection;
|
||||||
@@ -2,7 +2,7 @@ import React, { useCallback, useMemo } from 'react';
|
|||||||
import { SidebarExpandIcon } from '../ui/icons';
|
import { SidebarExpandIcon } from '../ui/icons';
|
||||||
import { createDocumentsSurface } from '../documents/DocumentsPanel';
|
import { createDocumentsSurface } from '../documents/DocumentsPanel';
|
||||||
import { createDocumentViewerSurface } from '../preview/DocumentViewerPanel';
|
import { createDocumentViewerSurface } from '../preview/DocumentViewerPanel';
|
||||||
import { createDesktopSurface } from '../DesktopWorkspace';
|
import { createDesktopSurface } from '../desktop/DesktopWorkspace';
|
||||||
|
|
||||||
export const useWorkspaceSurface = ({
|
export const useWorkspaceSurface = ({
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
@@ -20,7 +20,6 @@ export const useWorkspaceSurface = ({
|
|||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
notifyApiError,
|
notifyApiError,
|
||||||
handleThumbnailRegeneration,
|
|
||||||
closeDocumentPreview,
|
closeDocumentPreview,
|
||||||
parentBreadcrumb,
|
parentBreadcrumb,
|
||||||
onNavigateParent,
|
onNavigateParent,
|
||||||
@@ -92,7 +91,6 @@ export const useWorkspaceSurface = ({
|
|||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
notifyApiError,
|
notifyApiError,
|
||||||
onRegenerate: handleThumbnailRegeneration,
|
|
||||||
onClose: closeDocumentPreview,
|
onClose: closeDocumentPreview,
|
||||||
renderSidebarToggle,
|
renderSidebarToggle,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
@@ -117,7 +115,6 @@ export const useWorkspaceSurface = ({
|
|||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
notifyApiError,
|
notifyApiError,
|
||||||
handleThumbnailRegeneration,
|
|
||||||
closeDocumentPreview,
|
closeDocumentPreview,
|
||||||
renderSidebarToggle,
|
renderSidebarToggle,
|
||||||
detailPanelProps,
|
detailPanelProps,
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import DesktopPreviewCard from './DesktopPreviewCard';
|
||||||
|
import { resolveCorrespondents } from '../documents/correspondents';
|
||||||
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
|
import { preventAll } from './events';
|
||||||
|
|
||||||
|
const DesktopDocumentCard = ({
|
||||||
|
doc,
|
||||||
|
style,
|
||||||
|
shouldLoad,
|
||||||
|
dragging,
|
||||||
|
matchesFilter,
|
||||||
|
tagTargetActive,
|
||||||
|
tagTargetPending,
|
||||||
|
selected,
|
||||||
|
docTagTokens,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
handleNavigatorSnapshot,
|
||||||
|
cardPointerHandlers,
|
||||||
|
onDocumentOpen,
|
||||||
|
onTagDragEnter,
|
||||||
|
onTagDragOver,
|
||||||
|
onTagDragLeave,
|
||||||
|
onTagDrop,
|
||||||
|
onDocTagPointerDown,
|
||||||
|
onDocTagDragStart,
|
||||||
|
onDocTagDrag,
|
||||||
|
onDocTagDragEnd,
|
||||||
|
pendingRemovalTag,
|
||||||
|
registerNode,
|
||||||
|
}) => {
|
||||||
|
const correspondents = useMemo(() => resolveCorrespondents(doc), [doc]);
|
||||||
|
const tags = Array.isArray(doc?.tags) ? doc.tags : [];
|
||||||
|
|
||||||
|
const itemClasses = ['desk-item'];
|
||||||
|
if (dragging) itemClasses.push('is-dragging');
|
||||||
|
if (tagTargetActive) itemClasses.push('is-tag-target');
|
||||||
|
if (tagTargetPending) itemClasses.push('is-tag-pending');
|
||||||
|
if (!matchesFilter) itemClasses.push('is-filtered-out');
|
||||||
|
if (selected) itemClasses.push('is-selected');
|
||||||
|
|
||||||
|
const ariaHidden = matchesFilter ? undefined : 'true';
|
||||||
|
const dataTagIds = docTagTokens || undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={doc.id}
|
||||||
|
className={itemClasses.join(' ')}
|
||||||
|
style={style}
|
||||||
|
role="button"
|
||||||
|
data-doc-id={doc.id}
|
||||||
|
data-tag-ids={dataTagIds}
|
||||||
|
aria-hidden={ariaHidden}
|
||||||
|
ref={registerNode}
|
||||||
|
{...cardPointerHandlers}
|
||||||
|
onDragEnter={(event) => onTagDragEnter(event, doc.id)}
|
||||||
|
onDragOver={(event) => onTagDragOver(event, doc.id)}
|
||||||
|
onDragLeave={(event) => onTagDragLeave(event, doc.id)}
|
||||||
|
onDrop={(event) => onTagDrop(event, doc)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
preventAll(event);
|
||||||
|
onDocumentOpen?.(doc.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="desk-item__body">
|
||||||
|
<DesktopPreviewCard
|
||||||
|
doc={doc}
|
||||||
|
title={doc.title}
|
||||||
|
ensureAssetUrl={ensureAssetUrl}
|
||||||
|
getDocumentAsset={getDocumentAsset}
|
||||||
|
onNavigatorSnapshot={handleNavigatorSnapshot}
|
||||||
|
shouldLoad={shouldLoad}
|
||||||
|
/>
|
||||||
|
{correspondents.length > 0 && (
|
||||||
|
<div className="desk-item__correspondents" aria-hidden="true">
|
||||||
|
{correspondents.map((correspondent) => (
|
||||||
|
<span
|
||||||
|
key={correspondent.key}
|
||||||
|
className="badge desk-correspondent-chip"
|
||||||
|
title={correspondent.name}
|
||||||
|
>
|
||||||
|
<span className="desk-correspondent-chip__label">{correspondent.name}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="desk-item__tags" aria-hidden="true">
|
||||||
|
{tags.map((tag) => {
|
||||||
|
if (pendingRemovalTag && pendingRemovalTag.docId === doc.id && pendingRemovalTag.tagId === tag.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const colorStyle = getTagColorStyle(tag.color);
|
||||||
|
const pendingRemoval =
|
||||||
|
pendingRemovalTag && pendingRemovalTag.docId === doc.id && pendingRemovalTag.tagId === tag.id;
|
||||||
|
const tagClasses = ['badge', 'tag-chip', 'tag-chip--draggable'];
|
||||||
|
if (pendingRemoval) tagClasses.push('tag-chip--tear-pending');
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={tag.id}
|
||||||
|
className={tagClasses.join(' ')}
|
||||||
|
style={colorStyle || undefined}
|
||||||
|
title={tag.label}
|
||||||
|
draggable
|
||||||
|
data-desk-tag-chip="true"
|
||||||
|
onPointerDownCapture={(event) => {
|
||||||
|
onDocTagPointerDown(event, doc, tag);
|
||||||
|
}}
|
||||||
|
onDragStart={(event) => onDocTagDragStart(event, doc, tag)}
|
||||||
|
onDrag={onDocTagDrag}
|
||||||
|
onDragEnd={(event) => onDocTagDragEnd(event)}
|
||||||
|
>
|
||||||
|
<span className="tag-chip__label">{tag.label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(DesktopDocumentCard);
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
|
||||||
|
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
||||||
|
import { preventAll } from './events';
|
||||||
|
|
||||||
|
const DesktopPreviewCard = ({
|
||||||
|
doc,
|
||||||
|
title,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
prefetch = 3,
|
||||||
|
onNavigatorSnapshot,
|
||||||
|
shouldLoad = true,
|
||||||
|
}) => {
|
||||||
|
const navigator = useAssetNavigator({
|
||||||
|
document: doc,
|
||||||
|
assetType: 'preview',
|
||||||
|
ensureAssetUrl: shouldLoad ? ensureAssetUrl : null,
|
||||||
|
getAsset: getDocumentAsset,
|
||||||
|
prefetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { currentUrl, cardinality, canGoPrev, canGoNext, currentMetadata, ordinal } = navigator;
|
||||||
|
const docId = doc?.id ?? null;
|
||||||
|
|
||||||
|
const metadataWidth = Number(currentMetadata?.width);
|
||||||
|
const metadataHeight = Number(currentMetadata?.height);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onNavigatorSnapshot || !docId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const snapshot = {
|
||||||
|
url: currentUrl || null,
|
||||||
|
alt: title,
|
||||||
|
canGoPrev,
|
||||||
|
canGoNext,
|
||||||
|
goPrev: navigator.goPrev,
|
||||||
|
goNext: navigator.goNext,
|
||||||
|
ordinal,
|
||||||
|
width: Number.isFinite(metadataWidth) && metadataWidth > 0 ? metadataWidth : null,
|
||||||
|
height: Number.isFinite(metadataHeight) && metadataHeight > 0 ? metadataHeight : null,
|
||||||
|
};
|
||||||
|
onNavigatorSnapshot(docId, snapshot);
|
||||||
|
return () => onNavigatorSnapshot(docId, null);
|
||||||
|
}, [
|
||||||
|
docId,
|
||||||
|
currentUrl,
|
||||||
|
title,
|
||||||
|
canGoPrev,
|
||||||
|
canGoNext,
|
||||||
|
ordinal,
|
||||||
|
metadataWidth,
|
||||||
|
metadataHeight,
|
||||||
|
navigator.goPrev,
|
||||||
|
navigator.goNext,
|
||||||
|
onNavigatorSnapshot,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hasPreview = Boolean(currentUrl);
|
||||||
|
const cardClasses = ['desk-item__card'];
|
||||||
|
if (!hasPreview) cardClasses.push('desk-item__card--empty');
|
||||||
|
const showNav = hasPreview && (cardinality > 1 || canGoPrev || canGoNext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cardClasses.join(' ')}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
if (event instanceof DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasPreview ? (
|
||||||
|
<img
|
||||||
|
src={currentUrl}
|
||||||
|
alt={title}
|
||||||
|
draggable={false}
|
||||||
|
onDragStart={(event) => event.preventDefault()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="desk-item__empty">
|
||||||
|
<div className="desk-item__placeholder">DOC</div>
|
||||||
|
<div className="desk-item__title" title={title}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showNav ? (
|
||||||
|
<div className="desk-card__nav">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="desk-card__nav-button"
|
||||||
|
onClick={(event) => {
|
||||||
|
preventAll(event);
|
||||||
|
navigator.goPrev();
|
||||||
|
}}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
preventAll(event);
|
||||||
|
}}
|
||||||
|
onPointerUp={(event) => {
|
||||||
|
preventAll(event);
|
||||||
|
}}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
preventAll(event);
|
||||||
|
}}
|
||||||
|
onMouseUp={(event) => {
|
||||||
|
preventAll(event);
|
||||||
|
}}
|
||||||
|
disabled={!canGoPrev}
|
||||||
|
aria-label="Previous preview"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="desk-card__nav-button"
|
||||||
|
onClick={(event) => {
|
||||||
|
preventAll(event);
|
||||||
|
navigator.goNext();
|
||||||
|
}}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
preventAll(event);
|
||||||
|
}}
|
||||||
|
onPointerUp={(event) => {
|
||||||
|
preventAll(event);
|
||||||
|
}}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
preventAll(event);
|
||||||
|
}}
|
||||||
|
onMouseUp={(event) => {
|
||||||
|
preventAll(event);
|
||||||
|
}}
|
||||||
|
disabled={!canGoNext}
|
||||||
|
aria-label="Next preview"
|
||||||
|
>
|
||||||
|
<ArrowRightIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DesktopPreviewCard;
|
||||||
|
|
||||||
@@ -16,6 +16,9 @@
|
|||||||
transition: box-shadow 0.16s ease;
|
transition: box-shadow 0.16s ease;
|
||||||
outline: none;
|
outline: none;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desk-shell {
|
.desk-shell {
|
||||||
@@ -32,6 +35,12 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-canvas:focus,
|
||||||
|
.desk-canvas:focus-visible {
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desk-empty {
|
.desk-empty {
|
||||||
@@ -98,7 +107,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.desk-item__tags {
|
.desk-item__tags {
|
||||||
--tag-scale: 1;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
@@ -107,23 +115,54 @@
|
|||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
transform-origin: top right;
|
transform-origin: top right;
|
||||||
transform: scale(var(--tag-scale)) translate(-0.5em, 0.5em);
|
transform: translate(-0.5em, 0.5em);
|
||||||
transition: transform 0.28s ease;
|
transition: transform 0.28s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.desk-item__correspondents {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
transform-origin: bottom left;
|
||||||
|
transform: translate(0.5em, -0.5em);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-correspondent-chip {
|
||||||
|
pointer-events: none;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 0.18rem 0.55rem;
|
||||||
|
max-width: min(16rem, 80%);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
background: color-mix(in oklch, var(--surface-subtle) 90%, transparent);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-correspondent-chip__label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.tag-chip--draggable {
|
.tag-chip--draggable {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
transition: transform 0.16s ease, opacity 0.2s ease, box-shadow 0.2s ease;
|
transition: transform 0.16s ease, opacity 0.2s ease, box-shadow 0.2s ease;
|
||||||
box-shadow: 2px 2px 4px var(--shadow-medium);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.desk-help-overlay {
|
.desk-help-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 1400;
|
z-index: 5000000;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -220,7 +259,6 @@
|
|||||||
|
|
||||||
.tag-chip--draggable.is-drag-hidden {
|
.tag-chip--draggable.is-drag-hidden {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.desk-item__tags .tag-chip {
|
.desk-item__tags .tag-chip {
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +0,0 @@
|
|||||||
import React, { createContext, useContext } from 'react';
|
|
||||||
|
|
||||||
const DesktopContext = createContext(null);
|
|
||||||
|
|
||||||
export const DesktopProvider = ({ value, children }) => (
|
|
||||||
<DesktopContext.Provider value={value}>{children}</DesktopContext.Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const useDesktopContext = () => {
|
|
||||||
const context = useContext(DesktopContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useDesktopContext must be used within a DesktopProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DesktopContext;
|
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
const DB_NAME = 'papercrate_desk';
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
const LAYOUT_STORE = 'layouts';
|
||||||
|
|
||||||
|
const currentDbPromise = { value: null };
|
||||||
|
|
||||||
|
const openDatabase = () => {
|
||||||
|
if (currentDbPromise.value) {
|
||||||
|
return currentDbPromise.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentDbPromise.value = new Promise((resolve, reject) => {
|
||||||
|
if (typeof indexedDB === 'undefined') {
|
||||||
|
reject(new Error('IndexedDB not available'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const db = request.result;
|
||||||
|
if (!db.objectStoreNames.contains(LAYOUT_STORE)) {
|
||||||
|
const store = db.createObjectStore(LAYOUT_STORE, {
|
||||||
|
keyPath: ['tenantId', 'viewId', 'documentId'],
|
||||||
|
});
|
||||||
|
store.createIndex('tenantViewIdx', ['tenantId', 'viewId'], { unique: false });
|
||||||
|
store.createIndex('tenantIdx', 'tenantId', { unique: false });
|
||||||
|
store.createIndex('updatedIdx', 'updatedAt', { unique: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
resolve(request.result);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
reject(request.error || new Error('Failed to open IndexedDB'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return currentDbPromise.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestToPromise = (request, defaultValue) => new Promise((resolve, reject) => {
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const { result } = request;
|
||||||
|
resolve(result ?? defaultValue);
|
||||||
|
};
|
||||||
|
request.onerror = () => {
|
||||||
|
reject(request.error || new Error('IndexedDB request failed'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const iterateCursor = (request, iteratee) => new Promise((resolve, reject) => {
|
||||||
|
request.onsuccess = (event) => {
|
||||||
|
const cursor = event.target.result;
|
||||||
|
if (!cursor) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
iteratee(cursor);
|
||||||
|
cursor.continue();
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onerror = () => {
|
||||||
|
reject(request.error || new Error('IndexedDB cursor failed'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const transactionComplete = (transaction) => new Promise((resolve, reject) => {
|
||||||
|
transaction.oncomplete = () => {
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
transaction.onerror = () => {
|
||||||
|
reject(transaction.error || new Error('IndexedDB transaction failed'));
|
||||||
|
};
|
||||||
|
transaction.onabort = () => {
|
||||||
|
reject(transaction.error || new Error('IndexedDB transaction aborted'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const withStore = async (mode, handler) => {
|
||||||
|
const db = await openDatabase();
|
||||||
|
const transaction = db.transaction(LAYOUT_STORE, mode);
|
||||||
|
const store = transaction.objectStore(LAYOUT_STORE);
|
||||||
|
const done = transactionComplete(transaction);
|
||||||
|
try {
|
||||||
|
const result = await handler(store, transaction);
|
||||||
|
await done;
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
transaction.abort();
|
||||||
|
} catch (abortError) {
|
||||||
|
console.warn('[desk] Failed to abort transaction', abortError);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await done;
|
||||||
|
} catch (suppressed) {
|
||||||
|
// noop – prefer original error
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchLayoutRecords = async ({ tenantId, viewId }) => {
|
||||||
|
if (!tenantId || !viewId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await withStore('readonly', (store) => {
|
||||||
|
const index = store.index('tenantViewIdx');
|
||||||
|
return requestToPromise(index.getAll([tenantId, viewId]), []);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[desk] Failed to read layout records', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertLayoutRecords = async ({ tenantId, viewId, entries }) => {
|
||||||
|
if (!tenantId || !viewId || !Array.isArray(entries) || !entries.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withStore('readwrite', (store) => {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (!entry || !entry.documentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.put({
|
||||||
|
tenantId,
|
||||||
|
viewId,
|
||||||
|
documentId: entry.documentId,
|
||||||
|
centerX: Number(entry.centerX) || 0,
|
||||||
|
centerY: Number(entry.centerY) || 0,
|
||||||
|
rotation: Number(entry.rotation) || 0,
|
||||||
|
zIndex: Number(entry.zIndex) || 0,
|
||||||
|
updatedAt: entry.updatedAt || timestamp,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[desk] Failed to upsert layout records', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteTenantLayouts = async (tenantId) => {
|
||||||
|
if (!tenantId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await withStore('readwrite', (store) => {
|
||||||
|
const index = store.index('tenantIdx');
|
||||||
|
const request = index.openCursor(tenantId);
|
||||||
|
return iterateCursor(request, (cursor) => {
|
||||||
|
cursor.delete();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[desk] Failed to clean tenant layouts', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const closeDeskDatabase = () => {
|
||||||
|
if (!currentDbPromise.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentDbPromise.value = currentDbPromise.value.then((db) => {
|
||||||
|
try {
|
||||||
|
db.close();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[desk] Failed to close IndexedDB', error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -13,3 +13,19 @@ export const preventAll = (event) => {
|
|||||||
console.warn('[events] stopPropagation failed', error);
|
console.warn('[events] stopPropagation failed', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const safeInvoke = (fn, ...args) => (typeof fn === 'function' ? fn(...args) : undefined);
|
||||||
|
|
||||||
|
export const getPointerPosition = (event, { fallbackToPage = true } = {}) => {
|
||||||
|
if (!event) {
|
||||||
|
return { x: 0, y: 0 };
|
||||||
|
}
|
||||||
|
const clientX = Number.isFinite(event.clientX) ? event.clientX : null;
|
||||||
|
const clientY = Number.isFinite(event.clientY) ? event.clientY : null;
|
||||||
|
const pageX = fallbackToPage && Number.isFinite(event.pageX) ? event.pageX : null;
|
||||||
|
const pageY = fallbackToPage && Number.isFinite(event.pageY) ? event.pageY : null;
|
||||||
|
return {
|
||||||
|
x: clientX ?? pageX ?? 0,
|
||||||
|
y: clientY ?? pageY ?? 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { safeInvoke } from '../events.js';
|
||||||
|
|
||||||
|
export const CLICK_ACTIONS = {
|
||||||
|
selectSingle: 'selectSingle',
|
||||||
|
openDetail: 'openDetail',
|
||||||
|
addCard: 'addCard',
|
||||||
|
addStack: 'addStack',
|
||||||
|
none: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DRAG_ACTIONS = {
|
||||||
|
dragSelectSingle: 'dragSelectSingle',
|
||||||
|
dragSelection: 'dragSelection',
|
||||||
|
dragSelectStack: 'dragSelectStack',
|
||||||
|
none: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STACK_HIT_EPSILON = 4;
|
||||||
|
export const POINTER_DRAG_THRESHOLD_SQUARED = 16;
|
||||||
|
export const LONG_PRESS_DURATION_MS = 450;
|
||||||
|
|
||||||
|
export const withinThreshold = (dx, dy, thresholdSquared) => (dx * dx + dy * dy) <= thresholdSquared;
|
||||||
|
|
||||||
|
export const createPointerIntent = ({
|
||||||
|
doc,
|
||||||
|
entryDescriptor,
|
||||||
|
selectedDocumentIds,
|
||||||
|
metaKey,
|
||||||
|
pointerButton,
|
||||||
|
pointerType,
|
||||||
|
stackHits,
|
||||||
|
}) => {
|
||||||
|
const alreadySelected = selectedDocumentIds.includes(doc.id);
|
||||||
|
const selectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0;
|
||||||
|
|
||||||
|
let clickAction = CLICK_ACTIONS.none;
|
||||||
|
let dragAction = DRAG_ACTIONS.none;
|
||||||
|
|
||||||
|
if (metaKey) {
|
||||||
|
clickAction = CLICK_ACTIONS.addStack;
|
||||||
|
dragAction = DRAG_ACTIONS.dragSelectStack;
|
||||||
|
} else if (alreadySelected) {
|
||||||
|
clickAction = CLICK_ACTIONS.selectSingle;
|
||||||
|
dragAction = selectionCount > 1 ? DRAG_ACTIONS.dragSelection : DRAG_ACTIONS.dragSelectSingle;
|
||||||
|
} else {
|
||||||
|
clickAction = CLICK_ACTIONS.selectSingle;
|
||||||
|
dragAction = DRAG_ACTIONS.dragSelectSingle;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stackList = Array.isArray(stackHits) && stackHits.length > 0
|
||||||
|
? stackHits.slice()
|
||||||
|
: [String(doc.id)];
|
||||||
|
|
||||||
|
const stackDocIdsForDrag = dragAction === DRAG_ACTIONS.dragSelectStack ? stackList : null;
|
||||||
|
const stackDocIdsForClick = clickAction === CLICK_ACTIONS.addStack ? stackList : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
docId: doc.id,
|
||||||
|
entryDescriptor,
|
||||||
|
pointerType,
|
||||||
|
pointerButton,
|
||||||
|
selectedAtDown: alreadySelected,
|
||||||
|
selectionCountAtDown: selectionCount,
|
||||||
|
metaKey,
|
||||||
|
clickAction,
|
||||||
|
dragAction,
|
||||||
|
stackDocIdsForDrag,
|
||||||
|
stackDocIdsForClick,
|
||||||
|
stackReplaceOnClick: clickAction === CLICK_ACTIONS.addStack,
|
||||||
|
stackReplaceOnDrag: dragAction === DRAG_ACTIONS.dragSelectStack,
|
||||||
|
clickSelectionApplied: false,
|
||||||
|
stackSelectionApplied: false,
|
||||||
|
longPressTriggered: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const applyClickPlanImmediately = ({ intent, event, onEntryPointer, onDocumentStackSelect }) => {
|
||||||
|
switch (intent.clickAction) {
|
||||||
|
case CLICK_ACTIONS.selectSingle:
|
||||||
|
case CLICK_ACTIONS.addCard:
|
||||||
|
safeInvoke(onEntryPointer, intent.entryDescriptor, event);
|
||||||
|
intent.clickSelectionApplied = true;
|
||||||
|
break;
|
||||||
|
case CLICK_ACTIONS.addStack:
|
||||||
|
if (Array.isArray(intent.stackDocIdsForClick) && intent.stackDocIdsForClick.length > 0) {
|
||||||
|
safeInvoke(
|
||||||
|
onDocumentStackSelect,
|
||||||
|
intent.stackDocIdsForClick,
|
||||||
|
event,
|
||||||
|
{ replace: intent.stackReplaceOnClick },
|
||||||
|
);
|
||||||
|
intent.clickSelectionApplied = true;
|
||||||
|
intent.stackSelectionApplied = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case CLICK_ACTIONS.openDetail:
|
||||||
|
default:
|
||||||
|
intent.clickSelectionApplied = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const finalizeClickSelection = ({ intent, event, onEntryPointer, onDocumentStackSelect }) => {
|
||||||
|
if (!intent || intent.clickSelectionApplied) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyClickPlanImmediately({ intent, event, onEntryPointer, onDocumentStackSelect });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const applyLongPressSelection = ({ intent, stackDocIds, syntheticEvent, onDocumentStackSelect }) => {
|
||||||
|
if (!intent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stackCopy = Array.isArray(stackDocIds) && stackDocIds.length > 0
|
||||||
|
? stackDocIds.slice()
|
||||||
|
: [intent.docId];
|
||||||
|
|
||||||
|
safeInvoke(onDocumentStackSelect, stackCopy, syntheticEvent, { replace: true });
|
||||||
|
|
||||||
|
intent.clickAction = CLICK_ACTIONS.addStack;
|
||||||
|
intent.dragAction = DRAG_ACTIONS.dragSelectStack;
|
||||||
|
intent.stackDocIdsForClick = stackCopy;
|
||||||
|
intent.stackDocIdsForDrag = stackCopy;
|
||||||
|
intent.stackReplaceOnClick = true;
|
||||||
|
intent.stackReplaceOnDrag = true;
|
||||||
|
intent.clickSelectionApplied = true;
|
||||||
|
intent.stackSelectionApplied = true;
|
||||||
|
intent.longPressTriggered = true;
|
||||||
|
};
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
CLICK_ACTIONS,
|
||||||
|
LONG_PRESS_DURATION_MS,
|
||||||
|
POINTER_DRAG_THRESHOLD_SQUARED,
|
||||||
|
STACK_HIT_EPSILON,
|
||||||
|
applyClickPlanImmediately,
|
||||||
|
applyLongPressSelection,
|
||||||
|
createPointerIntent,
|
||||||
|
finalizeClickSelection,
|
||||||
|
withinThreshold,
|
||||||
|
} from './pointerUtils';
|
||||||
|
import { getPointerPosition, safeInvoke } from '../events.js';
|
||||||
|
|
||||||
|
const buildEntryDescriptor = (docId) => ({
|
||||||
|
type: 'document',
|
||||||
|
id: docId,
|
||||||
|
key: `document:${docId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useDeskPointer = ({
|
||||||
|
containerRef,
|
||||||
|
items,
|
||||||
|
layoutRef,
|
||||||
|
ensureDocumentSize,
|
||||||
|
activeTagSet,
|
||||||
|
handlePointerDown,
|
||||||
|
handlePointerMove,
|
||||||
|
handlePointerUp,
|
||||||
|
handlePointerCancel,
|
||||||
|
onEntryPointer,
|
||||||
|
onDocumentStackSelect,
|
||||||
|
onPromoteSelection,
|
||||||
|
onDocumentOpen,
|
||||||
|
selectedDocumentIds,
|
||||||
|
detailPanelOpen,
|
||||||
|
onCloseDetailPanel,
|
||||||
|
openOverlayForDoc = null,
|
||||||
|
}) => {
|
||||||
|
const pointerIntentRef = useRef(null);
|
||||||
|
const pointerStartRef = useRef({ x: 0, y: 0 });
|
||||||
|
const pointerMovedRef = useRef(false);
|
||||||
|
const longPressTimerRef = useRef(null);
|
||||||
|
const longPressActiveRef = useRef(false);
|
||||||
|
const resetLongPressState = useCallback(() => {
|
||||||
|
if (longPressTimerRef.current) {
|
||||||
|
clearTimeout(longPressTimerRef.current);
|
||||||
|
longPressTimerRef.current = null;
|
||||||
|
}
|
||||||
|
longPressActiveRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resolveStackDocIds = useCallback(
|
||||||
|
(event, targetDocId = null) => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container || !event) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const pointerCanvasX = event.clientX - rect.left;
|
||||||
|
const pointerCanvasY = event.clientY - rect.top;
|
||||||
|
|
||||||
|
if (!Number.isFinite(pointerCanvasX) || !Number.isFinite(pointerCanvasY)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = [];
|
||||||
|
|
||||||
|
items.forEach((doc) => {
|
||||||
|
if (!doc?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const docKey = String(doc.id);
|
||||||
|
const layout = layoutRef.current.get(docKey);
|
||||||
|
if (!layout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeInfo = ensureDocumentSize(doc);
|
||||||
|
if (!sizeInfo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { width, height } = sizeInfo;
|
||||||
|
if (!width || !height) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTagSet.size) {
|
||||||
|
const docTagKeys = Array.isArray(doc.tags)
|
||||||
|
? doc.tags.map((tag) => (tag ? tag.id : null)).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
if (!docTagKeys.some((key) => activeTagSet.has(key))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const centerX = Number(layout.centerX);
|
||||||
|
const centerY = Number(layout.centerY);
|
||||||
|
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rotationDeg = Number(layout.rotation) || 0;
|
||||||
|
const rotationRad = (rotationDeg * Math.PI) / 180;
|
||||||
|
const dx = pointerCanvasX - centerX;
|
||||||
|
const dy = pointerCanvasY - centerY;
|
||||||
|
const cosRotation = Math.cos(-rotationRad);
|
||||||
|
const sinRotation = Math.sin(-rotationRad);
|
||||||
|
const localX = dx * cosRotation - dy * sinRotation;
|
||||||
|
const localY = dx * sinRotation + dy * cosRotation;
|
||||||
|
const halfWidth = width / 2;
|
||||||
|
const halfHeight = height / 2;
|
||||||
|
|
||||||
|
const containsPointer =
|
||||||
|
Math.abs(localX) <= halfWidth + STACK_HIT_EPSILON
|
||||||
|
&& Math.abs(localY) <= halfHeight + STACK_HIT_EPSILON;
|
||||||
|
|
||||||
|
candidates.push({
|
||||||
|
id: docKey,
|
||||||
|
z: Number.isFinite(layout.z) ? layout.z : 0,
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
halfWidth,
|
||||||
|
halfHeight,
|
||||||
|
containsPointer,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const pointerCandidates = candidates.filter((candidate) => candidate.containsPointer);
|
||||||
|
if (!pointerCandidates.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedByZ = [...pointerCandidates].sort((a, b) => (b.z ?? 0) - (a.z ?? 0));
|
||||||
|
const targetKey = targetDocId != null ? String(targetDocId) : sortedByZ[0].id;
|
||||||
|
|
||||||
|
const primary = sortedByZ.find((candidate) => candidate.id === targetKey) || sortedByZ[0];
|
||||||
|
if (!primary) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const radius = Math.max(Math.min(primary.halfWidth, primary.halfHeight) * 1.2, 6);
|
||||||
|
const radiusSquared = radius * radius;
|
||||||
|
|
||||||
|
const selected = candidates
|
||||||
|
.filter((candidate) => {
|
||||||
|
if (!candidate?.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const dx = candidate.centerX - primary.centerX;
|
||||||
|
const dy = candidate.centerY - primary.centerY;
|
||||||
|
return dx * dx + dy * dy <= radiusSquared + 1e-4;
|
||||||
|
})
|
||||||
|
.sort((a, b) => (b.z ?? 0) - (a.z ?? 0));
|
||||||
|
|
||||||
|
if (targetKey) {
|
||||||
|
const targetIndex = selected.findIndex((entry) => entry.id === targetKey);
|
||||||
|
if (targetIndex > 0) {
|
||||||
|
const [targetEntry] = selected.splice(targetIndex, 1);
|
||||||
|
selected.unshift(targetEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected
|
||||||
|
.map((candidate) => candidate.id)
|
||||||
|
.filter((id, index, array) => array.indexOf(id) === index);
|
||||||
|
},
|
||||||
|
[activeTagSet, containerRef, ensureDocumentSize, items, layoutRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
const scheduleLongPress = useCallback(
|
||||||
|
({ doc, modifierActive, pointerType }) => {
|
||||||
|
if (modifierActive || pointerType !== 'touch') {
|
||||||
|
longPressActiveRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
longPressActiveRef.current = true;
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
longPressTimerRef.current = window.setTimeout(() => {
|
||||||
|
if (!longPressActiveRef.current || pointerMovedRef.current) {
|
||||||
|
resetLongPressState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intent = pointerIntentRef.current;
|
||||||
|
if (!intent || intent.docId !== doc.id) {
|
||||||
|
resetLongPressState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const syntheticEvent = {
|
||||||
|
clientX: pointerStartRef.current.x,
|
||||||
|
clientY: pointerStartRef.current.y,
|
||||||
|
};
|
||||||
|
const stackHits = resolveStackDocIds(syntheticEvent, doc.id);
|
||||||
|
applyLongPressSelection({
|
||||||
|
intent,
|
||||||
|
stackDocIds: stackHits,
|
||||||
|
syntheticEvent,
|
||||||
|
onDocumentStackSelect,
|
||||||
|
});
|
||||||
|
pointerIntentRef.current = intent;
|
||||||
|
resetLongPressState();
|
||||||
|
}, LONG_PRESS_DURATION_MS);
|
||||||
|
},
|
||||||
|
[onDocumentStackSelect, resolveStackDocIds, resetLongPressState],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => () => resetLongPressState(), [resetLongPressState]);
|
||||||
|
|
||||||
|
const handleCardPointerDown = useCallback(
|
||||||
|
(event, doc) => {
|
||||||
|
if (!doc?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pointerStartRef.current = getPointerPosition(event, { fallbackToPage: false });
|
||||||
|
pointerMovedRef.current = false;
|
||||||
|
resetLongPressState();
|
||||||
|
|
||||||
|
const pointerButton = typeof event.button === 'number' ? event.button : 0;
|
||||||
|
const pointerType = typeof event.pointerType === 'string' ? event.pointerType : '';
|
||||||
|
const metaKey = (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey;
|
||||||
|
const modifierActive = Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey);
|
||||||
|
|
||||||
|
const entryDescriptor = buildEntryDescriptor(doc.id);
|
||||||
|
const stackHits = metaKey ? resolveStackDocIds(event, doc.id) : null;
|
||||||
|
|
||||||
|
const intent = createPointerIntent({
|
||||||
|
doc,
|
||||||
|
entryDescriptor,
|
||||||
|
selectedDocumentIds,
|
||||||
|
metaKey,
|
||||||
|
pointerButton,
|
||||||
|
pointerType,
|
||||||
|
stackHits,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (intent.selectedAtDown) {
|
||||||
|
safeInvoke(onPromoteSelection, doc.id, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyClickPlanImmediately({
|
||||||
|
intent,
|
||||||
|
event,
|
||||||
|
onEntryPointer,
|
||||||
|
onDocumentStackSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
pointerIntentRef.current = intent;
|
||||||
|
|
||||||
|
handlePointerDown(event, doc.id, {
|
||||||
|
stackDocIds: intent.stackDocIdsForDrag,
|
||||||
|
stackSelectionApplied: intent.stackSelectionApplied,
|
||||||
|
wasSelected: intent.selectedAtDown,
|
||||||
|
modifierActive,
|
||||||
|
stackReplace: intent.stackReplaceOnDrag,
|
||||||
|
});
|
||||||
|
|
||||||
|
scheduleLongPress({
|
||||||
|
doc,
|
||||||
|
modifierActive,
|
||||||
|
pointerType,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[
|
||||||
|
handlePointerDown,
|
||||||
|
onPromoteSelection,
|
||||||
|
onEntryPointer,
|
||||||
|
onDocumentStackSelect,
|
||||||
|
resolveStackDocIds,
|
||||||
|
resetLongPressState,
|
||||||
|
scheduleLongPress,
|
||||||
|
selectedDocumentIds,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCardPointerMove = useCallback(
|
||||||
|
(event) => {
|
||||||
|
const start = pointerStartRef.current;
|
||||||
|
const { x, y } = getPointerPosition(event, { fallbackToPage: false });
|
||||||
|
const dx = x - start.x;
|
||||||
|
const dy = y - start.y;
|
||||||
|
if (!withinThreshold(dx, dy, POINTER_DRAG_THRESHOLD_SQUARED)) {
|
||||||
|
pointerMovedRef.current = true;
|
||||||
|
resetLongPressState();
|
||||||
|
}
|
||||||
|
handlePointerMove(event);
|
||||||
|
},
|
||||||
|
[handlePointerMove, resetLongPressState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCardPointerUp = useCallback(
|
||||||
|
(event, doc) => {
|
||||||
|
const pointerState = pointerIntentRef.current;
|
||||||
|
const pointerMoved = pointerMovedRef.current;
|
||||||
|
|
||||||
|
resetLongPressState();
|
||||||
|
handlePointerUp(event);
|
||||||
|
|
||||||
|
if (!pointerMoved && pointerState) {
|
||||||
|
finalizeClickSelection({
|
||||||
|
intent: pointerState,
|
||||||
|
event,
|
||||||
|
onEntryPointer,
|
||||||
|
onDocumentStackSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
pointerState.clickAction === CLICK_ACTIONS.openDetail
|
||||||
|
&& !pointerState.longPressTriggered
|
||||||
|
&& pointerState.docId === doc.id
|
||||||
|
) {
|
||||||
|
const expectedButton = typeof pointerState.pointerButton === 'number'
|
||||||
|
? pointerState.pointerButton
|
||||||
|
: 0;
|
||||||
|
const releasedButton = typeof event.button === 'number'
|
||||||
|
? event.button
|
||||||
|
: expectedButton;
|
||||||
|
const isPrimaryRelease = expectedButton === 0 && releasedButton === 0;
|
||||||
|
const stillSelected = Array.isArray(selectedDocumentIds)
|
||||||
|
&& selectedDocumentIds.includes(doc.id);
|
||||||
|
if (isPrimaryRelease && stillSelected) {
|
||||||
|
const useSelection = pointerState.selectedAtDown && pointerState.selectionCountAtDown > 0;
|
||||||
|
safeInvoke(onDocumentOpen, doc.id, { useSelection });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pointerIntentRef.current = null;
|
||||||
|
pointerMovedRef.current = false;
|
||||||
|
},
|
||||||
|
[
|
||||||
|
handlePointerUp,
|
||||||
|
onDocumentOpen,
|
||||||
|
onDocumentStackSelect,
|
||||||
|
onEntryPointer,
|
||||||
|
resetLongPressState,
|
||||||
|
selectedDocumentIds,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCardPointerCancel = useCallback(
|
||||||
|
(event) => {
|
||||||
|
pointerMovedRef.current = false;
|
||||||
|
resetLongPressState();
|
||||||
|
pointerIntentRef.current = null;
|
||||||
|
handlePointerCancel(event);
|
||||||
|
},
|
||||||
|
[handlePointerCancel, resetLongPressState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getCardPointerHandlers = useCallback(
|
||||||
|
(doc) => ({
|
||||||
|
onPointerDown: (event) => handleCardPointerDown(event, doc),
|
||||||
|
onPointerMove: handleCardPointerMove,
|
||||||
|
onPointerUp: (event) => handleCardPointerUp(event, doc),
|
||||||
|
onPointerCancel: handleCardPointerCancel,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
handleCardPointerCancel,
|
||||||
|
handleCardPointerDown,
|
||||||
|
handleCardPointerMove,
|
||||||
|
handleCardPointerUp,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleShellKeyDown = useCallback(
|
||||||
|
(event) => {
|
||||||
|
if (!event || event.defaultPrevented) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { key } = event;
|
||||||
|
if (key !== ' ' && key !== 'Space' && key !== 'Spacebar') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = event.target;
|
||||||
|
if (target instanceof HTMLElement) {
|
||||||
|
const tagName = target.tagName ? target.tagName.toLowerCase() : '';
|
||||||
|
if (
|
||||||
|
target.isContentEditable
|
||||||
|
|| tagName === 'input'
|
||||||
|
|| tagName === 'textarea'
|
||||||
|
|| tagName === 'select'
|
||||||
|
|| tagName === 'button'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof openOverlayForDoc === 'function'
|
||||||
|
&& Array.isArray(selectedDocumentIds)
|
||||||
|
&& selectedDocumentIds.length > 0
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
const targetId = selectedDocumentIds[selectedDocumentIds.length - 1];
|
||||||
|
if (targetId) {
|
||||||
|
openOverlayForDoc(targetId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailPanelOpen) {
|
||||||
|
event.preventDefault();
|
||||||
|
safeInvoke(onCloseDetailPanel);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[detailPanelOpen, onCloseDetailPanel, openOverlayForDoc, selectedDocumentIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCardPointerHandlers,
|
||||||
|
handleShellKeyDown,
|
||||||
|
focusShell: () => {
|
||||||
|
const shell = containerRef.current;
|
||||||
|
if (shell && typeof shell.focus === 'function') {
|
||||||
|
shell.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDeskPointer;
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
import { getPointerPosition, preventAll, safeInvoke } from '../events.js';
|
||||||
|
import {
|
||||||
|
isTagTransferEvent,
|
||||||
|
parseTagTransferPayload,
|
||||||
|
writeTagTransferData,
|
||||||
|
} from '../../documents/tagTransfer';
|
||||||
|
|
||||||
|
const TAG_REMOVE_DISTANCE = 160;
|
||||||
|
const DEBUG_DROP = false;
|
||||||
|
|
||||||
|
const createDragPreview = (node, clientX, clientY) => {
|
||||||
|
if (!(node instanceof HTMLElement)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
const safeClientX = Number.isFinite(clientX) ? clientX : rect.left + rect.width / 2;
|
||||||
|
const safeClientY = Number.isFinite(clientY) ? clientY : rect.top + rect.height / 2;
|
||||||
|
const offsetX = Math.min(Math.max(safeClientX - rect.left, 0), rect.width);
|
||||||
|
const offsetY = Math.min(Math.max(safeClientY - rect.top, 0), rect.height);
|
||||||
|
const clone = node.cloneNode(true);
|
||||||
|
clone.style.position = 'absolute';
|
||||||
|
clone.style.top = '-9999px';
|
||||||
|
clone.style.left = '-9999px';
|
||||||
|
clone.style.pointerEvents = 'none';
|
||||||
|
clone.style.opacity = '1';
|
||||||
|
clone.style.transform = 'none';
|
||||||
|
document.body.appendChild(clone);
|
||||||
|
return { clone, offsetX, offsetY };
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupPreview = (previewNode) => {
|
||||||
|
if (previewNode && previewNode.parentNode) {
|
||||||
|
previewNode.parentNode.removeChild(previewNode);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeskTagInteractions = ({
|
||||||
|
engine,
|
||||||
|
onAssignTagToDocument,
|
||||||
|
onRemoveTagFromDocument,
|
||||||
|
requestCanvasFocus,
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
const draggingTagRef = useRef(null);
|
||||||
|
const pendingDocTagDragRef = useRef(null);
|
||||||
|
const removalCursorActiveRef = useRef(false);
|
||||||
|
|
||||||
|
const updateRemovalCursor = useCallback((active) => {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (removalCursorActiveRef.current === active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = document.body;
|
||||||
|
if (!body) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removalCursorActiveRef.current = active;
|
||||||
|
if (active) {
|
||||||
|
body.classList.add('desk-cursor-remove');
|
||||||
|
} else {
|
||||||
|
body.classList.remove('desk-cursor-remove');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
updateRemovalCursor(false);
|
||||||
|
},
|
||||||
|
[updateRemovalCursor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isTagTransfer = useCallback((event) => isTagTransferEvent(event), []);
|
||||||
|
|
||||||
|
const handleTagDragEnd = useCallback(() => {
|
||||||
|
updateRemovalCursor(false);
|
||||||
|
engine.setTagDropTargetId(null);
|
||||||
|
}, [engine, updateRemovalCursor]);
|
||||||
|
|
||||||
|
const finalizeTagDrag = useCallback(
|
||||||
|
(dropEffect = 'none') => {
|
||||||
|
const state = draggingTagRef.current;
|
||||||
|
if (!state) {
|
||||||
|
updateRemovalCursor(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
draggingTagRef.current = null;
|
||||||
|
|
||||||
|
const node = state.element;
|
||||||
|
const showNode = () => {
|
||||||
|
if (node instanceof HTMLElement) {
|
||||||
|
node.classList.remove('is-drag-hidden');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const scheduleShowNode = () => {
|
||||||
|
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||||
|
window.requestAnimationFrame(showNode);
|
||||||
|
} else {
|
||||||
|
setTimeout(showNode, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
cleanupPreview(state.previewClone);
|
||||||
|
|
||||||
|
const shouldRemove =
|
||||||
|
!state.dropHandled
|
||||||
|
&& dropEffect === 'none'
|
||||||
|
&& state.sourceDocId
|
||||||
|
&& (state.distance || 0) >= TAG_REMOVE_DISTANCE;
|
||||||
|
|
||||||
|
if (!shouldRemove) {
|
||||||
|
scheduleShowNode();
|
||||||
|
updateRemovalCursor(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRemovalCursor(false);
|
||||||
|
engine.setPendingRemovalTag({ docId: state.sourceDocId, tagId: state.tagId });
|
||||||
|
const removePromise = safeInvoke(onRemoveTagFromDocument, state.sourceDocId, state.tagId);
|
||||||
|
if (!removePromise || typeof removePromise.then !== 'function') {
|
||||||
|
scheduleShowNode();
|
||||||
|
engine.setPendingRemovalTag(null);
|
||||||
|
updateRemovalCursor(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await removePromise;
|
||||||
|
void DEBUG_DROP;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove tag after drag', error);
|
||||||
|
scheduleShowNode();
|
||||||
|
} finally {
|
||||||
|
engine.setPendingRemovalTag(null);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
[engine, onRemoveTagFromDocument, updateRemovalCursor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDocTagPointerDown = useCallback((event, doc, tag) => {
|
||||||
|
if (!doc || !tag) {
|
||||||
|
pendingDocTagDragRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { x: startX, y: startY } = getPointerPosition(event);
|
||||||
|
pendingDocTagDragRef.current = {
|
||||||
|
docId: doc.id,
|
||||||
|
tagId: tag.id,
|
||||||
|
startX,
|
||||||
|
startY,
|
||||||
|
};
|
||||||
|
updateRemovalCursor(false);
|
||||||
|
}, [updateRemovalCursor]);
|
||||||
|
|
||||||
|
const markActiveTagDropHandled = useCallback((tagId, sourceDocId = null) => {
|
||||||
|
const state = draggingTagRef.current;
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.tagId !== tagId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sourceDocId && state.sourceDocId !== sourceDocId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.dropHandled = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runTagHoverTransition = useCallback(
|
||||||
|
(event, docId, { applyTarget = false, applyPending = false, updateCursor = true } = {}) => {
|
||||||
|
if (!isTagTransfer(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
preventAll(event);
|
||||||
|
if (updateCursor) {
|
||||||
|
updateRemovalCursor(false);
|
||||||
|
}
|
||||||
|
const stringId = docId != null ? String(docId) : null;
|
||||||
|
if (applyTarget) {
|
||||||
|
engine.setTagDropTargetId(stringId);
|
||||||
|
}
|
||||||
|
if (applyPending) {
|
||||||
|
engine.setPendingTagDocId(stringId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[engine, isTagTransfer, updateRemovalCursor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTagDragEnterDoc = useCallback(
|
||||||
|
(event, docId) => runTagHoverTransition(event, docId, { applyTarget: true }),
|
||||||
|
[runTagHoverTransition],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTagDragOverDoc = useCallback(
|
||||||
|
(event, docId) => runTagHoverTransition(event, docId, { applyTarget: true, applyPending: true }),
|
||||||
|
[runTagHoverTransition],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTagDragLeaveDoc = useCallback(
|
||||||
|
(event) => runTagHoverTransition(event, null, { applyTarget: true, applyPending: true }),
|
||||||
|
[runTagHoverTransition],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCanvasDragOver = useCallback(
|
||||||
|
(event) => runTagHoverTransition(event, null),
|
||||||
|
[runTagHoverTransition],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCanvasDragLeave = useCallback(
|
||||||
|
(event) => runTagHoverTransition(event, null, { applyTarget: true, applyPending: true }),
|
||||||
|
[runTagHoverTransition],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCanvasDrop = useCallback(
|
||||||
|
(event) => runTagHoverTransition(event, null, { applyTarget: true, applyPending: true }),
|
||||||
|
[runTagHoverTransition],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTagDropOnDoc = useCallback(
|
||||||
|
(event, doc) => {
|
||||||
|
if (!doc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isTagTransfer(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
preventAll(event);
|
||||||
|
engine.setTagDropTargetId(null);
|
||||||
|
engine.setPendingTagDocId(null);
|
||||||
|
|
||||||
|
const payload = parseTagTransferPayload(event);
|
||||||
|
if (!payload || !payload.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markActiveTagDropHandled(payload.id, payload.sourceDocId);
|
||||||
|
|
||||||
|
if (payload.sourceDocId === doc.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestCanvasFocus?.();
|
||||||
|
|
||||||
|
void safeInvoke(onAssignTagToDocument, doc.id, {
|
||||||
|
id: payload.id,
|
||||||
|
label: payload.label || '',
|
||||||
|
sourceDocId: payload.sourceDocId ?? null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[engine, isTagTransfer, markActiveTagDropHandled, onAssignTagToDocument, requestCanvasFocus],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDocTagDragStart = useCallback(
|
||||||
|
(event, doc, tag) => {
|
||||||
|
if (!event?.dataTransfer || !doc || !tag) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
writeTagTransferData(event.dataTransfer, tag, doc.id);
|
||||||
|
|
||||||
|
const { x: pointerX, y: pointerY } = getPointerPosition(event, { fallbackToPage: false });
|
||||||
|
const { clone, offsetX, offsetY } = createDragPreview(event.currentTarget, pointerX, pointerY) || {};
|
||||||
|
if (clone && typeof event.dataTransfer.setDragImage === 'function') {
|
||||||
|
event.dataTransfer.setDragImage(clone, offsetX || 0, offsetY || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
||||||
|
if (element) {
|
||||||
|
element.classList.add('is-drag-hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
draggingTagRef.current = {
|
||||||
|
element,
|
||||||
|
previewClone: clone,
|
||||||
|
sourceDocId: doc.id,
|
||||||
|
tagId: tag.id,
|
||||||
|
initialX: pointerX,
|
||||||
|
initialY: pointerY,
|
||||||
|
distance: 0,
|
||||||
|
dropHandled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
updateRemovalCursor(false);
|
||||||
|
},
|
||||||
|
[updateRemovalCursor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDocTagDrag = useCallback((event) => {
|
||||||
|
const state = draggingTagRef.current;
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { x, y } = getPointerPosition(event);
|
||||||
|
const dx = x - (state.initialX || 0);
|
||||||
|
const dy = y - (state.initialY || 0);
|
||||||
|
state.distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (state.distance >= TAG_REMOVE_DISTANCE) {
|
||||||
|
updateRemovalCursor(true);
|
||||||
|
} else {
|
||||||
|
updateRemovalCursor(false);
|
||||||
|
}
|
||||||
|
}, [updateRemovalCursor]);
|
||||||
|
|
||||||
|
const handleDocTagDragEnd = useCallback(
|
||||||
|
(event) => {
|
||||||
|
finalizeTagDrag(event?.dataTransfer?.dropEffect || 'none');
|
||||||
|
const state = draggingTagRef.current;
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const element = state.element;
|
||||||
|
if (element) {
|
||||||
|
element.classList.remove('is-drag-hidden');
|
||||||
|
}
|
||||||
|
cleanupPreview(state.previewClone);
|
||||||
|
draggingTagRef.current = null;
|
||||||
|
},
|
||||||
|
[finalizeTagDrag],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
draggingTagRef.current = null;
|
||||||
|
pendingDocTagDragRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleTagDragEnterDoc,
|
||||||
|
handleTagDragOverDoc,
|
||||||
|
handleTagDragLeaveDoc,
|
||||||
|
handleTagDropOnDoc,
|
||||||
|
handleDocTagPointerDown,
|
||||||
|
handleDocTagDragStart,
|
||||||
|
handleDocTagDrag,
|
||||||
|
handleDocTagDragEnd,
|
||||||
|
handleTagDragEnd,
|
||||||
|
markActiveTagDropHandled,
|
||||||
|
handleCanvasDragOver,
|
||||||
|
handleCanvasDragLeave,
|
||||||
|
handleCanvasDrop,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDeskTagInteractions;
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
const useDeskWorkspaceProps = ({
|
||||||
|
documents,
|
||||||
|
searchResults,
|
||||||
|
breadcrumbs,
|
||||||
|
currentFolderName,
|
||||||
|
documentsViewMode,
|
||||||
|
handleDocumentsViewModeChange,
|
||||||
|
handleDeskExit,
|
||||||
|
refreshCurrentFolder,
|
||||||
|
inspectDocument,
|
||||||
|
handleEntryPointerCore,
|
||||||
|
promoteSelectionOrder,
|
||||||
|
currentTenantId,
|
||||||
|
selectedDocumentIds,
|
||||||
|
selectedFolderIds,
|
||||||
|
clearDocumentSelection,
|
||||||
|
detailPanelOpen,
|
||||||
|
handleDetailPanelClose,
|
||||||
|
resolveThumbnailUrlForDoc,
|
||||||
|
handleDocumentTagDrop,
|
||||||
|
handleTagRemove,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
activeTagFilters,
|
||||||
|
handleDeleteSelection,
|
||||||
|
tags,
|
||||||
|
correspondents,
|
||||||
|
documentLookup,
|
||||||
|
tagLookupById,
|
||||||
|
handleBulkTagAddFromDetail,
|
||||||
|
handleBulkTagRemoveFromDetail,
|
||||||
|
handleBulkCorrespondentAdd,
|
||||||
|
handleBulkCorrespondentRemove,
|
||||||
|
handleBulkSelectionReanalyze,
|
||||||
|
folderOptions,
|
||||||
|
moveDocumentsToFolder,
|
||||||
|
searchIncludeDescendants,
|
||||||
|
toggleSearchIncludeDescendants,
|
||||||
|
selectedEntries,
|
||||||
|
selectionAnchorRef,
|
||||||
|
applySelection,
|
||||||
|
resolveDocumentRowKey,
|
||||||
|
showingSearchResults,
|
||||||
|
searchQuery,
|
||||||
|
activeCorrespondentFilters,
|
||||||
|
selectedFolder,
|
||||||
|
setDeskHelpOpen,
|
||||||
|
deskHelpOpen,
|
||||||
|
openDetailPanel,
|
||||||
|
}) => {
|
||||||
|
const handleDeskDocumentStackSelect = useCallback(
|
||||||
|
(docIds) => {
|
||||||
|
if (!Array.isArray(docIds) || docIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowKeys = docIds
|
||||||
|
.map((id) => resolveDocumentRowKey(id))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (!rowKeys.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextKeys = [...selectedEntries];
|
||||||
|
rowKeys.forEach((key) => {
|
||||||
|
if (!nextKeys.includes(key)) {
|
||||||
|
nextKeys.push(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const anchor = rowKeys[0]
|
||||||
|
|| selectionAnchorRef.current
|
||||||
|
|| nextKeys[nextKeys.length - 1];
|
||||||
|
|
||||||
|
applySelection(nextKeys, {
|
||||||
|
anchor,
|
||||||
|
interactedKeys: rowKeys,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[applySelection, resolveDocumentRowKey, selectedEntries, selectionAnchorRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeskDocumentOpen = useCallback(
|
||||||
|
(docId, { useSelection = false } = {}) => {
|
||||||
|
const selectionDocIds = Array.isArray(selectedDocumentIds)
|
||||||
|
? selectedDocumentIds
|
||||||
|
: [];
|
||||||
|
let targetIds = [];
|
||||||
|
|
||||||
|
if ((useSelection || selectionDocIds.includes(docId)) && selectionDocIds.length) {
|
||||||
|
targetIds = selectionDocIds;
|
||||||
|
} else if (selectionDocIds.length) {
|
||||||
|
targetIds = selectionDocIds;
|
||||||
|
} else if (docId) {
|
||||||
|
targetIds = [docId];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetIds.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
openDetailPanel({ documentIds: targetIds });
|
||||||
|
},
|
||||||
|
[openDetailPanel, selectedDocumentIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeskHelpOpen = useCallback(() => {
|
||||||
|
setDeskHelpOpen(true);
|
||||||
|
}, [setDeskHelpOpen]);
|
||||||
|
|
||||||
|
const handleDeskHelpClose = useCallback(() => {
|
||||||
|
setDeskHelpOpen(false);
|
||||||
|
}, [setDeskHelpOpen]);
|
||||||
|
|
||||||
|
const deskViewId = useMemo(() => {
|
||||||
|
if (showingSearchResults) {
|
||||||
|
const trimmedQuery = searchQuery.trim();
|
||||||
|
const tagsKey = [...activeTagFilters].sort().join(',');
|
||||||
|
const correspondentsKey = [...activeCorrespondentFilters].sort().join(',');
|
||||||
|
return `search:${trimmedQuery}|tags:${tagsKey}|corr:${correspondentsKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderKey = selectedFolder && selectedFolder !== '' ? selectedFolder : 'root';
|
||||||
|
return `folder:${folderKey}`;
|
||||||
|
}, [
|
||||||
|
showingSearchResults,
|
||||||
|
searchQuery,
|
||||||
|
activeTagFilters,
|
||||||
|
activeCorrespondentFilters,
|
||||||
|
selectedFolder,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
documents,
|
||||||
|
searchResults,
|
||||||
|
breadcrumbs,
|
||||||
|
currentFolderName,
|
||||||
|
viewMode: documentsViewMode,
|
||||||
|
onViewModeChange: handleDocumentsViewModeChange,
|
||||||
|
onExit: handleDeskExit,
|
||||||
|
onRefresh: refreshCurrentFolder,
|
||||||
|
onDocumentOpen: handleDeskDocumentOpen,
|
||||||
|
onInspectDocument: inspectDocument,
|
||||||
|
onEntryPointer: handleEntryPointerCore,
|
||||||
|
onDocumentStackSelect: handleDeskDocumentStackSelect,
|
||||||
|
onPromoteSelection: promoteSelectionOrder,
|
||||||
|
onOpenHelp: handleDeskHelpOpen,
|
||||||
|
helpOpen: deskHelpOpen,
|
||||||
|
onHelpClose: handleDeskHelpClose,
|
||||||
|
tenantId: currentTenantId,
|
||||||
|
viewId: deskViewId,
|
||||||
|
selectedDocumentIds,
|
||||||
|
selectedFolderIds,
|
||||||
|
onClearSelection: clearDocumentSelection,
|
||||||
|
detailPanelOpen,
|
||||||
|
onCloseDetailPanel: handleDetailPanelClose,
|
||||||
|
resolveThumbnailUrl: resolveThumbnailUrlForDoc,
|
||||||
|
onAssignTagToDocument: handleDocumentTagDrop,
|
||||||
|
onRemoveTagFromDocument: handleTagRemove,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
activeTagIds: activeTagFilters,
|
||||||
|
onDeleteSelection: handleDeleteSelection,
|
||||||
|
tags,
|
||||||
|
correspondents,
|
||||||
|
documentLookup,
|
||||||
|
tagLookupById,
|
||||||
|
onBulkTagAdd: handleBulkTagAddFromDetail,
|
||||||
|
onBulkTagRemove: handleBulkTagRemoveFromDetail,
|
||||||
|
onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
|
||||||
|
onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
|
||||||
|
onBulkReanalyze: handleBulkSelectionReanalyze,
|
||||||
|
folderOptions,
|
||||||
|
onMoveDocumentsToFolder: moveDocumentsToFolder,
|
||||||
|
searchIncludeDescendants,
|
||||||
|
onToggleSearchIncludeDescendants: toggleSearchIncludeDescendants,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
documents,
|
||||||
|
searchResults,
|
||||||
|
breadcrumbs,
|
||||||
|
currentFolderName,
|
||||||
|
documentsViewMode,
|
||||||
|
handleDocumentsViewModeChange,
|
||||||
|
handleDeskExit,
|
||||||
|
refreshCurrentFolder,
|
||||||
|
handleDeskDocumentOpen,
|
||||||
|
inspectDocument,
|
||||||
|
handleEntryPointerCore,
|
||||||
|
handleDeskDocumentStackSelect,
|
||||||
|
promoteSelectionOrder,
|
||||||
|
handleDeskHelpOpen,
|
||||||
|
deskHelpOpen,
|
||||||
|
handleDeskHelpClose,
|
||||||
|
currentTenantId,
|
||||||
|
deskViewId,
|
||||||
|
selectedDocumentIds,
|
||||||
|
selectedFolderIds,
|
||||||
|
clearDocumentSelection,
|
||||||
|
detailPanelOpen,
|
||||||
|
handleDetailPanelClose,
|
||||||
|
resolveThumbnailUrlForDoc,
|
||||||
|
handleDocumentTagDrop,
|
||||||
|
handleTagRemove,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
activeTagFilters,
|
||||||
|
handleDeleteSelection,
|
||||||
|
tags,
|
||||||
|
correspondents,
|
||||||
|
documentLookup,
|
||||||
|
tagLookupById,
|
||||||
|
handleBulkTagAddFromDetail,
|
||||||
|
handleBulkTagRemoveFromDetail,
|
||||||
|
handleBulkCorrespondentAdd,
|
||||||
|
handleBulkCorrespondentRemove,
|
||||||
|
handleBulkSelectionReanalyze,
|
||||||
|
folderOptions,
|
||||||
|
moveDocumentsToFolder,
|
||||||
|
searchIncludeDescendants,
|
||||||
|
toggleSearchIncludeDescendants,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDeskWorkspaceProps;
|
||||||
@@ -1,271 +1,148 @@
|
|||||||
import { useCallback, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { useDesktopContext } from './context';
|
import { preventAll, safeInvoke } from './events';
|
||||||
import { preventAll } from './events';
|
import { clamp } from './math';
|
||||||
import { clamp, formatTransform } from './math';
|
|
||||||
import usePointerTap from '../ui/usePointerTap';
|
import usePointerTap from '../ui/usePointerTap';
|
||||||
|
import { MIN_TIMESTEP, MAX_TIMESTEP, applyDomTransform } from './workspaceEngine';
|
||||||
|
|
||||||
const DRAG_HYSTERESIS_PX = 4;
|
const DRAG_HYSTERESIS_PX = 4;
|
||||||
const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
|
const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
|
||||||
const MIN_TIMESTEP = 1 / 120;
|
|
||||||
const MAX_TIMESTEP = 1 / 20;
|
|
||||||
const MAX_DYNAMIC_ROTATION = 4;
|
|
||||||
const MAX_ANGULAR_VELOCITY = 180;
|
|
||||||
const ANGULAR_DAMPING = 11;
|
|
||||||
const TORQUE_TO_ACCELERATION = 0.006;
|
|
||||||
const SETTLE_ANGULAR_VELOCITY = 1.2;
|
|
||||||
const EDGE_COLLISION_THRESHOLD = 0.5;
|
const EDGE_COLLISION_THRESHOLD = 0.5;
|
||||||
|
|
||||||
const useDocumentDrag = () => {
|
const getEventTargetElement = (event) => {
|
||||||
|
if (typeof Element === 'undefined' || !event) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const candidate = event.target || (event.nativeEvent ? event.nativeEvent.target : null);
|
||||||
|
return candidate instanceof Element ? candidate : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useDocumentDrag = (options = {}) => {
|
||||||
const {
|
const {
|
||||||
|
engine,
|
||||||
layoutRef,
|
layoutRef,
|
||||||
|
dragTransformsRef,
|
||||||
itemRefs,
|
itemRefs,
|
||||||
documentLookup,
|
documentLookup,
|
||||||
ensureDocumentSize,
|
ensureDocumentSize,
|
||||||
resolveBaseMetrics,
|
resolveBaseMetrics,
|
||||||
bringToFront,
|
bringToFront,
|
||||||
setDraggingId,
|
setDraggingId,
|
||||||
syncLayoutSnapshot,
|
|
||||||
canvasSize,
|
canvasSize,
|
||||||
openOverlayForDoc,
|
openOverlayForDoc,
|
||||||
recalcVisibleDocIds,
|
recalcVisibleDocIds,
|
||||||
settings,
|
settings,
|
||||||
containerRef,
|
containerRef,
|
||||||
onDocumentOpen,
|
|
||||||
onInspectDocument,
|
onInspectDocument,
|
||||||
onDocumentStackSelect,
|
onDocumentStackSelect,
|
||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
markLayoutDirty,
|
markLayoutDirty,
|
||||||
} = useDesktopContext();
|
} = options;
|
||||||
|
|
||||||
const applyTransform = useCallback(
|
const {
|
||||||
(docId, centerX, centerY, width, height, rotation, scale = 1) => {
|
canvasPadding = 24,
|
||||||
const node = itemRefs.current.get(docId);
|
defaultCanvasWidth = 1024,
|
||||||
if (!node) {
|
defaultCanvasHeight = 680,
|
||||||
return;
|
debugDrag = false,
|
||||||
}
|
} = settings || {};
|
||||||
node.style.transform = formatTransform(
|
|
||||||
centerX - width / 2,
|
useEffect(
|
||||||
centerY - height / 2,
|
() => () => {
|
||||||
rotation,
|
engine?.disposeInertiaAnimations?.();
|
||||||
scale,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[itemRefs],
|
[engine],
|
||||||
);
|
|
||||||
|
|
||||||
const finalizeGroupDrag = useCallback(
|
|
||||||
(dragState) => {
|
|
||||||
if (!dragState?.groupItems) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dragState.groupItems.forEach((item) => {
|
|
||||||
if (!item) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entryItem = layoutRef.current.get(item.docId) || {};
|
|
||||||
const centerX = item.currentCenterX ?? entryItem.centerX ?? dragState.originCenterX;
|
|
||||||
const centerY = item.currentCenterY ?? entryItem.centerY ?? dragState.originCenterY;
|
|
||||||
const rotation = item.displayRotation ?? entryItem.rotation ?? 0;
|
|
||||||
|
|
||||||
layoutRef.current.set(item.docId, {
|
|
||||||
...entryItem,
|
|
||||||
centerX,
|
|
||||||
centerY,
|
|
||||||
rotation,
|
|
||||||
});
|
|
||||||
|
|
||||||
applyTransform(
|
|
||||||
item.docId,
|
|
||||||
centerX,
|
|
||||||
centerY,
|
|
||||||
item.width,
|
|
||||||
item.height,
|
|
||||||
rotation,
|
|
||||||
item.docId === dragState.docKey ? dragState.dragScale || 1 : 1,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
markLayoutDirty?.();
|
|
||||||
},
|
|
||||||
[applyTransform, layoutRef, markLayoutDirty],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const tapHandler = usePointerTap({
|
const tapHandler = usePointerTap({
|
||||||
delay: 220,
|
delay: 220,
|
||||||
onSingle: ({ data, event }) => {
|
onSingle: () => {},
|
||||||
if (!data || !data.docId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof onInspectDocument === 'function') {
|
|
||||||
onInspectDocument(data.docId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onDocumentOpen?.(data.docId);
|
|
||||||
},
|
|
||||||
onDouble: ({ data, event }) => {
|
onDouble: ({ data, event }) => {
|
||||||
if (!data || !data.docId) {
|
if (!data || !data.docId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) {
|
if (event?.altKey) {
|
||||||
|
openOverlayForDoc(data.docId, data.originInfo);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openOverlayForDoc(data.docId, data.originInfo);
|
if (typeof onInspectDocument === 'function') {
|
||||||
|
onInspectDocument(data.docId, event);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const dragStateRef = useRef(null);
|
const dragStateRef = useRef(null);
|
||||||
const inertiaAnimationsRef = useRef(new Map());
|
|
||||||
const { canvasPadding, defaultCanvasWidth, defaultCanvasHeight, debugDrag } = settings;
|
|
||||||
|
|
||||||
const cancelInertiaAnimation = useCallback((docId) => {
|
const setDragTransform = useCallback((docKey, transform) => {
|
||||||
if (typeof window === 'undefined') {
|
if (!docKey) {
|
||||||
inertiaAnimationsRef.current.delete(docId);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const existing = inertiaAnimationsRef.current.get(docId);
|
const map = dragTransformsRef?.current;
|
||||||
if (existing && typeof window.cancelAnimationFrame === 'function') {
|
if (!map) {
|
||||||
window.cancelAnimationFrame(existing.frameId);
|
|
||||||
}
|
|
||||||
inertiaAnimationsRef.current.delete(docId);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const integrateRotation = useCallback(
|
|
||||||
(simulationState, dt, torque = 0, dampingOverride = null) => {
|
|
||||||
const { docId } = simulationState;
|
|
||||||
const entry = layoutRef.current.get(docId);
|
|
||||||
if (!entry) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const centerX = Number(entry.centerX);
|
|
||||||
const centerY = Number(entry.centerY);
|
|
||||||
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const torqueAcceleration = torque * TORQUE_TO_ACCELERATION;
|
|
||||||
let angularVelocity = simulationState.angularVelocity + torqueAcceleration * dt;
|
|
||||||
angularVelocity = clamp(angularVelocity, -MAX_ANGULAR_VELOCITY, MAX_ANGULAR_VELOCITY);
|
|
||||||
|
|
||||||
const dampingConstant = typeof dampingOverride === 'number' ? dampingOverride : ANGULAR_DAMPING;
|
|
||||||
const dampingFactor = Math.exp(-dampingConstant * dt);
|
|
||||||
angularVelocity *= dampingFactor;
|
|
||||||
|
|
||||||
let dynamicRotation = simulationState.dynamicRotation + angularVelocity * dt;
|
|
||||||
if (dynamicRotation > MAX_DYNAMIC_ROTATION) {
|
|
||||||
dynamicRotation = MAX_DYNAMIC_ROTATION;
|
|
||||||
angularVelocity = Math.min(angularVelocity, 0);
|
|
||||||
} else if (dynamicRotation < -MAX_DYNAMIC_ROTATION) {
|
|
||||||
dynamicRotation = -MAX_DYNAMIC_ROTATION;
|
|
||||||
angularVelocity = Math.max(angularVelocity, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
simulationState.angularVelocity = angularVelocity;
|
|
||||||
simulationState.dynamicRotation = dynamicRotation;
|
|
||||||
simulationState.rotation = simulationState.restRotation + dynamicRotation;
|
|
||||||
|
|
||||||
const rotation = simulationState.rotation;
|
|
||||||
layoutRef.current.set(docId, { ...entry, rotation });
|
|
||||||
|
|
||||||
const node = itemRefs.current.get(docId);
|
|
||||||
if (node) {
|
|
||||||
node.style.transform = formatTransform(
|
|
||||||
centerX - simulationState.width / 2,
|
|
||||||
centerY - simulationState.height / 2,
|
|
||||||
rotation,
|
|
||||||
simulationState.dragScale || 1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSettled = Math.abs(angularVelocity) < SETTLE_ANGULAR_VELOCITY;
|
|
||||||
return isSettled;
|
|
||||||
},
|
|
||||||
[itemRefs, layoutRef],
|
|
||||||
);
|
|
||||||
|
|
||||||
const startInertiaAnimation = useCallback(
|
|
||||||
(docId, baseState) => {
|
|
||||||
if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cancelInertiaAnimation(docId);
|
map.set(String(docKey), transform);
|
||||||
const now =
|
}, [dragTransformsRef]);
|
||||||
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
|
||||||
? performance.now()
|
|
||||||
: Date.now();
|
|
||||||
const simulationState = {
|
|
||||||
...baseState,
|
|
||||||
docId,
|
|
||||||
dragScale: baseState.dragScale || 1,
|
|
||||||
lastTimestamp: now,
|
|
||||||
};
|
|
||||||
|
|
||||||
const step = (timestamp) => {
|
const clearDragTransforms = useCallback(() => {
|
||||||
const safeTimestamp = Number.isFinite(timestamp) ? timestamp : now + 16;
|
const map = dragTransformsRef?.current;
|
||||||
const previous = simulationState.lastTimestamp;
|
if (!map || typeof map.clear !== 'function') {
|
||||||
let dt = (safeTimestamp - previous) / 1000;
|
|
||||||
if (!Number.isFinite(dt) || dt <= 0) {
|
|
||||||
dt = MIN_TIMESTEP;
|
|
||||||
}
|
|
||||||
dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP);
|
|
||||||
simulationState.lastTimestamp = safeTimestamp;
|
|
||||||
|
|
||||||
const settled = integrateRotation(simulationState, dt, 0);
|
|
||||||
if (settled) {
|
|
||||||
inertiaAnimationsRef.current.delete(docId);
|
|
||||||
syncLayoutSnapshot();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
simulationState.frameId = window.requestAnimationFrame(step);
|
map.clear();
|
||||||
};
|
}, [dragTransformsRef]);
|
||||||
|
|
||||||
simulationState.frameId = window.requestAnimationFrame(step);
|
const commitActiveDragTransforms = useCallback((docIds = null) => {
|
||||||
inertiaAnimationsRef.current.set(docId, simulationState);
|
const map = dragTransformsRef?.current;
|
||||||
},
|
if (!map || !map.size) {
|
||||||
[cancelInertiaAnimation, integrateRotation, syncLayoutSnapshot],
|
return;
|
||||||
);
|
}
|
||||||
|
const keys = Array.isArray(docIds) && docIds.length
|
||||||
|
? docIds.map((id) => (id != null ? String(id) : null)).filter(Boolean)
|
||||||
|
: Array.from(map.keys());
|
||||||
|
keys.forEach((key) => {
|
||||||
|
const transform = map.get(key);
|
||||||
|
if (!transform) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previous = layoutRef.current.get(key) || {};
|
||||||
|
layoutRef.current.set(key, {
|
||||||
|
...previous,
|
||||||
|
centerX: transform.centerX,
|
||||||
|
centerY: transform.centerY,
|
||||||
|
rotation: transform.rotation ?? previous.rotation ?? 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
markLayoutDirty?.();
|
||||||
|
}, [dragTransformsRef, layoutRef, markLayoutDirty]);
|
||||||
|
|
||||||
const finishDrag = useCallback(
|
const finishDrag = useCallback(
|
||||||
(pointerId) => {
|
(pointerId, { clearTransforms = true } = {}) => {
|
||||||
const state = dragStateRef.current;
|
const state = dragStateRef.current;
|
||||||
if (!state || state.pointerId !== pointerId) {
|
if (state && state.pointerId === pointerId) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
const capturedTarget = state.capturedTarget;
|
const capturedTarget = state.capturedTarget;
|
||||||
if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') {
|
if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') {
|
||||||
try {
|
try {
|
||||||
capturedTarget.releasePointerCapture(pointerId);
|
capturedTarget.releasePointerCapture(pointerId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (debugDrag) {
|
if (debugDrag) {
|
||||||
console.warn('[desk] releasePointerCapture failed', error);
|
void error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dragStateRef.current = null;
|
dragStateRef.current = null;
|
||||||
setDraggingId((current) => (current === state.docId ? null : current));
|
setDraggingId(null);
|
||||||
syncLayoutSnapshot();
|
engine?.endDrag?.();
|
||||||
|
if (clearTransforms) {
|
||||||
|
clearDragTransforms();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[debugDrag, setDraggingId, syncLayoutSnapshot],
|
[clearDragTransforms, debugDrag, engine, setDraggingId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePointerDown = useCallback(
|
const handlePointerDown = useCallback(
|
||||||
(event, docIdInput, options = {}) => {
|
(event, docIdInput, options = {}) => {
|
||||||
if (debugDrag) {
|
const targetElement = getEventTargetElement(event);
|
||||||
console.log(
|
if (targetElement && typeof targetElement.closest === 'function' && targetElement.closest('[data-desk-tag-chip="true"]')) {
|
||||||
'[desk] handlePointerDown fired for doc',
|
return;
|
||||||
docIdInput,
|
|
||||||
'button',
|
|
||||||
event.button,
|
|
||||||
'pointerType',
|
|
||||||
event.pointerType,
|
|
||||||
'pointerId',
|
|
||||||
event.pointerId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
preventAll(event);
|
preventAll(event);
|
||||||
|
|
||||||
@@ -275,56 +152,67 @@ const useDocumentDrag = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelInertiaAnimation(docId);
|
engine?.cancelInertiaAnimation?.(docKey);
|
||||||
|
|
||||||
const doc = documentLookup.get(docKey);
|
const doc = documentLookup.get(docKey);
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stackDocIdsOption = Array.isArray(options?.stackDocIds)
|
const stackDocIdsOptionRaw = options?.stackDocIds;
|
||||||
? options.stackDocIds
|
const stackDocIdsOption = Array.isArray(stackDocIdsOptionRaw)
|
||||||
|
? stackDocIdsOptionRaw
|
||||||
.map((value) => (value != null ? String(value) : null))
|
.map((value) => (value != null ? String(value) : null))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
: null;
|
: null;
|
||||||
const stackSelectionAppliedInitial = Boolean(options?.stackSelectionApplied);
|
const stackSelectionAppliedInitial = Boolean(options?.stackSelectionApplied);
|
||||||
|
const wasSelectedAtPointerDown = Boolean(options?.wasSelected);
|
||||||
|
const pointerModifierActive = typeof options?.modifierActive === 'boolean'
|
||||||
|
? options.modifierActive
|
||||||
|
: Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey);
|
||||||
|
const stackReplace = Boolean(options?.stackReplace);
|
||||||
|
|
||||||
let selectionIds = Array.isArray(selectedDocumentIds)
|
let selectionIds = Array.isArray(selectedDocumentIds)
|
||||||
? selectedDocumentIds.map((id) => String(id))
|
? selectedDocumentIds.map((id) => String(id))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
if (!stackDocIdsOption && !pointerModifierActive && !wasSelectedAtPointerDown) {
|
||||||
|
selectionIds = [docKey];
|
||||||
|
}
|
||||||
|
|
||||||
if (stackDocIdsOption && stackDocIdsOption.length) {
|
if (stackDocIdsOption && stackDocIdsOption.length) {
|
||||||
selectionIds = stackDocIdsOption;
|
const selectionSet = new Set(selectionIds);
|
||||||
|
stackDocIdsOption.forEach((value) => {
|
||||||
|
if (value != null) {
|
||||||
|
selectionSet.add(String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
selectionIds = Array.from(selectionSet);
|
||||||
}
|
}
|
||||||
|
|
||||||
const metaOrCtrl = event.metaKey || event.ctrlKey;
|
const metaOrCtrl = event.metaKey || event.ctrlKey;
|
||||||
if (!stackDocIdsOption && metaOrCtrl && !selectionIds.includes(docKey)) {
|
if (!stackDocIdsOption && metaOrCtrl && !selectionIds.includes(docKey)) {
|
||||||
selectionIds = [...selectionIds, docKey];
|
selectionIds = [...selectionIds, docKey];
|
||||||
}
|
}
|
||||||
let groupDocIds = [];
|
|
||||||
if (stackDocIdsOption && stackDocIdsOption.length) {
|
selectionIds = selectionIds
|
||||||
groupDocIds = stackDocIdsOption.filter((id, index, array) => {
|
|
||||||
const unique = array.indexOf(id) === index;
|
|
||||||
return unique && documentLookup.has(id);
|
|
||||||
});
|
|
||||||
} else if (selectionIds.includes(docKey) && selectionIds.length > 1) {
|
|
||||||
groupDocIds = selectionIds
|
|
||||||
.map((id) => String(id))
|
.map((id) => String(id))
|
||||||
.filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id));
|
.filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id));
|
||||||
|
|
||||||
|
if (!selectionIds.includes(docKey)) {
|
||||||
|
selectionIds.unshift(docKey);
|
||||||
}
|
}
|
||||||
if (!groupDocIds.includes(docKey)) {
|
|
||||||
groupDocIds.unshift(docKey);
|
if (!selectionIds.length) {
|
||||||
|
selectionIds = [docKey];
|
||||||
}
|
}
|
||||||
groupDocIds = groupDocIds.filter((id, index, array) => array.indexOf(id) === index);
|
|
||||||
if (!groupDocIds.length) {
|
const isGroupDrag = selectionIds.length > 1;
|
||||||
groupDocIds = [docKey];
|
|
||||||
}
|
|
||||||
const isGroupDrag = groupDocIds.length > 1;
|
|
||||||
|
|
||||||
if (isGroupDrag) {
|
if (isGroupDrag) {
|
||||||
groupDocIds.forEach((id) => {
|
selectionIds.forEach((id) => {
|
||||||
if (id !== docKey) {
|
if (id !== docKey) {
|
||||||
cancelInertiaAnimation(id);
|
engine?.cancelInertiaAnimation?.(id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -342,10 +230,21 @@ const useDocumentDrag = () => {
|
|||||||
const centerX = typeof entry?.centerX === 'number' ? entry.centerX : defaultCenterX;
|
const centerX = typeof entry?.centerX === 'number' ? entry.centerX : defaultCenterX;
|
||||||
const centerY = typeof entry?.centerY === 'number' ? entry.centerY : defaultCenterY;
|
const centerY = typeof entry?.centerY === 'number' ? entry.centerY : defaultCenterY;
|
||||||
|
|
||||||
const modifierPressed = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
|
const modifierPressed = pointerModifierActive;
|
||||||
if (!modifierPressed) {
|
if (!modifierPressed) {
|
||||||
if (isGroupDrag) {
|
if (isGroupDrag) {
|
||||||
groupDocIds.forEach((id) => bringToFront(id));
|
const layout = layoutRef.current;
|
||||||
|
const ordered = [...selectionIds]
|
||||||
|
.filter((id, index, array) => array.indexOf(id) === index)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aZ = layout.get(a)?.z ?? 0;
|
||||||
|
const bZ = layout.get(b)?.z ?? 0;
|
||||||
|
return aZ - bZ;
|
||||||
|
});
|
||||||
|
|
||||||
|
ordered.forEach((id) => {
|
||||||
|
bringToFront(id === docKey ? docId : id);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
bringToFront(docId);
|
bringToFront(docId);
|
||||||
}
|
}
|
||||||
@@ -361,7 +260,7 @@ const useDocumentDrag = () => {
|
|||||||
capturedTarget.setPointerCapture(event.pointerId);
|
capturedTarget.setPointerCapture(event.pointerId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (debugDrag) {
|
if (debugDrag) {
|
||||||
console.warn('[desk] setPointerCapture failed', error);
|
void error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -380,8 +279,7 @@ const useDocumentDrag = () => {
|
|||||||
const localPointerOffsetX = pointerOffsetX * cosInitial - pointerOffsetY * sinInitial;
|
const localPointerOffsetX = pointerOffsetX * cosInitial - pointerOffsetY * sinInitial;
|
||||||
const localPointerOffsetY = pointerOffsetX * sinInitial + pointerOffsetY * cosInitial;
|
const localPointerOffsetY = pointerOffsetX * sinInitial + pointerOffsetY * cosInitial;
|
||||||
|
|
||||||
const stackRandom = () => Math.random();
|
const groupItems = selectionIds.map((id) => {
|
||||||
const groupItems = groupDocIds.map((id, index) => {
|
|
||||||
const itemDoc = documentLookup.get(id);
|
const itemDoc = documentLookup.get(id);
|
||||||
const itemSize = ensureDocumentSize(itemDoc) || sizeInfo;
|
const itemSize = ensureDocumentSize(itemDoc) || sizeInfo;
|
||||||
const itemWidth = itemSize.width || docWidth;
|
const itemWidth = itemSize.width || docWidth;
|
||||||
@@ -391,10 +289,8 @@ const useDocumentDrag = () => {
|
|||||||
typeof itemEntry?.centerX === 'number' ? itemEntry.centerX : canvasPadding + itemWidth / 2;
|
typeof itemEntry?.centerX === 'number' ? itemEntry.centerX : canvasPadding + itemWidth / 2;
|
||||||
const itemCenterY =
|
const itemCenterY =
|
||||||
typeof itemEntry?.centerY === 'number' ? itemEntry.centerY : canvasPadding + itemHeight / 2;
|
typeof itemEntry?.centerY === 'number' ? itemEntry.centerY : canvasPadding + itemHeight / 2;
|
||||||
const radius = index === 0 ? 0 : 24 + index * 8;
|
const baseOffsetX = itemCenterX - centerX;
|
||||||
const offsetAngle = (index * 1.618 + stackRandom() * 0.5) * Math.PI;
|
const baseOffsetY = itemCenterY - centerY;
|
||||||
const offsetX = Math.cos(offsetAngle) * radius;
|
|
||||||
const offsetY = Math.sin(offsetAngle) * radius;
|
|
||||||
const initialRotation = itemEntry?.rotation ?? 0;
|
const initialRotation = itemEntry?.rotation ?? 0;
|
||||||
const targetRotation = initialRotation;
|
const targetRotation = initialRotation;
|
||||||
return {
|
return {
|
||||||
@@ -403,8 +299,10 @@ const useDocumentDrag = () => {
|
|||||||
height: itemHeight,
|
height: itemHeight,
|
||||||
currentCenterX: itemCenterX,
|
currentCenterX: itemCenterX,
|
||||||
currentCenterY: itemCenterY,
|
currentCenterY: itemCenterY,
|
||||||
offsetX,
|
baseOffsetX,
|
||||||
offsetY,
|
baseOffsetY,
|
||||||
|
offsetX: baseOffsetX,
|
||||||
|
offsetY: baseOffsetY,
|
||||||
initialRotation,
|
initialRotation,
|
||||||
displayRotation: initialRotation,
|
displayRotation: initialRotation,
|
||||||
targetRotation,
|
targetRotation,
|
||||||
@@ -421,11 +319,13 @@ const useDocumentDrag = () => {
|
|||||||
const hasStackSource = Array.isArray(stackDocIdsOption) && stackDocIdsOption.length > 1;
|
const hasStackSource = Array.isArray(stackDocIdsOption) && stackDocIdsOption.length > 1;
|
||||||
|
|
||||||
dragStateRef.current = {
|
dragStateRef.current = {
|
||||||
docId,
|
docId: docKey,
|
||||||
docKey,
|
docKey,
|
||||||
pointerId: event.pointerId,
|
pointerId: event.pointerId,
|
||||||
originCenterX: centerX,
|
originCenterX: centerX,
|
||||||
originCenterY: centerY,
|
originCenterY: centerY,
|
||||||
|
currentCenterX: centerX,
|
||||||
|
currentCenterY: centerY,
|
||||||
startX: event.clientX,
|
startX: event.clientX,
|
||||||
startY: event.clientY,
|
startY: event.clientY,
|
||||||
rotation: entry?.rotation ?? 0,
|
rotation: entry?.rotation ?? 0,
|
||||||
@@ -447,14 +347,34 @@ const useDocumentDrag = () => {
|
|||||||
containerRectLeft: containerLeft,
|
containerRectLeft: containerLeft,
|
||||||
containerRectTop: containerTop,
|
containerRectTop: containerTop,
|
||||||
isGroup: isGroupDrag,
|
isGroup: isGroupDrag,
|
||||||
groupDocIds,
|
activeDocIds: selectionIds,
|
||||||
groupItems,
|
groupItems,
|
||||||
groupElevated: !isGroupDrag,
|
groupElevated: !isGroupDrag,
|
||||||
stackDocIds: hasStackSource ? stackDocIdsOption : null,
|
stackDocIds: hasStackSource ? stackDocIdsOption : null,
|
||||||
stackSelectionApplied: stackSelectionAppliedInitial || !hasStackSource,
|
stackSelectionApplied: stackSelectionAppliedInitial || !hasStackSource,
|
||||||
|
stackReplace,
|
||||||
};
|
};
|
||||||
|
|
||||||
setDraggingId(docId);
|
const state = dragStateRef.current;
|
||||||
|
|
||||||
|
clearDragTransforms();
|
||||||
|
state.groupItems.forEach((item) => {
|
||||||
|
if (!item?.docId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDragTransform(item.docId, {
|
||||||
|
centerX: item.currentCenterX,
|
||||||
|
centerY: item.currentCenterY,
|
||||||
|
rotation: item.displayRotation ?? item.initialRotation ?? 0,
|
||||||
|
width: item.width,
|
||||||
|
height: item.height,
|
||||||
|
scale: item.docId === state.docKey ? state.dragScale || 1 : 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
engine?.beginDrag?.(state.activeDocIds);
|
||||||
|
|
||||||
|
setDraggingId(docKey);
|
||||||
|
|
||||||
if (isGroupDrag) {
|
if (isGroupDrag) {
|
||||||
groupItems.forEach((item) => {
|
groupItems.forEach((item) => {
|
||||||
@@ -464,22 +384,25 @@ const useDocumentDrag = () => {
|
|||||||
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;
|
||||||
node.style.transform = formatTransform(
|
const itemEntry = layoutRef.current.get(item.docId) || null;
|
||||||
item.currentCenterX - item.width / 2,
|
applyDomTransform(node, {
|
||||||
item.currentCenterY - item.height / 2,
|
centerX: item.currentCenterX,
|
||||||
item.displayRotation,
|
centerY: item.currentCenterY,
|
||||||
1,
|
width: item.width,
|
||||||
);
|
height: item.height,
|
||||||
|
rotation: item.displayRotation ?? 0,
|
||||||
|
scale: 1,
|
||||||
|
zIndex: itemEntry?.z,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
}, [
|
||||||
[
|
|
||||||
bringToFront,
|
bringToFront,
|
||||||
canvasPadding,
|
canvasPadding,
|
||||||
cancelInertiaAnimation,
|
|
||||||
containerRef,
|
containerRef,
|
||||||
documentLookup,
|
documentLookup,
|
||||||
|
engine,
|
||||||
ensureDocumentSize,
|
ensureDocumentSize,
|
||||||
layoutRef,
|
layoutRef,
|
||||||
resolveBaseMetrics,
|
resolveBaseMetrics,
|
||||||
@@ -487,27 +410,17 @@ const useDocumentDrag = () => {
|
|||||||
setDraggingId,
|
setDraggingId,
|
||||||
debugDrag,
|
debugDrag,
|
||||||
itemRefs,
|
itemRefs,
|
||||||
],
|
clearDragTransforms,
|
||||||
);
|
setDragTransform,
|
||||||
|
]);
|
||||||
|
|
||||||
const handlePointerMove = useCallback(
|
const handlePointerMove = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
const state = dragStateRef.current;
|
const state = dragStateRef.current;
|
||||||
if (!state) {
|
if (!state) {
|
||||||
if (debugDrag) {
|
|
||||||
console.log('[desk] handlePointerMove: no drag state for pointer', event.pointerId);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (state.pointerId !== event.pointerId) {
|
if (state.pointerId !== event.pointerId) {
|
||||||
if (debugDrag) {
|
|
||||||
console.log(
|
|
||||||
'[desk] handlePointerMove: pointer mismatch expected',
|
|
||||||
state.pointerId,
|
|
||||||
'got',
|
|
||||||
event.pointerId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
preventAll(event);
|
preventAll(event);
|
||||||
@@ -531,19 +444,16 @@ const useDocumentDrag = () => {
|
|||||||
}
|
}
|
||||||
state.moved = true;
|
state.moved = true;
|
||||||
if (
|
if (
|
||||||
state.isGroup
|
!state.stackSelectionApplied
|
||||||
&& !state.stackSelectionApplied
|
|
||||||
&& Array.isArray(state.stackDocIds)
|
&& Array.isArray(state.stackDocIds)
|
||||||
&& state.stackDocIds.length > 1
|
&& state.stackDocIds.length > 0
|
||||||
) {
|
) {
|
||||||
if (typeof onDocumentStackSelect === 'function') {
|
safeInvoke(onDocumentStackSelect, state.stackDocIds, event, { replace: state.stackReplace });
|
||||||
onDocumentStackSelect(state.stackDocIds);
|
|
||||||
}
|
|
||||||
state.stackSelectionApplied = true;
|
state.stackSelectionApplied = true;
|
||||||
}
|
}
|
||||||
if (!state.groupElevated) {
|
if (!state.groupElevated) {
|
||||||
const layout = layoutRef.current;
|
const layout = layoutRef.current;
|
||||||
const sortedGroup = state.groupDocIds
|
const sortedGroup = state.activeDocIds
|
||||||
.filter((id) => id !== state.docKey)
|
.filter((id) => id !== state.docKey)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const aZ = layout.get(a)?.z ?? 0;
|
const aZ = layout.get(a)?.z ?? 0;
|
||||||
@@ -551,11 +461,8 @@ const useDocumentDrag = () => {
|
|||||||
return aZ - bZ;
|
return aZ - bZ;
|
||||||
});
|
});
|
||||||
|
|
||||||
sortedGroup.forEach((id) => {
|
sortedGroup.forEach((id) => bringToFront(id));
|
||||||
bringToFront(id);
|
bringToFront(state.docKey);
|
||||||
});
|
|
||||||
|
|
||||||
bringToFront(state.docId);
|
|
||||||
state.groupElevated = true;
|
state.groupElevated = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -576,22 +483,8 @@ const useDocumentDrag = () => {
|
|||||||
const centerX = clamp(desiredCenterX, minCenterX, maxCenterX);
|
const centerX = clamp(desiredCenterX, minCenterX, maxCenterX);
|
||||||
const centerY = clamp(desiredCenterY, minCenterY, maxCenterY);
|
const centerY = clamp(desiredCenterY, minCenterY, maxCenterY);
|
||||||
|
|
||||||
const primaryEntry = layoutRef.current.get(state.docKey) || {};
|
state.currentCenterX = centerX;
|
||||||
const primaryRotation = state.rotation ?? primaryEntry.rotation ?? 0;
|
state.currentCenterY = centerY;
|
||||||
layoutRef.current.set(state.docKey, {
|
|
||||||
...primaryEntry,
|
|
||||||
centerX,
|
|
||||||
centerY,
|
|
||||||
});
|
|
||||||
const primaryNode = itemRefs.current.get(state.docId);
|
|
||||||
if (primaryNode) {
|
|
||||||
primaryNode.style.transform = formatTransform(
|
|
||||||
centerX - docWidth / 2,
|
|
||||||
centerY - docHeight / 2,
|
|
||||||
primaryRotation,
|
|
||||||
state.dragScale || 1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
state.groupItems.forEach((item) => {
|
state.groupItems.forEach((item) => {
|
||||||
const isPrimary = item.docId === state.docKey;
|
const isPrimary = item.docId === state.docKey;
|
||||||
@@ -599,14 +492,15 @@ const useDocumentDrag = () => {
|
|||||||
if (isPrimary) {
|
if (isPrimary) {
|
||||||
item.currentCenterX = centerX;
|
item.currentCenterX = centerX;
|
||||||
item.currentCenterY = centerY;
|
item.currentCenterY = centerY;
|
||||||
item.offsetX *= 0.92;
|
item.offsetX = item.baseOffsetX ?? 0;
|
||||||
item.offsetY *= 0.92;
|
item.offsetY = item.baseOffsetY ?? 0;
|
||||||
item.displayRotation = state.rotation ?? item.displayRotation ?? 0;
|
item.displayRotation = state.rotation ?? item.displayRotation ?? 0;
|
||||||
} else {
|
} else {
|
||||||
item.offsetX *= 0.92;
|
const decay = 0.82;
|
||||||
item.offsetY *= 0.92;
|
const currentOffsetX = (item.offsetX ?? item.baseOffsetX ?? 0) * decay;
|
||||||
if (Math.abs(item.offsetX) < 1) item.offsetX = 0;
|
const currentOffsetY = (item.offsetY ?? item.baseOffsetY ?? 0) * decay;
|
||||||
if (Math.abs(item.offsetY) < 1) item.offsetY = 0;
|
item.offsetX = Math.abs(currentOffsetX) < 0.5 ? 0 : currentOffsetX;
|
||||||
|
item.offsetY = Math.abs(currentOffsetY) < 0.5 ? 0 : currentOffsetY;
|
||||||
|
|
||||||
const targetX = centerX + item.offsetX;
|
const targetX = centerX + item.offsetX;
|
||||||
const targetY = centerY + item.offsetY;
|
const targetY = centerY + item.offsetY;
|
||||||
@@ -627,23 +521,20 @@ const useDocumentDrag = () => {
|
|||||||
item.displayRotation += (item.targetRotation - item.displayRotation) * rotationBlend;
|
item.displayRotation += (item.targetRotation - item.displayRotation) * rotationBlend;
|
||||||
}
|
}
|
||||||
|
|
||||||
const entryItem = layoutRef.current.get(item.docId) || {};
|
const entry = layoutRef.current.get(item.docId) || null;
|
||||||
layoutRef.current.set(item.docId, {
|
const payload = {
|
||||||
...entryItem,
|
|
||||||
centerX: item.currentCenterX,
|
centerX: item.currentCenterX,
|
||||||
centerY: item.currentCenterY,
|
centerY: item.currentCenterY,
|
||||||
rotation: item.displayRotation ?? entryItem.rotation ?? 0,
|
rotation: item.displayRotation ?? 0,
|
||||||
});
|
width: item.width,
|
||||||
|
height: item.height,
|
||||||
|
scale: isPrimary ? state.dragScale || 1 : 1,
|
||||||
|
zIndex: entry?.z,
|
||||||
|
};
|
||||||
|
|
||||||
applyTransform(
|
setDragTransform(item.docId, payload);
|
||||||
item.docId,
|
const node = itemRefs.current.get(item.docId);
|
||||||
item.currentCenterX,
|
applyDomTransform(node, payload);
|
||||||
item.currentCenterY,
|
|
||||||
item.width,
|
|
||||||
item.height,
|
|
||||||
item.displayRotation ?? entryItem.rotation ?? 0,
|
|
||||||
isPrimary ? state.dragScale || 1 : 1,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
state.lastClientX = event.clientX;
|
state.lastClientX = event.clientX;
|
||||||
@@ -655,18 +546,9 @@ const useDocumentDrag = () => {
|
|||||||
? performance.now()
|
? performance.now()
|
||||||
: Date.now();
|
: Date.now();
|
||||||
|
|
||||||
recalcVisibleDocIds();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (state.locked) {
|
if (state.locked) {
|
||||||
if (debugDrag) {
|
|
||||||
console.log('[desk] handlePointerMove: locked drag for doc', state.docId);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = layoutRef.current.get(state.docId);
|
|
||||||
if (!entry) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -689,7 +571,8 @@ const useDocumentDrag = () => {
|
|||||||
const pointerCanvasX = event.clientX - containerLeft;
|
const pointerCanvasX = event.clientX - containerLeft;
|
||||||
const pointerCanvasY = event.clientY - containerTop;
|
const pointerCanvasY = event.clientY - containerTop;
|
||||||
|
|
||||||
const rotationDeg = entry?.rotation ?? 0;
|
const entry = layoutRef.current.get(state.docKey) || {};
|
||||||
|
const rotationDeg = state.rotation ?? entry.rotation ?? 0;
|
||||||
const rotationRad = (rotationDeg * Math.PI) / 180;
|
const rotationRad = (rotationDeg * Math.PI) / 180;
|
||||||
const cosRot = Math.cos(rotationRad);
|
const cosRot = Math.cos(rotationRad);
|
||||||
const sinRot = Math.sin(rotationRad);
|
const sinRot = Math.sin(rotationRad);
|
||||||
@@ -698,8 +581,12 @@ const useDocumentDrag = () => {
|
|||||||
const rotatedOffsetY =
|
const rotatedOffsetY =
|
||||||
state.localPointerOffsetX * sinRot + state.localPointerOffsetY * cosRot;
|
state.localPointerOffsetX * sinRot + state.localPointerOffsetY * cosRot;
|
||||||
|
|
||||||
const previousCenterX = Number.isFinite(entry.centerX) ? entry.centerX : state.originCenterX;
|
const previousCenterX = Number.isFinite(state.currentCenterX)
|
||||||
const previousCenterY = Number.isFinite(entry.centerY) ? entry.centerY : state.originCenterY;
|
? state.currentCenterX
|
||||||
|
: state.originCenterX;
|
||||||
|
const previousCenterY = Number.isFinite(state.currentCenterY)
|
||||||
|
? state.currentCenterY
|
||||||
|
: state.originCenterY;
|
||||||
|
|
||||||
const absCos = Math.abs(cosRot);
|
const absCos = Math.abs(cosRot);
|
||||||
const absSin = Math.abs(sinRot);
|
const absSin = Math.abs(sinRot);
|
||||||
@@ -731,7 +618,7 @@ const useDocumentDrag = () => {
|
|||||||
if (distanceSquared < DRAG_HYSTERESIS_SQUARED) {
|
if (distanceSquared < DRAG_HYSTERESIS_SQUARED) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
bringToFront(state.docId);
|
bringToFront(state.docKey);
|
||||||
state.moved = true;
|
state.moved = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -748,28 +635,23 @@ const useDocumentDrag = () => {
|
|||||||
currentCenterY = previousCenterY;
|
currentCenterY = previousCenterY;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = { ...entry, centerX: currentCenterX, centerY: currentCenterY };
|
state.currentCenterX = currentCenterX;
|
||||||
layoutRef.current.set(state.docId, updated);
|
state.currentCenterY = currentCenterY;
|
||||||
|
|
||||||
applyTransform(
|
const layoutEntry = layoutRef.current.get(state.docKey) || null;
|
||||||
state.docId,
|
const transformPayload = {
|
||||||
currentCenterX,
|
centerX: currentCenterX,
|
||||||
currentCenterY,
|
centerY: currentCenterY,
|
||||||
state.width,
|
rotation: rotationDeg,
|
||||||
state.height,
|
width: state.width,
|
||||||
rotationDeg,
|
height: state.height,
|
||||||
state.dragScale || 1,
|
scale: state.dragScale || 1,
|
||||||
);
|
zIndex: layoutEntry?.z,
|
||||||
|
};
|
||||||
|
|
||||||
const primaryNode = itemRefs.current.get(state.docId);
|
setDragTransform(state.docKey, transformPayload);
|
||||||
if (primaryNode) {
|
const primaryNode = itemRefs.current.get(state.docKey);
|
||||||
primaryNode.style.transform = formatTransform(
|
applyDomTransform(primaryNode, transformPayload);
|
||||||
currentCenterX - state.width / 2,
|
|
||||||
currentCenterY - state.height / 2,
|
|
||||||
rotationDeg,
|
|
||||||
state.dragScale || 1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const offsetX = pointerCanvasX - currentCenterX;
|
const offsetX = pointerCanvasX - currentCenterX;
|
||||||
const offsetY = pointerCanvasY - currentCenterY;
|
const offsetY = pointerCanvasY - currentCenterY;
|
||||||
@@ -810,10 +692,7 @@ const useDocumentDrag = () => {
|
|||||||
state.localPointerOffsetY = updatedLocalOffsetY;
|
state.localPointerOffsetY = updatedLocalOffsetY;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (debugDrag) {
|
void debugDrag;
|
||||||
console.log('[desk] handlePointerMove: moved doc', state.docId, 'to', currentCenterX, currentCenterY);
|
|
||||||
}
|
|
||||||
recalcVisibleDocIds();
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
bringToFront,
|
bringToFront,
|
||||||
@@ -825,10 +704,9 @@ const useDocumentDrag = () => {
|
|||||||
containerRef,
|
containerRef,
|
||||||
layoutRef,
|
layoutRef,
|
||||||
itemRefs,
|
itemRefs,
|
||||||
applyTransform,
|
|
||||||
recalcVisibleDocIds,
|
|
||||||
debugDrag,
|
debugDrag,
|
||||||
onDocumentStackSelect,
|
onDocumentStackSelect,
|
||||||
|
setDragTransform,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -841,13 +719,15 @@ const useDocumentDrag = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.isGroup) {
|
if (state.isGroup) {
|
||||||
finalizeGroupDrag(state);
|
engine?.finalizeGroupDrag?.(state);
|
||||||
|
commitActiveDragTransforms(state.activeDocIds);
|
||||||
finishDrag(event.pointerId);
|
finishDrag(event.pointerId);
|
||||||
recalcVisibleDocIds();
|
recalcVisibleDocIds();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.moved) {
|
if (state.moved) {
|
||||||
|
commitActiveDragTransforms([state.docKey]);
|
||||||
const inertiaState = {
|
const inertiaState = {
|
||||||
restRotation: state.restRotation,
|
restRotation: state.restRotation,
|
||||||
dynamicRotation: state.dynamicRotation,
|
dynamicRotation: state.dynamicRotation,
|
||||||
@@ -857,13 +737,13 @@ const useDocumentDrag = () => {
|
|||||||
height: state.height,
|
height: state.height,
|
||||||
dragScale: state.dragScale || 1,
|
dragScale: state.dragScale || 1,
|
||||||
};
|
};
|
||||||
const docId = state.docId;
|
const docId = state.docKey;
|
||||||
finishDrag(event.pointerId);
|
finishDrag(event.pointerId);
|
||||||
startInertiaAnimation(docId, inertiaState);
|
engine?.startInertiaAnimation?.(docId, inertiaState);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const docId = state.docId;
|
const docId = state.docKey;
|
||||||
const metaPressed = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
|
const metaPressed = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
|
||||||
if (!metaPressed) {
|
if (!metaPressed) {
|
||||||
bringToFront(docId);
|
bringToFront(docId);
|
||||||
@@ -885,10 +765,10 @@ const useDocumentDrag = () => {
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
bringToFront,
|
bringToFront,
|
||||||
|
commitActiveDragTransforms,
|
||||||
documentLookup,
|
documentLookup,
|
||||||
|
engine,
|
||||||
finishDrag,
|
finishDrag,
|
||||||
finalizeGroupDrag,
|
|
||||||
startInertiaAnimation,
|
|
||||||
recalcVisibleDocIds,
|
recalcVisibleDocIds,
|
||||||
tapHandler,
|
tapHandler,
|
||||||
],
|
],
|
||||||
@@ -899,12 +779,14 @@ const useDocumentDrag = () => {
|
|||||||
const state = dragStateRef.current;
|
const state = dragStateRef.current;
|
||||||
if (state && state.pointerId === event.pointerId && state.moved) {
|
if (state && state.pointerId === event.pointerId && state.moved) {
|
||||||
if (state.isGroup) {
|
if (state.isGroup) {
|
||||||
finalizeGroupDrag(state);
|
engine?.finalizeGroupDrag?.(state);
|
||||||
|
commitActiveDragTransforms(state.activeDocIds);
|
||||||
finishDrag(event.pointerId);
|
finishDrag(event.pointerId);
|
||||||
recalcVisibleDocIds();
|
recalcVisibleDocIds();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commitActiveDragTransforms([state.docKey]);
|
||||||
const inertiaState = {
|
const inertiaState = {
|
||||||
restRotation: state.restRotation,
|
restRotation: state.restRotation,
|
||||||
dynamicRotation: state.dynamicRotation,
|
dynamicRotation: state.dynamicRotation,
|
||||||
@@ -914,14 +796,14 @@ const useDocumentDrag = () => {
|
|||||||
height: state.height,
|
height: state.height,
|
||||||
dragScale: state.dragScale || 1,
|
dragScale: state.dragScale || 1,
|
||||||
};
|
};
|
||||||
const docId = state.docId;
|
const docId = state.docKey;
|
||||||
finishDrag(event.pointerId);
|
finishDrag(event.pointerId);
|
||||||
startInertiaAnimation(docId, inertiaState);
|
engine?.startInertiaAnimation?.(docId, inertiaState);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
finishDrag(event.pointerId);
|
finishDrag(event.pointerId);
|
||||||
},
|
},
|
||||||
[finalizeGroupDrag, finishDrag, recalcVisibleDocIds, startInertiaAnimation],
|
[commitActiveDragTransforms, engine, finishDrag, recalcVisibleDocIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+218
-587
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
|
import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
|
||||||
import usePointerTap from '../ui/usePointerTap';
|
|
||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
@@ -18,8 +17,6 @@ const ensureDocumentRoot = () => {
|
|||||||
return document.body;
|
return document.body;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CLICK_DELAY_MS = 240;
|
|
||||||
|
|
||||||
const PreviewZoomOverlay = ({
|
const PreviewZoomOverlay = ({
|
||||||
open = false,
|
open = false,
|
||||||
display = null,
|
display = null,
|
||||||
@@ -102,6 +99,7 @@ const PreviewZoomOverlay = ({
|
|||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !isNativeScale) {
|
if (!open || !isNativeScale) {
|
||||||
return;
|
return;
|
||||||
@@ -151,6 +149,19 @@ const PreviewZoomOverlay = ({
|
|||||||
|
|
||||||
const activeDisplay = open && display?.url ? display : displaySnapshot;
|
const activeDisplay = open && display?.url ? display : displaySnapshot;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeDisplay?.url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
if (scrollEl) {
|
||||||
|
scrollEl.scrollLeft = 0;
|
||||||
|
scrollEl.scrollTop = 0;
|
||||||
|
}
|
||||||
|
focusRef.current = null;
|
||||||
|
}, [activeDisplay?.url]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!renderBackdrop || !activeDisplay?.url) {
|
if (!renderBackdrop || !activeDisplay?.url) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -233,19 +244,9 @@ const PreviewZoomOverlay = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const pointerTapHandler = usePointerTap({
|
const handleImageClick = (event) => {
|
||||||
delay: CLICK_DELAY_MS,
|
|
||||||
onSingle: () => {
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
onDouble: ({ clientX, clientY }) => {
|
|
||||||
toggleZoomAtPoint(clientX, clientY);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleImagePointerDown = (event) => {
|
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
pointerTapHandler(event);
|
toggleZoomAtPoint(event.clientX ?? 0, event.clientY ?? 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!renderBackdrop || !activeDisplay?.url || !portalTarget) {
|
if (!renderBackdrop || !activeDisplay?.url || !portalTarget) {
|
||||||
@@ -301,13 +302,13 @@ const PreviewZoomOverlay = ({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={stageClassName}
|
className={stageClassName}
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={containerClassName}
|
className={containerClassName}
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={effectiveDisplay.url}
|
src={effectiveDisplay.url}
|
||||||
@@ -321,7 +322,7 @@ const PreviewZoomOverlay = ({
|
|||||||
height: event.currentTarget.naturalHeight || null,
|
height: event.currentTarget.naturalHeight || null,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onPointerDown={handleImagePointerDown}
|
onClick={handleImageClick}
|
||||||
style={imageStyle}
|
style={imageStyle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import { resolveDocumentAssetUrl } from '../asset_manager';
|
||||||
|
import { useDetailPanel } from '../app/useDetailPanel';
|
||||||
|
import {
|
||||||
|
DEFAULT_FOLDER_NAME,
|
||||||
|
getRowId,
|
||||||
|
isDocumentRowKey,
|
||||||
|
} from '../app/appLayoutUtils';
|
||||||
|
|
||||||
|
const useDetailWorkspace = ({
|
||||||
|
documents,
|
||||||
|
searchResults,
|
||||||
|
focusedDocumentId,
|
||||||
|
selectionOrder,
|
||||||
|
selectedDocumentIds,
|
||||||
|
documentLookup,
|
||||||
|
folderNodes,
|
||||||
|
ensureFolderData,
|
||||||
|
detailPanelControlRef,
|
||||||
|
detailFolderFetchRef,
|
||||||
|
previewEntries,
|
||||||
|
previewDocumentId,
|
||||||
|
activePreviewId,
|
||||||
|
openDocumentPreview,
|
||||||
|
promoteSelectionOrder,
|
||||||
|
handleDocumentTitleUpdate,
|
||||||
|
handleDocumentIssuedUpdate,
|
||||||
|
handleDocumentTagAdd,
|
||||||
|
handleTagRemove,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
ensurePreviewData,
|
||||||
|
correspondents,
|
||||||
|
handleCorrespondentAdd,
|
||||||
|
handleCorrespondentRemove,
|
||||||
|
resolveApiPath,
|
||||||
|
selectFolder,
|
||||||
|
tags,
|
||||||
|
tagLookupById,
|
||||||
|
}) => {
|
||||||
|
const selectedDocument = useMemo(() => {
|
||||||
|
if (!focusedDocumentId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const pool = searchResults ?? documents;
|
||||||
|
return pool.find((doc) => doc.id === focusedDocumentId) || null;
|
||||||
|
}, [focusedDocumentId, searchResults, documents]);
|
||||||
|
|
||||||
|
const orderedSelectedDocuments = useMemo(() => {
|
||||||
|
const ordered = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
const pushDoc = (doc) => {
|
||||||
|
if (doc?.id && !seen.has(doc.id)) {
|
||||||
|
ordered.push(doc);
|
||||||
|
seen.add(doc.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
selectionOrder.forEach((key) => {
|
||||||
|
if (!isDocumentRowKey(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const docId = getRowId(key);
|
||||||
|
const doc = documentLookup.get(docId) || null;
|
||||||
|
pushDoc(doc);
|
||||||
|
});
|
||||||
|
|
||||||
|
selectedDocumentIds.forEach((docId) => {
|
||||||
|
if (seen.has(docId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const doc = documentLookup.get(docId) || null;
|
||||||
|
pushDoc(doc);
|
||||||
|
});
|
||||||
|
|
||||||
|
return ordered;
|
||||||
|
}, [selectionOrder, documentLookup, selectedDocumentIds]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
detailPanelOpen,
|
||||||
|
detailPanelDocument,
|
||||||
|
openDetailPanel,
|
||||||
|
closeDetailPanel,
|
||||||
|
} = useDetailPanel({
|
||||||
|
documentLookup,
|
||||||
|
orderedSelectedDocuments,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
detailPanelControlRef.current = {
|
||||||
|
open: openDetailPanel,
|
||||||
|
close: closeDetailPanel,
|
||||||
|
};
|
||||||
|
}, [detailPanelControlRef, openDetailPanel, closeDetailPanel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!orderedSelectedDocuments.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visited = new Set();
|
||||||
|
|
||||||
|
orderedSelectedDocuments.forEach((doc) => {
|
||||||
|
const folderId = doc?.folder_id;
|
||||||
|
if (!folderId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentId = folderId;
|
||||||
|
let guard = 0;
|
||||||
|
|
||||||
|
while (currentId && currentId !== 'root' && guard < 32) {
|
||||||
|
guard += 1;
|
||||||
|
if (visited.has(currentId)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
visited.add(currentId);
|
||||||
|
|
||||||
|
const node = folderNodes.get(currentId);
|
||||||
|
if (!node) {
|
||||||
|
if (!detailFolderFetchRef.current.has(currentId)) {
|
||||||
|
detailFolderFetchRef.current.add(currentId);
|
||||||
|
ensureFolderData(currentId, { force: false, includeDocuments: false })
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn('Failed to preload folder metadata for detail path', currentId, error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
detailFolderFetchRef.current.delete(currentId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentId = node.parentId ?? 'root';
|
||||||
|
if (!parentId || parentId === 'root') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
currentId = parentId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [orderedSelectedDocuments, folderNodes, ensureFolderData, detailFolderFetchRef]);
|
||||||
|
|
||||||
|
const resolveFolderPath = useCallback(
|
||||||
|
(folderId) => {
|
||||||
|
if (!folderId || folderId === 'root') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = [];
|
||||||
|
const visited = new Set();
|
||||||
|
let currentId = folderId;
|
||||||
|
let guard = 0;
|
||||||
|
|
||||||
|
while (currentId && guard < 32 && !visited.has(currentId)) {
|
||||||
|
guard += 1;
|
||||||
|
visited.add(currentId);
|
||||||
|
|
||||||
|
if (currentId === 'root') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = folderNodes.get(currentId);
|
||||||
|
if (!node) {
|
||||||
|
segments.push({ id: currentId, name: '…' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
segments.push({ id: node.id, name: node.name || 'Folder' });
|
||||||
|
|
||||||
|
const parentId = node.parentId ?? 'root';
|
||||||
|
if (!parentId || parentId === 'root') {
|
||||||
|
segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentId = parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!segments.some((segment) => segment.id === 'root')) {
|
||||||
|
segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments.reverse();
|
||||||
|
},
|
||||||
|
[folderNodes],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedPreviewEntry = useMemo(() => {
|
||||||
|
if (!selectedDocument) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return previewEntries.get(selectedDocument.id) || null;
|
||||||
|
}, [selectedDocument, previewEntries]);
|
||||||
|
|
||||||
|
const previewWorkspaceEntry = useMemo(() => {
|
||||||
|
if (!previewDocumentId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return previewEntries.get(previewDocumentId) || null;
|
||||||
|
}, [previewDocumentId, previewEntries]);
|
||||||
|
|
||||||
|
const previewWorkspaceDocument = useMemo(() => {
|
||||||
|
if (!previewDocumentId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const pool = searchResults ?? documents;
|
||||||
|
return pool.find((doc) => doc.id === previewDocumentId) || null;
|
||||||
|
}, [previewDocumentId, searchResults, documents]);
|
||||||
|
|
||||||
|
const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument);
|
||||||
|
|
||||||
|
const resolveThumbnailUrlForDoc = useCallback(
|
||||||
|
(doc) =>
|
||||||
|
resolveDocumentAssetUrl(doc, 'thumbnail', {
|
||||||
|
ensureAssetUrl,
|
||||||
|
getAsset: getDocumentAsset,
|
||||||
|
}),
|
||||||
|
[ensureAssetUrl, getDocumentAsset],
|
||||||
|
);
|
||||||
|
|
||||||
|
const inspectDocument = useCallback(
|
||||||
|
(documentId) => {
|
||||||
|
if (!documentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openDetailPanel({ documentIds: [documentId] });
|
||||||
|
},
|
||||||
|
[openDetailPanel],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDetailPanelClose = useCallback(() => {
|
||||||
|
closeDetailPanel();
|
||||||
|
}, [closeDetailPanel]);
|
||||||
|
|
||||||
|
const detailPanelProps = useMemo(
|
||||||
|
() => ({
|
||||||
|
document: detailPanelDocument,
|
||||||
|
tags,
|
||||||
|
tagLookupById,
|
||||||
|
onTagAdd: handleDocumentTagAdd,
|
||||||
|
onTagRemove: handleTagRemove,
|
||||||
|
previewEntry: selectedPreviewEntry,
|
||||||
|
onOpenPreview: openDocumentPreview,
|
||||||
|
onPromoteSelection: promoteSelectionOrder,
|
||||||
|
activePreviewId,
|
||||||
|
onUpdateTitle: handleDocumentTitleUpdate,
|
||||||
|
onUpdateIssued: handleDocumentIssuedUpdate,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
ensurePreviewData,
|
||||||
|
correspondents,
|
||||||
|
onCorrespondentAdd: handleCorrespondentAdd,
|
||||||
|
onCorrespondentRemove: handleCorrespondentRemove,
|
||||||
|
resolveApiPath,
|
||||||
|
onFolderNavigate: selectFolder,
|
||||||
|
onClose: handleDetailPanelClose,
|
||||||
|
resolveFolderPath,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
activePreviewId,
|
||||||
|
correspondents,
|
||||||
|
detailPanelDocument,
|
||||||
|
ensureAssetUrl,
|
||||||
|
ensurePreviewData,
|
||||||
|
getDocumentAsset,
|
||||||
|
handleCorrespondentAdd,
|
||||||
|
handleCorrespondentRemove,
|
||||||
|
handleDetailPanelClose,
|
||||||
|
handleDocumentTagAdd,
|
||||||
|
handleDocumentIssuedUpdate,
|
||||||
|
handleDocumentTitleUpdate,
|
||||||
|
handleTagRemove,
|
||||||
|
openDocumentPreview,
|
||||||
|
promoteSelectionOrder,
|
||||||
|
resolveApiPath,
|
||||||
|
resolveFolderPath,
|
||||||
|
selectFolder,
|
||||||
|
selectedPreviewEntry,
|
||||||
|
tags,
|
||||||
|
tagLookupById,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
detailPanelProps,
|
||||||
|
detailPanelOpen,
|
||||||
|
openDetailPanel,
|
||||||
|
closeDetailPanel,
|
||||||
|
handleDetailPanelClose,
|
||||||
|
inspectDocument,
|
||||||
|
previewActive,
|
||||||
|
previewWorkspaceDocument,
|
||||||
|
previewWorkspaceEntry,
|
||||||
|
resolveThumbnailUrlForDoc,
|
||||||
|
resolveFolderPath,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDetailWorkspace;
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import DocumentSummarySection from './DocumentSummarySection';
|
||||||
|
import { buildDocumentMetadataItems, extractDocumentMetadataPayload } from './documentMetadata';
|
||||||
|
|
||||||
|
const DocumentInfoPanel = ({
|
||||||
|
document,
|
||||||
|
summaryProps = {},
|
||||||
|
metadataItems: metadataItemsProp,
|
||||||
|
metadataPayload: metadataPayloadProp,
|
||||||
|
metadataTabLabel = 'Metadata',
|
||||||
|
detailsTabLabel = 'Details',
|
||||||
|
contentConfig: contentConfigProp = null,
|
||||||
|
activeTab: controlledActiveTab,
|
||||||
|
onTabChange,
|
||||||
|
defaultTabId = 'details',
|
||||||
|
resetKey = null,
|
||||||
|
classNamePrefix = 'document-info',
|
||||||
|
hideTabNavWhenSingle = true,
|
||||||
|
}) => {
|
||||||
|
const base = classNamePrefix;
|
||||||
|
|
||||||
|
const metadataItems = useMemo(() => {
|
||||||
|
if (Array.isArray(metadataItemsProp) && metadataItemsProp.length) {
|
||||||
|
return metadataItemsProp;
|
||||||
|
}
|
||||||
|
return buildDocumentMetadataItems(document);
|
||||||
|
}, [metadataItemsProp, document]);
|
||||||
|
|
||||||
|
const metadataPayload = useMemo(() => {
|
||||||
|
if (metadataPayloadProp !== undefined) {
|
||||||
|
return metadataPayloadProp;
|
||||||
|
}
|
||||||
|
return extractDocumentMetadataPayload(document);
|
||||||
|
}, [metadataPayloadProp, document]);
|
||||||
|
|
||||||
|
const contentConfig = contentConfigProp || null;
|
||||||
|
const contentEnabled = Boolean(contentConfig && (contentConfig.enabled ?? true));
|
||||||
|
const showContentTab = Boolean(contentConfig && ((contentConfig.forceDisplay ?? contentEnabled)));
|
||||||
|
|
||||||
|
const [contentState, setContentState] = useState(() => {
|
||||||
|
if (!contentConfig) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!contentEnabled || typeof contentConfig.loadContent !== 'function') {
|
||||||
|
return { status: contentEnabled ? 'idle' : 'unavailable', data: null, error: null };
|
||||||
|
}
|
||||||
|
return { status: 'idle', data: null, error: null };
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contentConfig || !showContentTab) {
|
||||||
|
setContentState(null);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contentEnabled || typeof contentConfig.loadContent !== 'function') {
|
||||||
|
setContentState({ status: contentEnabled ? 'idle' : 'unavailable', data: null, error: null });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
setContentState({ status: 'loading', data: null, error: null });
|
||||||
|
|
||||||
|
Promise.resolve(contentConfig.loadContent({ signal: controller.signal }))
|
||||||
|
.then((result) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result && result.length) {
|
||||||
|
setContentState({ status: 'loaded', data: result, error: null });
|
||||||
|
} else {
|
||||||
|
setContentState({ status: 'empty', data: '', error: null });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (cancelled || error?.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setContentState({
|
||||||
|
status: 'error',
|
||||||
|
data: null,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
controller.abort();
|
||||||
|
contentConfig.onCancel?.();
|
||||||
|
};
|
||||||
|
}, [contentConfig, contentEnabled, showContentTab, document?.id, resetKey]);
|
||||||
|
|
||||||
|
const visibleTabs = useMemo(() => {
|
||||||
|
const tabsList = [];
|
||||||
|
|
||||||
|
tabsList.push({
|
||||||
|
id: 'details',
|
||||||
|
label: detailsTabLabel,
|
||||||
|
render: () => (
|
||||||
|
<section className={`${base}__section`}>
|
||||||
|
{metadataItems.length ? (
|
||||||
|
<dl className={`${base}__section-list`}>
|
||||||
|
{metadataItems.map(({ label, value }) => (
|
||||||
|
<div className={`${base}__section-item`} key={label}>
|
||||||
|
<dt>{label}</dt>
|
||||||
|
<dd>{value || '—'}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
) : (
|
||||||
|
<p className={`${base}__section-placeholder`}>No details available.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (showContentTab && contentConfig) {
|
||||||
|
tabsList.push({
|
||||||
|
id: contentConfig.id || 'content',
|
||||||
|
label: contentConfig.label || 'Content',
|
||||||
|
render: () => {
|
||||||
|
const messageClass = `${base}__message`;
|
||||||
|
const errorClass = `${base}__message ${base}__message--error`;
|
||||||
|
const objectClass = `${base}__object ${base}__object--ocr-text`;
|
||||||
|
|
||||||
|
if (!contentEnabled || !contentConfig.loadContent) {
|
||||||
|
return (
|
||||||
|
<div className={messageClass}>
|
||||||
|
{contentConfig.unavailableMessage || 'Content not available.'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contentState) {
|
||||||
|
return (
|
||||||
|
<div className={messageClass}>
|
||||||
|
{contentConfig.emptyMessage || 'No content available.'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (contentState.status) {
|
||||||
|
case 'loading':
|
||||||
|
return (
|
||||||
|
<div className={messageClass}>
|
||||||
|
{contentConfig.loadingMessage || 'Loading content…'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'error': {
|
||||||
|
const errorMessage =
|
||||||
|
contentConfig.errorMessage
|
||||||
|
|| (contentState.error instanceof Error ? contentState.error.message : null)
|
||||||
|
|| 'Failed to load content.';
|
||||||
|
return <div className={errorClass}>{errorMessage}</div>;
|
||||||
|
}
|
||||||
|
case 'empty':
|
||||||
|
return (
|
||||||
|
<div className={messageClass}>
|
||||||
|
{contentConfig.emptyMessage || 'No content available.'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'loaded':
|
||||||
|
return (
|
||||||
|
<pre className={objectClass}>{contentState.data}</pre>
|
||||||
|
);
|
||||||
|
case 'unavailable':
|
||||||
|
return (
|
||||||
|
<div className={messageClass}>
|
||||||
|
{contentConfig.unavailableMessage || 'Content not available.'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className={messageClass}>
|
||||||
|
{contentConfig.emptyMessage || 'No content available.'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadataPayload) {
|
||||||
|
tabsList.push({
|
||||||
|
id: 'metadata',
|
||||||
|
label: metadataTabLabel,
|
||||||
|
render: () => (
|
||||||
|
<section className={`${base}__section ${base}__section--metadata-json`}>
|
||||||
|
<pre className={`${base}__metadata-json`}>
|
||||||
|
{JSON.stringify(metadataPayload, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tabsList;
|
||||||
|
}, [
|
||||||
|
base,
|
||||||
|
detailsTabLabel,
|
||||||
|
metadataItems,
|
||||||
|
contentConfig,
|
||||||
|
contentEnabled,
|
||||||
|
contentState,
|
||||||
|
metadataPayload,
|
||||||
|
metadataTabLabel,
|
||||||
|
showContentTab,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const fallbackTabId = useMemo(() => {
|
||||||
|
if (!visibleTabs.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (defaultTabId && visibleTabs.some((tab) => tab.id === defaultTabId)) {
|
||||||
|
return defaultTabId;
|
||||||
|
}
|
||||||
|
return visibleTabs[0].id;
|
||||||
|
}, [visibleTabs, defaultTabId]);
|
||||||
|
|
||||||
|
const renderTabContent = (tab, context = {}) => {
|
||||||
|
if (!tab) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof tab.render === 'function') {
|
||||||
|
return tab.render(context);
|
||||||
|
}
|
||||||
|
if (tab.component) {
|
||||||
|
const TabComponent = tab.component;
|
||||||
|
return <TabComponent {...context} />;
|
||||||
|
}
|
||||||
|
return React.isValidElement(tab.render) ? tab.render : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isControlled = controlledActiveTab !== undefined && controlledActiveTab !== null;
|
||||||
|
const [uncontrolledTab, setUncontrolledTab] = useState(
|
||||||
|
isControlled ? controlledActiveTab : fallbackTabId,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isControlled) {
|
||||||
|
setUncontrolledTab(fallbackTabId);
|
||||||
|
}
|
||||||
|
}, [fallbackTabId, resetKey, isControlled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isControlled && controlledActiveTab && !visibleTabs.some((tab) => tab.id === controlledActiveTab)) {
|
||||||
|
const nextTab = fallbackTabId;
|
||||||
|
if (nextTab && nextTab !== controlledActiveTab) {
|
||||||
|
onTabChange?.(nextTab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isControlled, controlledActiveTab, visibleTabs, fallbackTabId, onTabChange]);
|
||||||
|
|
||||||
|
const activeTabId = isControlled ? controlledActiveTab : uncontrolledTab;
|
||||||
|
|
||||||
|
const handleTabSelect = (tabId) => {
|
||||||
|
if (!visibleTabs.some((tab) => tab.id === tabId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isControlled) {
|
||||||
|
setUncontrolledTab(tabId);
|
||||||
|
}
|
||||||
|
if (tabId !== activeTabId) {
|
||||||
|
onTabChange?.(tabId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const singleTab = visibleTabs.length === 1 ? visibleTabs[0] : null;
|
||||||
|
const shouldHideNav = hideTabNavWhenSingle && singleTab;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DocumentSummarySection
|
||||||
|
document={document}
|
||||||
|
{...summaryProps}
|
||||||
|
/>
|
||||||
|
{shouldHideNav ? (
|
||||||
|
<div className={`${base}__tabpanes ${base}__tabpanes--single`}>
|
||||||
|
<div className={`${base}__tabpanel`}>
|
||||||
|
{renderTabContent(singleTab, { document })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={`${base}__tabs-wrapper`}>
|
||||||
|
<div className={`${base}__tabs`} role="tablist" aria-label="Document details">
|
||||||
|
{visibleTabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab.id === activeTabId}
|
||||||
|
className={`${base}__tab${tab.id === activeTabId ? ' is-active' : ''}`}
|
||||||
|
onClick={() => handleTabSelect(tab.id)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={`${base}__tabpanes`}>
|
||||||
|
{visibleTabs.map((tab) => (
|
||||||
|
tab.id === activeTabId ? (
|
||||||
|
<div key={tab.id} role="tabpanel" className={`${base}__tabpanel`}>
|
||||||
|
{renderTabContent(tab, { document })}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentInfoPanel;
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FolderIcon } from '../ui/icons';
|
import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons';
|
||||||
import DocumentThumbnailImage from './DocumentThumbnailImage';
|
import DocumentThumbnailImage from './DocumentThumbnailImage';
|
||||||
import CorrespondentLinks from './CorrespondentLinks';
|
import CorrespondentLinks from './CorrespondentLinks';
|
||||||
import { getTagColorStyle } from '../utils/colors';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
import { resolveCorrespondents } from './correspondents';
|
import { resolveCorrespondents } from './correspondents';
|
||||||
import { writeTagTransferData } from './tagTransfer';
|
import { writeTagTransferData } from './tagTransfer';
|
||||||
|
import useInlineRename from './useInlineRename';
|
||||||
|
|
||||||
const DocumentsGrid = ({
|
const DocumentsGrid = ({
|
||||||
entries,
|
entries,
|
||||||
@@ -20,7 +21,7 @@ const DocumentsGrid = ({
|
|||||||
onFolderDragStart,
|
onFolderDragStart,
|
||||||
onFolderDragEnd,
|
onFolderDragEnd,
|
||||||
onDocumentClick,
|
onDocumentClick,
|
||||||
onDocumentOpen,
|
onDocumentActivate,
|
||||||
onDocumentDragStart,
|
onDocumentDragStart,
|
||||||
onDocumentDragEnd,
|
onDocumentDragEnd,
|
||||||
onDocumentTagDragOver,
|
onDocumentTagDragOver,
|
||||||
@@ -35,7 +36,42 @@ const DocumentsGrid = ({
|
|||||||
onCorrespondentClick,
|
onCorrespondentClick,
|
||||||
activeCorrespondentIdSet,
|
activeCorrespondentIdSet,
|
||||||
onClearSelection,
|
onClearSelection,
|
||||||
}) => (
|
onDocumentRename,
|
||||||
|
onFolderRename,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
editingId: editingDocumentId,
|
||||||
|
draftValue: documentDraft,
|
||||||
|
setDraftValue: setDocumentDraft,
|
||||||
|
beginEditing: beginDocumentEditing,
|
||||||
|
cancelEditing: cancelDocumentEditing,
|
||||||
|
submitEditing: submitDocumentEditing,
|
||||||
|
savingId: savingDocumentId,
|
||||||
|
attachInputRef: attachDocumentInputRef,
|
||||||
|
} = useInlineRename(onDocumentRename, {
|
||||||
|
getCurrentValue: (doc) => doc?.title ?? '',
|
||||||
|
getEntityId: (doc) => doc?.id ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
editingId: editingFolderId,
|
||||||
|
draftValue: folderDraft,
|
||||||
|
setDraftValue: setFolderDraft,
|
||||||
|
beginEditing: beginFolderEditing,
|
||||||
|
cancelEditing: cancelFolderEditing,
|
||||||
|
submitEditing: submitFolderEditing,
|
||||||
|
savingId: savingFolderId,
|
||||||
|
attachInputRef: attachFolderInputRef,
|
||||||
|
} = useInlineRename(onFolderRename, {
|
||||||
|
getCurrentValue: (folder) => folder?.name ?? '',
|
||||||
|
getEntityId: (folder) => folder?.id ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentSelectionCount = selectedDocumentIdsSet?.size ?? 0;
|
||||||
|
const folderSelectionCount = selectedFolderIdsSet?.size ?? 0;
|
||||||
|
const totalSelectionCount = documentSelectionCount + folderSelectionCount;
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
className="documents-grid"
|
className="documents-grid"
|
||||||
role="list"
|
role="list"
|
||||||
@@ -58,6 +94,14 @@ const DocumentsGrid = ({
|
|||||||
const classes = ['document-card', 'folder-card'];
|
const classes = ['document-card', 'folder-card'];
|
||||||
if (isDraggingFolder) classes.push('is-dragging');
|
if (isDraggingFolder) classes.push('is-dragging');
|
||||||
if (isSelectedFolder) classes.push('selected');
|
if (isSelectedFolder) classes.push('selected');
|
||||||
|
const canRenameFolder = folder.id !== 'root' && typeof onFolderRename === 'function';
|
||||||
|
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
|
||||||
@@ -89,9 +133,83 @@ const DocumentsGrid = ({
|
|||||||
<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">
|
||||||
<div className="folder-card__name" title={folder.name}>
|
{isFolderEditing ? (
|
||||||
{folder.name}
|
<div className="folder-card__edit doc-title-edit">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
ref={attachFolderInputRef}
|
||||||
|
value={folderDraftValue}
|
||||||
|
onChange={(event) => setFolderDraft(event.target.value)}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
submitFolderEditing(folder);
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
cancelFolderEditing(event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={(event) => {
|
||||||
|
const nextFocus = event.relatedTarget;
|
||||||
|
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
|
||||||
|
cancelFolderEditing();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
aria-label="Save name"
|
||||||
|
title="Save name"
|
||||||
|
disabled={!canSubmitFolder || isFolderSaving}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
submitFolderEditing(folder);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
aria-label="Cancel"
|
||||||
|
title="Cancel"
|
||||||
|
onClick={(event) => cancelFolderEditing(event)}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
</div>
|
</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.stopPropagation();
|
||||||
|
beginFolderEditing(folder);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{folder.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -111,6 +229,13 @@ const DocumentsGrid = ({
|
|||||||
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 documentDraftValue = isEditingDoc ? documentDraft : doc.title;
|
||||||
|
const trimmedDocumentDraft = isEditingDoc ? documentDraft.trim() : '';
|
||||||
|
const isDocumentSaving = savingDocumentId === doc.id;
|
||||||
|
const canSubmitDocument =
|
||||||
|
isEditingDoc && trimmedDocumentDraft.length > 0 && trimmedDocumentDraft !== doc.title;
|
||||||
|
const allowInlineDocumentEdit = onDocumentRename && isSelected && totalSelectionCount === 1;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={entry.key}
|
key={entry.key}
|
||||||
@@ -119,7 +244,7 @@ const DocumentsGrid = ({
|
|||||||
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={() => onDocumentOpen?.(doc.id)}
|
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)}
|
||||||
@@ -149,7 +274,82 @@ const DocumentsGrid = ({
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="doc-name__primary">{doc.title}</span>
|
{isEditingDoc ? (
|
||||||
|
<div className="document-card__title-edit doc-title-edit">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
ref={attachDocumentInputRef}
|
||||||
|
value={documentDraftValue}
|
||||||
|
onChange={(event) => setDocumentDraft(event.target.value)}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
submitDocumentEditing(doc);
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
cancelDocumentEditing(event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={(event) => {
|
||||||
|
const nextFocus = event.relatedTarget;
|
||||||
|
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
|
||||||
|
cancelDocumentEditing();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
aria-label="Save title"
|
||||||
|
title="Save title"
|
||||||
|
disabled={!canSubmitDocument || isDocumentSaving}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
submitDocumentEditing(doc);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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.stopPropagation();
|
||||||
|
beginDocumentEditing(doc);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{doc.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{visibleTags.length > 0 && (
|
{visibleTags.length > 0 && (
|
||||||
<div className="document-card__tags">
|
<div className="document-card__tags">
|
||||||
@@ -205,5 +405,6 @@ const DocumentsGrid = ({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default DocumentsGrid;
|
export default DocumentsGrid;
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons';
|
||||||
FolderIcon,
|
|
||||||
EditIcon,
|
|
||||||
DownloadIcon,
|
|
||||||
TrashIcon,
|
|
||||||
} from '../ui/icons';
|
|
||||||
import { getTagColorStyle } from '../utils/colors';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
import DocumentThumbnailImage from './DocumentThumbnailImage';
|
import DocumentThumbnailImage from './DocumentThumbnailImage';
|
||||||
import CorrespondentLinks from './CorrespondentLinks';
|
import CorrespondentLinks from './CorrespondentLinks';
|
||||||
import { resolveCorrespondents } from './correspondents';
|
import { resolveCorrespondents } from './correspondents';
|
||||||
import { writeTagTransferData } from './tagTransfer';
|
import { writeTagTransferData } from './tagTransfer';
|
||||||
|
import useInlineRename from './useInlineRename';
|
||||||
|
|
||||||
|
const formatDate = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
const timestamp = Date.parse(value);
|
||||||
|
if (Number.isNaN(timestamp)) {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
return new Date(timestamp).toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
const DocumentsList = ({
|
const DocumentsList = ({
|
||||||
entries,
|
entries,
|
||||||
@@ -20,7 +27,6 @@ const DocumentsList = ({
|
|||||||
draggedFolderId,
|
draggedFolderId,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
getDownloadHref,
|
|
||||||
onFolderClick,
|
onFolderClick,
|
||||||
onFolderSelect,
|
onFolderSelect,
|
||||||
onFolderDragOver,
|
onFolderDragOver,
|
||||||
@@ -29,29 +35,66 @@ const DocumentsList = ({
|
|||||||
onFolderDragStart,
|
onFolderDragStart,
|
||||||
onFolderDragEnd,
|
onFolderDragEnd,
|
||||||
onFolderRename,
|
onFolderRename,
|
||||||
onFolderDelete,
|
|
||||||
onDocumentClick,
|
onDocumentClick,
|
||||||
onDocumentOpen,
|
onDocumentActivate,
|
||||||
onDocumentDragStart,
|
onDocumentDragStart,
|
||||||
onDocumentDragEnd,
|
onDocumentDragEnd,
|
||||||
onDocumentTagDragOver,
|
onDocumentTagDragOver,
|
||||||
onDocumentTagDragLeave,
|
onDocumentTagDragLeave,
|
||||||
onDocumentTagDrop,
|
onDocumentTagDrop,
|
||||||
onDocumentRename,
|
onDocumentRename,
|
||||||
onDocumentDelete,
|
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
onTagClick,
|
onTagClick,
|
||||||
onCorrespondentClick,
|
onCorrespondentClick,
|
||||||
activeCorrespondentIdSet,
|
activeCorrespondentIdSet,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
}) => (
|
onClearSelection,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
editingId: editingDocumentId,
|
||||||
|
draftValue: documentDraft,
|
||||||
|
setDraftValue: setDocumentDraft,
|
||||||
|
beginEditing: beginDocumentEditing,
|
||||||
|
cancelEditing: cancelDocumentEditing,
|
||||||
|
submitEditing: submitDocumentEditing,
|
||||||
|
savingId: savingDocumentId,
|
||||||
|
attachInputRef: attachDocumentInputRef,
|
||||||
|
} = useInlineRename(onDocumentRename, {
|
||||||
|
getCurrentValue: (doc) => doc?.title ?? '',
|
||||||
|
getEntityId: (doc) => doc?.id ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
editingId: editingFolderId,
|
||||||
|
draftValue: folderDraft,
|
||||||
|
setDraftValue: setFolderDraft,
|
||||||
|
beginEditing: beginFolderEditing,
|
||||||
|
cancelEditing: cancelFolderEditing,
|
||||||
|
submitEditing: submitFolderEditing,
|
||||||
|
savingId: savingFolderId,
|
||||||
|
attachInputRef: attachFolderInputRef,
|
||||||
|
} = useInlineRename(onFolderRename, {
|
||||||
|
getCurrentValue: (folder) => folder?.name ?? '',
|
||||||
|
getEntityId: (folder) => folder?.id ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const documentSelectionCount = selectedDocumentIdsSet?.size ?? 0;
|
||||||
|
const folderSelectionCount = selectedFolderIdsSet?.size ?? 0;
|
||||||
|
const totalSelectionCount = documentSelectionCount + folderSelectionCount;
|
||||||
|
|
||||||
|
return (
|
||||||
<table aria-multiselectable="true">
|
<table aria-multiselectable="true">
|
||||||
<thead>
|
<thead
|
||||||
|
onClick={() => {
|
||||||
|
onClearSelection?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="thumb-column"></th>
|
<th className="thumb-column"></th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Issued</th>
|
<th>Issued</th>
|
||||||
<th>Actions</th>
|
<th>Added</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -65,6 +108,14 @@ const DocumentsList = ({
|
|||||||
const isDraggingFolder = draggedFolderId === folder.id;
|
const isDraggingFolder = draggedFolderId === folder.id;
|
||||||
const isSelectedFolder = selectedFolderIdsSet?.has(folder.id);
|
const isSelectedFolder = selectedFolderIdsSet?.has(folder.id);
|
||||||
const rowKey = `folder:${folder.id}`;
|
const rowKey = `folder:${folder.id}`;
|
||||||
|
const canRenameFolder = folder.id !== 'root' && typeof onFolderRename === 'function';
|
||||||
|
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 (
|
||||||
<tr
|
<tr
|
||||||
@@ -100,48 +151,90 @@ const DocumentsList = ({
|
|||||||
</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>{folder.name}</span>
|
<span className="doc-name__title">
|
||||||
</div>
|
<span className="doc-name__primary">
|
||||||
</td>
|
{isFolderEditing ? (
|
||||||
<td></td>
|
<span className="doc-title-edit">
|
||||||
<td className="actions">
|
<input
|
||||||
<div className="action-buttons">
|
type="text"
|
||||||
{folder.id !== 'root' && onFolderRename && (
|
ref={attachFolderInputRef}
|
||||||
|
value={folderDraftValue}
|
||||||
|
onChange={(event) => setFolderDraft(event.target.value)}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
submitFolderEditing(folder);
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
cancelFolderEditing(event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={(event) => {
|
||||||
|
const nextFocus = event.relatedTarget;
|
||||||
|
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
|
||||||
|
cancelFolderEditing();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button"
|
className="icon-button"
|
||||||
title="Rename"
|
aria-label="Save name"
|
||||||
aria-label={`Rename folder ${folder.name}`}
|
title="Save name"
|
||||||
|
disabled={!canSubmitFolder || isFolderSaving}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const nextName = window.prompt('Rename folder', folder.name);
|
submitFolderEditing(folder);
|
||||||
if (!nextName) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const trimmed = nextName.trim();
|
|
||||||
if (!trimmed || trimmed === folder.name) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onFolderRename?.(folder.id, trimmed);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EditIcon className="icon-inline" />
|
<CheckIcon />
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button danger"
|
className="icon-button"
|
||||||
title="Delete"
|
aria-label="Cancel"
|
||||||
aria-label={`Delete folder ${folder.name}`}
|
title="Cancel"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
cancelFolderEditing(event);
|
||||||
onFolderDelete?.(folder.id);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TrashIcon className="icon-inline" />
|
<CloseIcon />
|
||||||
</button>
|
</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.stopPropagation();
|
||||||
|
beginFolderEditing(folder);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{folder.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td>—</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -155,8 +248,17 @@ const DocumentsList = ({
|
|||||||
const rowClasses = ['document'];
|
const rowClasses = ['document'];
|
||||||
if (isSelected) rowClasses.push('selected');
|
if (isSelected) rowClasses.push('selected');
|
||||||
if (isDraggingDoc) rowClasses.push('is-dragging');
|
if (isDraggingDoc) rowClasses.push('is-dragging');
|
||||||
const downloadHref = getDownloadHref?.(doc) || null;
|
|
||||||
const correspondents = resolveCorrespondents(doc);
|
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 (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={entry.key}
|
key={entry.key}
|
||||||
@@ -164,7 +266,7 @@ const DocumentsList = ({
|
|||||||
id={`document-row-${doc.id}`}
|
id={`document-row-${doc.id}`}
|
||||||
data-doc-id={doc.id}
|
data-doc-id={doc.id}
|
||||||
onClick={(event) => onDocumentClick?.(doc, event)}
|
onClick={(event) => onDocumentClick?.(doc, event)}
|
||||||
onDoubleClick={() => onDocumentOpen?.(doc.id)}
|
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)}
|
||||||
@@ -194,7 +296,84 @@ const DocumentsList = ({
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="doc-name__primary">{doc.title}</span>
|
<span className="doc-name__primary">
|
||||||
|
{isEditingDoc ? (
|
||||||
|
<span className="doc-title-edit">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
ref={attachDocumentInputRef}
|
||||||
|
value={documentDraftValue}
|
||||||
|
onChange={(event) => setDocumentDraft(event.target.value)}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
submitDocumentEditing(doc);
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
cancelDocumentEditing(event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={(event) => {
|
||||||
|
const nextFocus = event.relatedTarget;
|
||||||
|
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
|
||||||
|
cancelDocumentEditing();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
aria-label="Save title"
|
||||||
|
title="Save title"
|
||||||
|
disabled={!canSubmitDocument || isDocumentSaving}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
submitDocumentEditing(doc);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
aria-label="Cancel"
|
||||||
|
title="Cancel"
|
||||||
|
onClick={(event) => {
|
||||||
|
cancelDocumentEditing(event);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="doc-name__primary-text"
|
||||||
|
role={allowInlineDocumentEdit ? 'button' : undefined}
|
||||||
|
tabIndex={allowInlineDocumentEdit ? 0 : undefined}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (!allowInlineDocumentEdit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
beginDocumentEditing(doc);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (!allowInlineDocumentEdit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
beginDocumentEditing(doc);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{doc.title}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{(doc.tags || []).length > 0 && (
|
{(doc.tags || []).length > 0 && (
|
||||||
@@ -244,79 +423,14 @@ const DocumentsList = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>{issuedLabel}</td>
|
||||||
{(() => {
|
<td>{addedLabel}</td>
|
||||||
const issuedAt = doc.issued_at || null;
|
|
||||||
if (!issuedAt) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
const timestamp = Date.parse(issuedAt);
|
|
||||||
if (Number.isNaN(timestamp)) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
return new Date(timestamp).toLocaleDateString();
|
|
||||||
})()}
|
|
||||||
</td>
|
|
||||||
<td className="actions">
|
|
||||||
<div className="action-buttons">
|
|
||||||
{onDocumentRename && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button"
|
|
||||||
title="Rename"
|
|
||||||
aria-label={`Rename document ${doc.title}`}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
const nextName = window.prompt('Rename document', doc.title);
|
|
||||||
if (!nextName) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const trimmed = nextName.trim();
|
|
||||||
if (!trimmed || trimmed === doc.title) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onDocumentRename?.(doc.id, trimmed);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EditIcon className="icon-inline" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{downloadHref ? (
|
|
||||||
<a
|
|
||||||
className="icon-button"
|
|
||||||
href={downloadHref}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
title="Download"
|
|
||||||
aria-label="Download document"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
onAuxClick={(event) => event.stopPropagation()}
|
|
||||||
onContextMenu={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<DownloadIcon className="icon-inline" />
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span className="meta">No download</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button danger"
|
|
||||||
title="Delete"
|
|
||||||
aria-label="Delete document"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onDocumentDelete?.(doc.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TrashIcon className="icon-inline" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default DocumentsList;
|
export default DocumentsList;
|
||||||
|
|||||||
@@ -6,13 +6,22 @@ import {
|
|||||||
RefreshIcon,
|
RefreshIcon,
|
||||||
MinusVerticalIcon,
|
MinusVerticalIcon,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
|
FoldersIcon,
|
||||||
|
FoldersOffIcon,
|
||||||
|
SortAscendingLettersIcon,
|
||||||
|
SortDescendingLettersIcon,
|
||||||
} from '../ui/icons';
|
} from '../ui/icons';
|
||||||
|
import QuickAddMenu from '../ui/QuickAddMenu';
|
||||||
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
||||||
import createWorkspaceSurfaceConfig from './workspaceHeader';
|
import createWorkspaceSurfaceConfig from './workspaceHeader';
|
||||||
import DetailPanel from '../detail/DetailPanel';
|
import DetailPanel from '../detail/DetailPanel';
|
||||||
import DocumentsGrid from './DocumentsGrid';
|
import DocumentsGrid from './DocumentsGrid';
|
||||||
import DocumentsList from './DocumentsList';
|
import DocumentsList from './DocumentsList';
|
||||||
import { isTagTransferEvent } from './tagTransfer';
|
import { isTagTransferEvent } from './tagTransfer';
|
||||||
|
import SelectionFloatingActions from './SelectionFloatingActions';
|
||||||
|
import PreviewZoomOverlay from '../detail/PreviewZoomOverlay';
|
||||||
|
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
||||||
|
import { isPointerModifierEvent, isPrimaryPointerEvent } from './useEntryPointer';
|
||||||
|
|
||||||
const DEFAULT_GRID_ICON_SIZE = 144;
|
const DEFAULT_GRID_ICON_SIZE = 144;
|
||||||
|
|
||||||
@@ -21,6 +30,19 @@ const EntryType = {
|
|||||||
document: 'document',
|
document: 'document',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SORT_OPTIONS = [
|
||||||
|
{ value: 'title', label: 'Title' },
|
||||||
|
{ value: 'issued_at', label: 'Issued date' },
|
||||||
|
{ value: 'created_at', label: 'Added' },
|
||||||
|
{ value: 'updated_at', label: 'Updated date' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SORT_LABEL_LOOKUP = SORT_OPTIONS.reduce((accumulator, option) => {
|
||||||
|
const next = accumulator;
|
||||||
|
next[option.value] = option.label;
|
||||||
|
return next;
|
||||||
|
}, {});
|
||||||
|
|
||||||
const DocumentsPanel = ({
|
const DocumentsPanel = ({
|
||||||
currentFolderName,
|
currentFolderName,
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
@@ -36,27 +58,22 @@ const DocumentsPanel = ({
|
|||||||
onFolderDragStart,
|
onFolderDragStart,
|
||||||
onFolderDragEnd,
|
onFolderDragEnd,
|
||||||
draggedFolderId,
|
draggedFolderId,
|
||||||
onFolderDelete,
|
|
||||||
onFolderRename,
|
onFolderRename,
|
||||||
selectedFolderIds = [],
|
selectedFolderIds = [],
|
||||||
onDocumentOpen,
|
|
||||||
selectedDocumentIds = [],
|
selectedDocumentIds = [],
|
||||||
focusedRowKey,
|
focusedRowKey,
|
||||||
draggingDocumentIds = [],
|
draggingDocumentIds = [],
|
||||||
onDocumentDragStart,
|
onDocumentDragStart,
|
||||||
onDocumentDragEnd,
|
onDocumentDragEnd,
|
||||||
onDocumentDelete,
|
|
||||||
onDocumentRename,
|
onDocumentRename,
|
||||||
onRowSelection = null,
|
onEntryPointer = null,
|
||||||
onOpenDetailPanel = null,
|
onEntrySelection = null,
|
||||||
|
onInspectDocument = null,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
activeCorrespondentIds = [],
|
activeCorrespondentIds = [],
|
||||||
onDocumentListFocus,
|
|
||||||
onDocumentListKeyDown,
|
|
||||||
onFocusedRowChange,
|
onFocusedRowChange,
|
||||||
ensureAssetUrl = null,
|
ensureAssetUrl = null,
|
||||||
getDocumentAsset = () => null,
|
getDocumentAsset = () => null,
|
||||||
getDownloadHref,
|
|
||||||
onTagClick,
|
onTagClick,
|
||||||
onCorrespondentClick,
|
onCorrespondentClick,
|
||||||
isSearchLoading = false,
|
isSearchLoading = false,
|
||||||
@@ -64,6 +81,7 @@ const DocumentsPanel = ({
|
|||||||
viewMode = 'list',
|
viewMode = 'list',
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
onClearSelection,
|
onClearSelection,
|
||||||
|
selectedEntries = [],
|
||||||
showHeader = true,
|
showHeader = true,
|
||||||
}) => {
|
}) => {
|
||||||
const showingSearchResults = searchResults !== null;
|
const showingSearchResults = searchResults !== null;
|
||||||
@@ -120,6 +138,239 @@ const DocumentsPanel = ({
|
|||||||
}, []);
|
}, []);
|
||||||
const isGridView = viewMode === 'grid';
|
const isGridView = viewMode === 'grid';
|
||||||
const isDeskView = viewMode === 'desk';
|
const isDeskView = viewMode === 'desk';
|
||||||
|
|
||||||
|
const [previewDocId, setPreviewDocId] = useState(null);
|
||||||
|
|
||||||
|
const previewDoc = useMemo(() => {
|
||||||
|
if (!previewDocId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return rows.find((doc) => doc?.id === previewDocId) || null;
|
||||||
|
}, [previewDocId, rows]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (previewDocId && !previewDoc) {
|
||||||
|
setPreviewDocId(null);
|
||||||
|
}
|
||||||
|
}, [previewDocId, previewDoc]);
|
||||||
|
|
||||||
|
const previewNavigator = useAssetNavigator({
|
||||||
|
document: previewDoc,
|
||||||
|
assetType: 'preview',
|
||||||
|
ensureAssetUrl,
|
||||||
|
getAsset: getDocumentAsset,
|
||||||
|
prefetch: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
currentUrl: previewUrl,
|
||||||
|
canGoPrev: previewCanGoPrev,
|
||||||
|
canGoNext: previewCanGoNext,
|
||||||
|
goPrev: previewGoPrev,
|
||||||
|
goNext: previewGoNext,
|
||||||
|
} = previewNavigator;
|
||||||
|
|
||||||
|
const previewDisplay = useMemo(() => {
|
||||||
|
if (!previewDoc || !previewUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
url: previewUrl,
|
||||||
|
alt: previewDoc.title,
|
||||||
|
canGoPrev: Boolean(previewCanGoPrev),
|
||||||
|
canGoNext: Boolean(previewCanGoNext),
|
||||||
|
goPrev: previewGoPrev,
|
||||||
|
goNext: previewGoNext,
|
||||||
|
};
|
||||||
|
}, [previewDoc, previewUrl, previewCanGoPrev, previewCanGoNext, previewGoPrev, previewGoNext]);
|
||||||
|
|
||||||
|
const closePreviewOverlay = useCallback(() => {
|
||||||
|
setPreviewDocId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDocumentPreviewZoom = useCallback(
|
||||||
|
(doc) => {
|
||||||
|
if (!doc || !doc.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previewAsset = typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, 'preview') : null;
|
||||||
|
if (!previewAsset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPreviewDocId(doc.id);
|
||||||
|
},
|
||||||
|
[getDocumentAsset],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDocumentActivate = useCallback(
|
||||||
|
(doc, event) => {
|
||||||
|
if (!doc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event) {
|
||||||
|
if (typeof event.preventDefault === 'function') {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
if (typeof event.stopPropagation === 'function') {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event?.altKey) {
|
||||||
|
handleDocumentPreviewZoom(doc);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onInspectDocument?.(doc.id, event);
|
||||||
|
},
|
||||||
|
[handleDocumentPreviewZoom, onInspectDocument],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedRowKeySet = useMemo(() => new Set(selectedEntries || []), [selectedEntries]);
|
||||||
|
const navigableRows = useMemo(
|
||||||
|
() => entries.map((entry) => ({ key: entry.key, type: entry.type, id: entry.id })),
|
||||||
|
[entries],
|
||||||
|
);
|
||||||
|
const navigableRowKeys = useMemo(() => navigableRows.map((row) => row.key), [navigableRows]);
|
||||||
|
|
||||||
|
const getEntryByKey = useCallback(
|
||||||
|
(rowKey) => entries.find((entry) => entry.key === rowKey) || null,
|
||||||
|
[entries],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePanelFocus = useCallback(() => {
|
||||||
|
let resolvedKey = null;
|
||||||
|
|
||||||
|
if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) {
|
||||||
|
resolvedKey = focusedRowKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolvedKey) {
|
||||||
|
for (let index = selectedEntries.length - 1; index >= 0; index -= 1) {
|
||||||
|
const candidate = selectedEntries[index];
|
||||||
|
if (navigableRowKeys.includes(candidate)) {
|
||||||
|
resolvedKey = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolvedKey && navigableRows.length) {
|
||||||
|
resolvedKey = navigableRows[0].key;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolvedKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onFocusedRowChange?.(resolvedKey);
|
||||||
|
|
||||||
|
if (!selectedRowKeySet.has(resolvedKey) && typeof onEntrySelection === 'function') {
|
||||||
|
onEntrySelection(resolvedKey, {
|
||||||
|
shiftKey: false,
|
||||||
|
preventDefault: () => {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
focusedRowKey,
|
||||||
|
navigableRowKeys,
|
||||||
|
navigableRows,
|
||||||
|
onEntrySelection,
|
||||||
|
onFocusedRowChange,
|
||||||
|
selectedEntries,
|
||||||
|
selectedRowKeySet,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handlePanelKeyDown = useCallback(
|
||||||
|
(event) => {
|
||||||
|
const { key, shiftKey } = event;
|
||||||
|
const triggers = ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', ' ', 'Space', 'Spacebar'];
|
||||||
|
if (!triggers.includes(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!navigableRows.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
let activeKey =
|
||||||
|
focusedRowKey && navigableRowKeys.includes(focusedRowKey)
|
||||||
|
? focusedRowKey
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!activeKey) {
|
||||||
|
if (selectedEntries.length) {
|
||||||
|
for (let index = selectedEntries.length - 1; index >= 0; index -= 1) {
|
||||||
|
const candidate = selectedEntries[index];
|
||||||
|
if (navigableRowKeys.includes(candidate)) {
|
||||||
|
activeKey = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeKey) {
|
||||||
|
activeKey = key === 'ArrowUp' ? navigableRowKeys[navigableRowKeys.length - 1] : navigableRowKeys[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = navigableRowKeys.indexOf(activeKey);
|
||||||
|
const activeRow = currentIndex === -1 ? null : navigableRows[currentIndex];
|
||||||
|
|
||||||
|
if (key === 'Enter' || key === ' ' || key === 'Space' || key === 'Spacebar') {
|
||||||
|
if (activeRow) {
|
||||||
|
onEntrySelection?.(activeRow.key, event);
|
||||||
|
if (activeRow.type === EntryType.folder) {
|
||||||
|
onFolderSelect?.(activeRow.id);
|
||||||
|
} else {
|
||||||
|
const entry = getEntryByKey(activeRow.key);
|
||||||
|
if (entry?.document) {
|
||||||
|
handleDocumentPreviewZoom(entry.document);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextIndex = currentIndex;
|
||||||
|
if (key === 'ArrowDown') {
|
||||||
|
nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, navigableRows.length - 1);
|
||||||
|
} else if (key === 'ArrowUp') {
|
||||||
|
nextIndex = currentIndex === -1 ? navigableRows.length - 1 : Math.max(currentIndex - 1, 0);
|
||||||
|
} else if (key === 'Home') {
|
||||||
|
nextIndex = 0;
|
||||||
|
} else if (key === 'End') {
|
||||||
|
nextIndex = navigableRows.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextIndex === -1 || nextIndex >= navigableRows.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRow = navigableRows[nextIndex];
|
||||||
|
if (!targetRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onFocusedRowChange?.(targetRow.key);
|
||||||
|
onEntrySelection?.(targetRow.key, {
|
||||||
|
shiftKey,
|
||||||
|
preventDefault: () => {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[
|
||||||
|
focusedRowKey,
|
||||||
|
getEntryByKey,
|
||||||
|
navigableRowKeys,
|
||||||
|
navigableRows,
|
||||||
|
onEntrySelection,
|
||||||
|
onFocusedRowChange,
|
||||||
|
onFolderSelect,
|
||||||
|
selectedEntries,
|
||||||
|
handleDocumentPreviewZoom,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const isListView = viewMode === 'list';
|
const isListView = viewMode === 'list';
|
||||||
const gridIconSize = DEFAULT_GRID_ICON_SIZE;
|
const gridIconSize = DEFAULT_GRID_ICON_SIZE;
|
||||||
const handleSetViewMode = useCallback(
|
const handleSetViewMode = useCallback(
|
||||||
@@ -242,56 +493,20 @@ const DocumentsPanel = ({
|
|||||||
[isTagDragEvent, onDocumentTagDrop],
|
[isTagDragEvent, onDocumentTagDrop],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleEntryClick = useCallback(
|
|
||||||
(entry, event) => {
|
|
||||||
if (!entry || !entry.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (entry.type === EntryType.document && suppressDocumentClickRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rowKey = entry.type === EntryType.document ? `document:${entry.id}` : `folder:${entry.id}`;
|
|
||||||
|
|
||||||
if (rowKey && typeof onRowSelection === 'function') {
|
|
||||||
onRowSelection(rowKey, event);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.type === EntryType.document) {
|
|
||||||
const hasModifier = Boolean(
|
|
||||||
event && (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey),
|
|
||||||
);
|
|
||||||
if (!hasModifier && typeof onOpenDetailPanel === 'function') {
|
|
||||||
onOpenDetailPanel();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.type === EntryType.folder) {
|
|
||||||
const hasModifier = Boolean(
|
|
||||||
event && (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey),
|
|
||||||
);
|
|
||||||
const isPrimaryClick = Boolean(event && event.type === 'click' && event.button === 0);
|
|
||||||
if (!hasModifier && isPrimaryClick && typeof onFolderSelect === 'function') {
|
|
||||||
onFolderSelect(entry.id);
|
|
||||||
}
|
|
||||||
if (scrollRef.current) {
|
|
||||||
scrollRef.current.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
onFocusedRowChange?.(rowKey);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onRowSelection, onOpenDetailPanel, onFocusedRowChange, onFolderSelect],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDocumentClick = useCallback(
|
const handleDocumentClick = useCallback(
|
||||||
(doc, event) => {
|
(doc, event) => {
|
||||||
if (!doc) {
|
if (!doc || suppressDocumentClickRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleEntryClick({ type: EntryType.document, id: doc.id, document: doc }, event);
|
|
||||||
|
if (typeof onEntryPointer === 'function') {
|
||||||
|
onEntryPointer(
|
||||||
|
{ type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc },
|
||||||
|
event,
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[handleEntryClick],
|
[onEntryPointer],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFolderClick = useCallback(
|
const handleFolderClick = useCallback(
|
||||||
@@ -299,9 +514,24 @@ const DocumentsPanel = ({
|
|||||||
if (!folder) {
|
if (!folder) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleEntryClick({ type: EntryType.folder, id: folder.id, folder }, event);
|
|
||||||
|
if (typeof onEntryPointer === 'function') {
|
||||||
|
onEntryPointer(
|
||||||
|
{ type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder },
|
||||||
|
event,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isPointerModifierEvent(event)
|
||||||
|
&& isPrimaryPointerEvent(event)
|
||||||
|
&& scrollRef.current
|
||||||
|
) {
|
||||||
|
scrollRef.current.focus({ preventScroll: true });
|
||||||
|
onFocusedRowChange?.(`folder:${folder.id}`);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[handleEntryClick],
|
[onEntryPointer, onFocusedRowChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDocumentDragStartLocal = useCallback(
|
const handleDocumentDragStartLocal = useCallback(
|
||||||
@@ -330,7 +560,6 @@ const DocumentsPanel = ({
|
|||||||
const showDefaultEmptyState = !showingSearchResults && !isFilterActive && entries.length === 0;
|
const showDefaultEmptyState = !showingSearchResults && !isFilterActive && entries.length === 0;
|
||||||
const showListSearchEmptyState = showingSearchResults && !hasDocumentEntries && !isGridView && !isSearchLoading;
|
const showListSearchEmptyState = showingSearchResults && !hasDocumentEntries && !isGridView && !isSearchLoading;
|
||||||
const showGridSearchEmptyState = isGridView && showingSearchResults && !hasDocumentEntries && !isSearchLoading;
|
const showGridSearchEmptyState = isGridView && showingSearchResults && !hasDocumentEntries && !isSearchLoading;
|
||||||
const showSearchHint = showingSearchResults && rows.length > 0;
|
|
||||||
const breadcrumbEntries = useMemo(() => (Array.isArray(breadcrumbs) ? breadcrumbs.filter(Boolean) : []), [breadcrumbs]);
|
const breadcrumbEntries = useMemo(() => (Array.isArray(breadcrumbs) ? breadcrumbs.filter(Boolean) : []), [breadcrumbs]);
|
||||||
const trailEntries = useMemo(() => {
|
const trailEntries = useMemo(() => {
|
||||||
if (!breadcrumbEntries.length) {
|
if (!breadcrumbEntries.length) {
|
||||||
@@ -347,6 +576,7 @@ const DocumentsPanel = ({
|
|||||||
}, [breadcrumbEntries, currentFolderName, onFolderSelect]);
|
}, [breadcrumbEntries, currentFolderName, onFolderSelect]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<section
|
<section
|
||||||
className={`documents-panel documents-panel--view-${isGridView ? 'grid' : 'list'}`}
|
className={`documents-panel documents-panel--view-${isGridView ? 'grid' : 'list'}`}
|
||||||
>
|
>
|
||||||
@@ -424,16 +654,14 @@ const DocumentsPanel = ({
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onFocus={(event) => {
|
onFocus={(event) => {
|
||||||
if (event.target === scrollRef.current) {
|
if (event.target === scrollRef.current) {
|
||||||
onDocumentListFocus?.();
|
handlePanelFocus();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.target !== scrollRef.current) {
|
if (event.target !== scrollRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (onDocumentListKeyDown) {
|
handlePanelKeyDown(event);
|
||||||
onDocumentListKeyDown(event);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (event.target === event.currentTarget) {
|
if (event.target === event.currentTarget) {
|
||||||
@@ -457,7 +685,7 @@ const DocumentsPanel = ({
|
|||||||
onFolderDragStart={onFolderDragStart}
|
onFolderDragStart={onFolderDragStart}
|
||||||
onFolderDragEnd={onFolderDragEnd}
|
onFolderDragEnd={onFolderDragEnd}
|
||||||
onDocumentClick={handleDocumentClick}
|
onDocumentClick={handleDocumentClick}
|
||||||
onDocumentOpen={onDocumentOpen}
|
onDocumentActivate={handleDocumentActivate}
|
||||||
onDocumentDragStart={handleDocumentDragStartLocal}
|
onDocumentDragStart={handleDocumentDragStartLocal}
|
||||||
onDocumentDragEnd={handleDocumentDragEndLocal}
|
onDocumentDragEnd={handleDocumentDragEndLocal}
|
||||||
onDocumentTagDragOver={handleDocumentTagDragOver}
|
onDocumentTagDragOver={handleDocumentTagDragOver}
|
||||||
@@ -472,6 +700,8 @@ const DocumentsPanel = ({
|
|||||||
onCorrespondentClick={onCorrespondentClick}
|
onCorrespondentClick={onCorrespondentClick}
|
||||||
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||||
onClearSelection={onClearSelection}
|
onClearSelection={onClearSelection}
|
||||||
|
onDocumentRename={onDocumentRename}
|
||||||
|
onFolderRename={onFolderRename}
|
||||||
/>
|
/>
|
||||||
) : !showTableRows ? null : (
|
) : !showTableRows ? null : (
|
||||||
<DocumentsList
|
<DocumentsList
|
||||||
@@ -483,7 +713,6 @@ const DocumentsPanel = ({
|
|||||||
draggedFolderId={draggedFolderId}
|
draggedFolderId={draggedFolderId}
|
||||||
ensureAssetUrl={ensureAssetUrl}
|
ensureAssetUrl={ensureAssetUrl}
|
||||||
getDocumentAsset={getDocumentAsset}
|
getDocumentAsset={getDocumentAsset}
|
||||||
getDownloadHref={getDownloadHref}
|
|
||||||
onFolderClick={handleFolderClick}
|
onFolderClick={handleFolderClick}
|
||||||
onFolderSelect={onFolderSelect}
|
onFolderSelect={onFolderSelect}
|
||||||
onFolderDragOver={onFolderDragOver}
|
onFolderDragOver={onFolderDragOver}
|
||||||
@@ -492,49 +721,180 @@ const DocumentsPanel = ({
|
|||||||
onFolderDragStart={onFolderDragStart}
|
onFolderDragStart={onFolderDragStart}
|
||||||
onFolderDragEnd={onFolderDragEnd}
|
onFolderDragEnd={onFolderDragEnd}
|
||||||
onFolderRename={onFolderRename}
|
onFolderRename={onFolderRename}
|
||||||
onFolderDelete={onFolderDelete}
|
|
||||||
onDocumentClick={handleDocumentClick}
|
onDocumentClick={handleDocumentClick}
|
||||||
onDocumentOpen={onDocumentOpen}
|
onDocumentActivate={handleDocumentActivate}
|
||||||
onDocumentDragStart={handleDocumentDragStartLocal}
|
onDocumentDragStart={handleDocumentDragStartLocal}
|
||||||
onDocumentDragEnd={handleDocumentDragEndLocal}
|
onDocumentDragEnd={handleDocumentDragEndLocal}
|
||||||
onDocumentTagDragOver={handleDocumentTagDragOver}
|
onDocumentTagDragOver={handleDocumentTagDragOver}
|
||||||
onDocumentTagDragLeave={handleDocumentTagDragLeave}
|
onDocumentTagDragLeave={handleDocumentTagDragLeave}
|
||||||
onDocumentTagDrop={handleDocumentTagDrop}
|
onDocumentTagDrop={handleDocumentTagDrop}
|
||||||
onDocumentRename={onDocumentRename}
|
onDocumentRename={onDocumentRename}
|
||||||
onDocumentDelete={onDocumentDelete}
|
|
||||||
tagLookupById={tagLookupById}
|
tagLookupById={tagLookupById}
|
||||||
onTagClick={onTagClick}
|
onTagClick={onTagClick}
|
||||||
onCorrespondentClick={onCorrespondentClick}
|
onCorrespondentClick={onCorrespondentClick}
|
||||||
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||||
scrollRef={scrollRef}
|
scrollRef={scrollRef}
|
||||||
|
onClearSelection={onClearSelection}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showSearchHint && (
|
|
||||||
<div className="search-hint">
|
|
||||||
Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
<PreviewZoomOverlay
|
||||||
|
open={Boolean(previewDocId)}
|
||||||
|
display={previewDisplay}
|
||||||
|
onClose={closePreviewOverlay}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DocumentsPanel;
|
export default DocumentsPanel;
|
||||||
|
|
||||||
|
const SortFieldQuickMenu = ({ sortField, onChange }) => {
|
||||||
|
const currentOption = useMemo(
|
||||||
|
() => SORT_OPTIONS.find((option) => option.value === sortField) || SORT_OPTIONS[0],
|
||||||
|
[sortField],
|
||||||
|
);
|
||||||
|
|
||||||
|
const options = useMemo(
|
||||||
|
() => SORT_OPTIONS.map((option) => ({ id: option.value, label: option.label })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(value, option) => {
|
||||||
|
if (typeof onChange !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextValue = option?.id || option?.original?.id || value;
|
||||||
|
if (nextValue) {
|
||||||
|
onChange(nextValue);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const label = currentOption?.label || SORT_LABEL_LOOKUP[currentOption?.value] || 'Title';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QuickAddMenu
|
||||||
|
className="documents-sort__quickmenu"
|
||||||
|
options={options}
|
||||||
|
onSelectOption={handleSelect}
|
||||||
|
triggerClassName="view-toggle__button documents-sort__trigger quick-add__trigger"
|
||||||
|
triggerContent={(
|
||||||
|
<span className="documents-sort__trigger-content">
|
||||||
|
<span className="documents-sort__label">{label}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
triggerAriaLabel={`Sort by ${label}`}
|
||||||
|
triggerTitle={`Sort by ${label}`}
|
||||||
|
placeholder="Select sort field"
|
||||||
|
menuMinWidth={200}
|
||||||
|
align="start"
|
||||||
|
positionStrategy="absolute"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const createDocumentsTableHeaderActions = ({
|
export const createDocumentsTableHeaderActions = ({
|
||||||
viewMode,
|
viewMode,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onShowDeskHelp = null,
|
onShowDeskHelp = null,
|
||||||
|
sortField = 'title',
|
||||||
|
onSortFieldChange = null,
|
||||||
|
sortDirection = 'asc',
|
||||||
|
onSortDirectionToggle = null,
|
||||||
|
isFilterActive = false,
|
||||||
|
includeDescendants = true,
|
||||||
|
onToggleIncludeDescendants = null,
|
||||||
}) => {
|
}) => {
|
||||||
const isListView = viewMode === 'list';
|
const isListView = viewMode === 'list';
|
||||||
const isGridView = viewMode === 'grid';
|
const isGridView = viewMode === 'grid';
|
||||||
const isDeskView = viewMode === 'desk';
|
const isDeskView = viewMode === 'desk';
|
||||||
|
|
||||||
|
const sortDirectionIsDesc = sortDirection === 'desc';
|
||||||
|
const sortDirectionTitle = sortDirectionIsDesc
|
||||||
|
? 'Sorting Z → A. Click to switch to ascending.'
|
||||||
|
: 'Sorting A → Z. Click to switch to descending.';
|
||||||
|
const includeDescendantsToggle = isFilterActive && typeof onToggleIncludeDescendants === 'function'
|
||||||
|
? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button documents-toolbar__toggle"
|
||||||
|
onClick={onToggleIncludeDescendants}
|
||||||
|
aria-pressed={!includeDescendants}
|
||||||
|
aria-label={includeDescendants ? 'Include subfolders' : 'Limit to current folder'}
|
||||||
|
title={includeDescendants
|
||||||
|
? 'Including subfolders. Click to limit the search to the current folder.'
|
||||||
|
: 'Limiting to the current folder. Click to include subfolders again.'}
|
||||||
|
>
|
||||||
|
{includeDescendants ? <FoldersIcon /> : <FoldersOffIcon />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const sortControls = typeof onSortFieldChange === 'function'
|
||||||
|
? (
|
||||||
|
<div className="documents-actions__sort-group">
|
||||||
|
<SortFieldQuickMenu sortField={sortField} onChange={onSortFieldChange} />
|
||||||
|
{typeof onSortDirectionToggle === 'function' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button documents-toolbar__toggle documents-sort__direction"
|
||||||
|
onClick={onSortDirectionToggle}
|
||||||
|
aria-pressed={sortDirectionIsDesc}
|
||||||
|
aria-label={sortDirectionIsDesc ? 'Sort descending' : 'Sort ascending'}
|
||||||
|
title={sortDirectionTitle}
|
||||||
|
>
|
||||||
|
{sortDirectionIsDesc ? (
|
||||||
|
<SortDescendingLettersIcon size={18} />
|
||||||
|
) : (
|
||||||
|
<SortAscendingLettersIcon size={18} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{isDeskView && typeof onShowDeskHelp === 'function' ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={onShowDeskHelp}
|
||||||
|
aria-label="Show desk view tips"
|
||||||
|
title="Show desk view tips"
|
||||||
|
>
|
||||||
|
<InfoIcon />
|
||||||
|
</button>
|
||||||
|
<span className="main-content__actions-divider" aria-hidden="true">
|
||||||
|
<MinusVerticalIcon />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{includeDescendantsToggle ? (
|
||||||
|
<>
|
||||||
|
{includeDescendantsToggle}
|
||||||
|
<span className="main-content__actions-divider" aria-hidden="true">
|
||||||
|
<MinusVerticalIcon />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{sortControls ? (
|
||||||
|
<>
|
||||||
|
{sortControls}
|
||||||
|
<span className="main-content__actions-divider" aria-hidden="true">
|
||||||
|
<MinusVerticalIcon />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<div className="view-toggle" role="group" aria-label="Change view">
|
<div className="view-toggle" role="group" aria-label="Change view">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -576,17 +936,6 @@ export const createDocumentsTableHeaderActions = ({
|
|||||||
>
|
>
|
||||||
<RefreshIcon />
|
<RefreshIcon />
|
||||||
</button>
|
</button>
|
||||||
{isDeskView && typeof onShowDeskHelp === 'function' ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button"
|
|
||||||
onClick={onShowDeskHelp}
|
|
||||||
aria-label="Show desk view tips"
|
|
||||||
title="Show desk view tips"
|
|
||||||
>
|
|
||||||
<InfoIcon />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -603,9 +952,32 @@ export const createDocumentsSurface = ({
|
|||||||
currentFolderName,
|
currentFolderName,
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
searchResults,
|
searchResults,
|
||||||
|
isFilterActive,
|
||||||
viewMode,
|
viewMode,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
sortField,
|
||||||
|
sortDirection,
|
||||||
|
onSortFieldChange,
|
||||||
|
onSortDirectionToggle,
|
||||||
|
selectedDocumentIds,
|
||||||
|
selectedFolderIds,
|
||||||
|
onDeleteSelection,
|
||||||
|
onClearSelection,
|
||||||
|
tags,
|
||||||
|
correspondents,
|
||||||
|
documentLookup,
|
||||||
|
tagLookupById,
|
||||||
|
onBulkTagAdd,
|
||||||
|
onBulkTagRemove,
|
||||||
|
onBulkCorrespondentAdd,
|
||||||
|
onBulkCorrespondentRemove,
|
||||||
|
onBulkReanalyze,
|
||||||
|
folderOptions,
|
||||||
|
onMoveDocumentsToFolder,
|
||||||
|
searchIncludeDescendants,
|
||||||
|
onToggleSearchIncludeDescendants,
|
||||||
|
onInspectDocument,
|
||||||
} = tableProps;
|
} = tableProps;
|
||||||
|
|
||||||
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
|
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
|
||||||
@@ -613,12 +985,48 @@ export const createDocumentsSurface = ({
|
|||||||
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
|
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|
||||||
|
const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0;
|
||||||
|
const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0;
|
||||||
|
const selectionCount = documentSelectionCount + folderSelectionCount;
|
||||||
|
|
||||||
const actions = createDocumentsTableHeaderActions({
|
const actions = createDocumentsTableHeaderActions({
|
||||||
viewMode,
|
viewMode,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
sortField,
|
||||||
|
onSortFieldChange,
|
||||||
|
sortDirection,
|
||||||
|
onSortDirectionToggle,
|
||||||
|
isFilterActive,
|
||||||
|
includeDescendants: searchIncludeDescendants,
|
||||||
|
onToggleIncludeDescendants: onToggleSearchIncludeDescendants,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const floatingActions = selectionCount > 0
|
||||||
|
? (
|
||||||
|
<SelectionFloatingActions
|
||||||
|
selectionCount={selectionCount}
|
||||||
|
selectedDocumentIds={selectedDocumentIds}
|
||||||
|
selectedFolderIds={selectedFolderIds}
|
||||||
|
documentLookup={documentLookup}
|
||||||
|
tags={tags}
|
||||||
|
tagLookupById={tagLookupById}
|
||||||
|
correspondents={correspondents}
|
||||||
|
onBulkTagAdd={onBulkTagAdd}
|
||||||
|
onBulkTagRemove={onBulkTagRemove}
|
||||||
|
onBulkCorrespondentAdd={onBulkCorrespondentAdd}
|
||||||
|
onBulkCorrespondentRemove={onBulkCorrespondentRemove}
|
||||||
|
onBulkReanalyze={onBulkReanalyze}
|
||||||
|
onDeleteSelection={onDeleteSelection}
|
||||||
|
onClearSelection={onClearSelection}
|
||||||
|
folderOptions={folderOptions}
|
||||||
|
onMoveDocumentsToFolder={onMoveDocumentsToFolder}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
|
||||||
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||||
const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
|
const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
|
||||||
|
|
||||||
@@ -632,7 +1040,15 @@ export const createDocumentsSurface = ({
|
|||||||
onNavigateParent,
|
onNavigateParent,
|
||||||
actions,
|
actions,
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
content: <DocumentsPanel {...tableProps} showHeader={false} />,
|
selectionLabel: null,
|
||||||
|
floatingActions,
|
||||||
|
content: (
|
||||||
|
<DocumentsPanel
|
||||||
|
{...tableProps}
|
||||||
|
showHeader={false}
|
||||||
|
onInspectDocument={onInspectDocument}
|
||||||
|
/>
|
||||||
|
),
|
||||||
detail,
|
detail,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import useFloatingMenu from '../ui/useFloatingMenu';
|
||||||
|
import {
|
||||||
|
PlusIcon,
|
||||||
|
CheckIcon,
|
||||||
|
CircleDashedCheckIcon,
|
||||||
|
} from '../ui/icons';
|
||||||
|
|
||||||
|
const STATE_ORDER = {
|
||||||
|
all: 0,
|
||||||
|
partial: 1,
|
||||||
|
none: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeItems = (items) =>
|
||||||
|
(Array.isArray(items) ? items : [])
|
||||||
|
.filter((item) => item && typeof item.label === 'string' && item.label.trim().length > 0)
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id ?? item.label,
|
||||||
|
label: item.label.trim(),
|
||||||
|
state: item.state === 'all' ? 'all' : item.state === 'partial' ? 'partial' : 'none',
|
||||||
|
count: typeof item.count === 'number' ? item.count : null,
|
||||||
|
total: typeof item.total === 'number' ? item.total : null,
|
||||||
|
payload: item.payload ?? item,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const SelectionAssignmentMenu = ({
|
||||||
|
label,
|
||||||
|
items = [],
|
||||||
|
placeholder = 'Search…',
|
||||||
|
emptyMessage = 'No entries',
|
||||||
|
createLabel = null,
|
||||||
|
onToggle,
|
||||||
|
onCreate,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
triggerContent = null,
|
||||||
|
showStateIndicators = true,
|
||||||
|
showCounts = true,
|
||||||
|
onOpenMenu = null,
|
||||||
|
renderItemLabel = null,
|
||||||
|
}) => {
|
||||||
|
const anchorRef = useRef(null);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isOpen,
|
||||||
|
toggle,
|
||||||
|
close,
|
||||||
|
menuRef,
|
||||||
|
menuStyle,
|
||||||
|
updatePosition,
|
||||||
|
} = useFloatingMenu({
|
||||||
|
anchorRef,
|
||||||
|
align: 'center',
|
||||||
|
positionStrategy: 'absolute',
|
||||||
|
minWidth: 220,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (disabled && isOpen) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}, [disabled, isOpen, close]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
setQuery('');
|
||||||
|
setPending(false);
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
updatePosition();
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
inputRef.current.select?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [isOpen, updatePosition]);
|
||||||
|
|
||||||
|
const normalizedItems = useMemo(() => normalizeItems(items), [items]);
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
const search = query.trim().toLowerCase();
|
||||||
|
const sorted = normalizedItems.slice().sort((a, b) => {
|
||||||
|
const stateDiff = STATE_ORDER[a.state] - STATE_ORDER[b.state];
|
||||||
|
if (stateDiff !== 0) {
|
||||||
|
return stateDiff;
|
||||||
|
}
|
||||||
|
return a.label.localeCompare(b.label);
|
||||||
|
});
|
||||||
|
if (!search) {
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
return sorted.filter((item) => item.label.toLowerCase().includes(search));
|
||||||
|
}, [normalizedItems, query]);
|
||||||
|
|
||||||
|
const handleToggle = useCallback(
|
||||||
|
async (item) => {
|
||||||
|
if (!item || typeof onToggle !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(true);
|
||||||
|
try {
|
||||||
|
await onToggle(item);
|
||||||
|
setPending(false);
|
||||||
|
close();
|
||||||
|
} catch (error) {
|
||||||
|
setPending(false);
|
||||||
|
console.error('[selection-assignment] toggle failed', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onToggle, close],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreate = useCallback(
|
||||||
|
async () => {
|
||||||
|
if (typeof onCreate !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = query.trim();
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(true);
|
||||||
|
try {
|
||||||
|
await onCreate(value);
|
||||||
|
setPending(false);
|
||||||
|
close();
|
||||||
|
} catch (error) {
|
||||||
|
setPending(false);
|
||||||
|
console.error('[selection-assignment] creation failed', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onCreate, query, close],
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingLabels = useMemo(
|
||||||
|
() => new Set(normalizedItems.map((item) => item.label.toLowerCase())),
|
||||||
|
[normalizedItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
const canCreate = Boolean(onCreate);
|
||||||
|
const showCreateOption = canCreate
|
||||||
|
&& query.trim().length > 0
|
||||||
|
&& !existingLabels.has(query.trim().toLowerCase());
|
||||||
|
|
||||||
|
const handleTriggerClick = useCallback(() => {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isOpen) {
|
||||||
|
onOpenMenu?.();
|
||||||
|
}
|
||||||
|
toggle();
|
||||||
|
}, [disabled, isOpen, onOpenMenu, toggle]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className ? `selection-assignment ${className}` : 'selection-assignment'}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={anchorRef}
|
||||||
|
className="quick-add__chip quick-add__trigger panel-floating-actions__trigger"
|
||||||
|
onClick={handleTriggerClick}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{triggerContent ? triggerContent : (
|
||||||
|
<span className="quick-add__chip-label">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{isOpen ? (
|
||||||
|
<div
|
||||||
|
className="menu menu--floating selection-assignment__menu"
|
||||||
|
ref={menuRef}
|
||||||
|
style={menuStyle || undefined}
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<div className="selection-assignment__header">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
aria-label={placeholder}
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="selection-assignment__list" role="presentation">
|
||||||
|
{filteredItems.length ? (
|
||||||
|
filteredItems.map((item) => {
|
||||||
|
const isAll = item.state === 'all';
|
||||||
|
const isPartial = item.state === 'partial';
|
||||||
|
const icon = showStateIndicators
|
||||||
|
? isAll
|
||||||
|
? <CheckIcon className="selection-assignment__icon" aria-hidden="true" />
|
||||||
|
: isPartial
|
||||||
|
? <CircleDashedCheckIcon className="selection-assignment__icon" aria-hidden="true" />
|
||||||
|
: <span className="selection-assignment__icon selection-assignment__icon--empty" aria-hidden="true" />
|
||||||
|
: null;
|
||||||
|
const countLabel = showCounts && item.total && (isPartial || isAll)
|
||||||
|
? `${item.count ?? 0}/${item.total}`
|
||||||
|
: null;
|
||||||
|
const labelContent = renderItemLabel ? renderItemLabel(item) : item.label;
|
||||||
|
const labelClassName = [
|
||||||
|
'selection-assignment__label',
|
||||||
|
(!showStateIndicators || !icon) ? 'selection-assignment__label--nowrap' : null,
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
className={`menu__item selection-assignment__item selection-assignment__item--${item.state}`}
|
||||||
|
onClick={() => handleToggle(item)}
|
||||||
|
disabled={pending}
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span className={labelClassName}>
|
||||||
|
{labelContent}
|
||||||
|
</span>
|
||||||
|
{countLabel ? (
|
||||||
|
<span className="selection-assignment__count">{countLabel}</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="menu__empty selection-assignment__empty">{emptyMessage}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showCreateOption ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="menu__item selection-assignment__create"
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
<PlusIcon className="selection-assignment__icon" aria-hidden="true" />
|
||||||
|
<span className="selection-assignment__label">
|
||||||
|
{createLabel ? `${createLabel} “${query.trim()}”` : `Create “${query.trim()}”`}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SelectionAssignmentMenu;
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
TrashIcon,
|
||||||
|
AnalyzeIcon,
|
||||||
|
IconX,
|
||||||
|
FolderOutlineIcon,
|
||||||
|
TagIcon,
|
||||||
|
CorrespondentIcon,
|
||||||
|
LoaderIcon,
|
||||||
|
} from '../ui/icons';
|
||||||
|
import SelectionAssignmentMenu from './SelectionAssignmentMenu';
|
||||||
|
import SelectionSummary from './SelectionSummary';
|
||||||
|
import { api, useAppState } from '../app/appState';
|
||||||
|
|
||||||
|
const normalizeDocumentList = (selectedDocumentIds) =>
|
||||||
|
Array.isArray(selectedDocumentIds) ? selectedDocumentIds.filter(Boolean) : [];
|
||||||
|
|
||||||
|
const ROOT_FOLDER_LABEL = 'Documents';
|
||||||
|
|
||||||
|
const buildFolderTreeOptions = (tree) => {
|
||||||
|
const entries = [];
|
||||||
|
|
||||||
|
const traverse = (nodes, parentSegments) => {
|
||||||
|
if (!Array.isArray(nodes) || nodes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (!node || !node.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = typeof node.name === 'string' && node.name.trim().length
|
||||||
|
? node.name.trim()
|
||||||
|
: 'Folder';
|
||||||
|
const nextSegments = parentSegments.concat([name]);
|
||||||
|
const label = nextSegments.join('/');
|
||||||
|
entries.push({ id: node.id, label });
|
||||||
|
if (Array.isArray(node.children) && node.children.length) {
|
||||||
|
traverse(node.children, nextSegments);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
traverse(Array.isArray(tree) ? tree : [], [ROOT_FOLDER_LABEL]);
|
||||||
|
|
||||||
|
entries.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }));
|
||||||
|
|
||||||
|
return [{ id: 'root', label: ROOT_FOLDER_LABEL }, ...entries];
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTagAssignments = (selectedDocuments, tagLookupById, tags, total) => {
|
||||||
|
if (!total) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = new Map();
|
||||||
|
|
||||||
|
const ensureEntry = (id, label, color = null) => {
|
||||||
|
const key = id ?? label;
|
||||||
|
if (!key || !label) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!map.has(key)) {
|
||||||
|
map.set(key, {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
color,
|
||||||
|
count: 0,
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return map.get(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
selectedDocuments.forEach((doc) => {
|
||||||
|
(doc?.tags || []).forEach((tag) => {
|
||||||
|
const lookupColor = tag?.id && tagLookupById instanceof Map ? tagLookupById.get(tag.id)?.color : null;
|
||||||
|
const entry = ensureEntry(tag?.id, tag?.label, tag?.color ?? lookupColor ?? null);
|
||||||
|
if (entry) {
|
||||||
|
entry.count += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
(tags || []).forEach((tag) => {
|
||||||
|
const lookupColor = tag?.id && tagLookupById instanceof Map ? tagLookupById.get(tag.id)?.color : null;
|
||||||
|
ensureEntry(tag?.id, tag?.label, tag?.color ?? lookupColor ?? null);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(map.values()).map((entry) => {
|
||||||
|
const count = entry.count || 0;
|
||||||
|
const state = count === total ? 'all' : count > 0 ? 'partial' : 'none';
|
||||||
|
return {
|
||||||
|
id: entry.id ?? entry.label,
|
||||||
|
label: entry.label,
|
||||||
|
color: entry.color ?? null,
|
||||||
|
count,
|
||||||
|
total,
|
||||||
|
state,
|
||||||
|
payload: entry,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCorrespondentAssignments = (selectedDocuments, correspondents, total) => {
|
||||||
|
if (!total) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = new Map();
|
||||||
|
|
||||||
|
const ensureEntry = (id, name) => {
|
||||||
|
const key = id ?? name;
|
||||||
|
if (!key || !name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!map.has(key)) {
|
||||||
|
map.set(key, {
|
||||||
|
id,
|
||||||
|
label: name,
|
||||||
|
count: 0,
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return map.get(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
selectedDocuments.forEach((doc) => {
|
||||||
|
(doc?.correspondents || []).forEach((entry) => {
|
||||||
|
const target = ensureEntry(entry?.id, entry?.name);
|
||||||
|
if (target) {
|
||||||
|
target.count += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
(correspondents || []).forEach((entry) => {
|
||||||
|
ensureEntry(entry?.id, entry?.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(map.values()).map((entry) => {
|
||||||
|
const count = entry.count || 0;
|
||||||
|
const state = count === total ? 'all' : count > 0 ? 'partial' : 'none';
|
||||||
|
return {
|
||||||
|
id: entry.id ?? entry.label,
|
||||||
|
label: entry.label,
|
||||||
|
count,
|
||||||
|
total,
|
||||||
|
state,
|
||||||
|
payload: entry,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const SelectionFloatingActions = ({
|
||||||
|
selectionCount = 0,
|
||||||
|
selectedDocumentIds,
|
||||||
|
selectedFolderIds = [],
|
||||||
|
documentLookup,
|
||||||
|
tags,
|
||||||
|
tagLookupById,
|
||||||
|
correspondents,
|
||||||
|
folderOptions = [],
|
||||||
|
onBulkTagAdd,
|
||||||
|
onBulkTagRemove,
|
||||||
|
onBulkCorrespondentAdd,
|
||||||
|
onBulkCorrespondentRemove,
|
||||||
|
onBulkReanalyze,
|
||||||
|
onDeleteSelection,
|
||||||
|
onClearSelection = null,
|
||||||
|
onMoveDocumentsToFolder,
|
||||||
|
}) => {
|
||||||
|
const { token, tenant } = useAppState();
|
||||||
|
const tenantId = tenant?.id ?? null;
|
||||||
|
|
||||||
|
const [remoteFolderOptions, setRemoteFolderOptions] = useState(null);
|
||||||
|
const [loadingFolders, setLoadingFolders] = useState(false);
|
||||||
|
const folderTreeFetchRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRemoteFolderOptions(null);
|
||||||
|
folderTreeFetchRef.current = null;
|
||||||
|
setLoadingFolders(false);
|
||||||
|
}, [tenantId, token]);
|
||||||
|
|
||||||
|
const requestFolderTree = useCallback(async () => {
|
||||||
|
if (!token) {
|
||||||
|
setRemoteFolderOptions([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(remoteFolderOptions)) {
|
||||||
|
return remoteFolderOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folderTreeFetchRef.current) {
|
||||||
|
return folderTreeFetchRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchPromise = (async () => {
|
||||||
|
setLoadingFolders(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/folders/tree');
|
||||||
|
const options = buildFolderTreeOptions(data);
|
||||||
|
setRemoteFolderOptions(options);
|
||||||
|
return options;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[selection] Failed to load folder tree', error);
|
||||||
|
setRemoteFolderOptions([]);
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
setLoadingFolders(false);
|
||||||
|
folderTreeFetchRef.current = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
folderTreeFetchRef.current = fetchPromise;
|
||||||
|
return fetchPromise;
|
||||||
|
}, [remoteFolderOptions, token]);
|
||||||
|
|
||||||
|
const handleMoveMenuOpen = useCallback(() => {
|
||||||
|
requestFolderTree();
|
||||||
|
}, [requestFolderTree]);
|
||||||
|
|
||||||
|
const effectiveFolderOptions = useMemo(() => {
|
||||||
|
if (remoteFolderOptions !== null) {
|
||||||
|
return remoteFolderOptions;
|
||||||
|
}
|
||||||
|
return Array.isArray(folderOptions) ? folderOptions : [];
|
||||||
|
}, [remoteFolderOptions, folderOptions]);
|
||||||
|
|
||||||
|
const documentIdList = useMemo(
|
||||||
|
() => normalizeDocumentList(selectedDocumentIds),
|
||||||
|
[selectedDocumentIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const folderIdList = useMemo(
|
||||||
|
() => normalizeDocumentList(selectedFolderIds),
|
||||||
|
[selectedFolderIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const documentCount = documentIdList.length;
|
||||||
|
const folderCount = folderIdList.length;
|
||||||
|
const totalCount = typeof selectionCount === 'number'
|
||||||
|
? selectionCount
|
||||||
|
: documentCount + folderCount;
|
||||||
|
|
||||||
|
const selectedDocuments = useMemo(() => {
|
||||||
|
if (!documentIdList.length || !(documentLookup instanceof Map)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return documentIdList
|
||||||
|
.map((id) => documentLookup.get(id))
|
||||||
|
.filter(Boolean);
|
||||||
|
}, [documentIdList, documentLookup]);
|
||||||
|
|
||||||
|
const selectedDocCount = selectedDocuments.length;
|
||||||
|
|
||||||
|
const moveAssignments = useMemo(() => {
|
||||||
|
if (!Array.isArray(effectiveFolderOptions)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return effectiveFolderOptions
|
||||||
|
.map((option) => {
|
||||||
|
const id = option?.id ?? option?.value ?? option;
|
||||||
|
if (!id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const label = option?.label || option?.name || String(id);
|
||||||
|
const segments = label.split('/');
|
||||||
|
const depth = Math.max(segments.length - 1, 0);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
state: 'none',
|
||||||
|
count: null,
|
||||||
|
total: null,
|
||||||
|
payload: {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
segments,
|
||||||
|
depth,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}, [effectiveFolderOptions]);
|
||||||
|
|
||||||
|
const tagAssignments = useMemo(
|
||||||
|
() => buildTagAssignments(selectedDocuments, tagLookupById, tags, selectedDocCount),
|
||||||
|
[selectedDocuments, tagLookupById, tags, selectedDocCount],
|
||||||
|
);
|
||||||
|
|
||||||
|
const correspondentAssignments = useMemo(
|
||||||
|
() => buildCorrespondentAssignments(selectedDocuments, correspondents, selectedDocCount),
|
||||||
|
[selectedDocuments, correspondents, selectedDocCount],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderFolderLabel = useCallback((item) => {
|
||||||
|
const segments = item?.payload?.segments || (item?.label ? item.label.split('/') : []);
|
||||||
|
const depth = item?.payload?.depth ?? Math.max(segments.length - 1, 0);
|
||||||
|
const clampedDepth = Math.min(depth, 6);
|
||||||
|
const indentWidth = clampedDepth > 0 ? clampedDepth * 0.9 : 0;
|
||||||
|
const name = segments.length ? segments[segments.length - 1] : item?.label || 'Folder';
|
||||||
|
const parentPath = segments.length > 1 ? segments.slice(0, -1).join(' / ') : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{indentWidth ? (
|
||||||
|
<span
|
||||||
|
className="selection-assignment__indent"
|
||||||
|
style={{ width: `${indentWidth}rem` }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span className="selection-assignment__folder-label">
|
||||||
|
<span className="selection-assignment__folder-name">{name}</span>
|
||||||
|
{parentPath ? (
|
||||||
|
<span className="selection-assignment__folder-path">{parentPath}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleTagAssignment = useCallback(
|
||||||
|
async (item) => {
|
||||||
|
if (!selectedDocCount || !item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.state === 'all') {
|
||||||
|
await onBulkTagRemove?.({ label: item.label, input: null, documentIds: documentIdList });
|
||||||
|
} else {
|
||||||
|
await onBulkTagAdd?.({ label: item.label, input: null, documentIds: documentIdList });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedDocCount, onBulkTagAdd, onBulkTagRemove, documentIdList],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateTagAssignment = useCallback(
|
||||||
|
async (label) => {
|
||||||
|
if (!selectedDocCount || !label) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onBulkTagAdd?.({ label, input: null, documentIds: documentIdList });
|
||||||
|
},
|
||||||
|
[selectedDocCount, onBulkTagAdd, documentIdList],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleCorrespondentAssignment = useCallback(
|
||||||
|
async (item) => {
|
||||||
|
if (!selectedDocCount || !item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.state === 'all') {
|
||||||
|
if (!item.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onBulkCorrespondentRemove?.({
|
||||||
|
assignments: [{ correspondent_id: item.id }],
|
||||||
|
documentIds: documentIdList,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await onBulkCorrespondentAdd?.({ name: item.label, input: null, documentIds: documentIdList });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedDocCount, onBulkCorrespondentAdd, onBulkCorrespondentRemove, documentIdList],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateCorrespondentAssignment = useCallback(
|
||||||
|
async (name) => {
|
||||||
|
if (!selectedDocCount || !name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onBulkCorrespondentAdd?.({ name, input: null, documentIds: documentIdList });
|
||||||
|
},
|
||||||
|
[selectedDocCount, onBulkCorrespondentAdd, documentIdList],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMoveSelectionToFolder = useCallback(
|
||||||
|
async (option) => {
|
||||||
|
if (!documentIdList.length || typeof onMoveDocumentsToFolder !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = option?.id ?? option?.value ?? option;
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onMoveDocumentsToFolder(documentIdList, value);
|
||||||
|
},
|
||||||
|
[documentIdList, onMoveDocumentsToFolder],
|
||||||
|
);
|
||||||
|
|
||||||
|
const summaryNode = totalCount > 0 ? (
|
||||||
|
<SelectionSummary
|
||||||
|
documentCount={documentCount}
|
||||||
|
folderCount={folderCount}
|
||||||
|
totalCount={totalCount}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{summaryNode ? (
|
||||||
|
<span className="panel-floating__label">{summaryNode}</span>
|
||||||
|
) : null}
|
||||||
|
<div className="panel-floating-actions">
|
||||||
|
{typeof onMoveDocumentsToFolder === 'function' ? (
|
||||||
|
<SelectionAssignmentMenu
|
||||||
|
label="Move"
|
||||||
|
triggerContent={(
|
||||||
|
<span className="quick-add__chip-label">
|
||||||
|
{loadingFolders ? (
|
||||||
|
<LoaderIcon className="icon-inline icon--spin" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<FolderOutlineIcon className="icon-inline" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{' '}
|
||||||
|
Move
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
items={moveAssignments}
|
||||||
|
placeholder="Search folders…"
|
||||||
|
emptyMessage={loadingFolders ? 'Loading folders…' : 'No folders'}
|
||||||
|
onToggle={(item) => handleMoveSelectionToFolder(item?.payload || item)}
|
||||||
|
disabled={!documentCount || (loadingFolders && !moveAssignments.length)}
|
||||||
|
createLabel={null}
|
||||||
|
showStateIndicators={false}
|
||||||
|
showCounts={false}
|
||||||
|
onOpenMenu={handleMoveMenuOpen}
|
||||||
|
renderItemLabel={renderFolderLabel}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<SelectionAssignmentMenu
|
||||||
|
label="Tags"
|
||||||
|
triggerContent={(
|
||||||
|
<span className="quick-add__chip-label">
|
||||||
|
<TagIcon className="icon-inline" aria-hidden="true" /> Tags
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
items={tagAssignments}
|
||||||
|
placeholder="Search tags…"
|
||||||
|
emptyMessage="No tags"
|
||||||
|
createLabel="Create"
|
||||||
|
onToggle={handleToggleTagAssignment}
|
||||||
|
onCreate={handleCreateTagAssignment}
|
||||||
|
disabled={!documentCount}
|
||||||
|
/>
|
||||||
|
<SelectionAssignmentMenu
|
||||||
|
label="Correspondents"
|
||||||
|
triggerContent={(
|
||||||
|
<span className="quick-add__chip-label">
|
||||||
|
<CorrespondentIcon className="icon-inline" aria-hidden="true" /> Correspondents
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
items={correspondentAssignments}
|
||||||
|
placeholder="Search correspondents…"
|
||||||
|
emptyMessage="No correspondents"
|
||||||
|
createLabel="Create"
|
||||||
|
onToggle={handleToggleCorrespondentAssignment}
|
||||||
|
onCreate={handleCreateCorrespondentAssignment}
|
||||||
|
disabled={!documentCount}
|
||||||
|
/>
|
||||||
|
{typeof onBulkReanalyze === 'function' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button panel-floating-actions__button"
|
||||||
|
onClick={() => onBulkReanalyze(documentIdList)}
|
||||||
|
aria-label="Re-run analysis for selection"
|
||||||
|
title="Re-run analysis for selection"
|
||||||
|
disabled={documentIdList.length === 0}
|
||||||
|
>
|
||||||
|
<AnalyzeIcon className="icon-inline" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{typeof onDeleteSelection === 'function' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button danger panel-floating-actions__button"
|
||||||
|
onClick={onDeleteSelection}
|
||||||
|
aria-label="Delete selected items"
|
||||||
|
disabled={totalCount === 0}
|
||||||
|
>
|
||||||
|
<TrashIcon className="icon-inline" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{typeof onClearSelection === 'function' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button panel-floating-actions__button"
|
||||||
|
onClick={onClearSelection}
|
||||||
|
aria-label="Clear selection"
|
||||||
|
title="Clear selection"
|
||||||
|
disabled={totalCount === 0}
|
||||||
|
>
|
||||||
|
<IconX className="icon-inline" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SelectionFloatingActions;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FileIcon, FolderOutlineIcon } from '../ui/icons';
|
||||||
|
|
||||||
|
const SelectionSummary = ({ documentCount = 0, folderCount = 0, totalCount = 0 }) => {
|
||||||
|
const docCount = Number(documentCount) || 0;
|
||||||
|
const folderCountNumber = Number(folderCount) || 0;
|
||||||
|
const aggregateCount = docCount + folderCountNumber;
|
||||||
|
const resolvedTotal = Number(totalCount) || aggregateCount;
|
||||||
|
|
||||||
|
if (!docCount && !folderCountNumber && !resolvedTotal) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = [];
|
||||||
|
|
||||||
|
if (docCount) {
|
||||||
|
tokens.push({
|
||||||
|
key: 'documents',
|
||||||
|
count: docCount,
|
||||||
|
icon: <FileIcon className="selection-summary__icon" size={16} />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folderCountNumber) {
|
||||||
|
tokens.push({
|
||||||
|
key: 'folders',
|
||||||
|
count: folderCountNumber,
|
||||||
|
icon: <FolderOutlineIcon className="selection-summary__icon" size={16} />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tokens.length) {
|
||||||
|
const count = resolvedTotal;
|
||||||
|
return (
|
||||||
|
<span className="selection-summary selection-summary--text">
|
||||||
|
{`${count} item${count === 1 ? '' : 's'}`}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="selection-summary">
|
||||||
|
{tokens.map((token, index) => (
|
||||||
|
<React.Fragment key={token.key}>
|
||||||
|
{index > 0 ? <span className="selection-summary__separator">·</span> : null}
|
||||||
|
<span className="selection-summary__token">
|
||||||
|
<span className="selection-summary__count">{token.count}</span>
|
||||||
|
{token.icon}
|
||||||
|
</span>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SelectionSummary;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
const formatDateTime = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildDocumentMetadataItems = (document) => {
|
||||||
|
if (!document) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = document.current_version || {};
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: 'Created at', value: formatDateTime(document.created_at) },
|
||||||
|
{ label: 'Updated at', value: formatDateTime(document.updated_at) },
|
||||||
|
{
|
||||||
|
label: 'Filename',
|
||||||
|
value: document.filename,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Original filename',
|
||||||
|
value: document.original_name || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'SHA-256 checksum',
|
||||||
|
value: metadata.checksum || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Content type',
|
||||||
|
value: document.content_type || '—',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractDocumentMetadataPayload = (document) => {
|
||||||
|
if (!document || !document.metadata) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const keys = Object.keys(document.metadata);
|
||||||
|
if (!keys.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return document.metadata;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default buildDocumentMetadataItems;
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
const useBulkDocumentActions = ({
|
||||||
|
api,
|
||||||
|
resolveTargetDocumentIds,
|
||||||
|
correspondentLookupByName,
|
||||||
|
handleCorrespondentCreate,
|
||||||
|
refreshCurrentFolder,
|
||||||
|
setStatusMessage,
|
||||||
|
selectedDocumentIds,
|
||||||
|
selectedFolderIds,
|
||||||
|
handleDocumentsDelete,
|
||||||
|
handleFolderDelete,
|
||||||
|
clearDocumentSelection,
|
||||||
|
setLoading,
|
||||||
|
}) => {
|
||||||
|
const handleBulkCorrespondentAdd = useCallback(
|
||||||
|
async ({ name, input, documentIds }) => {
|
||||||
|
const trimmed = typeof name === 'string' ? name.trim() : '';
|
||||||
|
if (!trimmed) {
|
||||||
|
setStatusMessage('Correspondent name is required.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targets = resolveTargetDocumentIds(documentIds);
|
||||||
|
if (!targets.length) {
|
||||||
|
setStatusMessage('Select documents before assigning correspondents.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = correspondentLookupByName.get(trimmed.toLowerCase()) || null;
|
||||||
|
if (!target) {
|
||||||
|
try {
|
||||||
|
target = await handleCorrespondentCreate({ name: trimmed });
|
||||||
|
} catch (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target?.id) {
|
||||||
|
setStatusMessage('Unable to resolve correspondent.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await api.post('/documents/bulk/correspondents', {
|
||||||
|
document_ids: targets,
|
||||||
|
assignments: [
|
||||||
|
{
|
||||||
|
correspondent_id: target.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
action: 'add',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { assigned = 0, removed = 0 } = response.data || {};
|
||||||
|
|
||||||
|
await refreshCurrentFolder();
|
||||||
|
const assignedSuffix = assigned === 1 ? '' : 's';
|
||||||
|
if (removed > 0) {
|
||||||
|
const removedSuffix = removed === 1 ? '' : 's';
|
||||||
|
setStatusMessage(
|
||||||
|
`Correspondent assigned (${assigned}) and replaced ${removed} link${removedSuffix}.`,
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setStatusMessage(
|
||||||
|
`Correspondent assigned to ${assigned} document${assignedSuffix}.`,
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input) {
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
api,
|
||||||
|
correspondentLookupByName,
|
||||||
|
handleCorrespondentCreate,
|
||||||
|
refreshCurrentFolder,
|
||||||
|
resolveTargetDocumentIds,
|
||||||
|
setStatusMessage,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBulkCorrespondentRemove = useCallback(
|
||||||
|
async ({ assignments = [], documentIds }) => {
|
||||||
|
if (!assignments.length) {
|
||||||
|
setStatusMessage('Select a correspondent to remove.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = resolveTargetDocumentIds(documentIds);
|
||||||
|
|
||||||
|
if (!targets.length) {
|
||||||
|
setStatusMessage('Select documents before removing correspondents.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedAssignments = assignments.map((entry) => ({
|
||||||
|
correspondent_id: entry.correspondent_id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await api.post('/documents/bulk/correspondents', {
|
||||||
|
document_ids: targets,
|
||||||
|
assignments: normalizedAssignments,
|
||||||
|
action: 'remove',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { assigned = 0, removed = 0 } = response.data || {};
|
||||||
|
await refreshCurrentFolder();
|
||||||
|
|
||||||
|
if (removed > 0) {
|
||||||
|
const removedSuffix = removed === 1 ? '' : 's';
|
||||||
|
setStatusMessage(
|
||||||
|
`Correspondent removed from ${removed} link${removedSuffix}.`,
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
} else if (assigned > 0) {
|
||||||
|
const assignedSuffix = assigned === 1 ? '' : 's';
|
||||||
|
setStatusMessage(`Correspondent updated ${assigned} link${assignedSuffix}.`, 'info');
|
||||||
|
} else {
|
||||||
|
setStatusMessage('No correspondents changed.', 'info');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, refreshCurrentFolder, resolveTargetDocumentIds, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteSelection = useCallback(async () => {
|
||||||
|
const docIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds : [];
|
||||||
|
const folderIds = Array.isArray(selectedFolderIds) ? selectedFolderIds : [];
|
||||||
|
|
||||||
|
if (docIds.length === 0 && folderIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (docIds.length) {
|
||||||
|
parts.push(`${docIds.length} document${docIds.length === 1 ? '' : 's'}`);
|
||||||
|
}
|
||||||
|
if (folderIds.length) {
|
||||||
|
parts.push(`${folderIds.length} folder${folderIds.length === 1 ? '' : 's'}`);
|
||||||
|
}
|
||||||
|
const descriptor = parts.join(' and ');
|
||||||
|
const confirmation = parts.length === 1
|
||||||
|
? `Delete ${descriptor}? Folders must be empty before deletion. You can restore documents later from trash.`
|
||||||
|
: `Delete ${descriptor}? Folders must be empty before deletion. You can restore documents later from trash.`;
|
||||||
|
|
||||||
|
if (!window.confirm(confirmation)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
let docsOk = true;
|
||||||
|
let foldersOk = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (docIds.length) {
|
||||||
|
docsOk = await handleDocumentsDelete(docIds, { showMessage: false, manageLoading: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folderIds.length) {
|
||||||
|
for (const folderId of folderIds) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const success = await handleFolderDelete(folderId, { showMessage: false, manageLoading: false });
|
||||||
|
if (!success) {
|
||||||
|
foldersOk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!docsOk || !foldersOk) {
|
||||||
|
setStatusMessage('Some items could not be deleted. Ensure folders are empty before deletion.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearDocumentSelection();
|
||||||
|
|
||||||
|
const successParts = [];
|
||||||
|
if (docIds.length) {
|
||||||
|
successParts.push(docIds.length === 1 ? 'Document deleted.' : 'Documents deleted.');
|
||||||
|
}
|
||||||
|
if (folderIds.length) {
|
||||||
|
successParts.push(folderIds.length === 1 ? 'Folder deleted.' : 'Folders deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatusMessage(successParts.join(' '), 'success');
|
||||||
|
}, [
|
||||||
|
clearDocumentSelection,
|
||||||
|
handleDocumentsDelete,
|
||||||
|
handleFolderDelete,
|
||||||
|
selectedDocumentIds,
|
||||||
|
selectedFolderIds,
|
||||||
|
setLoading,
|
||||||
|
setStatusMessage,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleBulkCorrespondentAdd,
|
||||||
|
handleBulkCorrespondentRemove,
|
||||||
|
handleDeleteSelection,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useBulkDocumentActions;
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
const useDocumentsPanelProps = ({
|
||||||
|
currentFolderName,
|
||||||
|
breadcrumbs,
|
||||||
|
refreshCurrentFolder,
|
||||||
|
currentSubfolders,
|
||||||
|
documents,
|
||||||
|
searchResults,
|
||||||
|
isFilterActive,
|
||||||
|
folderClickHandlers,
|
||||||
|
selectFolder,
|
||||||
|
handleFolderDragStart,
|
||||||
|
handleFolderDragEnd,
|
||||||
|
draggedFolderId,
|
||||||
|
handleFolderRename,
|
||||||
|
openDocumentPreview,
|
||||||
|
handleDocumentTitleUpdate,
|
||||||
|
selectedDocumentIds,
|
||||||
|
selectedFolderIds,
|
||||||
|
focusedRowKey,
|
||||||
|
draggedDocumentIds,
|
||||||
|
handleDocumentDragStart,
|
||||||
|
handleDocumentDragEnd,
|
||||||
|
searchLoading,
|
||||||
|
tagLookupById,
|
||||||
|
activeCorrespondentFilters,
|
||||||
|
selectedEntries,
|
||||||
|
setFocusedRowKey,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
toggleTagFilter,
|
||||||
|
toggleCorrespondentFilter,
|
||||||
|
handleDocumentTagDrop,
|
||||||
|
documentsViewMode,
|
||||||
|
documentsSortField,
|
||||||
|
documentsSortDirection,
|
||||||
|
handleDocumentsSortFieldChange,
|
||||||
|
handleDocumentsSortDirectionToggle,
|
||||||
|
searchIncludeDescendants,
|
||||||
|
toggleSearchIncludeDescendants,
|
||||||
|
handleDocumentsViewModeChange,
|
||||||
|
clearDocumentSelection,
|
||||||
|
handleDeleteSelection,
|
||||||
|
handleEntryPointerCore,
|
||||||
|
inspectDocument,
|
||||||
|
handleEntrySelection,
|
||||||
|
tags,
|
||||||
|
correspondents,
|
||||||
|
documentLookup,
|
||||||
|
handleBulkTagAddFromDetail,
|
||||||
|
handleBulkTagRemoveFromDetail,
|
||||||
|
handleBulkCorrespondentAdd,
|
||||||
|
handleBulkCorrespondentRemove,
|
||||||
|
handleBulkSelectionReanalyze,
|
||||||
|
folderOptions,
|
||||||
|
moveDocumentsToFolder,
|
||||||
|
}) =>
|
||||||
|
useMemo(
|
||||||
|
() => ({
|
||||||
|
currentFolderName,
|
||||||
|
breadcrumbs,
|
||||||
|
onRefresh: refreshCurrentFolder,
|
||||||
|
subfolders: currentSubfolders,
|
||||||
|
documents,
|
||||||
|
searchResults,
|
||||||
|
isFilterActive,
|
||||||
|
onFolderSelect: selectFolder,
|
||||||
|
onFolderDrop: folderClickHandlers.onDrop,
|
||||||
|
onFolderDragOver: folderClickHandlers.onDragOver,
|
||||||
|
onFolderDragLeave: folderClickHandlers.onDragLeave,
|
||||||
|
onFolderDragStart: handleFolderDragStart,
|
||||||
|
onFolderDragEnd: handleFolderDragEnd,
|
||||||
|
draggedFolderId,
|
||||||
|
onFolderRename: handleFolderRename,
|
||||||
|
onDocumentOpen: openDocumentPreview,
|
||||||
|
onDocumentRename: handleDocumentTitleUpdate,
|
||||||
|
selectedDocumentIds,
|
||||||
|
selectedFolderIds,
|
||||||
|
focusedRowKey,
|
||||||
|
draggingDocumentIds: draggedDocumentIds,
|
||||||
|
onDocumentDragStart: handleDocumentDragStart,
|
||||||
|
onDocumentDragEnd: handleDocumentDragEnd,
|
||||||
|
isSearchLoading: searchLoading,
|
||||||
|
tagLookupById,
|
||||||
|
activeCorrespondentIds: activeCorrespondentFilters,
|
||||||
|
selectedEntries,
|
||||||
|
onFocusedRowChange: setFocusedRowKey,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
onTagClick: toggleTagFilter,
|
||||||
|
onCorrespondentClick: toggleCorrespondentFilter,
|
||||||
|
onDocumentTagDrop: handleDocumentTagDrop,
|
||||||
|
viewMode: documentsViewMode,
|
||||||
|
sortField: documentsSortField,
|
||||||
|
sortDirection: documentsSortDirection,
|
||||||
|
onSortFieldChange: handleDocumentsSortFieldChange,
|
||||||
|
onSortDirectionToggle: handleDocumentsSortDirectionToggle,
|
||||||
|
searchIncludeDescendants,
|
||||||
|
onToggleSearchIncludeDescendants: toggleSearchIncludeDescendants,
|
||||||
|
onViewModeChange: handleDocumentsViewModeChange,
|
||||||
|
onClearSelection: clearDocumentSelection,
|
||||||
|
onDeleteSelection: handleDeleteSelection,
|
||||||
|
onEntryPointer: handleEntryPointerCore,
|
||||||
|
onInspectDocument: inspectDocument,
|
||||||
|
onEntrySelection: handleEntrySelection,
|
||||||
|
tags,
|
||||||
|
correspondents,
|
||||||
|
documentLookup,
|
||||||
|
onBulkTagAdd: handleBulkTagAddFromDetail,
|
||||||
|
onBulkTagRemove: handleBulkTagRemoveFromDetail,
|
||||||
|
onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
|
||||||
|
onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
|
||||||
|
onBulkReanalyze: handleBulkSelectionReanalyze,
|
||||||
|
folderOptions,
|
||||||
|
onMoveDocumentsToFolder: moveDocumentsToFolder,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
activeCorrespondentFilters,
|
||||||
|
breadcrumbs,
|
||||||
|
clearDocumentSelection,
|
||||||
|
correspondents,
|
||||||
|
currentFolderName,
|
||||||
|
currentSubfolders,
|
||||||
|
documents,
|
||||||
|
documentsSortDirection,
|
||||||
|
documentsSortField,
|
||||||
|
documentsViewMode,
|
||||||
|
documentLookup,
|
||||||
|
draggedDocumentIds,
|
||||||
|
draggedFolderId,
|
||||||
|
focusedRowKey,
|
||||||
|
folderClickHandlers,
|
||||||
|
handleBulkCorrespondentAdd,
|
||||||
|
handleBulkCorrespondentRemove,
|
||||||
|
handleBulkSelectionReanalyze,
|
||||||
|
handleBulkTagAddFromDetail,
|
||||||
|
handleBulkTagRemoveFromDetail,
|
||||||
|
handleDeleteSelection,
|
||||||
|
handleDocumentDragEnd,
|
||||||
|
handleDocumentDragStart,
|
||||||
|
handleDocumentTagDrop,
|
||||||
|
handleDocumentTitleUpdate,
|
||||||
|
handleDocumentsSortDirectionToggle,
|
||||||
|
handleDocumentsSortFieldChange,
|
||||||
|
handleDocumentsViewModeChange,
|
||||||
|
handleEntryPointerCore,
|
||||||
|
handleEntrySelection,
|
||||||
|
handleFolderDragEnd,
|
||||||
|
handleFolderDragStart,
|
||||||
|
handleFolderRename,
|
||||||
|
inspectDocument,
|
||||||
|
isFilterActive,
|
||||||
|
moveDocumentsToFolder,
|
||||||
|
openDocumentPreview,
|
||||||
|
refreshCurrentFolder,
|
||||||
|
searchIncludeDescendants,
|
||||||
|
searchLoading,
|
||||||
|
searchResults,
|
||||||
|
selectedDocumentIds,
|
||||||
|
selectedEntries,
|
||||||
|
selectedFolderIds,
|
||||||
|
selectFolder,
|
||||||
|
setFocusedRowKey,
|
||||||
|
tagLookupById,
|
||||||
|
tags,
|
||||||
|
toggleCorrespondentFilter,
|
||||||
|
toggleSearchIncludeDescendants,
|
||||||
|
toggleTagFilter,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
folderOptions,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export default useDocumentsPanelProps;
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
const useDocumentsSelection = ({
|
||||||
|
showingSearchResults,
|
||||||
|
currentSubfolders,
|
||||||
|
visibleDocuments,
|
||||||
|
resolveFolderRowKey,
|
||||||
|
resolveDocumentRowKey,
|
||||||
|
configureSelectionEnvironment,
|
||||||
|
visibleRowKeySet,
|
||||||
|
selectedEntries,
|
||||||
|
selectionAnchorRef,
|
||||||
|
promoteSelectionOrderRaw,
|
||||||
|
setFocusedDocumentId,
|
||||||
|
setActivePreviewId,
|
||||||
|
clearSelection,
|
||||||
|
focusedDocumentId,
|
||||||
|
setFocusedRowKey,
|
||||||
|
focusedRowKey,
|
||||||
|
isFolderRowKey,
|
||||||
|
}) => {
|
||||||
|
const navigableRows = useMemo(() => {
|
||||||
|
const entries = [];
|
||||||
|
if (!showingSearchResults) {
|
||||||
|
currentSubfolders.forEach((folder) => {
|
||||||
|
const key = resolveFolderRowKey(folder.id);
|
||||||
|
if (key) {
|
||||||
|
entries.push({ key, type: 'folder', id: folder.id });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
visibleDocuments.forEach((doc) => {
|
||||||
|
const key = resolveDocumentRowKey(doc.id);
|
||||||
|
if (key) {
|
||||||
|
entries.push({ key, type: 'document', id: doc.id });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return entries;
|
||||||
|
}, [showingSearchResults, currentSubfolders, visibleDocuments, resolveFolderRowKey, resolveDocumentRowKey]);
|
||||||
|
|
||||||
|
const navigableRowKeys = useMemo(
|
||||||
|
() => navigableRows.map((entry) => entry.key),
|
||||||
|
[navigableRows],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
configureSelectionEnvironment({
|
||||||
|
visibleRowKeySet,
|
||||||
|
navigableRowKeys,
|
||||||
|
});
|
||||||
|
}, [configureSelectionEnvironment, visibleRowKeySet, navigableRowKeys]);
|
||||||
|
|
||||||
|
const promoteSelectionOrder = useCallback(
|
||||||
|
(docId) => {
|
||||||
|
if (!docId) return;
|
||||||
|
promoteSelectionOrderRaw(docId);
|
||||||
|
const rowKey = resolveDocumentRowKey(docId);
|
||||||
|
if (rowKey) {
|
||||||
|
selectionAnchorRef.current = rowKey;
|
||||||
|
}
|
||||||
|
setFocusedDocumentId(docId);
|
||||||
|
setActivePreviewId(docId);
|
||||||
|
},
|
||||||
|
[promoteSelectionOrderRaw, resolveDocumentRowKey, selectionAnchorRef, setFocusedDocumentId, setActivePreviewId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearDocumentSelection = useCallback(() => {
|
||||||
|
clearSelection();
|
||||||
|
}, [clearSelection]);
|
||||||
|
|
||||||
|
const prevFocusedDocIdRef = useRef(focusedDocumentId);
|
||||||
|
useEffect(() => {
|
||||||
|
const previous = prevFocusedDocIdRef.current;
|
||||||
|
if (previous === focusedDocumentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prevFocusedDocIdRef.current = focusedDocumentId;
|
||||||
|
if (focusedDocumentId) {
|
||||||
|
setFocusedRowKey(resolveDocumentRowKey(focusedDocumentId));
|
||||||
|
} else {
|
||||||
|
setFocusedRowKey((current) => (isFolderRowKey(current) ? current : null));
|
||||||
|
}
|
||||||
|
}, [focusedDocumentId, resolveDocumentRowKey, setFocusedRowKey, isFolderRowKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!navigableRowKeys.length) {
|
||||||
|
if (focusedRowKey) {
|
||||||
|
setFocusedRowKey(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const docKey = focusedDocumentId ? resolveDocumentRowKey(focusedDocumentId) : null;
|
||||||
|
if (docKey && navigableRowKeys.includes(docKey)) {
|
||||||
|
setFocusedRowKey(docKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedKey = selectedEntries.find((key) => navigableRowKeys.includes(key));
|
||||||
|
if (selectedKey) {
|
||||||
|
setFocusedRowKey(selectedKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusedRowKey) {
|
||||||
|
setFocusedRowKey(null);
|
||||||
|
}
|
||||||
|
}, [focusedRowKey, focusedDocumentId, navigableRowKeys, selectedEntries, setFocusedRowKey, resolveDocumentRowKey]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
navigableRows,
|
||||||
|
navigableRowKeys,
|
||||||
|
promoteSelectionOrder,
|
||||||
|
clearDocumentSelection,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDocumentsSelection;
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||||
|
const TAG_TEXT_MIME_TYPE = 'text/plain';
|
||||||
|
|
||||||
|
const serializePayload = (payload) => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(payload);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[tagTransfer] Failed to serialize payload', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createTagTransferPayload = (tag, sourceDocId = null) => {
|
||||||
|
if (!tag || !tag.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: tag.id,
|
||||||
|
label: tag.label || '',
|
||||||
|
sourceDocId: sourceDocId ?? null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const writeTagTransferData = (dataTransfer, tag, sourceDocId = null) => {
|
||||||
|
if (!dataTransfer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = createTagTransferPayload(tag, sourceDocId);
|
||||||
|
if (!payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialized = serializePayload(payload);
|
||||||
|
if (!serialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
dataTransfer.setData(TAG_MIME_TYPES[0], serialized);
|
||||||
|
dataTransfer.setData(TAG_MIME_TYPES[1], serialized);
|
||||||
|
if (payload.label) {
|
||||||
|
dataTransfer.setData(TAG_TEXT_MIME_TYPE, payload.label);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[tagTransfer] Failed to write drag data', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readTagTransferData = (dataTransfer) => {
|
||||||
|
if (!dataTransfer) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < TAG_MIME_TYPES.length; index += 1) {
|
||||||
|
const type = TAG_MIME_TYPES[index];
|
||||||
|
try {
|
||||||
|
const raw = dataTransfer.getData(type);
|
||||||
|
if (raw) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[tagTransfer] Failed to read drag data for type', type, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseTagTransferPayload = (input) => {
|
||||||
|
const dataTransfer = input && 'dataTransfer' in input ? input.dataTransfer : input;
|
||||||
|
const raw = readTagTransferData(dataTransfer);
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[tagTransfer] Failed to parse drag payload', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isTagTransferEvent = (event) => {
|
||||||
|
const types = event?.dataTransfer?.types;
|
||||||
|
if (!types) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const typeList = Array.isArray(types) ? types : Array.from(types);
|
||||||
|
return TAG_MIME_TYPES.some((type) => typeList.includes(type));
|
||||||
|
};
|
||||||
|
|
||||||
|
export { TAG_MIME_TYPES };
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
export const isPointerModifierEvent = (event) =>
|
||||||
|
Boolean(event && (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey));
|
||||||
|
|
||||||
|
export const isPrimaryPointerEvent = (event) => {
|
||||||
|
if (!event) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof event.button === 'number' && event.button !== 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const type = typeof event.type === 'string' ? event.type.toLowerCase() : '';
|
||||||
|
return type === 'click' || type === 'pointerdown' || type === 'pointerup';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useEntryPointerHandler = ({
|
||||||
|
resolveDocumentRowKey,
|
||||||
|
resolveFolderRowKey,
|
||||||
|
onSelectDocument,
|
||||||
|
onSelectFolder,
|
||||||
|
}) =>
|
||||||
|
useCallback(
|
||||||
|
(entry, event) => {
|
||||||
|
if (!entry || !entry.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, id } = entry;
|
||||||
|
if (type !== 'document' && type !== 'folder') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowKey = entry.key
|
||||||
|
|| (type === 'document' ? resolveDocumentRowKey?.(id) : resolveFolderRowKey?.(id));
|
||||||
|
if (!rowKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modifierClick = isPointerModifierEvent(event);
|
||||||
|
const primaryClick = isPrimaryPointerEvent(event);
|
||||||
|
|
||||||
|
if (type === 'document') {
|
||||||
|
if (typeof onSelectDocument === 'function') {
|
||||||
|
onSelectDocument(id, event, { modifierClick, primaryClick, rowKey });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof onSelectFolder === 'function') {
|
||||||
|
onSelectFolder(id, event, { modifierClick, primaryClick, rowKey });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
resolveDocumentRowKey,
|
||||||
|
resolveFolderRowKey,
|
||||||
|
onSelectDocument,
|
||||||
|
onSelectFolder,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export default useEntryPointerHandler;
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const focusInput = (node) => {
|
||||||
|
if (!node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const applyFocus = () => {
|
||||||
|
node.focus();
|
||||||
|
if (typeof node.select === 'function') {
|
||||||
|
node.select();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||||
|
window.requestAnimationFrame(applyFocus);
|
||||||
|
} else {
|
||||||
|
applyFocus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const identity = (value) => value;
|
||||||
|
|
||||||
|
const useInlineRename = (
|
||||||
|
onRename,
|
||||||
|
{
|
||||||
|
getCurrentValue = identity,
|
||||||
|
getEntityId = (entity) => entity?.id ?? null,
|
||||||
|
} = {},
|
||||||
|
) => {
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
const [draftValue, setDraftValue] = useState('');
|
||||||
|
const [savingId, setSavingId] = useState(null);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
const resetState = useCallback(() => {
|
||||||
|
setEditingId(null);
|
||||||
|
setDraftValue('');
|
||||||
|
setSavingId(null);
|
||||||
|
inputRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const beginEditing = useCallback(
|
||||||
|
(entity, event) => {
|
||||||
|
if (!entity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
const entityId = getEntityId(entity);
|
||||||
|
if (!entityId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentValue = getCurrentValue(entity) ?? '';
|
||||||
|
setEditingId(entityId);
|
||||||
|
setDraftValue(currentValue);
|
||||||
|
setSavingId(null);
|
||||||
|
},
|
||||||
|
[getCurrentValue, getEntityId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelEditing = useCallback(
|
||||||
|
(event) => {
|
||||||
|
if (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
resetState();
|
||||||
|
},
|
||||||
|
[resetState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitEditing = useCallback(
|
||||||
|
async (entity) => {
|
||||||
|
if (!entity) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const entityId = getEntityId(entity);
|
||||||
|
if (!entityId || editingId !== entityId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const trimmed = draftValue.trim();
|
||||||
|
const currentValue = getCurrentValue(entity) ?? '';
|
||||||
|
if (!trimmed || trimmed === currentValue) {
|
||||||
|
resetState();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof onRename !== 'function') {
|
||||||
|
resetState();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
setSavingId(entityId);
|
||||||
|
try {
|
||||||
|
const result = await onRename(entityId, trimmed);
|
||||||
|
if (result === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
resetState();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setSavingId((current) => (current === entityId ? null : current));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[draftValue, editingId, getCurrentValue, getEntityId, onRename, resetState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const attachInputRef = useCallback(
|
||||||
|
(node) => {
|
||||||
|
if (node) {
|
||||||
|
inputRef.current = node;
|
||||||
|
focusInput(node);
|
||||||
|
} else if (inputRef.current) {
|
||||||
|
inputRef.current = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
editingId,
|
||||||
|
draftValue,
|
||||||
|
setDraftValue,
|
||||||
|
beginEditing,
|
||||||
|
cancelEditing,
|
||||||
|
submitEditing,
|
||||||
|
savingId,
|
||||||
|
attachInputRef,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useInlineRename;
|
||||||
@@ -6,6 +6,8 @@ export const createWorkspaceSurfaceConfig = ({
|
|||||||
sidebarToggle = null,
|
sidebarToggle = null,
|
||||||
actions = null,
|
actions = null,
|
||||||
breadcrumbs = null,
|
breadcrumbs = null,
|
||||||
|
selectionLabel = null,
|
||||||
|
floatingActions = null,
|
||||||
content = null,
|
content = null,
|
||||||
detail = null,
|
detail = null,
|
||||||
variant = 'documents',
|
variant = 'documents',
|
||||||
@@ -28,6 +30,8 @@ export const createWorkspaceSurfaceConfig = ({
|
|||||||
leading,
|
leading,
|
||||||
actions,
|
actions,
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
|
selectionLabel,
|
||||||
|
floatingActions,
|
||||||
},
|
},
|
||||||
content,
|
content,
|
||||||
detail,
|
detail,
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
export const useDocumentsStore = () => {
|
||||||
|
const [status, setStatus] = useState(null);
|
||||||
|
|
||||||
|
const setStatusMessage = useCallback((message, variant = 'info') => {
|
||||||
|
setStatus(message ? { message, variant } : null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { status, setStatusMessage };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDocumentsStore;
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
const useAuthManager = ({
|
||||||
|
apiClient,
|
||||||
|
token,
|
||||||
|
appStatus,
|
||||||
|
appDispatch,
|
||||||
|
notifyApiError,
|
||||||
|
setStatusMessage,
|
||||||
|
setLoading,
|
||||||
|
}) => {
|
||||||
|
const tokenRef = useRef(token);
|
||||||
|
const refreshPromiseRef = useRef(null);
|
||||||
|
const initialRefreshAttemptedRef = useRef(Boolean(token));
|
||||||
|
|
||||||
|
const refreshAccessToken = useCallback(async () => {
|
||||||
|
console.log('[Auth] Attempting to refresh access token…');
|
||||||
|
appDispatch({ type: 'TOKEN_REFRESH_START' });
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.post('/auth/refresh');
|
||||||
|
if (data?.access_token) {
|
||||||
|
appDispatch({
|
||||||
|
type: 'TOKEN_REFRESH_SUCCESS',
|
||||||
|
token: data.access_token,
|
||||||
|
tenant: data.tenant || null,
|
||||||
|
});
|
||||||
|
console.log('[Auth] Access token refreshed at', new Date().toISOString());
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
throw new Error('Missing access token in refresh response');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Auth] Failed to refresh access token', error);
|
||||||
|
appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: error?.message || null });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [apiClient, appDispatch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
tokenRef.current = token;
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token && !initialRefreshAttemptedRef.current && appStatus === 'logged-out') {
|
||||||
|
initialRefreshAttemptedRef.current = true;
|
||||||
|
console.log('[Auth] Attempting refresh at startup');
|
||||||
|
refreshAccessToken().catch(() => {});
|
||||||
|
}
|
||||||
|
}, [token, appStatus, refreshAccessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const requestInterceptor = apiClient.interceptors.request.use((config) => {
|
||||||
|
const currentToken = tokenRef.current;
|
||||||
|
if (currentToken) {
|
||||||
|
config.headers = config.headers || {};
|
||||||
|
if (!config.headers.Authorization) {
|
||||||
|
config.headers.Authorization = `Bearer ${currentToken}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseInterceptor = apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const { response, config } = error;
|
||||||
|
if (!response || !config) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = response.status;
|
||||||
|
const url = typeof config.url === 'string' ? config.url : '';
|
||||||
|
const isAuthRoute = url.includes('/auth/login') || url.includes('/auth/refresh');
|
||||||
|
|
||||||
|
if (status === 401 && !config._retry && !isAuthRoute) {
|
||||||
|
console.warn('[Auth] 401 received for', url, '- attempting token refresh');
|
||||||
|
|
||||||
|
if (!refreshPromiseRef.current) {
|
||||||
|
refreshPromiseRef.current = (async () => {
|
||||||
|
try {
|
||||||
|
return await refreshAccessToken();
|
||||||
|
} finally {
|
||||||
|
refreshPromiseRef.current = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newToken = await refreshPromiseRef.current;
|
||||||
|
if (!newToken) {
|
||||||
|
throw new Error('No token returned from refresh');
|
||||||
|
}
|
||||||
|
config._retry = true;
|
||||||
|
config.headers = config.headers || {};
|
||||||
|
config.headers.Authorization = `Bearer ${newToken}`;
|
||||||
|
console.log('[Auth] Retrying original request', url);
|
||||||
|
try {
|
||||||
|
return await apiClient(config);
|
||||||
|
} catch (retryError) {
|
||||||
|
if (retryError?.response?.status === 401) {
|
||||||
|
notifyApiError(retryError, 'Session expired. Please log in again.');
|
||||||
|
}
|
||||||
|
throw retryError;
|
||||||
|
}
|
||||||
|
} catch (refreshError) {
|
||||||
|
console.warn('[Auth] Refresh failed, clearing session');
|
||||||
|
notifyApiError(refreshError, 'Session expired. Please log in again.');
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
apiClient.interceptors.request.eject(requestInterceptor);
|
||||||
|
apiClient.interceptors.response.eject(responseInterceptor);
|
||||||
|
};
|
||||||
|
}, [apiClient, notifyApiError, refreshAccessToken]);
|
||||||
|
|
||||||
|
const handleLogout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await apiClient.post('/auth/logout');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Auth] Failed to revoke refresh token during logout', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
appDispatch({ type: 'LOGOUT' });
|
||||||
|
setStatusMessage('Logged out.', 'info');
|
||||||
|
}
|
||||||
|
}, [apiClient, appDispatch, setLoading, setStatusMessage]);
|
||||||
|
|
||||||
|
return { tokenRef, refreshAccessToken, handleLogout };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useAuthManager;
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
const useCorrespondents = ({
|
||||||
|
apiClient,
|
||||||
|
notifyApiError,
|
||||||
|
setStatusMessage,
|
||||||
|
tenantIdRef,
|
||||||
|
mapDocumentCaches,
|
||||||
|
}) => {
|
||||||
|
const [correspondents, setCorrespondents] = useState([]);
|
||||||
|
|
||||||
|
const refreshCorrespondents = useCallback(async () => {
|
||||||
|
const requestTenantId = tenantIdRef.current;
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get('/correspondents');
|
||||||
|
if (tenantIdRef.current !== requestTenantId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCorrespondents(data || []);
|
||||||
|
} catch (error) {
|
||||||
|
if (tenantIdRef.current !== requestTenantId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifyApiError(error, 'Unable to load correspondents.');
|
||||||
|
}
|
||||||
|
}, [apiClient, notifyApiError, tenantIdRef]);
|
||||||
|
|
||||||
|
const handleCorrespondentUpdate = useCallback(
|
||||||
|
async (correspondentId, changes) => {
|
||||||
|
if (!correspondentId) {
|
||||||
|
throw new Error('Missing correspondent identifier.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {};
|
||||||
|
if (typeof changes.name === 'string') {
|
||||||
|
const trimmed = changes.name.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error('Correspondent name cannot be empty.');
|
||||||
|
}
|
||||||
|
payload.name = trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(payload).length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.patch(`/correspondents/${correspondentId}`, payload);
|
||||||
|
await refreshCorrespondents();
|
||||||
|
setStatusMessage('Correspondent updated.', 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to update correspondent.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[apiClient, notifyApiError, refreshCorrespondents, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCorrespondentCreate = useCallback(
|
||||||
|
async ({ name }) => {
|
||||||
|
const trimmed = typeof name === 'string' ? name.trim() : '';
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error('Correspondent name is required.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.post('/correspondents', { name: trimmed });
|
||||||
|
await refreshCorrespondents();
|
||||||
|
setStatusMessage('Correspondent created.', 'success');
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to create correspondent.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[apiClient, notifyApiError, refreshCorrespondents, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCorrespondentDelete = useCallback(
|
||||||
|
async (correspondentId) => {
|
||||||
|
if (!correspondentId) {
|
||||||
|
throw new Error('Missing correspondent identifier.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripFromDoc = (doc) => {
|
||||||
|
if (!doc || !Array.isArray(doc.correspondents)) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
const next = doc.correspondents.filter((entry) => entry.id !== correspondentId);
|
||||||
|
if (next.length === doc.correspondents.length) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
return { ...doc, correspondents: next };
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/correspondents/${correspondentId}`);
|
||||||
|
await refreshCorrespondents();
|
||||||
|
|
||||||
|
if (typeof mapDocumentCaches === 'function') {
|
||||||
|
mapDocumentCaches(stripFromDoc);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatusMessage('Correspondent deleted.', 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to delete correspondent.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[apiClient, mapDocumentCaches, notifyApiError, refreshCorrespondents, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
correspondents,
|
||||||
|
refreshCorrespondents,
|
||||||
|
handleCorrespondentCreate,
|
||||||
|
handleCorrespondentUpdate,
|
||||||
|
handleCorrespondentDelete,
|
||||||
|
setCorrespondents,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useCorrespondents;
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
const useDocumentCorrespondentActions = ({
|
||||||
|
apiClient,
|
||||||
|
correspondents,
|
||||||
|
handleCorrespondentCreate,
|
||||||
|
refreshCurrentFolder,
|
||||||
|
notifyApiError,
|
||||||
|
setStatusMessage,
|
||||||
|
}) => {
|
||||||
|
const correspondentLookupByName = useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
correspondents.forEach((correspondent) => {
|
||||||
|
if (correspondent?.name) {
|
||||||
|
map.set(correspondent.name.toLowerCase(), correspondent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [correspondents]);
|
||||||
|
|
||||||
|
const handleDocumentCorrespondentAttach = useCallback(
|
||||||
|
async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => {
|
||||||
|
if (!documentId || !correspondentId) {
|
||||||
|
throw new Error('Missing document or correspondent.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/documents/${documentId}/correspondents`, {
|
||||||
|
assignments: [{ correspondent_id: correspondentId }],
|
||||||
|
replace: false,
|
||||||
|
});
|
||||||
|
if (refresh) {
|
||||||
|
await refreshCurrentFolder();
|
||||||
|
}
|
||||||
|
if (notify) {
|
||||||
|
setStatusMessage('Correspondent assigned.', 'success');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to assign correspondent.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[apiClient, refreshCurrentFolder, notifyApiError, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCorrespondentRemove = useCallback(
|
||||||
|
async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => {
|
||||||
|
if (!documentId || !correspondentId) {
|
||||||
|
throw new Error('Missing document or correspondent.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/documents/${documentId}/correspondents/${correspondentId}`);
|
||||||
|
if (refresh) {
|
||||||
|
await refreshCurrentFolder();
|
||||||
|
}
|
||||||
|
if (notify) {
|
||||||
|
setStatusMessage('Correspondent removed.', 'success');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to remove correspondent.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[apiClient, refreshCurrentFolder, notifyApiError, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCorrespondentAdd = useCallback(
|
||||||
|
async ({ document, name, input = null, option = null }) => {
|
||||||
|
if (!document?.id) {
|
||||||
|
throw new Error('Missing document for correspondent assignment.');
|
||||||
|
}
|
||||||
|
const trimmed = typeof name === 'string' ? name.trim() : '';
|
||||||
|
if (!trimmed) {
|
||||||
|
setStatusMessage('Correspondent name is required.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = null;
|
||||||
|
if (option && option.id) {
|
||||||
|
target = correspondentLookupByName.get(trimmed.toLowerCase()) || option;
|
||||||
|
} else {
|
||||||
|
target = correspondentLookupByName.get(trimmed.toLowerCase()) || null;
|
||||||
|
}
|
||||||
|
if (!target) {
|
||||||
|
try {
|
||||||
|
target = await handleCorrespondentCreate({ name: trimmed });
|
||||||
|
} catch (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target?.id) {
|
||||||
|
setStatusMessage('Unable to resolve correspondent.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleDocumentCorrespondentAttach({
|
||||||
|
documentId: document.id,
|
||||||
|
correspondentId: target.id,
|
||||||
|
});
|
||||||
|
if (input) {
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setStatusMessage('Failed to assign correspondent.', 'error');
|
||||||
|
console.error('[documents] assign correspondent failed', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
correspondentLookupByName,
|
||||||
|
handleCorrespondentCreate,
|
||||||
|
handleDocumentCorrespondentAttach,
|
||||||
|
setStatusMessage,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
correspondentLookupByName,
|
||||||
|
handleDocumentCorrespondentAttach,
|
||||||
|
handleCorrespondentRemove,
|
||||||
|
handleCorrespondentAdd,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDocumentCorrespondentActions;
|
||||||
|
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
const useDocumentDragHandlers = ({
|
||||||
|
selectedEntries,
|
||||||
|
selectedDocumentIds,
|
||||||
|
selectedFolderIds,
|
||||||
|
applySelection,
|
||||||
|
handleEntrySelection,
|
||||||
|
documentLookup,
|
||||||
|
setDraggedDocumentIds,
|
||||||
|
setDraggedFolderId,
|
||||||
|
resolveDocumentRowKey,
|
||||||
|
resolveFolderRowKey,
|
||||||
|
documentsViewMode,
|
||||||
|
}) => {
|
||||||
|
const dragPreviewRef = useRef(null);
|
||||||
|
|
||||||
|
const destroyDragPreview = useCallback(() => {
|
||||||
|
const node = dragPreviewRef.current;
|
||||||
|
if (node && node.parentNode) {
|
||||||
|
node.parentNode.removeChild(node);
|
||||||
|
}
|
||||||
|
dragPreviewRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => destroyDragPreview, [destroyDragPreview]);
|
||||||
|
|
||||||
|
const createDragPreview = useCallback(
|
||||||
|
({ documents = [], folders = [] } = {}) => {
|
||||||
|
destroyDragPreview();
|
||||||
|
|
||||||
|
const docEntries = (documents || []).filter(Boolean);
|
||||||
|
const folderEntries = (folders || []).filter(Boolean);
|
||||||
|
const totalCount = docEntries.length + folderEntries.length;
|
||||||
|
if (!totalCount) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxVisible = 4;
|
||||||
|
const size = 64;
|
||||||
|
const canvasSize = Math.round(size * 1.6);
|
||||||
|
|
||||||
|
const visibleItems = [];
|
||||||
|
docEntries.slice(0, maxVisible).forEach((doc) => {
|
||||||
|
visibleItems.push({ type: 'document', payload: doc });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (visibleItems.length < maxVisible) {
|
||||||
|
folderEntries
|
||||||
|
.slice(0, maxVisible - visibleItems.length)
|
||||||
|
.forEach((folderId) => visibleItems.push({ type: 'folder', payload: folderId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'document-drag-preview';
|
||||||
|
wrapper.style.width = `${canvasSize}px`;
|
||||||
|
wrapper.style.height = `${canvasSize}px`;
|
||||||
|
|
||||||
|
visibleItems.forEach((item, index) => {
|
||||||
|
const slot = document.createElement('div');
|
||||||
|
slot.className = 'document-drag-preview__item';
|
||||||
|
slot.style.setProperty('--index', String(index));
|
||||||
|
slot.style.width = `${size}px`;
|
||||||
|
slot.style.height = `${size}px`;
|
||||||
|
|
||||||
|
if (item.type === 'document') {
|
||||||
|
slot.textContent = item.payload?.title || 'Document';
|
||||||
|
} else {
|
||||||
|
slot.textContent = 'Folder';
|
||||||
|
}
|
||||||
|
wrapper.appendChild(slot);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(wrapper);
|
||||||
|
dragPreviewRef.current = wrapper;
|
||||||
|
return wrapper;
|
||||||
|
},
|
||||||
|
[destroyDragPreview],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDocumentDragStart = useCallback(
|
||||||
|
(event, documentOrId) => {
|
||||||
|
const documentId = typeof documentOrId === 'string' ? documentOrId : documentOrId?.id;
|
||||||
|
if (!documentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentKey = resolveDocumentRowKey(documentId);
|
||||||
|
if (!documentKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isGridView = documentsViewMode === 'grid';
|
||||||
|
const isAlreadySelected = selectedDocumentIds.includes(documentId);
|
||||||
|
const selection = isAlreadySelected
|
||||||
|
? [...selectedDocumentIds]
|
||||||
|
: isGridView
|
||||||
|
? [...selectedDocumentIds, documentId]
|
||||||
|
: [documentId];
|
||||||
|
const folderSelection = selectedFolderIds.length ? [...selectedFolderIds] : [];
|
||||||
|
|
||||||
|
if (!isAlreadySelected && !isGridView) {
|
||||||
|
applySelection([documentKey], {
|
||||||
|
anchor: documentKey,
|
||||||
|
interactedKeys: [documentKey],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewDocs = selection.map((id) => documentLookup.get(id) || null).filter(Boolean);
|
||||||
|
const previewNode = createDragPreview({
|
||||||
|
documents: previewDocs,
|
||||||
|
folders: folderSelection,
|
||||||
|
});
|
||||||
|
|
||||||
|
setDraggedDocumentIds(selection);
|
||||||
|
if (folderSelection.length) {
|
||||||
|
setDraggedFolderId(folderSelection[0] || null);
|
||||||
|
}
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
try {
|
||||||
|
event.dataTransfer.setData(
|
||||||
|
'application/x-papercrate-doc-list',
|
||||||
|
JSON.stringify(selection),
|
||||||
|
);
|
||||||
|
if (folderSelection.length) {
|
||||||
|
event.dataTransfer.setData(
|
||||||
|
'application/x-papercrate-folder-list',
|
||||||
|
JSON.stringify(folderSelection),
|
||||||
|
);
|
||||||
|
if (folderSelection.length === 1) {
|
||||||
|
event.dataTransfer.setData('application/x-papercrate-folder', folderSelection[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[documents] Failed to populate drag payload', error);
|
||||||
|
}
|
||||||
|
if (previewNode) {
|
||||||
|
const width = previewNode.offsetWidth || 96;
|
||||||
|
const height = previewNode.offsetHeight || 96;
|
||||||
|
event.dataTransfer.setDragImage(previewNode, width / 2, height / 2);
|
||||||
|
}
|
||||||
|
event.currentTarget.classList.add('dragging');
|
||||||
|
},
|
||||||
|
[
|
||||||
|
selectedDocumentIds,
|
||||||
|
selectedFolderIds,
|
||||||
|
applySelection,
|
||||||
|
documentLookup,
|
||||||
|
createDragPreview,
|
||||||
|
setDraggedFolderId,
|
||||||
|
setDraggedDocumentIds,
|
||||||
|
documentsViewMode,
|
||||||
|
resolveDocumentRowKey,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDocumentDragEnd = useCallback(
|
||||||
|
(event) => {
|
||||||
|
setDraggedDocumentIds([]);
|
||||||
|
event.currentTarget.classList.remove('dragging');
|
||||||
|
destroyDragPreview();
|
||||||
|
setDraggedFolderId(null);
|
||||||
|
},
|
||||||
|
[destroyDragPreview, setDraggedFolderId, setDraggedDocumentIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFolderDragStart = useCallback(
|
||||||
|
(event, folderId) => {
|
||||||
|
if (folderId === 'root') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.stopPropagation();
|
||||||
|
const folderKey = resolveFolderRowKey(folderId);
|
||||||
|
const isAlreadySelected = folderKey ? selectedEntries.includes(folderKey) : false;
|
||||||
|
|
||||||
|
let effectiveFolderSelection = selectedFolderIds;
|
||||||
|
let effectiveDocumentSelection = selectedDocumentIds;
|
||||||
|
|
||||||
|
if (!isAlreadySelected && folderKey) {
|
||||||
|
effectiveFolderSelection = [folderId];
|
||||||
|
effectiveDocumentSelection = [];
|
||||||
|
handleEntrySelection(folderKey, { preventDefault: () => {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueFolders = effectiveFolderSelection.length
|
||||||
|
? Array.from(new Set(effectiveFolderSelection.filter(Boolean)))
|
||||||
|
: [folderId];
|
||||||
|
|
||||||
|
setDraggedFolderId(folderId);
|
||||||
|
if (effectiveDocumentSelection.length) {
|
||||||
|
setDraggedDocumentIds(effectiveDocumentSelection);
|
||||||
|
}
|
||||||
|
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
try {
|
||||||
|
event.dataTransfer.setData(
|
||||||
|
'application/x-papercrate-folder-list',
|
||||||
|
JSON.stringify(uniqueFolders),
|
||||||
|
);
|
||||||
|
if (uniqueFolders.length === 1) {
|
||||||
|
event.dataTransfer.setData('application/x-papercrate-folder', uniqueFolders[0]);
|
||||||
|
}
|
||||||
|
if (effectiveDocumentSelection.length) {
|
||||||
|
event.dataTransfer.setData(
|
||||||
|
'application/x-papercrate-doc-list',
|
||||||
|
JSON.stringify(effectiveDocumentSelection),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[documents] Failed to populate folder drag payload', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewDocs = effectiveDocumentSelection
|
||||||
|
.map((id) => documentLookup.get(id) || null)
|
||||||
|
.filter(Boolean);
|
||||||
|
createDragPreview({ documents: previewDocs, folders: uniqueFolders });
|
||||||
|
event.currentTarget.classList.add('dragging');
|
||||||
|
},
|
||||||
|
[
|
||||||
|
selectedFolderIds,
|
||||||
|
selectedEntries,
|
||||||
|
selectedDocumentIds,
|
||||||
|
handleEntrySelection,
|
||||||
|
setDraggedFolderId,
|
||||||
|
setDraggedDocumentIds,
|
||||||
|
documentLookup,
|
||||||
|
createDragPreview,
|
||||||
|
resolveFolderRowKey,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFolderDragEnd = useCallback(
|
||||||
|
(event) => {
|
||||||
|
if (event?.currentTarget) {
|
||||||
|
event.currentTarget.classList.remove('dragging');
|
||||||
|
}
|
||||||
|
setDraggedFolderId(null);
|
||||||
|
setDraggedDocumentIds([]);
|
||||||
|
destroyDragPreview();
|
||||||
|
},
|
||||||
|
[setDraggedFolderId, setDraggedDocumentIds, destroyDragPreview],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleDocumentDragStart,
|
||||||
|
handleDocumentDragEnd,
|
||||||
|
handleFolderDragStart,
|
||||||
|
handleFolderDragEnd,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDocumentDragHandlers;
|
||||||
@@ -0,0 +1,633 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { DEFAULT_FOLDER_NAME, getRowId, isDocumentRowKey } from '../../app/appLayoutUtils';
|
||||||
|
|
||||||
|
const normalizeDocumentId = (value) => {
|
||||||
|
if (!value) return null;
|
||||||
|
if (typeof value === 'object' && value.id) {
|
||||||
|
return value.id;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useDocumentMutations = ({
|
||||||
|
api,
|
||||||
|
token,
|
||||||
|
documentLookup,
|
||||||
|
folderLabelMap,
|
||||||
|
ensureFolderData,
|
||||||
|
selectedFolder,
|
||||||
|
setSelectedFolder,
|
||||||
|
setDocuments,
|
||||||
|
setFolderContents,
|
||||||
|
setSearchResults,
|
||||||
|
setSelectedEntries,
|
||||||
|
setSelectionOrder,
|
||||||
|
selectionOrderRef,
|
||||||
|
selectionAnchorRef,
|
||||||
|
setFocusedDocumentId,
|
||||||
|
focusedDocumentId,
|
||||||
|
setFocusedRowKey,
|
||||||
|
focusedRowKey,
|
||||||
|
notifyApiError,
|
||||||
|
setStatusMessage,
|
||||||
|
setLoading,
|
||||||
|
mapDocumentCaches,
|
||||||
|
applySelectedFolder,
|
||||||
|
folderNodes,
|
||||||
|
setFolderNodes,
|
||||||
|
removeDocumentsFromCaches,
|
||||||
|
closeDocumentPreview,
|
||||||
|
previewDocumentId,
|
||||||
|
refreshCurrentFolder,
|
||||||
|
documentsViewMode,
|
||||||
|
updateDocumentCaches,
|
||||||
|
tagLookupById,
|
||||||
|
tags,
|
||||||
|
refreshTags,
|
||||||
|
tagManager,
|
||||||
|
extractDocumentFromResponse,
|
||||||
|
}) => {
|
||||||
|
const moveDocumentsToFolder = useCallback(
|
||||||
|
async (documentIds, targetFolderId) => {
|
||||||
|
const uniqueIds = Array.from(
|
||||||
|
new Set((documentIds || []).map((value) => normalizeDocumentId(value)).filter(Boolean)),
|
||||||
|
);
|
||||||
|
if (!uniqueIds.length) return;
|
||||||
|
|
||||||
|
const uniqueIdSet = new Set(uniqueIds);
|
||||||
|
const target = targetFolderId === 'root' ? null : targetFolderId;
|
||||||
|
const targetLabel =
|
||||||
|
target === null
|
||||||
|
? DEFAULT_FOLDER_NAME
|
||||||
|
: folderLabelMap.get(targetFolderId) || 'target folder';
|
||||||
|
|
||||||
|
const movedDocs = uniqueIds
|
||||||
|
.map((id) => {
|
||||||
|
const doc = documentLookup.get(id);
|
||||||
|
if (!doc) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
sourceFolderId: doc.folder_id ?? null,
|
||||||
|
document: doc,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const updatedDocsMap = new Map();
|
||||||
|
const resolveTargetName = () => {
|
||||||
|
if (!targetLabel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const segments = String(targetLabel).split('/');
|
||||||
|
return segments[segments.length - 1] || targetLabel;
|
||||||
|
};
|
||||||
|
const targetName = resolveTargetName();
|
||||||
|
|
||||||
|
movedDocs.forEach(({ id, document }) => {
|
||||||
|
if (!document) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated = {
|
||||||
|
...document,
|
||||||
|
folder_id: target,
|
||||||
|
};
|
||||||
|
if (targetLabel) {
|
||||||
|
updated.folder_path = targetLabel;
|
||||||
|
if (targetName) {
|
||||||
|
updated.folder_name = targetName;
|
||||||
|
}
|
||||||
|
} else if (target === null) {
|
||||||
|
updated.folder_path = DEFAULT_FOLDER_NAME;
|
||||||
|
updated.folder_name = DEFAULT_FOLDER_NAME;
|
||||||
|
}
|
||||||
|
updatedDocsMap.set(id, updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
const pruneRow = (collection) =>
|
||||||
|
collection.filter((key) => {
|
||||||
|
if (!isDocumentRowKey(key)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const id = getRowId(key);
|
||||||
|
return id ? !uniqueIdSet.has(id) : true;
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (uniqueIds.length === 1) {
|
||||||
|
await api.patch(`/documents/${uniqueIds[0]}/folder`, { folder_id: target });
|
||||||
|
} else {
|
||||||
|
await api.post('/documents/bulk/move', {
|
||||||
|
document_ids: uniqueIds,
|
||||||
|
folder_id: target,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = uniqueIds.length;
|
||||||
|
const suffix = count === 1 ? '' : 's';
|
||||||
|
setStatusMessage(`Moved ${count} document${suffix} to ${targetLabel}.`, 'success');
|
||||||
|
|
||||||
|
if (updatedDocsMap.size) {
|
||||||
|
mapDocumentCaches((doc) => {
|
||||||
|
if (!doc || !uniqueIdSet.has(doc.id)) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
const updated = updatedDocsMap.get(doc.id);
|
||||||
|
if (updated) {
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
return { ...doc, folder_id: target };
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
mapDocumentCaches((doc) => {
|
||||||
|
if (!doc || !uniqueIdSet.has(doc.id)) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
return { ...doc, folder_id: target };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uniqueIdSet.size) {
|
||||||
|
setSearchResults((prev) => {
|
||||||
|
if (!Array.isArray(prev) || !prev.length) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const filtered = prev.filter((doc) => doc && !uniqueIdSet.has(doc.id));
|
||||||
|
return filtered.length === prev.length ? prev : filtered;
|
||||||
|
});
|
||||||
|
setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id)));
|
||||||
|
setFolderContents((prev) => {
|
||||||
|
if (!prev.size) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
let changed = false;
|
||||||
|
const next = new Map(prev);
|
||||||
|
movedDocs.forEach(({ id, sourceFolderId }) => {
|
||||||
|
const sourceKey = sourceFolderId || 'root';
|
||||||
|
const entry = next.get(sourceKey);
|
||||||
|
if (!entry?.documents?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filteredDocs = entry.documents.filter((doc) => doc.id !== id);
|
||||||
|
if (filteredDocs.length !== entry.documents.length) {
|
||||||
|
changed = true;
|
||||||
|
next.set(sourceKey, { ...entry, documents: filteredDocs });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
|
||||||
|
setSelectedEntries((prev) => pruneRow(prev, uniqueIdSet));
|
||||||
|
setSelectionOrder((prev) => pruneRow(prev, uniqueIdSet));
|
||||||
|
selectionOrderRef.current = pruneRow(selectionOrderRef.current || [], uniqueIdSet);
|
||||||
|
if (
|
||||||
|
selectionAnchorRef.current &&
|
||||||
|
isDocumentRowKey(selectionAnchorRef.current) &&
|
||||||
|
uniqueIdSet.has(getRowId(selectionAnchorRef.current))
|
||||||
|
) {
|
||||||
|
selectionAnchorRef.current = null;
|
||||||
|
}
|
||||||
|
if (focusedDocumentId && uniqueIdSet.has(focusedDocumentId)) {
|
||||||
|
setFocusedDocumentId(null);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
focusedRowKey &&
|
||||||
|
isDocumentRowKey(focusedRowKey) &&
|
||||||
|
uniqueIdSet.has(getRowId(focusedRowKey))
|
||||||
|
) {
|
||||||
|
setFocusedRowKey(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetFolderId && targetFolderId !== selectedFolder) {
|
||||||
|
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to move documents.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
api,
|
||||||
|
documentLookup,
|
||||||
|
folderLabelMap,
|
||||||
|
ensureFolderData,
|
||||||
|
selectedFolder,
|
||||||
|
setSearchResults,
|
||||||
|
setDocuments,
|
||||||
|
setFolderContents,
|
||||||
|
setSelectedEntries,
|
||||||
|
setSelectionOrder,
|
||||||
|
selectionOrderRef,
|
||||||
|
selectionAnchorRef,
|
||||||
|
setFocusedDocumentId,
|
||||||
|
focusedDocumentId,
|
||||||
|
setFocusedRowKey,
|
||||||
|
focusedRowKey,
|
||||||
|
notifyApiError,
|
||||||
|
setStatusMessage,
|
||||||
|
setLoading,
|
||||||
|
mapDocumentCaches,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleThumbnailRegeneration = useCallback(
|
||||||
|
async (documentId) => {
|
||||||
|
if (!token) {
|
||||||
|
setStatusMessage('Log in to manage assets.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.post(`/documents/${documentId}/assets`, null, {
|
||||||
|
params: { force: true },
|
||||||
|
});
|
||||||
|
setStatusMessage('Document re-analysis queued.', 'info');
|
||||||
|
await refreshCurrentFolder();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to request thumbnail generation.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, token, refreshCurrentFolder, notifyApiError, setStatusMessage, setLoading],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDocumentsDelete = useCallback(
|
||||||
|
async (documentIds, { showMessage = true, manageLoading = true } = {}) => {
|
||||||
|
if (!documentIds || documentIds.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
setStatusMessage('Log in to manage documents.', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manageLoading) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
documentIds.map((documentId) => api.post(`/documents/${documentId}/trash`)),
|
||||||
|
);
|
||||||
|
|
||||||
|
removeDocumentsFromCaches(documentIds);
|
||||||
|
|
||||||
|
if (documentIds.includes(previewDocumentId)) {
|
||||||
|
closeDocumentPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showMessage) {
|
||||||
|
const message = documentIds.length === 1 ? 'Document deleted.' : 'Documents deleted.';
|
||||||
|
setStatusMessage(message, 'success');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to delete documents.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (manageLoading) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
api,
|
||||||
|
token,
|
||||||
|
removeDocumentsFromCaches,
|
||||||
|
previewDocumentId,
|
||||||
|
closeDocumentPreview,
|
||||||
|
notifyApiError,
|
||||||
|
setStatusMessage,
|
||||||
|
setLoading,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDocumentTitleUpdate = useCallback(
|
||||||
|
async (documentId, nextTitle) => {
|
||||||
|
const trimmed = typeof nextTitle === 'string' ? nextTitle.trim() : '';
|
||||||
|
if (!trimmed) {
|
||||||
|
setStatusMessage('Document title cannot be empty.', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.patch(`/documents/${documentId}`, { title: trimmed });
|
||||||
|
const updatedDocument = extractDocumentFromResponse?.(data);
|
||||||
|
|
||||||
|
updateDocumentCaches(documentId, (doc) => {
|
||||||
|
if (updatedDocument) {
|
||||||
|
return { ...doc, ...updatedDocument };
|
||||||
|
}
|
||||||
|
return { ...doc, title: trimmed };
|
||||||
|
});
|
||||||
|
|
||||||
|
setStatusMessage('Document title updated.', 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to update document title.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, extractDocumentFromResponse, notifyApiError, setLoading, setStatusMessage, updateDocumentCaches],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDocumentIssuedUpdate = useCallback(
|
||||||
|
async (documentId, nextIssuedDate) => {
|
||||||
|
setLoading(true);
|
||||||
|
const payload = { issued_at: nextIssuedDate || null };
|
||||||
|
try {
|
||||||
|
const { data } = await api.patch(`/documents/${documentId}`, payload);
|
||||||
|
const updatedDocument = extractDocumentFromResponse?.(data);
|
||||||
|
|
||||||
|
updateDocumentCaches(documentId, (doc) => {
|
||||||
|
if (updatedDocument) {
|
||||||
|
return { ...doc, ...updatedDocument };
|
||||||
|
}
|
||||||
|
return { ...doc, issued_at: payload.issued_at };
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = payload.issued_at ? 'Issued date updated.' : 'Issued date cleared.';
|
||||||
|
setStatusMessage(message, 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to update issued date.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, extractDocumentFromResponse, notifyApiError, setLoading, setStatusMessage, updateDocumentCaches],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDocumentTagAdd = useCallback(
|
||||||
|
async (document, label, extras = null) => {
|
||||||
|
const normalizedLabel = tagManager.normalizeLabel(label);
|
||||||
|
const optionCandidate =
|
||||||
|
extras && typeof extras === 'object' && 'option' in extras ? extras.option : null;
|
||||||
|
const input =
|
||||||
|
extras && typeof extras === 'object' && 'input' in extras ? extras.input : null;
|
||||||
|
|
||||||
|
let tag = null;
|
||||||
|
if (optionCandidate && optionCandidate.id) {
|
||||||
|
tag = tags.find((item) => item.id === optionCandidate.id) || optionCandidate;
|
||||||
|
}
|
||||||
|
if (!tag) {
|
||||||
|
tag =
|
||||||
|
tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!tag) {
|
||||||
|
const payload = tagManager.buildPayload({ label: normalizedLabel });
|
||||||
|
const { data } = await api.post('/tags', payload);
|
||||||
|
tag = data;
|
||||||
|
await refreshTags();
|
||||||
|
}
|
||||||
|
await api.post(`/documents/${document.id}/tags`, { tag_ids: [tag.id] });
|
||||||
|
setStatusMessage('Tag assigned.', 'success');
|
||||||
|
if (input && typeof input === 'object') {
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
await refreshCurrentFolder();
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError(error, 'Failed to assign tag.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, tags, refreshTags, refreshCurrentFolder, notifyApiError, setStatusMessage, tagManager],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDocumentTagAttach = useCallback(
|
||||||
|
async ({ documentId, tagId, tag: tagData = null }) => {
|
||||||
|
if (!documentId || !tagId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveTagForCache = () => {
|
||||||
|
const lookupTag = tagLookupById.get(tagId);
|
||||||
|
const source = lookupTag ?? tagData;
|
||||||
|
if (!source || source.id == null || typeof source.label !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: source.id,
|
||||||
|
label: source.label,
|
||||||
|
color: Object.prototype.hasOwnProperty.call(source, 'color') ? source.color : null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post(`/documents/${documentId}/tags`, { tag_ids: [tagId] });
|
||||||
|
updateDocumentCaches(documentId, (doc) => {
|
||||||
|
if (!doc) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
const currentTags = Array.isArray(doc.tags) ? doc.tags : [];
|
||||||
|
if (currentTags.some((existing) => existing?.id === tagId)) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
const resolvedTag = resolveTagForCache();
|
||||||
|
if (!resolvedTag) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
return { ...doc, tags: [...currentTags, resolvedTag] };
|
||||||
|
});
|
||||||
|
setStatusMessage('Tag assigned.', 'success');
|
||||||
|
if (documentsViewMode !== 'desk') {
|
||||||
|
await refreshCurrentFolder();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to assign tag.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
api,
|
||||||
|
refreshCurrentFolder,
|
||||||
|
documentsViewMode,
|
||||||
|
notifyApiError,
|
||||||
|
setStatusMessage,
|
||||||
|
updateDocumentCaches,
|
||||||
|
tagLookupById,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyTagRemovalToCaches = useCallback(
|
||||||
|
(documentId, tagId) => {
|
||||||
|
if (!documentId || !tagId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDocumentCaches(documentId, (doc) => {
|
||||||
|
if (!Array.isArray(doc.tags)) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
const nextTags = doc.tags.filter((tag) => tag.id !== tagId);
|
||||||
|
if (nextTags.length === doc.tags.length) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
return { ...doc, tags: nextTags };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateDocumentCaches],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTagRemove = useCallback(
|
||||||
|
async (documentId, tagId, { refreshTagList = true, showMessage = true } = {}) => {
|
||||||
|
if (!documentId || !tagId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.delete(`/documents/${documentId}/tags/${tagId}`);
|
||||||
|
applyTagRemovalToCaches(documentId, tagId);
|
||||||
|
if (refreshTagList) {
|
||||||
|
await refreshTags();
|
||||||
|
}
|
||||||
|
if (showMessage) {
|
||||||
|
setStatusMessage('Tag removed.', 'success');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to remove tag.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, applyTagRemovalToCaches, notifyApiError, refreshTags, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFolderDelete = useCallback(
|
||||||
|
async (folderId, { showMessage = true, manageLoading = true } = {}) => {
|
||||||
|
if (!token) {
|
||||||
|
if (showMessage) {
|
||||||
|
setStatusMessage('Log in to manage folders.', 'error');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!folderId || folderId === 'root') {
|
||||||
|
if (showMessage) {
|
||||||
|
setStatusMessage('The root folder cannot be removed.', 'error');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manageLoading) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contents = await ensureFolderData(folderId, {
|
||||||
|
force: true,
|
||||||
|
prefetchDepth: 1,
|
||||||
|
});
|
||||||
|
const hasChildren = (contents.subfolders || []).length > 0;
|
||||||
|
const hasDocs = (contents.documents || []).length > 0;
|
||||||
|
if (hasChildren || hasDocs) {
|
||||||
|
if (showMessage) {
|
||||||
|
setStatusMessage('Folder must be empty before it can be deleted.', 'error');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.delete(`/folders/${folderId}`);
|
||||||
|
|
||||||
|
setFolderNodes((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const node = next.get(folderId);
|
||||||
|
next.delete(folderId);
|
||||||
|
if (node) {
|
||||||
|
const parentId = node.parentId || 'root';
|
||||||
|
const parentNode = next.get(parentId);
|
||||||
|
if (parentNode) {
|
||||||
|
const remaining = parentNode.children.filter((id) => id !== folderId);
|
||||||
|
next.set(parentId, {
|
||||||
|
...parentNode,
|
||||||
|
children: remaining,
|
||||||
|
hasChildren: remaining.length > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
setFolderContents((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(folderId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selectedFolder === folderId) {
|
||||||
|
const node = folderNodes.get(folderId);
|
||||||
|
const parentId = node?.parentId || 'root';
|
||||||
|
setSelectedFolder(parentId);
|
||||||
|
const parentContents = await ensureFolderData(parentId, {
|
||||||
|
force: true,
|
||||||
|
prefetchDepth: 1,
|
||||||
|
});
|
||||||
|
applySelectedFolder(parentId, parentContents);
|
||||||
|
} else if (selectedFolder !== 'root') {
|
||||||
|
await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showMessage) {
|
||||||
|
setStatusMessage('Folder deleted.', 'success');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to delete folder.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
if (showMessage) {
|
||||||
|
setStatusMessage(message, 'error');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (manageLoading) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
api,
|
||||||
|
token,
|
||||||
|
ensureFolderData,
|
||||||
|
selectedFolder,
|
||||||
|
folderNodes,
|
||||||
|
setSelectedFolder,
|
||||||
|
applySelectedFolder,
|
||||||
|
setFolderNodes,
|
||||||
|
setFolderContents,
|
||||||
|
notifyApiError,
|
||||||
|
setStatusMessage,
|
||||||
|
setLoading,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
moveDocumentsToFolder,
|
||||||
|
handleThumbnailRegeneration,
|
||||||
|
handleDocumentsDelete,
|
||||||
|
handleDocumentTagAdd,
|
||||||
|
handleDocumentTagAttach,
|
||||||
|
handleDocumentTitleUpdate,
|
||||||
|
handleDocumentIssuedUpdate,
|
||||||
|
handleTagRemove,
|
||||||
|
handleFolderDelete,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDocumentMutations;
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
const useDocumentTagging = ({
|
||||||
|
apiClient,
|
||||||
|
tags,
|
||||||
|
tagManager,
|
||||||
|
refreshTags,
|
||||||
|
refreshCurrentFolder,
|
||||||
|
resolveTargetDocumentIds,
|
||||||
|
notifyApiError,
|
||||||
|
setStatusMessage,
|
||||||
|
setLoading,
|
||||||
|
}) => {
|
||||||
|
const bulkTagOperation = useCallback(
|
||||||
|
async ({ labels, action, documentIds }) => {
|
||||||
|
const normalized = labels.map((label) => label.trim()).filter((label) => label.length > 0);
|
||||||
|
if (!normalized.length) {
|
||||||
|
return { ok: false, reason: 'no-labels' };
|
||||||
|
}
|
||||||
|
const targetDocumentIds = resolveTargetDocumentIds(documentIds);
|
||||||
|
if (!targetDocumentIds.length) {
|
||||||
|
return { ok: false, reason: 'no-selection' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let tagIds = [];
|
||||||
|
|
||||||
|
if (action === 'remove') {
|
||||||
|
const missing = normalized.find(
|
||||||
|
(label) => !tags.some((tag) => tag.label.toLowerCase() === label.toLowerCase()),
|
||||||
|
);
|
||||||
|
if (missing) {
|
||||||
|
return { ok: false, reason: 'tag-missing', label: missing };
|
||||||
|
}
|
||||||
|
|
||||||
|
tagIds = normalized.map((label) => {
|
||||||
|
const tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase());
|
||||||
|
return tag?.id;
|
||||||
|
}).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (action === 'add') {
|
||||||
|
const createdIds = [];
|
||||||
|
for (const label of normalized) {
|
||||||
|
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
|
||||||
|
if (!tag) {
|
||||||
|
const payload = tagManager.buildPayload({ label });
|
||||||
|
const { data } = await apiClient.post('/tags', payload);
|
||||||
|
tag = data;
|
||||||
|
await refreshTags();
|
||||||
|
}
|
||||||
|
createdIds.push(tag.id);
|
||||||
|
}
|
||||||
|
tagIds = Array.from(new Set(createdIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
tagIds = Array.from(new Set(tagIds));
|
||||||
|
|
||||||
|
if (!tagIds.length) {
|
||||||
|
return { ok: false, reason: 'no-tags' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiClient.post('/documents/bulk/tags', {
|
||||||
|
document_ids: targetDocumentIds,
|
||||||
|
tag_ids: tagIds,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
|
||||||
|
await refreshCurrentFolder();
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
tagCount: tagIds.length,
|
||||||
|
docsCount: targetDocumentIds.length,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error.response?.data?.error ||
|
||||||
|
(action === 'add' ? 'Failed to assign tags.' : 'Failed to remove tags.');
|
||||||
|
notifyApiError(error, message);
|
||||||
|
return { ok: false, reason: 'request-failed' };
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
resolveTargetDocumentIds,
|
||||||
|
tags,
|
||||||
|
refreshTags,
|
||||||
|
refreshCurrentFolder,
|
||||||
|
notifyApiError,
|
||||||
|
setLoading,
|
||||||
|
tagManager,
|
||||||
|
apiClient,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBulkTagAddFromDetail = useCallback(
|
||||||
|
async ({ label, input, documentIds }) => {
|
||||||
|
const trimmed = typeof label === 'string' ? label.trim() : '';
|
||||||
|
if (!trimmed) {
|
||||||
|
setStatusMessage('Enter a tag label.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetIds = resolveTargetDocumentIds(documentIds);
|
||||||
|
if (!targetIds.length) {
|
||||||
|
setStatusMessage('Select documents before assigning tags.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await bulkTagOperation({
|
||||||
|
labels: [trimmed],
|
||||||
|
action: 'add',
|
||||||
|
documentIds: targetIds,
|
||||||
|
});
|
||||||
|
if (result?.ok) {
|
||||||
|
const { tagCount, docsCount } = result;
|
||||||
|
setStatusMessage(
|
||||||
|
`Assigned ${tagCount} tag${tagCount === 1 ? '' : 's'} to ${docsCount} document${
|
||||||
|
docsCount === 1 ? '' : 's'
|
||||||
|
}.`,
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
if (input) {
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[bulkTagOperation, resolveTargetDocumentIds, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBulkTagRemoveFromDetail = useCallback(
|
||||||
|
async ({ label, input, documentIds }) => {
|
||||||
|
const trimmed = typeof label === 'string' ? label.trim() : '';
|
||||||
|
if (!trimmed) {
|
||||||
|
setStatusMessage('Enter a tag label to remove.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetIds = resolveTargetDocumentIds(documentIds);
|
||||||
|
if (!targetIds.length) {
|
||||||
|
setStatusMessage('Select documents before removing tags.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await bulkTagOperation({
|
||||||
|
labels: [trimmed],
|
||||||
|
action: 'remove',
|
||||||
|
documentIds: targetIds,
|
||||||
|
});
|
||||||
|
if (result?.ok) {
|
||||||
|
const { docsCount } = result;
|
||||||
|
setStatusMessage(
|
||||||
|
`Removed tags from ${docsCount} document${docsCount === 1 ? '' : 's'}.`,
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
if (input) {
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
} else if (result?.reason === 'tag-missing') {
|
||||||
|
setStatusMessage(`Tag “${result.label}” not found.`, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[bulkTagOperation, resolveTargetDocumentIds, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBulkSelectionReanalyze = useCallback(
|
||||||
|
async (documentIdsOverride = null) => {
|
||||||
|
const targetIds = resolveTargetDocumentIds(documentIdsOverride);
|
||||||
|
if (!targetIds.length) {
|
||||||
|
setStatusMessage('Select documents before requesting re-analysis.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.post('/documents/bulk/reanalyze', {
|
||||||
|
document_ids: targetIds,
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
const queued = data?.queued ?? targetIds.length;
|
||||||
|
setStatusMessage(
|
||||||
|
`Queued re-analysis for ${queued} document${queued === 1 ? '' : 's'}.`,
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error.response?.data?.error || 'Failed to queue document re-analysis.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[resolveTargetDocumentIds, notifyApiError, setStatusMessage, setLoading, apiClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bulkTagOperation,
|
||||||
|
handleBulkTagAddFromDetail,
|
||||||
|
handleBulkTagRemoveFromDetail,
|
||||||
|
handleBulkSelectionReanalyze,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDocumentTagging;
|
||||||
|
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import useFileDrop from './useFileDrop';
|
||||||
|
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils';
|
||||||
|
|
||||||
|
const useDocumentUploads = ({
|
||||||
|
apiClient,
|
||||||
|
token,
|
||||||
|
selectedFolder,
|
||||||
|
currentFolderName,
|
||||||
|
ensureFolderData,
|
||||||
|
refreshCurrentFolder,
|
||||||
|
notifyApiError,
|
||||||
|
setStatusMessage,
|
||||||
|
setLoading,
|
||||||
|
shellRef,
|
||||||
|
}) => {
|
||||||
|
const [dropOverlayState, setDropOverlayState] = useState({
|
||||||
|
active: false,
|
||||||
|
folderName: DEFAULT_FOLDER_NAME,
|
||||||
|
});
|
||||||
|
const dragCounterRef = useRef(0);
|
||||||
|
const folderPathCacheRef = useRef(new Map());
|
||||||
|
|
||||||
|
const uploadFile = useCallback(
|
||||||
|
async (file, targetFolderId) => {
|
||||||
|
if (!file || file.size === 0) {
|
||||||
|
setStatusMessage('Skipped empty file.', 'error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file, file.name);
|
||||||
|
if (targetFolderId && targetFolderId !== 'root') {
|
||||||
|
formData.append('folder_id', targetFolderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, status } = await apiClient.post('/documents', formData);
|
||||||
|
const duplicate = data?.reused || status === 200;
|
||||||
|
setStatusMessage(
|
||||||
|
duplicate
|
||||||
|
? `${file.name} already exists; reused existing document.`
|
||||||
|
: `Uploaded ${file.name}`,
|
||||||
|
duplicate ? 'info' : 'success',
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || `Failed to upload ${file.name}.`;
|
||||||
|
notifyApiError(error, message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[apiClient, notifyApiError, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const ensureFolderPathOnServer = useCallback(
|
||||||
|
async (baseFolderId, segments) => {
|
||||||
|
const trimmedSegments = segments.map((segment) => segment.trim()).filter(Boolean);
|
||||||
|
if (trimmedSegments.length === 0) {
|
||||||
|
return baseFolderId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${baseFolderId ?? 'ROOT'}:${trimmedSegments.join('/')}`;
|
||||||
|
const cache = folderPathCacheRef.current;
|
||||||
|
if (cache.has(cacheKey)) {
|
||||||
|
return cache.get(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
parent_id: baseFolderId && baseFolderId !== 'root' ? baseFolderId : null,
|
||||||
|
segments: trimmedSegments,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await apiClient.post('/folders/path', payload);
|
||||||
|
cache.set(cacheKey, data.folder.id);
|
||||||
|
return data.folder.id;
|
||||||
|
},
|
||||||
|
[apiClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => {
|
||||||
|
if (!dataTransfer) {
|
||||||
|
throw new Error('No drop payload found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = Array.from(dataTransfer.items || []);
|
||||||
|
console.info('[Uploads] drop start', {
|
||||||
|
items: items.length,
|
||||||
|
files: (dataTransfer.files || []).length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
const seenKeys = new Set();
|
||||||
|
|
||||||
|
const pushFile = (file, ancestors = []) => {
|
||||||
|
if (!file) return;
|
||||||
|
const segments = (ancestors || []).filter(Boolean);
|
||||||
|
const key = `${segments.join('/')}/${file.name}:${file.size}`;
|
||||||
|
if (seenKeys.has(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seenKeys.add(key);
|
||||||
|
results.push({ file, segments });
|
||||||
|
};
|
||||||
|
|
||||||
|
const readAllEntries = async (reader) => {
|
||||||
|
const entries = [];
|
||||||
|
let batch = [];
|
||||||
|
do {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
|
||||||
|
if (batch.length) {
|
||||||
|
entries.push(...batch);
|
||||||
|
}
|
||||||
|
} while (batch.length);
|
||||||
|
return entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
const walkEntry = async (entry, ancestors = []) => {
|
||||||
|
if (!entry) return;
|
||||||
|
if (entry.isFile) {
|
||||||
|
const file = await new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
entry.file(resolve, reject);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Uploads] entry.file failed', error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
pushFile(file, ancestors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entry.isDirectory) {
|
||||||
|
const nextAncestors = entry.name ? [...ancestors, entry.name] : [...ancestors];
|
||||||
|
const reader = entry.createReader();
|
||||||
|
const entries = await readAllEntries(reader);
|
||||||
|
for (const child of entries) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await walkEntry(child, nextAncestors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
items.map(async (item, index) => {
|
||||||
|
if (item.kind !== 'file') return;
|
||||||
|
|
||||||
|
const fileFromItem = typeof item.getAsFile === 'function' ? item.getAsFile() : null;
|
||||||
|
if (fileFromItem) {
|
||||||
|
const relativePath =
|
||||||
|
typeof fileFromItem.webkitRelativePath === 'string'
|
||||||
|
? fileFromItem.webkitRelativePath
|
||||||
|
: '';
|
||||||
|
const segments = relativePath
|
||||||
|
? relativePath
|
||||||
|
.split('/')
|
||||||
|
.slice(0, -1)
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
pushFile(fileFromItem, segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof item.webkitGetAsEntry === 'function') {
|
||||||
|
try {
|
||||||
|
const entry = item.webkitGetAsEntry();
|
||||||
|
if (entry) {
|
||||||
|
await walkEntry(entry, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Uploads] webkitGetAsEntry failed', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fileFromItem) {
|
||||||
|
console.info('[Uploads] item missing file handle', index);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Array.from(dataTransfer.files || []).forEach((file) => {
|
||||||
|
if (!file) return;
|
||||||
|
const relativePath =
|
||||||
|
typeof file.webkitRelativePath === 'string' ? file.webkitRelativePath : '';
|
||||||
|
const segments = relativePath
|
||||||
|
? relativePath
|
||||||
|
.split('/')
|
||||||
|
.slice(0, -1)
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
pushFile(file, segments);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!results.length) {
|
||||||
|
throw new Error('No files detected in drop payload.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info('[Uploads] prepared files', results.length);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileDrop = useCallback(
|
||||||
|
async (dataTransfer, targetFolderId) => {
|
||||||
|
if (!token) {
|
||||||
|
setStatusMessage('Please log in before uploading.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
folderPathCacheRef.current.clear();
|
||||||
|
|
||||||
|
let extracted;
|
||||||
|
try {
|
||||||
|
extracted = await extractFilesFromDataTransfer(dataTransfer);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.message || 'Failed to process dropped files.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!extracted.length) {
|
||||||
|
setStatusMessage('No files to upload.', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const baseFolderId =
|
||||||
|
targetFolderId && targetFolderId !== 'root' ? targetFolderId : null;
|
||||||
|
|
||||||
|
for (const { file, segments } of extracted) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const destinationId = segments.length
|
||||||
|
? await ensureFolderPathOnServer(baseFolderId, segments)
|
||||||
|
: baseFolderId;
|
||||||
|
|
||||||
|
const uploadTarget =
|
||||||
|
destinationId ??
|
||||||
|
(targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root');
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await uploadFile(file, uploadTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshCurrentFolder();
|
||||||
|
|
||||||
|
if (
|
||||||
|
targetFolderId &&
|
||||||
|
targetFolderId !== 'root' &&
|
||||||
|
targetFolderId !== selectedFolder
|
||||||
|
) {
|
||||||
|
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError(error, 'Failed to upload files.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
token,
|
||||||
|
extractFilesFromDataTransfer,
|
||||||
|
ensureFolderPathOnServer,
|
||||||
|
uploadFile,
|
||||||
|
refreshCurrentFolder,
|
||||||
|
selectedFolder,
|
||||||
|
ensureFolderData,
|
||||||
|
notifyApiError,
|
||||||
|
setStatusMessage,
|
||||||
|
setLoading,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
useFileDrop({
|
||||||
|
shellRef,
|
||||||
|
token,
|
||||||
|
currentFolderName,
|
||||||
|
selectedFolder,
|
||||||
|
handleFileDrop,
|
||||||
|
hasFiles,
|
||||||
|
defaultFolderName: DEFAULT_FOLDER_NAME,
|
||||||
|
dragCounterRef,
|
||||||
|
dropOverlayState,
|
||||||
|
setDropOverlayState,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetUploadsState = useCallback(() => {
|
||||||
|
dragCounterRef.current = 0;
|
||||||
|
setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dropOverlayState,
|
||||||
|
setDropOverlayState,
|
||||||
|
dragCounterRef,
|
||||||
|
handleFileDrop,
|
||||||
|
uploadFile,
|
||||||
|
extractFilesFromDataTransfer,
|
||||||
|
resetUploadsState,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDocumentUploads;
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
const useDocuments = ({ setSearchResults, setFolderContents }) => {
|
||||||
|
const [documents, setDocuments] = useState([]);
|
||||||
|
|
||||||
|
const mapDocumentCaches = useCallback(
|
||||||
|
(mapper) => {
|
||||||
|
if (typeof mapper !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyToList = (list) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = list.map((doc) => {
|
||||||
|
const updated = mapper(doc);
|
||||||
|
if (updated === undefined || updated === doc) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
return changed ? next : list;
|
||||||
|
};
|
||||||
|
|
||||||
|
setDocuments((prev) => applyToList(prev));
|
||||||
|
setSearchResults((prev) => {
|
||||||
|
if (!Array.isArray(prev)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return applyToList(prev);
|
||||||
|
});
|
||||||
|
setFolderContents((prev) => {
|
||||||
|
if (!prev.size) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
let changed = false;
|
||||||
|
const next = new Map();
|
||||||
|
prev.forEach((contents, key) => {
|
||||||
|
const docs = Array.isArray(contents?.documents) ? contents.documents : null;
|
||||||
|
if (!docs || docs.length === 0) {
|
||||||
|
next.set(key, contents);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let docsChanged = false;
|
||||||
|
const updatedDocs = docs.map((doc) => {
|
||||||
|
const updated = mapper(doc);
|
||||||
|
if (updated === undefined || updated === doc) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
docsChanged = true;
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
if (docsChanged) {
|
||||||
|
changed = true;
|
||||||
|
next.set(key, { ...contents, documents: updatedDocs });
|
||||||
|
} else {
|
||||||
|
next.set(key, contents);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setFolderContents, setSearchResults],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateDocumentCaches = useCallback(
|
||||||
|
(documentId, updater) => {
|
||||||
|
if (!documentId || typeof updater !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mapDocumentCaches((doc) => {
|
||||||
|
if (!doc || doc.id !== documentId) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
const updated = updater(doc);
|
||||||
|
return updated === undefined ? doc : updated;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[mapDocumentCaches],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
documents,
|
||||||
|
setDocuments,
|
||||||
|
mapDocumentCaches,
|
||||||
|
updateDocumentCaches,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDocuments;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
const useFileDrop = ({
|
||||||
|
shellRef,
|
||||||
|
token,
|
||||||
|
currentFolderName,
|
||||||
|
selectedFolder,
|
||||||
|
handleFileDrop,
|
||||||
|
hasFiles,
|
||||||
|
defaultFolderName,
|
||||||
|
dragCounterRef,
|
||||||
|
setDropOverlayState,
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setDropOverlayState((prev) => ({ ...prev, active: false }));
|
||||||
|
dragCounterRef.current = 0;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnter = (event) => {
|
||||||
|
if (!hasFiles(event)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
dragCounterRef.current += 1;
|
||||||
|
setDropOverlayState({ active: true, folderName: currentFolderName });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event) => {
|
||||||
|
if (!hasFiles(event)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = 'copy';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (event) => {
|
||||||
|
if (!hasFiles(event)) return;
|
||||||
|
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
|
||||||
|
if (dragCounterRef.current === 0) {
|
||||||
|
setDropOverlayState((prev) => ({ ...prev, active: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = async (event) => {
|
||||||
|
if (!hasFiles(event)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
dragCounterRef.current = 0;
|
||||||
|
setDropOverlayState((prev) => ({ ...prev, active: false }));
|
||||||
|
await handleFileDrop(event.dataTransfer, selectedFolder);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dropTarget = shellRef.current;
|
||||||
|
if (!dropTarget) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
dropTarget.addEventListener('dragenter', handleDragEnter);
|
||||||
|
dropTarget.addEventListener('dragover', handleDragOver);
|
||||||
|
dropTarget.addEventListener('dragleave', handleDragLeave);
|
||||||
|
dropTarget.addEventListener('drop', handleDrop);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
dropTarget.removeEventListener('dragenter', handleDragEnter);
|
||||||
|
dropTarget.removeEventListener('dragover', handleDragOver);
|
||||||
|
dropTarget.removeEventListener('dragleave', handleDragLeave);
|
||||||
|
dropTarget.removeEventListener('drop', handleDrop);
|
||||||
|
dragCounterRef.current = 0;
|
||||||
|
setDropOverlayState((prev) => ({ ...prev, active: false }));
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
token,
|
||||||
|
handleFileDrop,
|
||||||
|
currentFolderName,
|
||||||
|
defaultFolderName,
|
||||||
|
selectedFolder,
|
||||||
|
hasFiles,
|
||||||
|
shellRef,
|
||||||
|
dragCounterRef,
|
||||||
|
setDropOverlayState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useFileDrop;
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
DEFAULT_FOLDER_NAME,
|
||||||
|
createRootNode,
|
||||||
|
getRowId,
|
||||||
|
isDocumentRowKey,
|
||||||
|
isFolderRowKey,
|
||||||
|
resolveDocumentRowKey,
|
||||||
|
resolveFolderRowKey,
|
||||||
|
} from '../../app/appLayoutUtils';
|
||||||
|
|
||||||
|
const useFolderTree = ({
|
||||||
|
initialSelectedFolder = 'root',
|
||||||
|
assetManager,
|
||||||
|
apiClient,
|
||||||
|
tenantIdRef,
|
||||||
|
documentsSortFieldRef,
|
||||||
|
documentsSortDirectionRef,
|
||||||
|
selectionHelpers,
|
||||||
|
setDocuments,
|
||||||
|
setFolderContents,
|
||||||
|
folderContentsRef,
|
||||||
|
}) => {
|
||||||
|
const [folderNodes, setFolderNodes] = useState(() => {
|
||||||
|
const rootNode = createRootNode();
|
||||||
|
return new Map([[rootNode.id, rootNode]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [selectedFolder, setSelectedFolder] = useState(initialSelectedFolder || 'root');
|
||||||
|
const [currentFolder, setCurrentFolder] = useState(null);
|
||||||
|
const [currentSubfolders, setCurrentSubfolders] = useState([]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
focusedDocumentId,
|
||||||
|
setFocusedDocumentId,
|
||||||
|
setSelectedEntries,
|
||||||
|
setSelectionOrder,
|
||||||
|
selectionOrderRef,
|
||||||
|
selectionAnchorRef,
|
||||||
|
} = selectionHelpers;
|
||||||
|
|
||||||
|
const applySelectedFolder = useCallback(
|
||||||
|
(folderId, contents) => {
|
||||||
|
const subfolders = contents?.subfolders ?? [];
|
||||||
|
const docs = assetManager.hydrateDocuments(contents?.documents ?? []);
|
||||||
|
const folderInfo = contents?.folder ?? null;
|
||||||
|
|
||||||
|
setCurrentSubfolders(subfolders);
|
||||||
|
setDocuments(docs);
|
||||||
|
setCurrentFolder(folderInfo);
|
||||||
|
|
||||||
|
const availableDocKeys = docs
|
||||||
|
.map((doc) => resolveDocumentRowKey(doc.id))
|
||||||
|
.filter(Boolean);
|
||||||
|
const availableDocKeySet = new Set(availableDocKeys);
|
||||||
|
const availableFolderKeys = new Set(
|
||||||
|
subfolders
|
||||||
|
.map((folder) => resolveFolderRowKey(folder.id))
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
|
||||||
|
let nextDocKeys = [];
|
||||||
|
let mergedSelection = [];
|
||||||
|
|
||||||
|
setSelectedEntries((previous) => {
|
||||||
|
const previousFolderKeys = previous
|
||||||
|
.filter(isFolderRowKey)
|
||||||
|
.filter((key) => availableFolderKeys.has(key));
|
||||||
|
const previousDocKeys = previous.filter(isDocumentRowKey);
|
||||||
|
nextDocKeys = previousDocKeys.filter((key) => availableDocKeySet.has(key));
|
||||||
|
mergedSelection = [...previousFolderKeys, ...nextDocKeys];
|
||||||
|
return mergedSelection;
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextFocus = (() => {
|
||||||
|
const currentFocusedKey = resolveDocumentRowKey(focusedDocumentId);
|
||||||
|
if (currentFocusedKey && availableDocKeySet.has(currentFocusedKey)) {
|
||||||
|
return focusedDocumentId;
|
||||||
|
}
|
||||||
|
if (nextDocKeys.length) {
|
||||||
|
const lastDocKey = nextDocKeys[nextDocKeys.length - 1];
|
||||||
|
return getRowId(lastDocKey) || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
setFocusedDocumentId(nextFocus);
|
||||||
|
selectionAnchorRef.current = nextDocKeys.length
|
||||||
|
? nextDocKeys[nextDocKeys.length - 1]
|
||||||
|
: null;
|
||||||
|
selectionOrderRef.current = mergedSelection;
|
||||||
|
setSelectionOrder(mergedSelection);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
assetManager,
|
||||||
|
focusedDocumentId,
|
||||||
|
selectionAnchorRef,
|
||||||
|
selectionOrderRef,
|
||||||
|
setDocuments,
|
||||||
|
setFocusedDocumentId,
|
||||||
|
setSelectedEntries,
|
||||||
|
setSelectionOrder,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const expandFolderAncestors = useCallback((targetId) => {
|
||||||
|
if (!targetId || targetId === 'root') {
|
||||||
|
setFolderNodes((prev) => {
|
||||||
|
const root = prev.get('root');
|
||||||
|
if (root?.expanded) return prev;
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set('root', { ...root, expanded: true });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFolderNodes((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
let currentId = targetId;
|
||||||
|
let guard = 0;
|
||||||
|
while (currentId && guard < 32) {
|
||||||
|
guard += 1;
|
||||||
|
const node = next.get(currentId);
|
||||||
|
if (!node) break;
|
||||||
|
if (!node.expanded) {
|
||||||
|
next.set(currentId, { ...node, expanded: true });
|
||||||
|
}
|
||||||
|
currentId = node.parentId ?? 'root';
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const ensureFolderData = useCallback(
|
||||||
|
async (
|
||||||
|
folderId,
|
||||||
|
{
|
||||||
|
includeDocuments = true,
|
||||||
|
prefetchDepth = 0,
|
||||||
|
force = false,
|
||||||
|
sortField = documentsSortFieldRef.current,
|
||||||
|
sortDirection = documentsSortDirectionRef.current,
|
||||||
|
} = {},
|
||||||
|
) => {
|
||||||
|
const requestTenantId = tenantIdRef.current;
|
||||||
|
const cached = folderContentsRef.current.get(folderId);
|
||||||
|
const cachedSortField = cached?.__sortField || documentsSortFieldRef.current;
|
||||||
|
const cachedSortDirection = cached?.__sortDirection || documentsSortDirectionRef.current;
|
||||||
|
const cachedSortMatches = cachedSortField === sortField && cachedSortDirection === sortDirection;
|
||||||
|
|
||||||
|
if (!force && cached) {
|
||||||
|
const includesDocuments = Boolean(cached.__includesDocuments);
|
||||||
|
if (!includeDocuments || (includesDocuments && cachedSortMatches)) {
|
||||||
|
if (prefetchDepth > 0) {
|
||||||
|
const subfolders = Array.isArray(cached.subfolders) ? cached.subfolders : [];
|
||||||
|
await Promise.allSettled(
|
||||||
|
subfolders.map((entry) =>
|
||||||
|
ensureFolderData(entry.id, {
|
||||||
|
includeDocuments: false,
|
||||||
|
prefetchDepth: prefetchDepth - 1,
|
||||||
|
force: false,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = folderId === 'root' ? 'root' : folderId;
|
||||||
|
const params = {};
|
||||||
|
if (!includeDocuments) {
|
||||||
|
params.include_documents = false;
|
||||||
|
} else {
|
||||||
|
params.sort = sortField;
|
||||||
|
params.dir = sortDirection;
|
||||||
|
}
|
||||||
|
const requestConfig = Object.keys(params).length ? { params } : {};
|
||||||
|
const { data } = await apiClient.get(`/folders/${path}/contents`, requestConfig);
|
||||||
|
const hydrated = assetManager.hydrateFolderContents(data);
|
||||||
|
const childFolders = Array.isArray(data.subfolders) ? data.subfolders : [];
|
||||||
|
const childIds = childFolders.map((child) => child.id);
|
||||||
|
|
||||||
|
const enriched = {
|
||||||
|
...hydrated,
|
||||||
|
__includesDocuments: includeDocuments,
|
||||||
|
__sortField: includeDocuments ? sortField : cachedSortField,
|
||||||
|
__sortDirection: includeDocuments ? sortDirection : cachedSortDirection,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tenantIdRef.current !== requestTenantId) {
|
||||||
|
return enriched;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFolderNodes((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existingNode = next.get(folderId) || {
|
||||||
|
id: folderId,
|
||||||
|
name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || 'Folder',
|
||||||
|
parentId: data.folder?.parent_id || 'root',
|
||||||
|
children: [],
|
||||||
|
expanded: folderId === 'root',
|
||||||
|
loaded: false,
|
||||||
|
hasChildren: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
next.set(folderId, {
|
||||||
|
...existingNode,
|
||||||
|
name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || existingNode.name,
|
||||||
|
parentId: data.folder?.parent_id ?? existingNode.parentId ?? 'root',
|
||||||
|
children: childIds,
|
||||||
|
expanded: folderId === 'root' ? true : existingNode.expanded,
|
||||||
|
loaded: true,
|
||||||
|
hasChildren: childIds.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
childFolders.forEach((child) => {
|
||||||
|
const childNode = next.get(child.id);
|
||||||
|
const previousChildren = Array.isArray(childNode?.children) ? childNode.children : [];
|
||||||
|
const childHasChildren = (() => {
|
||||||
|
if (childNode?.loaded) {
|
||||||
|
return previousChildren.length > 0;
|
||||||
|
}
|
||||||
|
if (Array.isArray(child?.subfolders)) {
|
||||||
|
return child.subfolders.length > 0;
|
||||||
|
}
|
||||||
|
if (typeof child?.has_children === 'boolean') {
|
||||||
|
return child.has_children;
|
||||||
|
}
|
||||||
|
if (typeof child?.hasChildren === 'boolean') {
|
||||||
|
return child.hasChildren;
|
||||||
|
}
|
||||||
|
if (typeof childNode?.hasChildren === 'boolean') {
|
||||||
|
return childNode.hasChildren;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})();
|
||||||
|
next.set(child.id, {
|
||||||
|
id: child.id,
|
||||||
|
name: child.name,
|
||||||
|
parentId: child.parent_id ?? 'root',
|
||||||
|
children: previousChildren,
|
||||||
|
expanded: childNode?.expanded ?? false,
|
||||||
|
loaded: childNode?.loaded ?? false,
|
||||||
|
hasChildren: childHasChildren,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (prefetchDepth > 0 && childIds.length > 0 && tenantIdRef.current === requestTenantId) {
|
||||||
|
await Promise.allSettled(
|
||||||
|
childIds.map((childId) =>
|
||||||
|
ensureFolderData(childId, {
|
||||||
|
includeDocuments: false,
|
||||||
|
force: false,
|
||||||
|
prefetchDepth: prefetchDepth - 1,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFolderContents((prev) => {
|
||||||
|
if (tenantIdRef.current !== requestTenantId) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = new Map(prev);
|
||||||
|
if (includeDocuments) {
|
||||||
|
next.set(folderId, enriched);
|
||||||
|
} else {
|
||||||
|
const existingEntry = next.get(folderId);
|
||||||
|
if (existingEntry) {
|
||||||
|
next.set(folderId, {
|
||||||
|
...existingEntry,
|
||||||
|
...hydrated,
|
||||||
|
documents: existingEntry.__includesDocuments
|
||||||
|
? existingEntry.documents
|
||||||
|
: hydrated.documents,
|
||||||
|
__includesDocuments: existingEntry.__includesDocuments || false,
|
||||||
|
__sortField: existingEntry.__sortField ?? enriched.__sortField,
|
||||||
|
__sortDirection: existingEntry.__sortDirection ?? enriched.__sortDirection,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
next.set(folderId, enriched);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
return enriched;
|
||||||
|
},
|
||||||
|
[
|
||||||
|
apiClient,
|
||||||
|
assetManager,
|
||||||
|
documentsSortDirectionRef,
|
||||||
|
documentsSortFieldRef,
|
||||||
|
tenantIdRef,
|
||||||
|
setFolderContents,
|
||||||
|
folderContentsRef,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const ensureFolderAncestorsLoaded = useCallback(
|
||||||
|
async (targetId) => {
|
||||||
|
if (!targetId || targetId === 'root') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let current = targetId;
|
||||||
|
let guard = 0;
|
||||||
|
while (current && current !== 'root' && guard < 32) {
|
||||||
|
guard += 1;
|
||||||
|
const node = folderNodes.get(current);
|
||||||
|
if (node?.loaded) {
|
||||||
|
current = node.parentId ?? 'root';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await ensureFolderData(current, { includeDocuments: false, prefetchDepth: 0 });
|
||||||
|
current = folderNodes.get(current)?.parentId ?? 'root';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[folderNodes, ensureFolderData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isInvalidFolderDrop = useCallback(
|
||||||
|
(sourceId, targetId) => {
|
||||||
|
if (!sourceId) return false;
|
||||||
|
if (!targetId || targetId === 'root') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (sourceId === targetId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = targetId;
|
||||||
|
const visited = new Set();
|
||||||
|
while (current && current !== 'root' && !visited.has(current)) {
|
||||||
|
visited.add(current);
|
||||||
|
if (current === sourceId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const node = folderNodes.get(current);
|
||||||
|
if (!node) break;
|
||||||
|
current = node.parentId ?? 'root';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[folderNodes],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetFolderTreeState = useCallback(() => {
|
||||||
|
const rootNode = createRootNode();
|
||||||
|
setFolderNodes(new Map([[rootNode.id, rootNode]]));
|
||||||
|
setFolderContents(new Map());
|
||||||
|
setSelectedFolder('root');
|
||||||
|
setCurrentFolder(null);
|
||||||
|
setCurrentSubfolders([]);
|
||||||
|
}, [setFolderContents]);
|
||||||
|
|
||||||
|
const currentFolderName = useMemo(() => {
|
||||||
|
if (selectedFolder === 'root' || !currentFolder) return DEFAULT_FOLDER_NAME;
|
||||||
|
return currentFolder.name;
|
||||||
|
}, [selectedFolder, currentFolder]);
|
||||||
|
|
||||||
|
const folderOptions = useMemo(() => {
|
||||||
|
const cache = new Map();
|
||||||
|
const computePath = (id) => {
|
||||||
|
if (cache.has(id)) {
|
||||||
|
return cache.get(id);
|
||||||
|
}
|
||||||
|
if (!id || id === 'root') {
|
||||||
|
cache.set('root', DEFAULT_FOLDER_NAME);
|
||||||
|
return DEFAULT_FOLDER_NAME;
|
||||||
|
}
|
||||||
|
const node = folderNodes.get(id);
|
||||||
|
if (!node) {
|
||||||
|
return 'Folder';
|
||||||
|
}
|
||||||
|
const parentId = node.parentId || 'root';
|
||||||
|
const parentPath = computePath(parentId);
|
||||||
|
const name = node.name || 'Folder';
|
||||||
|
const fullPath = parentId === 'root' ? name : `${parentPath}/${name}`;
|
||||||
|
cache.set(id, fullPath);
|
||||||
|
return fullPath;
|
||||||
|
};
|
||||||
|
|
||||||
|
const entries = [];
|
||||||
|
folderNodes.forEach((node, id) => {
|
||||||
|
if (!node) return;
|
||||||
|
entries.push({ id, label: computePath(id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
if (a.id === 'root') return -1;
|
||||||
|
if (b.id === 'root') return 1;
|
||||||
|
return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' });
|
||||||
|
});
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}, [folderNodes]);
|
||||||
|
|
||||||
|
const folderLabelMap = useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
folderOptions.forEach((option) => {
|
||||||
|
map.set(option.id, option.label);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [folderOptions]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
folderNodes,
|
||||||
|
setFolderNodes,
|
||||||
|
selectedFolder,
|
||||||
|
setSelectedFolder,
|
||||||
|
currentFolder,
|
||||||
|
setCurrentFolder,
|
||||||
|
currentSubfolders,
|
||||||
|
setCurrentSubfolders,
|
||||||
|
currentFolderName,
|
||||||
|
folderOptions,
|
||||||
|
folderLabelMap,
|
||||||
|
applySelectedFolder,
|
||||||
|
ensureFolderData,
|
||||||
|
ensureFolderAncestorsLoaded,
|
||||||
|
expandFolderAncestors,
|
||||||
|
isInvalidFolderDrop,
|
||||||
|
resetFolderTreeState,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useFolderTree;
|
||||||
@@ -0,0 +1,618 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils';
|
||||||
|
|
||||||
|
const useFolderTreeActions = ({
|
||||||
|
api,
|
||||||
|
token,
|
||||||
|
folderNodes,
|
||||||
|
setFolderNodes,
|
||||||
|
selectedFolder,
|
||||||
|
setSelectedFolder,
|
||||||
|
ensureFolderData,
|
||||||
|
ensureFolderAncestorsLoaded,
|
||||||
|
expandFolderAncestors,
|
||||||
|
applySelectedFolder,
|
||||||
|
notifyApiError,
|
||||||
|
setStatusMessage,
|
||||||
|
setLoading,
|
||||||
|
setFolderContents,
|
||||||
|
setCurrentFolder,
|
||||||
|
setSearchResults,
|
||||||
|
isFilterActive,
|
||||||
|
navigate,
|
||||||
|
handleFileDrop,
|
||||||
|
moveDocumentsToFolder,
|
||||||
|
draggedDocumentIds,
|
||||||
|
draggedFolderId,
|
||||||
|
setDraggedDocumentIds,
|
||||||
|
setDraggedFolderId,
|
||||||
|
isInvalidFolderDrop,
|
||||||
|
setCreatingFolder,
|
||||||
|
}) => {
|
||||||
|
const moveFolder = useCallback(
|
||||||
|
async (folderId, targetFolderId) => {
|
||||||
|
const node = folderNodes.get(folderId);
|
||||||
|
if (!node) {
|
||||||
|
setStatusMessage('Folder metadata unavailable. Try refreshing.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousParentKey = node.parentId ?? 'root';
|
||||||
|
const targetKey = targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root';
|
||||||
|
|
||||||
|
if (previousParentKey === targetKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent_id = targetKey === 'root' ? null : targetKey;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.patch(`/folders/${folderId}`, { parent_id });
|
||||||
|
|
||||||
|
setFolderNodes((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const currentNode = next.get(folderId);
|
||||||
|
if (!currentNode) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
next.set(folderId, { ...currentNode, parentId: parent_id ?? null });
|
||||||
|
|
||||||
|
const previousParent = next.get(previousParentKey);
|
||||||
|
if (previousParent) {
|
||||||
|
const remainingChildren = (previousParent.children || []).filter(
|
||||||
|
(childId) => childId !== folderId,
|
||||||
|
);
|
||||||
|
next.set(previousParentKey, {
|
||||||
|
...previousParent,
|
||||||
|
children: remainingChildren,
|
||||||
|
hasChildren: remainingChildren.length > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!next.has(targetKey)) {
|
||||||
|
next.set(targetKey, {
|
||||||
|
id: targetKey,
|
||||||
|
name: targetKey === 'root' ? DEFAULT_FOLDER_NAME : 'Folder',
|
||||||
|
parentId: targetKey === 'root' ? null : null,
|
||||||
|
children: [],
|
||||||
|
expanded: targetKey === 'root',
|
||||||
|
loaded: false,
|
||||||
|
hasChildren: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetNode = next.get(targetKey);
|
||||||
|
if (targetNode && !targetNode.children.includes(folderId)) {
|
||||||
|
next.set(targetKey, {
|
||||||
|
...targetNode,
|
||||||
|
children: [...targetNode.children, folderId],
|
||||||
|
hasChildren: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshTargets = new Set([previousParentKey, targetKey]);
|
||||||
|
for (const key of refreshTargets) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedFolder === folderId) {
|
||||||
|
await ensureFolderData(folderId, { force: true, prefetchDepth: 1 });
|
||||||
|
setSelectedFolder(folderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatusMessage('Folder moved.', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to move folder.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
|
||||||
|
const refreshTargets = new Set([previousParentKey, targetKey]);
|
||||||
|
for (const key of refreshTargets) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
api,
|
||||||
|
ensureFolderData,
|
||||||
|
folderNodes,
|
||||||
|
notifyApiError,
|
||||||
|
selectedFolder,
|
||||||
|
setFolderNodes,
|
||||||
|
setSelectedFolder,
|
||||||
|
setStatusMessage,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadFolder = useCallback(
|
||||||
|
async (folderId, { showLoading = true, preserveSearch = false } = {}) => {
|
||||||
|
const targetId = folderId || 'root';
|
||||||
|
setSelectedFolder(targetId);
|
||||||
|
await ensureFolderAncestorsLoaded(targetId);
|
||||||
|
expandFolderAncestors(targetId);
|
||||||
|
if (showLoading) setLoading(true);
|
||||||
|
try {
|
||||||
|
const contents = await ensureFolderData(targetId, { force: true, prefetchDepth: 1 });
|
||||||
|
if (targetId !== 'root') {
|
||||||
|
try {
|
||||||
|
await ensureFolderData('root', {
|
||||||
|
force: false,
|
||||||
|
includeDocuments: false,
|
||||||
|
prefetchDepth: 1,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to refresh root folder tree', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applySelectedFolder(targetId, contents);
|
||||||
|
if (!preserveSearch) {
|
||||||
|
setSearchResults(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError(error, 'Failed to load folder contents.');
|
||||||
|
} finally {
|
||||||
|
if (showLoading) setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
applySelectedFolder,
|
||||||
|
ensureFolderAncestorsLoaded,
|
||||||
|
ensureFolderData,
|
||||||
|
expandFolderAncestors,
|
||||||
|
notifyApiError,
|
||||||
|
setLoading,
|
||||||
|
setSearchResults,
|
||||||
|
setSelectedFolder,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectFolder = useCallback(
|
||||||
|
async (folderId, { replace = false, immediate = false } = {}) => {
|
||||||
|
const targetId = folderId && folderId !== 'root' ? folderId : 'root';
|
||||||
|
|
||||||
|
await ensureFolderAncestorsLoaded(targetId);
|
||||||
|
expandFolderAncestors(targetId);
|
||||||
|
|
||||||
|
if (!navigate || immediate) {
|
||||||
|
await loadFolder(targetId, { preserveSearch: isFilterActive });
|
||||||
|
setSelectedFolder(targetId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
|
||||||
|
navigate(path, { replace });
|
||||||
|
},
|
||||||
|
[
|
||||||
|
ensureFolderAncestorsLoaded,
|
||||||
|
expandFolderAncestors,
|
||||||
|
isFilterActive,
|
||||||
|
loadFolder,
|
||||||
|
navigate,
|
||||||
|
setSelectedFolder,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFolderRename = useCallback(
|
||||||
|
async (folderId, nextName) => {
|
||||||
|
if (!token) {
|
||||||
|
setStatusMessage('Log in to rename folders.', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const trimmed = typeof nextName === 'string' ? nextName.trim() : '';
|
||||||
|
if (!trimmed) {
|
||||||
|
setStatusMessage('Folder name cannot be empty.', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.patch(`/folders/${folderId}`, { name: trimmed });
|
||||||
|
|
||||||
|
setFolderNodes((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const node = next.get(folderId);
|
||||||
|
if (node) {
|
||||||
|
next.set(folderId, { ...node, name: trimmed });
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
setFolderContents((prev) => {
|
||||||
|
if (!prev.has(folderId)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = next.get(folderId) || {};
|
||||||
|
const folderInfo = existing.folder
|
||||||
|
? { ...existing.folder, name: trimmed }
|
||||||
|
: { id: folderId, name: trimmed };
|
||||||
|
next.set(folderId, { ...existing, folder: folderInfo });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
setCurrentFolder((prev) => (prev?.id === folderId ? { ...prev, name: trimmed } : prev));
|
||||||
|
setStatusMessage('Folder renamed.', 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to rename folder.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
api,
|
||||||
|
notifyApiError,
|
||||||
|
setCurrentFolder,
|
||||||
|
setFolderContents,
|
||||||
|
setFolderNodes,
|
||||||
|
setLoading,
|
||||||
|
setStatusMessage,
|
||||||
|
token,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFolderCreate = useCallback(
|
||||||
|
async (name) => {
|
||||||
|
if (!token) {
|
||||||
|
setStatusMessage('Log in to create folders.', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!name.trim()) {
|
||||||
|
setStatusMessage('Folder name cannot be empty.', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
name: name.trim(),
|
||||||
|
parent_id: selectedFolder === 'root' ? null : selectedFolder,
|
||||||
|
};
|
||||||
|
setCreatingFolder(true);
|
||||||
|
let succeeded = false;
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/folders', payload);
|
||||||
|
setStatusMessage('Folder created.', 'success');
|
||||||
|
setFolderNodes((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const parentId = payload.parent_id || 'root';
|
||||||
|
const parentNode = next.get(parentId);
|
||||||
|
if (parentNode) {
|
||||||
|
next.set(parentId, {
|
||||||
|
...parentNode,
|
||||||
|
children: parentNode.children.concat([data.folder.id]),
|
||||||
|
loaded: true,
|
||||||
|
hasChildren: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next.set(data.folder.id, {
|
||||||
|
id: data.folder.id,
|
||||||
|
name: data.folder.name,
|
||||||
|
parentId: parentId,
|
||||||
|
children: [],
|
||||||
|
expanded: false,
|
||||||
|
loaded: false,
|
||||||
|
hasChildren: false,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 });
|
||||||
|
await selectFolder(data.folder.id, { immediate: true });
|
||||||
|
succeeded = true;
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to create folder.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setCreatingFolder(false);
|
||||||
|
if (!succeeded) {
|
||||||
|
setStatusMessage('Folder creation failed.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
api,
|
||||||
|
ensureFolderData,
|
||||||
|
notifyApiError,
|
||||||
|
selectFolder,
|
||||||
|
selectedFolder,
|
||||||
|
setCreatingFolder,
|
||||||
|
setFolderNodes,
|
||||||
|
setStatusMessage,
|
||||||
|
token,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFolderDelete = useCallback(
|
||||||
|
async (folderId, { showMessage = true, manageLoading = true } = {}) => {
|
||||||
|
if (!token) {
|
||||||
|
if (showMessage) {
|
||||||
|
setStatusMessage('Log in to manage folders.', 'error');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!folderId || folderId === 'root') {
|
||||||
|
if (showMessage) {
|
||||||
|
setStatusMessage('The root folder cannot be removed.', 'error');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manageLoading) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contents = await ensureFolderData(folderId, {
|
||||||
|
force: true,
|
||||||
|
prefetchDepth: 1,
|
||||||
|
});
|
||||||
|
const hasChildren = (contents.subfolders || []).length > 0;
|
||||||
|
const hasDocs = (contents.documents || []).length > 0;
|
||||||
|
if (hasChildren || hasDocs) {
|
||||||
|
if (showMessage) {
|
||||||
|
setStatusMessage('Folder must be empty before it can be deleted.', 'error');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.delete(`/folders/${folderId}`);
|
||||||
|
|
||||||
|
setFolderNodes((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const node = next.get(folderId);
|
||||||
|
next.delete(folderId);
|
||||||
|
if (node) {
|
||||||
|
const parentId = node.parentId || 'root';
|
||||||
|
const parentNode = next.get(parentId);
|
||||||
|
if (parentNode) {
|
||||||
|
const remaining = parentNode.children.filter((id) => id !== folderId);
|
||||||
|
next.set(parentId, {
|
||||||
|
...parentNode,
|
||||||
|
children: remaining,
|
||||||
|
hasChildren: remaining.length > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
setFolderContents((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(folderId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selectedFolder === folderId) {
|
||||||
|
const node = folderNodes.get(folderId);
|
||||||
|
const parentId = node?.parentId || 'root';
|
||||||
|
setSelectedFolder(parentId);
|
||||||
|
const parentContents = await ensureFolderData(parentId, {
|
||||||
|
force: true,
|
||||||
|
prefetchDepth: 1,
|
||||||
|
});
|
||||||
|
applySelectedFolder(parentId, parentContents);
|
||||||
|
} else if (selectedFolder !== 'root') {
|
||||||
|
await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showMessage) {
|
||||||
|
setStatusMessage('Folder deleted.', 'success');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to delete folder.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
if (showMessage) {
|
||||||
|
setStatusMessage(message, 'error');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (manageLoading) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
api,
|
||||||
|
token,
|
||||||
|
applySelectedFolder,
|
||||||
|
ensureFolderData,
|
||||||
|
folderNodes,
|
||||||
|
notifyApiError,
|
||||||
|
selectedFolder,
|
||||||
|
setFolderContents,
|
||||||
|
setFolderNodes,
|
||||||
|
setLoading,
|
||||||
|
setSelectedFolder,
|
||||||
|
setStatusMessage,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const folderClickHandlers = useMemo(
|
||||||
|
() => ({
|
||||||
|
onToggle: async (folderId) => {
|
||||||
|
const node = folderNodes.get(folderId);
|
||||||
|
const nextExpanded = !(node?.expanded ?? false);
|
||||||
|
if (nextExpanded) {
|
||||||
|
try {
|
||||||
|
await ensureFolderData(folderId, {
|
||||||
|
includeDocuments: false,
|
||||||
|
prefetchDepth: 1,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError(error, 'Failed to load folder.');
|
||||||
|
}
|
||||||
|
} else if (node && !node.loaded) {
|
||||||
|
try {
|
||||||
|
await ensureFolderData(folderId, {
|
||||||
|
includeDocuments: false,
|
||||||
|
prefetchDepth: 1,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError(error, 'Failed to load folder.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFolderNodes((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const current = next.get(folderId);
|
||||||
|
if (!current) return prev;
|
||||||
|
next.set(folderId, { ...current, expanded: nextExpanded });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSelect: selectFolder,
|
||||||
|
onDrop: async (event, folderId) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.currentTarget.classList.remove('is-drop-target');
|
||||||
|
|
||||||
|
let folderIds = [];
|
||||||
|
try {
|
||||||
|
const rawFolderList = event.dataTransfer.getData('application/x-papercrate-folder-list');
|
||||||
|
if (rawFolderList) {
|
||||||
|
const parsed = JSON.parse(rawFolderList);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
folderIds = parsed.filter(Boolean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[folders] Failed to parse folder list drag payload', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!folderIds.length) {
|
||||||
|
let folderSourceId = draggedFolderId;
|
||||||
|
if (!folderSourceId) {
|
||||||
|
try {
|
||||||
|
if (event.dataTransfer.types?.includes('application/x-papercrate-folder')) {
|
||||||
|
folderSourceId = event.dataTransfer.getData('application/x-papercrate-folder');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[folders] Failed to read folder id from drag payload', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folderSourceId) {
|
||||||
|
folderIds = [folderSourceId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
folderIds = Array.from(new Set(folderIds.filter(Boolean)));
|
||||||
|
|
||||||
|
if (folderIds.length) {
|
||||||
|
setDraggedFolderId(null);
|
||||||
|
const invalidMove = folderIds.some((sourceId) => isInvalidFolderDrop(sourceId, folderId));
|
||||||
|
if (invalidMove) {
|
||||||
|
setStatusMessage(
|
||||||
|
'Cannot move a folder into itself or one of its descendants.',
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sourceId of folderIds) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await moveFolder(sourceId, folderId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasFiles(event)) {
|
||||||
|
await handleFileDrop(event.dataTransfer, folderId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let docIds = [];
|
||||||
|
try {
|
||||||
|
const raw = event.dataTransfer.getData('application/x-papercrate-doc-list');
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
docIds = parsed.filter(Boolean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[documents] Failed to parse document list drag payload', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!docIds.length) {
|
||||||
|
try {
|
||||||
|
const single = event.dataTransfer.getData('application/x-papercrate-doc');
|
||||||
|
if (single) {
|
||||||
|
docIds = [single];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[documents] Failed to read single document drag payload', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!docIds.length && draggedDocumentIds.length) {
|
||||||
|
docIds = draggedDocumentIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
docIds = Array.from(new Set(docIds));
|
||||||
|
|
||||||
|
if (!docIds.length || folderId === selectedFolder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraggedDocumentIds([]);
|
||||||
|
await moveDocumentsToFolder(docIds, folderId);
|
||||||
|
},
|
||||||
|
onDragOver: (event, folderId) => {
|
||||||
|
const folderDragActive = Boolean(draggedFolderId);
|
||||||
|
if (folderDragActive && isInvalidFolderDrop(draggedFolderId, folderId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasFiles(event)) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = 'copy';
|
||||||
|
event.currentTarget.classList.add('is-drop-target');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draggedDocumentIds.length || folderDragActive) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
event.currentTarget.classList.add('is-drop-target');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDragLeave: (event) => {
|
||||||
|
event.currentTarget.classList.remove('is-drop-target');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
draggedDocumentIds,
|
||||||
|
draggedFolderId,
|
||||||
|
ensureFolderData,
|
||||||
|
folderNodes,
|
||||||
|
handleFileDrop,
|
||||||
|
isInvalidFolderDrop,
|
||||||
|
moveDocumentsToFolder,
|
||||||
|
moveFolder,
|
||||||
|
notifyApiError,
|
||||||
|
selectFolder,
|
||||||
|
selectedFolder,
|
||||||
|
setDraggedDocumentIds,
|
||||||
|
setDraggedFolderId,
|
||||||
|
setFolderNodes,
|
||||||
|
setStatusMessage,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loadFolder,
|
||||||
|
selectFolder,
|
||||||
|
handleFolderRename,
|
||||||
|
handleFolderCreate,
|
||||||
|
handleFolderDelete,
|
||||||
|
folderClickHandlers,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useFolderTreeActions;
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
const useTags = ({
|
||||||
|
apiClient,
|
||||||
|
notifyApiError,
|
||||||
|
setStatusMessage,
|
||||||
|
tagManager,
|
||||||
|
tenantIdRef,
|
||||||
|
setActiveTagFilters,
|
||||||
|
mapDocumentCaches,
|
||||||
|
}) => {
|
||||||
|
const [tags, setTags] = useState([]);
|
||||||
|
|
||||||
|
const refreshTags = useCallback(async () => {
|
||||||
|
const requestTenantId = tenantIdRef.current;
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get('/tags');
|
||||||
|
if (tenantIdRef.current !== requestTenantId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTags(data || []);
|
||||||
|
} catch (error) {
|
||||||
|
if (tenantIdRef.current !== requestTenantId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifyApiError(error, 'Unable to load tags.');
|
||||||
|
}
|
||||||
|
}, [apiClient, notifyApiError, tenantIdRef]);
|
||||||
|
|
||||||
|
const handleTagUpdate = useCallback(
|
||||||
|
async (tagId, changes) => {
|
||||||
|
if (!tagId) {
|
||||||
|
throw new Error('Missing tag identifier.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {};
|
||||||
|
if (typeof changes.label === 'string') {
|
||||||
|
payload.label = changes.label;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(changes, 'color')) {
|
||||||
|
payload.color = changes.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(payload).length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.patch(`/tags/${tagId}`, payload);
|
||||||
|
await refreshTags();
|
||||||
|
setStatusMessage('Tag updated.', 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to update tag.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[apiClient, notifyApiError, refreshTags, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTagCreate = useCallback(
|
||||||
|
async ({ label, color } = {}) => {
|
||||||
|
const payload = tagManager.buildPayload({ label, color });
|
||||||
|
try {
|
||||||
|
await apiClient.post('/tags', payload);
|
||||||
|
await refreshTags();
|
||||||
|
setStatusMessage('Tag created.', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to create tag.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[apiClient, notifyApiError, refreshTags, setStatusMessage, tagManager],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTagDelete = useCallback(
|
||||||
|
async (tagId) => {
|
||||||
|
if (!tagId) {
|
||||||
|
throw new Error('Missing tag identifier.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/tags/${tagId}`);
|
||||||
|
setActiveTagFilters((prev) => prev.filter((id) => id !== tagId));
|
||||||
|
|
||||||
|
const stripTagFromDoc = (doc) => {
|
||||||
|
if (!doc || !Array.isArray(doc.tags)) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
const nextTags = doc.tags.filter((tag) => tag.id !== tagId);
|
||||||
|
if (nextTags.length === doc.tags.length) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
return { ...doc, tags: nextTags };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof mapDocumentCaches === 'function') {
|
||||||
|
mapDocumentCaches(stripTagFromDoc);
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshTags();
|
||||||
|
setStatusMessage('Tag deleted.', 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to delete tag.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[apiClient, mapDocumentCaches, notifyApiError, refreshTags, setActiveTagFilters, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tags,
|
||||||
|
refreshTags,
|
||||||
|
handleTagUpdate,
|
||||||
|
handleTagCreate,
|
||||||
|
handleTagDelete,
|
||||||
|
setTags,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useTags;
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
const useTenantManager = ({
|
||||||
|
apiClient,
|
||||||
|
appDispatch,
|
||||||
|
currentTenantId,
|
||||||
|
resetWorkspaceState,
|
||||||
|
setStatusMessage,
|
||||||
|
notifyApiError,
|
||||||
|
setLoading,
|
||||||
|
refreshTags,
|
||||||
|
refreshCorrespondents,
|
||||||
|
loadFolder,
|
||||||
|
handleDocumentsViewModeChange,
|
||||||
|
navigate,
|
||||||
|
tokenRef,
|
||||||
|
tenantIdRef,
|
||||||
|
}) => {
|
||||||
|
const handleTenantSelect = useCallback(
|
||||||
|
async (tenantOption, { refreshOnly = false } = {}) => {
|
||||||
|
const requestedTenantId = tenantOption?.id ?? null;
|
||||||
|
if (!refreshOnly && (!requestedTenantId || requestedTenantId === currentTenantId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (!refreshOnly) {
|
||||||
|
setStatusMessage('Switching tenant…', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshOnly) {
|
||||||
|
const { data } = await apiClient.get('/auth/tenants');
|
||||||
|
appDispatch({
|
||||||
|
type: 'SET_TENANTS',
|
||||||
|
tenants: Array.isArray(data?.tenants) ? data.tenants : [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await apiClient.post('/auth/select-tenant', {
|
||||||
|
tenant_id: requestedTenantId,
|
||||||
|
});
|
||||||
|
if (!data?.access_token) {
|
||||||
|
throw new Error('Missing access token in tenant switch response.');
|
||||||
|
}
|
||||||
|
|
||||||
|
appDispatch({ type: 'LOGOUT' });
|
||||||
|
resetWorkspaceState();
|
||||||
|
|
||||||
|
appDispatch({
|
||||||
|
type: 'LOGIN_SUCCESS',
|
||||||
|
token: data.access_token,
|
||||||
|
tenant: data.tenant || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.defaults.headers.common.Authorization = `Bearer ${data.access_token}`;
|
||||||
|
if (tokenRef) {
|
||||||
|
tokenRef.current = data.access_token;
|
||||||
|
}
|
||||||
|
if (tenantIdRef) {
|
||||||
|
tenantIdRef.current = data?.tenant?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(data?.tenants)) {
|
||||||
|
appDispatch({ type: 'SET_TENANTS', tenants: data.tenants });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDocumentsViewModeChange('list');
|
||||||
|
navigate('/documents', { replace: true });
|
||||||
|
|
||||||
|
await Promise.all([refreshTags(), refreshCorrespondents()]);
|
||||||
|
await loadFolder('root', { showLoading: false, preserveSearch: false });
|
||||||
|
|
||||||
|
const tenantLabel = data?.tenant?.name || data?.tenant?.id || 'tenant';
|
||||||
|
setStatusMessage(`Switched to ${tenantLabel}.`, 'info');
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError(error, 'Failed to switch tenant.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
apiClient,
|
||||||
|
appDispatch,
|
||||||
|
currentTenantId,
|
||||||
|
handleDocumentsViewModeChange,
|
||||||
|
loadFolder,
|
||||||
|
navigate,
|
||||||
|
notifyApiError,
|
||||||
|
refreshCorrespondents,
|
||||||
|
refreshTags,
|
||||||
|
resetWorkspaceState,
|
||||||
|
setLoading,
|
||||||
|
setStatusMessage,
|
||||||
|
tenantIdRef,
|
||||||
|
tokenRef,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { handleTenantSelect };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useTenantManager;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default api;
|
||||||
@@ -1,20 +1,14 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import { DownloadIcon, AnalyzeIcon, CloseIcon } from '../ui/icons';
|
import { DownloadIcon, CloseIcon } from '../ui/icons';
|
||||||
import DocumentSummarySection, {
|
import {
|
||||||
buildCorrespondentOptions,
|
buildCorrespondentOptions,
|
||||||
sortCorrespondents,
|
sortCorrespondents,
|
||||||
} from '../documents/DocumentSummarySection';
|
} from '../documents/DocumentSummarySection';
|
||||||
|
import DocumentInfoPanel from '../documents/DocumentInfoPanel';
|
||||||
|
import { extractDocumentMetadataPayload } from '../documents/documentMetadata';
|
||||||
import { createDocumentActionState } from '../documents/documentActions';
|
import { createDocumentActionState } from '../documents/documentActions';
|
||||||
import { resolveDocumentAssetUrl } from '../asset_manager';
|
import { resolveDocumentAssetUrl } from '../asset_manager';
|
||||||
|
|
||||||
const formatDateTime = (value) => {
|
|
||||||
if (!value) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
const date = new Date(value);
|
|
||||||
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const DocumentViewerPanel = ({
|
const DocumentViewerPanel = ({
|
||||||
document,
|
document,
|
||||||
documentId,
|
documentId,
|
||||||
@@ -42,32 +36,6 @@ const DocumentViewerPanel = ({
|
|||||||
[correspondents],
|
[correspondents],
|
||||||
);
|
);
|
||||||
|
|
||||||
const metadataItems = useMemo(() => {
|
|
||||||
if (!document) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
{ label: 'Created at', value: formatDateTime(document.created_at) },
|
|
||||||
{ label: 'Updated at', value: formatDateTime(document.updated_at) },
|
|
||||||
{
|
|
||||||
label: 'Filename',
|
|
||||||
value: document.filename,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Original filename',
|
|
||||||
value: document.original_name || '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'SHA-256 checksum',
|
|
||||||
value: document.current_version?.checksum || '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Content type',
|
|
||||||
value: document.content_type || '—',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}, [document]);
|
|
||||||
|
|
||||||
const previewContent = useMemo(() => {
|
const previewContent = useMemo(() => {
|
||||||
if (!document || !previewEntry?.url) {
|
if (!document || !previewEntry?.url) {
|
||||||
return null;
|
return null;
|
||||||
@@ -128,31 +96,41 @@ const DocumentViewerPanel = ({
|
|||||||
);
|
);
|
||||||
}, [previewEntry, document]);
|
}, [previewEntry, document]);
|
||||||
|
|
||||||
const metadataPayload = useMemo(() => {
|
const metadataPayload = useMemo(
|
||||||
if (!document || !document.metadata || Object.keys(document.metadata).length === 0) {
|
() => extractDocumentMetadataPayload(document),
|
||||||
return null;
|
[document],
|
||||||
}
|
);
|
||||||
return document.metadata;
|
|
||||||
}, [document]);
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState('details');
|
const summaryProps = useMemo(
|
||||||
useEffect(() => {
|
() => ({
|
||||||
setActiveTab('details');
|
tagLookupById,
|
||||||
}, [document?.id, hasOcr, metadataPayload]);
|
tagOptions,
|
||||||
|
onTagAdd,
|
||||||
|
onTagRemove,
|
||||||
|
correspondents: sortedCorrespondents,
|
||||||
|
correspondentOptions,
|
||||||
|
onCorrespondentAdd,
|
||||||
|
onCorrespondentRemove,
|
||||||
|
onUpdateTitle,
|
||||||
|
onUpdateIssued,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
tagLookupById,
|
||||||
|
tagOptions,
|
||||||
|
onTagAdd,
|
||||||
|
onTagRemove,
|
||||||
|
sortedCorrespondents,
|
||||||
|
correspondentOptions,
|
||||||
|
onCorrespondentAdd,
|
||||||
|
onCorrespondentRemove,
|
||||||
|
onUpdateTitle,
|
||||||
|
onUpdateIssued,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const [ocrContent, setOcrContent] = useState(null);
|
const loadOcrContent = useCallback(async ({ signal } = {}) => {
|
||||||
const [ocrLoading, setOcrLoading] = useState(false);
|
|
||||||
const [ocrError, setOcrError] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
if (!document || !hasOcr || typeof getDocumentAsset !== 'function') {
|
if (!document || !hasOcr || typeof getDocumentAsset !== 'function') {
|
||||||
setOcrContent(null);
|
return '';
|
||||||
setOcrLoading(false);
|
|
||||||
setOcrError(null);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateUrl = () =>
|
const updateUrl = () =>
|
||||||
@@ -162,77 +140,58 @@ const DocumentViewerPanel = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const asset = getDocumentAsset(document, 'ocr-text');
|
const asset = getDocumentAsset(document, 'ocr-text');
|
||||||
|
|
||||||
const ensureAndUpdate = async () => {
|
|
||||||
setOcrLoading(true);
|
|
||||||
setOcrError(null);
|
|
||||||
|
|
||||||
let url = updateUrl();
|
let url = updateUrl();
|
||||||
|
|
||||||
if (!url && document.id && asset?.id && typeof ensureAssetUrl === 'function') {
|
if (!url && document.id && asset?.id && typeof ensureAssetUrl === 'function') {
|
||||||
try {
|
|
||||||
await ensureAssetUrl(document.id, asset, { start: 1, limit: 1 });
|
await ensureAssetUrl(document.id, asset, { start: 1, limit: 1 });
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new DOMException('Aborted', 'AbortError');
|
||||||
|
}
|
||||||
url = updateUrl();
|
url = updateUrl();
|
||||||
} catch (error) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setOcrError('Unable to load OCR content.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let textContent = null;
|
if (!url) {
|
||||||
if (!cancelled && url) {
|
return '';
|
||||||
const controller = new AbortController();
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
mode: 'cors',
|
mode: 'cors',
|
||||||
credentials: 'omit',
|
credentials: 'omit',
|
||||||
signal: controller.signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Unexpected status: ${response.status}`);
|
throw new Error(`Unexpected status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
textContent = await response.text();
|
return response.text();
|
||||||
} catch (error) {
|
}, [document, hasOcr, getDocumentAsset, ensureAssetUrl]);
|
||||||
if (!cancelled) {
|
|
||||||
console.error('[OCR] Failed to fetch text', error);
|
|
||||||
setOcrError('Unable to load OCR content.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cancelled) {
|
const contentTabConfig = useMemo(
|
||||||
setOcrContent(textContent);
|
() => ({
|
||||||
}
|
enabled: hasOcr,
|
||||||
|
id: 'content',
|
||||||
controller.abort();
|
label: 'Content',
|
||||||
}
|
loadContent: loadOcrContent,
|
||||||
|
loadingMessage: 'Loading OCR content…',
|
||||||
if (!cancelled) {
|
emptyMessage: 'No OCR content available.',
|
||||||
if (!textContent) {
|
unavailableMessage: 'No OCR content available.',
|
||||||
setOcrContent(null);
|
errorMessage: 'Failed to load OCR content.',
|
||||||
}
|
}),
|
||||||
setOcrLoading(false);
|
[hasOcr, loadOcrContent],
|
||||||
}
|
);
|
||||||
};
|
|
||||||
|
|
||||||
ensureAndUpdate();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [document, hasOcr, ensureAssetUrl, getDocumentAsset]);
|
|
||||||
|
|
||||||
if (!document) {
|
if (!document) {
|
||||||
return (
|
return (
|
||||||
<section className="document-viewer document-viewer--loading">
|
<section className="document-viewer document-viewer--loading">
|
||||||
|
<div className="document-viewer__details-pane">
|
||||||
<div className="document-viewer__details">
|
<div className="document-viewer__details">
|
||||||
<div className="document-viewer__message">
|
<div className="document-viewer__message">
|
||||||
Loading document{documentId ? ` ${documentId}` : ''}…
|
Loading document{documentId ? ` ${documentId}` : ''}…
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="document-viewer__viewport">
|
<div className="document-viewer__viewport">
|
||||||
<div className="document-viewer__message">Preparing preview…</div>
|
<div className="document-viewer__message">Preparing preview…</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,96 +201,18 @@ const DocumentViewerPanel = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="document-viewer">
|
<section className="document-viewer">
|
||||||
|
<div className="document-viewer__details-pane">
|
||||||
<div className="document-viewer__details">
|
<div className="document-viewer__details">
|
||||||
<DocumentSummarySection
|
<DocumentInfoPanel
|
||||||
document={document}
|
document={document}
|
||||||
tagLookupById={tagLookupById}
|
summaryProps={summaryProps}
|
||||||
tagOptions={tagOptions}
|
metadataPayload={metadataPayload}
|
||||||
onTagAdd={onTagAdd}
|
contentConfig={contentTabConfig}
|
||||||
onTagRemove={onTagRemove}
|
defaultTabId="details"
|
||||||
correspondents={sortedCorrespondents}
|
classNamePrefix="document-viewer"
|
||||||
correspondentOptions={correspondentOptions}
|
hideTabNavWhenSingle={false}
|
||||||
onCorrespondentAdd={onCorrespondentAdd}
|
resetKey={document?.id}
|
||||||
onCorrespondentRemove={onCorrespondentRemove}
|
|
||||||
onUpdateTitle={onUpdateTitle}
|
|
||||||
onUpdateIssued={onUpdateIssued}
|
|
||||||
/>
|
/>
|
||||||
<div className="document-viewer__tabs-wrapper">
|
|
||||||
<div className="document-viewer__tabs" role="tablist" aria-label="Document details">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={activeTab === 'details'}
|
|
||||||
className={`document-viewer__tab${activeTab === 'details' ? ' is-active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('details')}
|
|
||||||
>
|
|
||||||
Details
|
|
||||||
</button>
|
|
||||||
{hasOcr ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={activeTab === 'content'}
|
|
||||||
className={`document-viewer__tab${activeTab === 'content' ? ' is-active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('content')}
|
|
||||||
>
|
|
||||||
Content
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
{metadataPayload ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={activeTab === 'metadata'}
|
|
||||||
className={`document-viewer__tab${activeTab === 'metadata' ? ' is-active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('metadata')}
|
|
||||||
>
|
|
||||||
Metadata
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="document-viewer__tabpanes">
|
|
||||||
{activeTab === 'details' ? (
|
|
||||||
<div role="tabpanel" className="document-viewer__tabpanel">
|
|
||||||
<section className="document-viewer__section">
|
|
||||||
<dl className="document-viewer__section-list">
|
|
||||||
{metadataItems.map(({ label, value }) => (
|
|
||||||
<div className="document-viewer__section-item" key={label}>
|
|
||||||
<dt>{label}</dt>
|
|
||||||
<dd>{value || '—'}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{activeTab === 'content' && hasOcr ? (
|
|
||||||
<div role="tabpanel" className="document-viewer__tabpanel">
|
|
||||||
{ocrLoading ? (
|
|
||||||
<div className="document-viewer__message">Loading OCR content…</div>
|
|
||||||
) : ocrError ? (
|
|
||||||
<div className="document-viewer__message document-viewer__message--error">
|
|
||||||
{ocrError}
|
|
||||||
</div>
|
|
||||||
) : ocrContent ? (
|
|
||||||
<pre className="document-viewer__object document-viewer__object--ocr-text">
|
|
||||||
{ocrContent}
|
|
||||||
</pre>
|
|
||||||
) : (
|
|
||||||
<div className="document-viewer__message">No OCR content available.</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{activeTab === 'metadata' && metadataPayload ? (
|
|
||||||
<div role="tabpanel" className="document-viewer__tabpanel">
|
|
||||||
<section className="document-viewer__section document-viewer__section--metadata-json">
|
|
||||||
<pre className="document-viewer__metadata-json">
|
|
||||||
{JSON.stringify(metadataPayload, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="document-viewer__viewport">
|
<div className="document-viewer__viewport">
|
||||||
@@ -350,7 +231,6 @@ export default DocumentViewerPanel;
|
|||||||
export const createDocumentViewerHeaderActions = ({
|
export const createDocumentViewerHeaderActions = ({
|
||||||
document,
|
document,
|
||||||
actionState,
|
actionState,
|
||||||
onRegenerate,
|
|
||||||
}) => {
|
}) => {
|
||||||
if (!document || !actionState) {
|
if (!document || !actionState) {
|
||||||
return null;
|
return null;
|
||||||
@@ -372,15 +252,6 @@ export const createDocumentViewerHeaderActions = ({
|
|||||||
<DownloadIcon />
|
<DownloadIcon />
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button"
|
|
||||||
onClick={() => onRegenerate(document.id)}
|
|
||||||
aria-label="Re-run analysis"
|
|
||||||
title="Re-run analysis"
|
|
||||||
>
|
|
||||||
<AnalyzeIcon />
|
|
||||||
</button>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -394,7 +265,6 @@ export const createDocumentViewerSurface = ({
|
|||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
notifyApiError,
|
notifyApiError,
|
||||||
onRegenerate,
|
|
||||||
onClose,
|
onClose,
|
||||||
renderSidebarToggle,
|
renderSidebarToggle,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
@@ -469,7 +339,6 @@ export const createDocumentViewerSurface = ({
|
|||||||
actions: createDocumentViewerHeaderActions({
|
actions: createDocumentViewerHeaderActions({
|
||||||
document,
|
document,
|
||||||
actionState,
|
actionState,
|
||||||
onRegenerate,
|
|
||||||
}),
|
}),
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,49 +1,41 @@
|
|||||||
import React, { useMemo, useState, useCallback, useEffect } from 'react';
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import PanelHeader from '../ui/PanelHeader';
|
import PanelHeader from '../ui/PanelHeader';
|
||||||
|
import { DEFAULT_SETTINGS_SECTIONS } from './sections';
|
||||||
const SECTIONS = [
|
|
||||||
{
|
|
||||||
id: 'passkeys',
|
|
||||||
label: 'Passkeys',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'apiTokens',
|
|
||||||
label: 'API tokens',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const SettingsModal = ({
|
const SettingsModal = ({
|
||||||
open,
|
open = false,
|
||||||
onClose,
|
onClose,
|
||||||
tokens = [],
|
sections,
|
||||||
loading = false,
|
defaultSectionId,
|
||||||
creating = false,
|
...sectionProps
|
||||||
deletingId = null,
|
|
||||||
regeneratingId = null,
|
|
||||||
updatingId = null,
|
|
||||||
onRefresh,
|
|
||||||
onCreate,
|
|
||||||
onDelete,
|
|
||||||
onRegenerate,
|
|
||||||
onUpdateCapabilities,
|
|
||||||
createdToken = null,
|
|
||||||
onDismissCreatedToken,
|
|
||||||
passkeys = [],
|
|
||||||
passkeysSupported = null,
|
|
||||||
passkeysLoading = false,
|
|
||||||
registeringPasskey = false,
|
|
||||||
revokingPasskeyId = null,
|
|
||||||
onRefreshPasskeys,
|
|
||||||
onRegisterPasskey,
|
|
||||||
onRevokePasskey,
|
|
||||||
}) => {
|
}) => {
|
||||||
const defaultSection = SECTIONS[0]?.id || 'passkeys';
|
const sectionList = useMemo(() => {
|
||||||
const [activeSection, setActiveSection] = useState(defaultSection);
|
if (Array.isArray(sections) && sections.length) {
|
||||||
const [newTokenLabel, setNewTokenLabel] = useState('');
|
return sections;
|
||||||
const [newTokenExpires, setNewTokenExpires] = useState('');
|
}
|
||||||
const [newTokenCapabilities, setNewTokenCapabilities] = useState([]);
|
return DEFAULT_SETTINGS_SECTIONS;
|
||||||
const [formError, setFormError] = useState(null);
|
}, [sections]);
|
||||||
const [newPasskeyNickname, setNewPasskeyNickname] = useState('');
|
|
||||||
|
const firstSectionId = sectionList[0]?.id ?? null;
|
||||||
|
const resolvedDefaultSection = defaultSectionId || firstSectionId;
|
||||||
|
|
||||||
|
const [activeSection, setActiveSection] = useState(resolvedDefaultSection);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setActiveSection(resolvedDefaultSection);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hasActiveSection = sectionList.some((section) => section.id === activeSection);
|
||||||
|
if (!hasActiveSection) {
|
||||||
|
setActiveSection(resolvedDefaultSection);
|
||||||
|
}
|
||||||
|
}, [open, sectionList, resolvedDefaultSection, activeSection]);
|
||||||
|
|
||||||
const handleBackdropClick = useCallback(() => {
|
const handleBackdropClick = useCallback(() => {
|
||||||
onClose?.();
|
onClose?.();
|
||||||
@@ -53,508 +45,21 @@ const SettingsModal = ({
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
setActiveSection(defaultSection);
|
|
||||||
setNewTokenLabel('');
|
|
||||||
setNewTokenExpires('');
|
|
||||||
setNewTokenCapabilities([]);
|
|
||||||
setFormError(null);
|
|
||||||
setNewPasskeyNickname('');
|
|
||||||
}
|
|
||||||
}, [open, defaultSection]);
|
|
||||||
|
|
||||||
const formatDateTime = useCallback((value) => {
|
|
||||||
if (!value) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
const timestamp = new Date(value);
|
|
||||||
if (Number.isNaN(timestamp.getTime())) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
return timestamp.toLocaleString();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
|
||||||
onRefresh?.();
|
|
||||||
}, [onRefresh]);
|
|
||||||
|
|
||||||
const handleCopyToken = useCallback(() => {
|
|
||||||
if (!createdToken) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (navigator?.clipboard?.writeText) {
|
|
||||||
navigator.clipboard.writeText(createdToken).catch(() => {});
|
|
||||||
}
|
|
||||||
}, [createdToken]);
|
|
||||||
|
|
||||||
const handleDismissSecret = useCallback(() => {
|
|
||||||
onDismissCreatedToken?.();
|
|
||||||
}, [onDismissCreatedToken]);
|
|
||||||
|
|
||||||
const handlePasskeyRefresh = useCallback(() => {
|
|
||||||
onRefreshPasskeys?.();
|
|
||||||
}, [onRefreshPasskeys]);
|
|
||||||
|
|
||||||
const handlePasskeyRegister = useCallback(
|
|
||||||
async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const nickname = newPasskeyNickname.trim();
|
|
||||||
const result = await onRegisterPasskey?.({ nickname });
|
|
||||||
if (result?.ok) {
|
|
||||||
setNewPasskeyNickname('');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[newPasskeyNickname, onRegisterPasskey],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePasskeyRevoke = useCallback(
|
|
||||||
async (passkey) => {
|
|
||||||
if (!passkey?.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const reasonInput = window.prompt('Optional reason for revoking this passkey:', '');
|
|
||||||
const reason = reasonInput ? reasonInput.trim() : undefined;
|
|
||||||
await onRevokePasskey?.(passkey.id, reason);
|
|
||||||
},
|
|
||||||
[onRevokePasskey],
|
|
||||||
);
|
|
||||||
|
|
||||||
const capabilityOptions = useMemo(
|
|
||||||
() => [
|
|
||||||
{ value: 'webdav', label: 'WebDAV access' },
|
|
||||||
{ value: 'api', label: 'REST API access' },
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleNewCapabilityChange = useCallback((capability, enabled) => {
|
|
||||||
setFormError(null);
|
|
||||||
setNewTokenCapabilities((previous) => {
|
|
||||||
if (enabled) {
|
|
||||||
if (previous.includes(capability)) {
|
|
||||||
return previous;
|
|
||||||
}
|
|
||||||
return [...previous, capability];
|
|
||||||
}
|
|
||||||
return previous.filter((value) => value !== capability);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleToggleTokenCapability = useCallback(
|
|
||||||
async (token, capability, enabled) => {
|
|
||||||
if (!token?.id || !onUpdateCapabilities) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = Array.isArray(token.capabilities) ? [...token.capabilities] : [];
|
|
||||||
let next;
|
|
||||||
if (enabled) {
|
|
||||||
if (existing.includes(capability)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
next = [...existing, capability];
|
|
||||||
} else {
|
|
||||||
next = existing.filter((value) => value !== capability);
|
|
||||||
if (next.length === 0) {
|
|
||||||
setFormError('Tokens must have at least one capability.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setFormError(null);
|
|
||||||
const result = await onUpdateCapabilities(token.id, next);
|
|
||||||
if (result === false) {
|
|
||||||
setFormError('Failed to update token capabilities.');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onUpdateCapabilities],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCreateToken = useCallback(
|
|
||||||
async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setFormError(null);
|
|
||||||
let normalizedLabel = newTokenLabel.trim();
|
|
||||||
if (normalizedLabel.length === 0) {
|
|
||||||
normalizedLabel = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
let normalizedExpires;
|
|
||||||
if (newTokenExpires) {
|
|
||||||
const parsed = new Date(newTokenExpires);
|
|
||||||
if (Number.isNaN(parsed.getTime())) {
|
|
||||||
setFormError('Enter a valid expiration date.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
normalizedExpires = parsed.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!newTokenCapabilities.length) {
|
|
||||||
setFormError('Select at least one capability.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await onCreate?.({
|
|
||||||
label: normalizedLabel,
|
|
||||||
expires_at: normalizedExpires,
|
|
||||||
capabilities: newTokenCapabilities,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result !== false) {
|
|
||||||
setNewTokenLabel('');
|
|
||||||
setNewTokenExpires('');
|
|
||||||
setNewTokenCapabilities([]);
|
|
||||||
setFormError(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[newTokenExpires, newTokenLabel, newTokenCapabilities, onCreate],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRegenerateToken = useCallback(
|
|
||||||
async (token) => {
|
|
||||||
if (!token?.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await onRegenerate?.(token.id);
|
|
||||||
},
|
|
||||||
[onRegenerate],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderApiTokensSection = useMemo(() => {
|
|
||||||
const hasTokens = Array.isArray(tokens) && tokens.length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="settings-section">
|
|
||||||
<div className="settings-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary"
|
|
||||||
onClick={handleRefresh}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? 'Refreshing…' : 'Refresh'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
API tokens can grant access to the REST API, WebDAV, or both. Select at least one
|
|
||||||
capability for each token. You can adjust capabilities for existing tokens at any time.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{createdToken ? (
|
|
||||||
<div className="settings-notice">
|
|
||||||
<p>
|
|
||||||
Copy this token now; you will not be able to view it again after closing this window.
|
|
||||||
</p>
|
|
||||||
<pre className="token-display">{createdToken}</pre>
|
|
||||||
<div className="settings-notice__actions">
|
|
||||||
<button type="button" className="secondary" onClick={handleCopyToken}>
|
|
||||||
Copy token
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={handleDismissSecret}>
|
|
||||||
Dismiss
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<form className="settings-form" onSubmit={handleCreateToken}>
|
|
||||||
<div className="settings-form__field">
|
|
||||||
<label htmlFor="api-token-label">Label</label>
|
|
||||||
<input
|
|
||||||
id="api-token-label"
|
|
||||||
type="text"
|
|
||||||
value={newTokenLabel}
|
|
||||||
onChange={(event) => setNewTokenLabel(event.target.value)}
|
|
||||||
placeholder="Personal API token"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="settings-form__field">
|
|
||||||
<label htmlFor="api-token-expires">Expires at</label>
|
|
||||||
<input
|
|
||||||
id="api-token-expires"
|
|
||||||
type="datetime-local"
|
|
||||||
value={newTokenExpires}
|
|
||||||
onChange={(event) => setNewTokenExpires(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<fieldset className="settings-form__field">
|
|
||||||
<legend>Capabilities</legend>
|
|
||||||
<div className="settings-form__choices">
|
|
||||||
{capabilityOptions.map((option) => {
|
|
||||||
const checked = newTokenCapabilities.includes(option.value);
|
|
||||||
return (
|
|
||||||
<label key={option.value} className="settings-choice">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
onChange={(event) =>
|
|
||||||
handleNewCapabilityChange(option.value, event.target.checked)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span>{option.label}</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<div className="settings-form__actions">
|
|
||||||
<button type="submit" disabled={creating}>
|
|
||||||
{creating ? 'Creating…' : 'Create token'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{formError ? <p className="settings-form__error">{formError}</p> : null}
|
|
||||||
|
|
||||||
{loading && !hasTokens ? (
|
|
||||||
<p className="settings-empty">Loading tokens…</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!loading && !hasTokens ? (
|
|
||||||
<p className="settings-empty">No API tokens yet.</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{hasTokens ? (
|
|
||||||
<table className="settings-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Label</th>
|
|
||||||
<th scope="col">Created</th>
|
|
||||||
<th scope="col">Last used</th>
|
|
||||||
<th scope="col">Expires</th>
|
|
||||||
<th scope="col">Capabilities</th>
|
|
||||||
<th scope="col">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{tokens.map((token) => {
|
|
||||||
const isRevoked = Boolean(token?.revoked_at);
|
|
||||||
const capabilitySet = Array.isArray(token?.capabilities)
|
|
||||||
? token.capabilities
|
|
||||||
: [];
|
|
||||||
return (
|
|
||||||
<tr key={token.id} className={isRevoked ? 'is-revoked' : undefined}>
|
|
||||||
<td>{token.label || '—'}</td>
|
|
||||||
<td>{formatDateTime(token.created_at)}</td>
|
|
||||||
<td>{formatDateTime(token.last_used_at)}</td>
|
|
||||||
<td>{formatDateTime(token.expires_at)}</td>
|
|
||||||
<td>
|
|
||||||
<div className="settings-form__choices">
|
|
||||||
{capabilityOptions.map((option) => {
|
|
||||||
const checked = capabilitySet.includes(option.value);
|
|
||||||
return (
|
|
||||||
<label key={option.value} className="settings-choice">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
disabled={
|
|
||||||
isRevoked
|
|
||||||
|| deletingId === token.id
|
|
||||||
|| regeneratingId === token.id
|
|
||||||
|| updatingId === token.id
|
|
||||||
}
|
|
||||||
onChange={(event) =>
|
|
||||||
handleToggleTokenCapability(
|
|
||||||
token,
|
|
||||||
option.value,
|
|
||||||
event.target.checked,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span>{option.label}</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{updatingId === token.id ? (
|
|
||||||
<span className="settings-status">Saving…</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="settings-table__actions">
|
|
||||||
{isRevoked ? (
|
|
||||||
<span className="settings-status">Revoked</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary"
|
|
||||||
onClick={() => handleRegenerateToken(token)}
|
|
||||||
disabled={
|
|
||||||
regeneratingId === token.id || deletingId === token.id
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{regeneratingId === token.id ? 'Regenerating…' : 'Regenerate'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="danger"
|
|
||||||
onClick={() => onDelete?.(token.id)}
|
|
||||||
disabled={
|
|
||||||
deletingId === token.id || regeneratingId === token.id
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{deletingId === token.id ? 'Revoking…' : 'Revoke'}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
tokens,
|
|
||||||
loading,
|
|
||||||
createdToken,
|
|
||||||
creating,
|
|
||||||
deletingId,
|
|
||||||
regeneratingId,
|
|
||||||
updatingId,
|
|
||||||
newTokenLabel,
|
|
||||||
newTokenExpires,
|
|
||||||
newTokenCapabilities,
|
|
||||||
formError,
|
|
||||||
capabilityOptions,
|
|
||||||
formatDateTime,
|
|
||||||
handleCopyToken,
|
|
||||||
handleCreateToken,
|
|
||||||
handleRegenerateToken,
|
|
||||||
handleRefresh,
|
|
||||||
onDelete,
|
|
||||||
handleDismissSecret,
|
|
||||||
handleNewCapabilityChange,
|
|
||||||
handleToggleTokenCapability,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const renderPasskeysSection = useMemo(() => {
|
|
||||||
const hasPasskeys = Array.isArray(passkeys) && passkeys.length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="settings-section">
|
|
||||||
<div className="settings-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary"
|
|
||||||
onClick={handlePasskeyRefresh}
|
|
||||||
disabled={passkeysLoading}
|
|
||||||
>
|
|
||||||
{passkeysLoading ? 'Refreshing…' : 'Refresh'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{passkeysSupported === false ? (
|
|
||||||
<p className="settings-empty">Passkeys are not enabled for this account.</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<form className="settings-form" onSubmit={handlePasskeyRegister}>
|
|
||||||
<div className="settings-form__field">
|
|
||||||
<label htmlFor="passkey-nickname">Nickname (optional)</label>
|
|
||||||
<input
|
|
||||||
id="passkey-nickname"
|
|
||||||
type="text"
|
|
||||||
placeholder="e.g. MacBook"
|
|
||||||
value={newPasskeyNickname}
|
|
||||||
onChange={(event) => setNewPasskeyNickname(event.target.value)}
|
|
||||||
disabled={registeringPasskey}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="settings-form__actions">
|
|
||||||
<button type="submit" disabled={registeringPasskey}>
|
|
||||||
{registeringPasskey ? 'Registering…' : 'Register passkey'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{passkeysLoading && !hasPasskeys ? (
|
|
||||||
<p className="settings-empty">Loading passkeys…</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!passkeysLoading && !hasPasskeys ? (
|
|
||||||
<p className="settings-empty">No passkeys registered yet.</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{hasPasskeys ? (
|
|
||||||
<table className="settings-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Nickname</th>
|
|
||||||
<th scope="col">Created</th>
|
|
||||||
<th scope="col">Last used</th>
|
|
||||||
<th scope="col">Transports</th>
|
|
||||||
<th scope="col">Status</th>
|
|
||||||
<th scope="col">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{passkeys.map((passkey) => {
|
|
||||||
const createdAt = passkey.created_at || passkey.createdAt;
|
|
||||||
const lastUsedAt = passkey.last_used_at || passkey.lastUsedAt;
|
|
||||||
const revokedAt = passkey.revoked_at || passkey.revokedAt;
|
|
||||||
const revokedReason = passkey.revoked_reason || passkey.revokedReason;
|
|
||||||
const revoked = Boolean(revokedAt);
|
|
||||||
const transports = Array.isArray(passkey.transports)
|
|
||||||
? passkey.transports.filter(Boolean)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={passkey.id} className={revoked ? 'is-revoked' : undefined}>
|
|
||||||
<td>{passkey.nickname || '—'}</td>
|
|
||||||
<td>{formatDateTime(createdAt)}</td>
|
|
||||||
<td>{formatDateTime(lastUsedAt)}</td>
|
|
||||||
<td>{transports.length ? transports.join(', ') : '—'}</td>
|
|
||||||
<td>
|
|
||||||
{revoked
|
|
||||||
? revokedReason
|
|
||||||
? `Revoked (${revokedReason})`
|
|
||||||
: 'Revoked'
|
|
||||||
: 'Active'}
|
|
||||||
</td>
|
|
||||||
<td className="settings-table__actions">
|
|
||||||
{revoked ? (
|
|
||||||
<span className="settings-status">Revoked</span>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="danger"
|
|
||||||
onClick={() => handlePasskeyRevoke(passkey)}
|
|
||||||
disabled={revokingPasskeyId === passkey.id}
|
|
||||||
>
|
|
||||||
{revokingPasskeyId === passkey.id ? 'Revoking…' : 'Revoke'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
passkeys,
|
|
||||||
passkeysLoading,
|
|
||||||
passkeysSupported,
|
|
||||||
registeringPasskey,
|
|
||||||
revokingPasskeyId,
|
|
||||||
newPasskeyNickname,
|
|
||||||
formatDateTime,
|
|
||||||
handlePasskeyRefresh,
|
|
||||||
handlePasskeyRegister,
|
|
||||||
handlePasskeyRevoke,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!open) {
|
if (!open) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeSectionConfig = sectionList.find((section) => section.id === activeSection);
|
||||||
|
let sectionContent = null;
|
||||||
|
if (activeSectionConfig) {
|
||||||
|
if (activeSectionConfig.component) {
|
||||||
|
const SectionComponent = activeSectionConfig.component;
|
||||||
|
sectionContent = <SectionComponent {...sectionProps} />;
|
||||||
|
} else if (typeof activeSectionConfig.render === 'function') {
|
||||||
|
sectionContent = activeSectionConfig.render(sectionProps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-backdrop" role="presentation" onClick={handleBackdropClick}>
|
<div className="modal-backdrop" role="presentation" onClick={handleBackdropClick}>
|
||||||
<div
|
<div
|
||||||
@@ -578,7 +83,7 @@ const SettingsModal = ({
|
|||||||
<div className="settings-modal__body">
|
<div className="settings-modal__body">
|
||||||
<nav className="settings-modal__sidebar" aria-label="Settings sections">
|
<nav className="settings-modal__sidebar" aria-label="Settings sections">
|
||||||
<ul>
|
<ul>
|
||||||
{SECTIONS.map((section) => (
|
{sectionList.map((section) => (
|
||||||
<li key={section.id}>
|
<li key={section.id}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -592,11 +97,9 @@ const SettingsModal = ({
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="settings-modal__content">
|
<div className="settings-modal__content">
|
||||||
{activeSection === 'passkeys' ? renderPasskeysSection : null}
|
{sectionContent || (
|
||||||
{activeSection === 'apiTokens' ? renderApiTokensSection : null}
|
|
||||||
{activeSection !== 'passkeys' && activeSection !== 'apiTokens' ? (
|
|
||||||
<p>Select a settings section.</p>
|
<p>Select a settings section.</p>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { CheckIcon, ChevronDownIcon } from '../../ui/icons';
|
||||||
|
|
||||||
|
const CapabilityDropdown = ({
|
||||||
|
id,
|
||||||
|
options = [],
|
||||||
|
selectedValues = [],
|
||||||
|
onSelect,
|
||||||
|
onDeselect,
|
||||||
|
formatLabel,
|
||||||
|
disabled = false,
|
||||||
|
loading = false,
|
||||||
|
summaryLabel = 'capabilities',
|
||||||
|
}) => {
|
||||||
|
const anchorRef = useRef(null);
|
||||||
|
const menuRef = useRef(null);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsOpen((previous) => !previous);
|
||||||
|
}, [disabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerEvent = (event) => {
|
||||||
|
if (anchorRef.current?.contains(event.target) || menuRef.current?.contains(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handlePointerEvent);
|
||||||
|
document.addEventListener('touchstart', handlePointerEvent, { passive: true });
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handlePointerEvent);
|
||||||
|
document.removeEventListener('touchstart', handlePointerEvent);
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [close, isOpen]);
|
||||||
|
|
||||||
|
const handleOptionClick = useCallback((value) => {
|
||||||
|
if (selectedValues.includes(value)) {
|
||||||
|
onDeselect?.(value);
|
||||||
|
} else {
|
||||||
|
onSelect?.(value);
|
||||||
|
}
|
||||||
|
}, [onDeselect, onSelect, selectedValues]);
|
||||||
|
|
||||||
|
const total = options.length;
|
||||||
|
const selectedCount = selectedValues.length;
|
||||||
|
|
||||||
|
const summaryText = total
|
||||||
|
? `${selectedCount}/${total} ${summaryLabel} enabled`
|
||||||
|
: selectedCount
|
||||||
|
? `${selectedCount} ${summaryLabel} selected`
|
||||||
|
: loading
|
||||||
|
? `Loading ${summaryLabel}…`
|
||||||
|
: `No ${summaryLabel}`;
|
||||||
|
|
||||||
|
const buttonText = selectedCount || loading || total
|
||||||
|
? summaryText
|
||||||
|
: `Select ${summaryLabel}`;
|
||||||
|
|
||||||
|
const emptyMessage = loading
|
||||||
|
? `Loading ${summaryLabel}…`
|
||||||
|
: `No ${summaryLabel} available.`;
|
||||||
|
|
||||||
|
const isDisabled = disabled || (total === 0 && !selectedCount) || loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="capability-dropdown">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="capability-dropdown__trigger"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-controls={id ? `${id}-menu` : undefined}
|
||||||
|
onClick={toggle}
|
||||||
|
disabled={isDisabled}
|
||||||
|
ref={anchorRef}
|
||||||
|
>
|
||||||
|
<span>{buttonText}</span>
|
||||||
|
<ChevronDownIcon className="capability-dropdown__chevron" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{isOpen ? (
|
||||||
|
<div
|
||||||
|
id={id ? `${id}-menu` : undefined}
|
||||||
|
role="menu"
|
||||||
|
ref={menuRef}
|
||||||
|
className="menu menu--floating capability-dropdown__menu"
|
||||||
|
>
|
||||||
|
{total ? (
|
||||||
|
options.map((option) => {
|
||||||
|
const value = option?.value ?? option?.id ?? option;
|
||||||
|
const label = typeof formatLabel === 'function'
|
||||||
|
? formatLabel(value)
|
||||||
|
: option?.label || String(value);
|
||||||
|
const selected = selectedValues.includes(value);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="menuitemcheckbox"
|
||||||
|
aria-checked={selected}
|
||||||
|
className={`menu__item capability-dropdown__option${selected ? ' is-selected' : ''}`}
|
||||||
|
onClick={() => handleOptionClick(value)}
|
||||||
|
>
|
||||||
|
<span className="capability-dropdown__option-icon">
|
||||||
|
{selected ? <CheckIcon className="icon-inline" aria-hidden="true" /> : null}
|
||||||
|
</span>
|
||||||
|
<span className="capability-dropdown__option-label">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="capability-dropdown__empty">{emptyMessage}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CapabilityDropdown;
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
const formatDateTime = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
const timestamp = new Date(value);
|
||||||
|
if (Number.isNaN(timestamp.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return timestamp.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const ApiTokensSection = ({
|
||||||
|
tokens = [],
|
||||||
|
loading = false,
|
||||||
|
creating = false,
|
||||||
|
deletingId = null,
|
||||||
|
regeneratingId = null,
|
||||||
|
createdToken = null,
|
||||||
|
capabilitySets = [],
|
||||||
|
capabilitySetsLoading = false,
|
||||||
|
capabilities = [],
|
||||||
|
capabilitiesLoading = false,
|
||||||
|
onRefresh,
|
||||||
|
onCreate,
|
||||||
|
onDelete,
|
||||||
|
onRegenerate,
|
||||||
|
onDismissCreatedToken,
|
||||||
|
onRefreshCapabilitySets,
|
||||||
|
onRefreshCapabilities,
|
||||||
|
}) => {
|
||||||
|
const [newTokenLabel, setNewTokenLabel] = useState('');
|
||||||
|
const [newTokenExpires, setNewTokenExpires] = useState('');
|
||||||
|
const [newTokenCapabilitySetId, setNewTokenCapabilitySetId] = useState('');
|
||||||
|
const [formError, setFormError] = useState(null);
|
||||||
|
const supportsClipboardWrite = typeof navigator !== 'undefined'
|
||||||
|
&& Boolean(navigator?.clipboard?.writeText);
|
||||||
|
const [canCopyToken, setCanCopyToken] = useState(supportsClipboardWrite);
|
||||||
|
const [copyFeedback, setCopyFeedback] = useState(null);
|
||||||
|
|
||||||
|
const capabilitySetOptions = useMemo(
|
||||||
|
() => capabilitySets.map((set) => ({
|
||||||
|
value: set.id,
|
||||||
|
label: set.label || set.slug || set.id,
|
||||||
|
capabilities: Array.isArray(set.capabilities) ? set.capabilities : [],
|
||||||
|
})),
|
||||||
|
[capabilitySets],
|
||||||
|
);
|
||||||
|
|
||||||
|
const capabilitySetMap = useMemo(
|
||||||
|
() => Object.fromEntries(capabilitySetOptions.map((option) => [option.value, option])),
|
||||||
|
[capabilitySetOptions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const capabilitySelectionOptions = useMemo(() => (
|
||||||
|
Array.isArray(capabilities)
|
||||||
|
? capabilities.map((capability) => {
|
||||||
|
if (typeof capability !== 'string') {
|
||||||
|
return { value: capability, label: String(capability) };
|
||||||
|
}
|
||||||
|
const [namespace, action] = capability.split(':');
|
||||||
|
if (!namespace || !action) {
|
||||||
|
return { value: capability, label: capability };
|
||||||
|
}
|
||||||
|
const formattedNamespace = `${namespace.charAt(0).toUpperCase()}${namespace.slice(1)}`;
|
||||||
|
const formattedAction = action.replace(/_/g, ' ');
|
||||||
|
return {
|
||||||
|
value: capability,
|
||||||
|
label: `${formattedNamespace}: ${formattedAction}`,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
), [capabilities]);
|
||||||
|
|
||||||
|
const capabilityLabelMap = useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
capabilitySelectionOptions.forEach(({ value, label }) => {
|
||||||
|
map.set(value, label || String(value));
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [capabilitySelectionOptions]);
|
||||||
|
|
||||||
|
const formatCapabilityLabel = useCallback((value) => (
|
||||||
|
capabilityLabelMap.get(value) || String(value)
|
||||||
|
), [capabilityLabelMap]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!capabilitySetOptions.length) {
|
||||||
|
setNewTokenCapabilitySetId('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!newTokenCapabilitySetId
|
||||||
|
|| !capabilitySetOptions.some((option) => option.value === newTokenCapabilitySetId)) {
|
||||||
|
setNewTokenCapabilitySetId(capabilitySetOptions[0].value);
|
||||||
|
}
|
||||||
|
}, [capabilitySetOptions, newTokenCapabilitySetId]);
|
||||||
|
|
||||||
|
const selectedTokenCapabilitySetCapabilities = useMemo(
|
||||||
|
() => capabilitySetMap[newTokenCapabilitySetId]?.capabilities || [],
|
||||||
|
[capabilitySetMap, newTokenCapabilitySetId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
if (onRefresh) {
|
||||||
|
onRefresh();
|
||||||
|
}
|
||||||
|
onRefreshCapabilitySets?.();
|
||||||
|
onRefreshCapabilities?.();
|
||||||
|
}, [onRefresh, onRefreshCapabilities, onRefreshCapabilitySets]);
|
||||||
|
|
||||||
|
const handleCopyToken = useCallback(async () => {
|
||||||
|
if (!createdToken || !canCopyToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showSuccess = () =>
|
||||||
|
setCopyFeedback({ type: 'success', message: 'Token copied to clipboard.' });
|
||||||
|
const showFailure = () =>
|
||||||
|
setCopyFeedback({
|
||||||
|
type: 'error',
|
||||||
|
message:
|
||||||
|
'Copy failed. Your browser may require HTTPS access; please select the token manually.',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(createdToken);
|
||||||
|
showSuccess();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
// Some browsers expose writeText but still reject outside secure context
|
||||||
|
setCanCopyToken(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
showFailure();
|
||||||
|
}, [createdToken, canCopyToken]);
|
||||||
|
|
||||||
|
const handleDismissSecret = useCallback(() => {
|
||||||
|
setCopyFeedback(null);
|
||||||
|
onDismissCreatedToken?.();
|
||||||
|
}, [onDismissCreatedToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCopyFeedback(null);
|
||||||
|
if (typeof navigator !== 'undefined') {
|
||||||
|
setCanCopyToken(Boolean(navigator?.clipboard?.writeText));
|
||||||
|
}
|
||||||
|
}, [createdToken]);
|
||||||
|
|
||||||
|
const handleNewCapabilitySetChange = useCallback((event) => {
|
||||||
|
setFormError(null);
|
||||||
|
setNewTokenCapabilitySetId(event.target.value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateToken = useCallback(
|
||||||
|
async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setFormError(null);
|
||||||
|
let normalizedLabel = newTokenLabel.trim();
|
||||||
|
if (normalizedLabel.length === 0) {
|
||||||
|
normalizedLabel = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedExpires;
|
||||||
|
if (newTokenExpires) {
|
||||||
|
const parsed = new Date(newTokenExpires);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
setFormError('Enter a valid expiration date.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
normalizedExpires = parsed.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!capabilitySetOptions.length) {
|
||||||
|
setFormError('Capability sets are still loading.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedCapabilitySetId = newTokenCapabilitySetId || capabilitySetOptions[0]?.value;
|
||||||
|
if (!selectedCapabilitySetId) {
|
||||||
|
setFormError('Select a capability set.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await onCreate?.({
|
||||||
|
label: normalizedLabel,
|
||||||
|
expires_at: normalizedExpires,
|
||||||
|
capability_set_id: selectedCapabilitySetId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result !== false) {
|
||||||
|
setNewTokenLabel('');
|
||||||
|
setNewTokenExpires('');
|
||||||
|
setNewTokenCapabilitySetId(capabilitySetOptions[0]?.value || '');
|
||||||
|
setFormError(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
capabilitySetOptions,
|
||||||
|
newTokenCapabilitySetId,
|
||||||
|
newTokenExpires,
|
||||||
|
newTokenLabel,
|
||||||
|
onCreate,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRegenerateToken = useCallback(
|
||||||
|
async (token) => {
|
||||||
|
if (!token?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onRegenerate?.(token.id);
|
||||||
|
},
|
||||||
|
[onRegenerate],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<div className="settings-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={loading || capabilitySetsLoading}
|
||||||
|
>
|
||||||
|
{loading || capabilitySetsLoading ? 'Refreshing…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
API tokens use predefined capability sets. Choose the set that matches the access you need when
|
||||||
|
creating or updating a token.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{createdToken ? (
|
||||||
|
<div className="settings-notice">
|
||||||
|
<p>
|
||||||
|
Copy this token now; you will not be able to view it again after closing this window.
|
||||||
|
</p>
|
||||||
|
<pre className="token-display">{createdToken}</pre>
|
||||||
|
<div className="settings-notice__actions">
|
||||||
|
{canCopyToken ? (
|
||||||
|
<button type="button" className="secondary" onClick={handleCopyToken}>
|
||||||
|
Copy token
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button type="button" onClick={handleDismissSecret}>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{copyFeedback ? (
|
||||||
|
<p className={copyFeedback.type === 'error' ? 'settings-form__error' : 'settings-status'}>
|
||||||
|
{copyFeedback.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<form className="settings-form" onSubmit={handleCreateToken}>
|
||||||
|
<div className="settings-form__field">
|
||||||
|
<label htmlFor="api-token-label">Label</label>
|
||||||
|
<input
|
||||||
|
id="api-token-label"
|
||||||
|
type="text"
|
||||||
|
value={newTokenLabel}
|
||||||
|
onChange={(event) => setNewTokenLabel(event.target.value)}
|
||||||
|
placeholder="Personal API token"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="settings-form__field">
|
||||||
|
<label htmlFor="api-token-expires">Expires at</label>
|
||||||
|
<input
|
||||||
|
id="api-token-expires"
|
||||||
|
type="datetime-local"
|
||||||
|
value={newTokenExpires}
|
||||||
|
onChange={(event) => setNewTokenExpires(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="settings-form__field settings-form__field--full">
|
||||||
|
<label htmlFor="api-token-capability-set">Capability set</label>
|
||||||
|
<select
|
||||||
|
id="api-token-capability-set"
|
||||||
|
value={newTokenCapabilitySetId}
|
||||||
|
onChange={handleNewCapabilitySetChange}
|
||||||
|
disabled={
|
||||||
|
creating
|
||||||
|
|| capabilitySetsLoading
|
||||||
|
|| !capabilitySetOptions.length
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{capabilitySetOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{capabilitySetsLoading ? (
|
||||||
|
<small>Loading capability sets…</small>
|
||||||
|
) : null}
|
||||||
|
{!capabilitySetsLoading && !capabilitySetOptions.length ? (
|
||||||
|
<small>No capability sets available yet.</small>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="settings-form__field settings-form__field--full">
|
||||||
|
{selectedTokenCapabilitySetCapabilities.length ? (
|
||||||
|
<div className="settings-capability-list">
|
||||||
|
{selectedTokenCapabilitySetCapabilities.map((value) => (
|
||||||
|
<span key={value} className="settings-capability-list__item badge tag-chip">
|
||||||
|
<span className="tag-chip__label">{formatCapabilityLabel(value)}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="settings-capability-picker__placeholder">No capabilities selected.</span>
|
||||||
|
)}
|
||||||
|
{capabilitiesLoading ? (
|
||||||
|
<small>Loading capabilities…</small>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="settings-form__actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
creating
|
||||||
|
|| capabilitySetsLoading
|
||||||
|
|| !capabilitySetOptions.length
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{creating ? 'Creating…' : 'Create token'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{formError ? (
|
||||||
|
<p className="settings-form__error">{formError}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading && !tokens.length ? (
|
||||||
|
<p className="settings-empty">Loading tokens…</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && !tokens.length ? (
|
||||||
|
<p className="settings-empty">No API tokens yet.</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{tokens.length ? (
|
||||||
|
<table className="settings-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Label</th>
|
||||||
|
<th scope="col">Created</th>
|
||||||
|
<th scope="col">Last used</th>
|
||||||
|
<th scope="col">Expires</th>
|
||||||
|
<th scope="col">Capability set</th>
|
||||||
|
<th scope="col">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tokens.map((token) => {
|
||||||
|
const isRevoked = Boolean(token?.revoked_at);
|
||||||
|
const selectedSet = token?.capability_set_id
|
||||||
|
? capabilitySetMap[token.capability_set_id]
|
||||||
|
: null;
|
||||||
|
const capabilityList = Array.isArray(token?.capabilities) && token.capabilities.length
|
||||||
|
? token.capabilities
|
||||||
|
: selectedSet?.capabilities || [];
|
||||||
|
const capabilitySetLabel = selectedSet?.label
|
||||||
|
|| selectedSet?.slug
|
||||||
|
|| token.capability_set_id
|
||||||
|
|| '—';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={token.id} className={isRevoked ? 'is-revoked' : undefined}>
|
||||||
|
<td>{token.label || '—'}</td>
|
||||||
|
<td>{formatDateTime(token.created_at)}</td>
|
||||||
|
<td>{formatDateTime(token.last_used_at)}</td>
|
||||||
|
<td>{formatDateTime(token.expires_at)}</td>
|
||||||
|
<td>
|
||||||
|
<div className="settings-capability-set">
|
||||||
|
<span className="settings-capability-set__label">{capabilitySetLabel}</span>
|
||||||
|
</div>
|
||||||
|
{capabilitySetsLoading ? (
|
||||||
|
<small>Loading capability sets…</small>
|
||||||
|
) : null}
|
||||||
|
{!capabilitySetsLoading && capabilityList.length ? (
|
||||||
|
<div className="settings-capability-list settings-capability-list--compact">
|
||||||
|
{capabilityList.map((value) => (
|
||||||
|
<span key={value} className="settings-capability-list__item badge tag-chip">
|
||||||
|
<span className="tag-chip__label">{formatCapabilityLabel(value)}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="settings-table__actions">
|
||||||
|
{isRevoked ? (
|
||||||
|
<span className="settings-status">Revoked</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => handleRegenerateToken(token)}
|
||||||
|
disabled={
|
||||||
|
regeneratingId === token.id || deletingId === token.id
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{regeneratingId === token.id ? 'Regenerating…' : 'Regenerate'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger"
|
||||||
|
onClick={() => onDelete?.(token.id)}
|
||||||
|
disabled={
|
||||||
|
deletingId === token.id || regeneratingId === token.id
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{deletingId === token.id ? 'Revoking…' : 'Revoke'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const API_TOKENS_SECTION = {
|
||||||
|
id: 'apiTokens',
|
||||||
|
label: 'API tokens',
|
||||||
|
component: ApiTokensSection,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiTokensSection;
|
||||||
@@ -0,0 +1,582 @@
|
|||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { IconX } from '../../ui/icons';
|
||||||
|
import CapabilityDropdown from '../components/CapabilityDropdown';
|
||||||
|
|
||||||
|
const CapabilitySetsSection = ({
|
||||||
|
capabilitySets = [],
|
||||||
|
capabilitySetsLoading = false,
|
||||||
|
creatingCapabilitySet = false,
|
||||||
|
savingCapabilitySetId = null,
|
||||||
|
deletingCapabilitySetId = null,
|
||||||
|
supportsCapabilitySetLabels = false,
|
||||||
|
capabilities = [],
|
||||||
|
capabilitiesLoading = false,
|
||||||
|
onRefreshCapabilitySets,
|
||||||
|
onRefreshCapabilities,
|
||||||
|
onRefresh,
|
||||||
|
onCreateCapabilitySet,
|
||||||
|
onUpdateCapabilitySet,
|
||||||
|
onDeleteCapabilitySet,
|
||||||
|
}) => {
|
||||||
|
const [newCapabilitySetSlug, setNewCapabilitySetSlug] = useState('');
|
||||||
|
const [newCapabilitySetLabel, setNewCapabilitySetLabel] = useState('');
|
||||||
|
const [newCapabilitySetCapabilities, setNewCapabilitySetCapabilities] = useState([]);
|
||||||
|
const [capabilitySetFormError, setCapabilitySetFormError] = useState(null);
|
||||||
|
|
||||||
|
const [editingCapabilitySetId, setEditingCapabilitySetId] = useState(null);
|
||||||
|
const [editCapabilitySetSlug, setEditCapabilitySetSlug] = useState('');
|
||||||
|
const [editCapabilitySetLabel, setEditCapabilitySetLabel] = useState('');
|
||||||
|
const [editCapabilitySetCapabilities, setEditCapabilitySetCapabilities] = useState([]);
|
||||||
|
const [capabilitySetEditError, setCapabilitySetEditError] = useState(null);
|
||||||
|
|
||||||
|
const capabilitySelectionOptions = useMemo(() => (
|
||||||
|
Array.isArray(capabilities)
|
||||||
|
? capabilities.map((capability) => {
|
||||||
|
if (typeof capability !== 'string') {
|
||||||
|
return { value: capability, label: String(capability) };
|
||||||
|
}
|
||||||
|
const [namespace, action] = capability.split(':');
|
||||||
|
if (!namespace || !action) {
|
||||||
|
return { value: capability, label: capability };
|
||||||
|
}
|
||||||
|
const formattedNamespace = `${namespace.charAt(0).toUpperCase()}${namespace.slice(1)}`;
|
||||||
|
const formattedAction = action.replace(/_/g, ' ');
|
||||||
|
return {
|
||||||
|
value: capability,
|
||||||
|
label: `${formattedNamespace}: ${formattedAction}`,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
), [capabilities]);
|
||||||
|
|
||||||
|
const capabilityLabelMap = useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
capabilitySelectionOptions.forEach(({ value, label }) => {
|
||||||
|
map.set(value, label || String(value));
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [capabilitySelectionOptions]);
|
||||||
|
|
||||||
|
const capabilityOrder = useMemo(() => {
|
||||||
|
const order = new Map();
|
||||||
|
capabilitySelectionOptions.forEach((option, index) => {
|
||||||
|
order.set(option.value, index);
|
||||||
|
});
|
||||||
|
return order;
|
||||||
|
}, [capabilitySelectionOptions]);
|
||||||
|
|
||||||
|
const sortCapabilityValues = useCallback((values) => {
|
||||||
|
if (!Array.isArray(values)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [...values].sort((a, b) => {
|
||||||
|
const indexA = capabilityOrder.has(a) ? capabilityOrder.get(a) : Number.MAX_SAFE_INTEGER;
|
||||||
|
const indexB = capabilityOrder.has(b) ? capabilityOrder.get(b) : Number.MAX_SAFE_INTEGER;
|
||||||
|
if (indexA === indexB) {
|
||||||
|
return String(a).localeCompare(String(b));
|
||||||
|
}
|
||||||
|
return indexA - indexB;
|
||||||
|
});
|
||||||
|
}, [capabilityOrder]);
|
||||||
|
|
||||||
|
const formatCapabilityLabel = useCallback((value) => (
|
||||||
|
capabilityLabelMap.get(value) || String(value)
|
||||||
|
), [capabilityLabelMap]);
|
||||||
|
|
||||||
|
const capabilitySetOptions = useMemo(
|
||||||
|
() => capabilitySets.map((set) => ({
|
||||||
|
value: set.id,
|
||||||
|
label: set.label || set.slug || set.id,
|
||||||
|
capabilities: Array.isArray(set.capabilities) ? set.capabilities : [],
|
||||||
|
isSystem: Boolean(set?.is_system),
|
||||||
|
version: typeof set?.cap_version === 'number' ? set.cap_version : null,
|
||||||
|
})),
|
||||||
|
[capabilitySets],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasCapabilitySets = capabilitySetOptions.length > 0;
|
||||||
|
const columnCount = supportsCapabilitySetLabels ? 6 : 5;
|
||||||
|
|
||||||
|
const handleCapabilitySetsRefresh = useCallback(() => {
|
||||||
|
if (onRefreshCapabilitySets) {
|
||||||
|
onRefreshCapabilitySets();
|
||||||
|
} else {
|
||||||
|
onRefresh?.();
|
||||||
|
}
|
||||||
|
onRefreshCapabilities?.();
|
||||||
|
}, [onRefresh, onRefreshCapabilities, onRefreshCapabilitySets]);
|
||||||
|
|
||||||
|
const handleAddCapabilityToNewSet = useCallback((option) => {
|
||||||
|
const value = option?.value ?? option?.id ?? option;
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCapabilitySetFormError(null);
|
||||||
|
setNewCapabilitySetCapabilities((previous) => {
|
||||||
|
if (previous.includes(value)) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
return sortCapabilityValues([...previous, value]);
|
||||||
|
});
|
||||||
|
}, [sortCapabilityValues]);
|
||||||
|
|
||||||
|
const handleRemoveCapabilityFromNewSet = useCallback((value) => {
|
||||||
|
setCapabilitySetFormError(null);
|
||||||
|
setNewCapabilitySetCapabilities((previous) => previous.filter((item) => item !== value));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateCapabilitySetSubmit = useCallback(
|
||||||
|
async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setCapabilitySetFormError(null);
|
||||||
|
|
||||||
|
if (!Array.isArray(newCapabilitySetCapabilities) || newCapabilitySetCapabilities.length === 0) {
|
||||||
|
setCapabilitySetFormError('Select at least one capability.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!capabilitySelectionOptions.length) {
|
||||||
|
setCapabilitySetFormError('Capabilities are still loading.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
slug: newCapabilitySetSlug,
|
||||||
|
label: newCapabilitySetLabel,
|
||||||
|
capabilities: sortCapabilityValues(newCapabilitySetCapabilities),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await onCreateCapabilitySet?.(payload);
|
||||||
|
if (result === false) {
|
||||||
|
setCapabilitySetFormError('Failed to create capability set.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNewCapabilitySetSlug('');
|
||||||
|
setNewCapabilitySetLabel('');
|
||||||
|
setNewCapabilitySetCapabilities([]);
|
||||||
|
setCapabilitySetFormError(null);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
capabilitySelectionOptions,
|
||||||
|
newCapabilitySetCapabilities,
|
||||||
|
newCapabilitySetLabel,
|
||||||
|
newCapabilitySetSlug,
|
||||||
|
onCreateCapabilitySet,
|
||||||
|
sortCapabilityValues,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStartEditCapabilitySet = useCallback((capabilitySet) => {
|
||||||
|
if (!capabilitySet) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCapabilitySetEditError(null);
|
||||||
|
setEditingCapabilitySetId(capabilitySet.id);
|
||||||
|
setEditCapabilitySetSlug(capabilitySet.slug || '');
|
||||||
|
setEditCapabilitySetLabel(capabilitySet.label || '');
|
||||||
|
setEditCapabilitySetCapabilities(
|
||||||
|
sortCapabilityValues(Array.isArray(capabilitySet.capabilities) ? capabilitySet.capabilities : []),
|
||||||
|
);
|
||||||
|
}, [sortCapabilityValues]);
|
||||||
|
|
||||||
|
const handleCancelEditCapabilitySet = useCallback(() => {
|
||||||
|
setEditingCapabilitySetId(null);
|
||||||
|
setEditCapabilitySetSlug('');
|
||||||
|
setEditCapabilitySetLabel('');
|
||||||
|
setEditCapabilitySetCapabilities([]);
|
||||||
|
setCapabilitySetEditError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddCapabilityToEditSet = useCallback((option) => {
|
||||||
|
const value = option?.value ?? option?.id ?? option;
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCapabilitySetEditError(null);
|
||||||
|
setEditCapabilitySetCapabilities((previous) => {
|
||||||
|
if (previous.includes(value)) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
return sortCapabilityValues([...previous, value]);
|
||||||
|
});
|
||||||
|
}, [sortCapabilityValues]);
|
||||||
|
|
||||||
|
const handleRemoveCapabilityFromEditSet = useCallback((value) => {
|
||||||
|
setCapabilitySetEditError(null);
|
||||||
|
setEditCapabilitySetCapabilities((previous) => previous.filter((item) => item !== value));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUpdateCapabilitySetSubmit = useCallback(
|
||||||
|
async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!editingCapabilitySetId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(editCapabilitySetCapabilities) || editCapabilitySetCapabilities.length === 0) {
|
||||||
|
setCapabilitySetEditError('Select at least one capability.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
slug: editCapabilitySetSlug,
|
||||||
|
label: editCapabilitySetLabel,
|
||||||
|
capabilities: sortCapabilityValues(editCapabilitySetCapabilities),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await onUpdateCapabilitySet?.(editingCapabilitySetId, payload);
|
||||||
|
if (result === false) {
|
||||||
|
setCapabilitySetEditError('Failed to update capability set.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCancelEditCapabilitySet();
|
||||||
|
},
|
||||||
|
[
|
||||||
|
editCapabilitySetCapabilities,
|
||||||
|
editCapabilitySetLabel,
|
||||||
|
editCapabilitySetSlug,
|
||||||
|
editingCapabilitySetId,
|
||||||
|
handleCancelEditCapabilitySet,
|
||||||
|
onUpdateCapabilitySet,
|
||||||
|
sortCapabilityValues,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteCapabilitySet = useCallback(
|
||||||
|
async (capabilitySet) => {
|
||||||
|
if (!capabilitySet?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const displayName = capabilitySet.slug || capabilitySet.label || capabilitySet.id;
|
||||||
|
const confirmed = window.confirm(`Delete capability set "${displayName}"?`);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await onDeleteCapabilitySet?.(capabilitySet.id);
|
||||||
|
if (result === false) {
|
||||||
|
setCapabilitySetEditError('Failed to delete capability set.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onDeleteCapabilitySet],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editingCapabilitySetId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const exists = capabilitySets.some((set) => set.id === editingCapabilitySetId);
|
||||||
|
if (!exists) {
|
||||||
|
handleCancelEditCapabilitySet();
|
||||||
|
}
|
||||||
|
}, [capabilitySets, editingCapabilitySetId, handleCancelEditCapabilitySet]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<div className="settings-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={handleCapabilitySetsRefresh}
|
||||||
|
disabled={capabilitySetsLoading}
|
||||||
|
>
|
||||||
|
{capabilitySetsLoading ? 'Refreshing…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Capability sets bundle permissions that you can assign to API tokens and user memberships.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form className="settings-form" onSubmit={handleCreateCapabilitySetSubmit}>
|
||||||
|
<div className="settings-form__field">
|
||||||
|
<label htmlFor="capability-set-slug">Slug</label>
|
||||||
|
<input
|
||||||
|
id="capability-set-slug"
|
||||||
|
type="text"
|
||||||
|
value={newCapabilitySetSlug}
|
||||||
|
onChange={(event) => setNewCapabilitySetSlug(event.target.value)}
|
||||||
|
placeholder="e.g. api_readonly"
|
||||||
|
disabled={creatingCapabilitySet || capabilitySetsLoading}
|
||||||
|
/>
|
||||||
|
<small>Leave blank to generate a slug automatically.</small>
|
||||||
|
</div>
|
||||||
|
{supportsCapabilitySetLabels ? (
|
||||||
|
<div className="settings-form__field">
|
||||||
|
<label htmlFor="capability-set-label">Label</label>
|
||||||
|
<input
|
||||||
|
id="capability-set-label"
|
||||||
|
type="text"
|
||||||
|
value={newCapabilitySetLabel}
|
||||||
|
onChange={(event) => setNewCapabilitySetLabel(event.target.value)}
|
||||||
|
placeholder="Friendly name (optional)"
|
||||||
|
disabled={creatingCapabilitySet || capabilitySetsLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="settings-form__field settings-form__field--full">
|
||||||
|
<label htmlFor="capability-set-capabilities">Capabilities</label>
|
||||||
|
<div className="settings-capability-picker" id="capability-set-capabilities">
|
||||||
|
<CapabilityDropdown
|
||||||
|
id="capability-set-capabilities"
|
||||||
|
options={capabilitySelectionOptions}
|
||||||
|
selectedValues={newCapabilitySetCapabilities}
|
||||||
|
onSelect={handleAddCapabilityToNewSet}
|
||||||
|
onDeselect={handleRemoveCapabilityFromNewSet}
|
||||||
|
formatLabel={formatCapabilityLabel}
|
||||||
|
disabled={
|
||||||
|
creatingCapabilitySet
|
||||||
|
|| capabilitySetsLoading
|
||||||
|
|| capabilitiesLoading
|
||||||
|
|| !capabilitySelectionOptions.length
|
||||||
|
}
|
||||||
|
loading={capabilitiesLoading}
|
||||||
|
summaryLabel="capabilities"
|
||||||
|
/>
|
||||||
|
{newCapabilitySetCapabilities.length ? (
|
||||||
|
<div className="settings-capability-picker__chips">
|
||||||
|
{newCapabilitySetCapabilities.map((value) => (
|
||||||
|
<span key={value} className="badge tag-chip">
|
||||||
|
<span className="tag-chip__label">{formatCapabilityLabel(value)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tag-chip__remove"
|
||||||
|
onClick={() => handleRemoveCapabilityFromNewSet(value)}
|
||||||
|
aria-label={`Remove capability ${formatCapabilityLabel(value)}`}
|
||||||
|
>
|
||||||
|
<IconX className="icon-inline" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="settings-capability-picker__placeholder">No capabilities selected.</span>
|
||||||
|
)}
|
||||||
|
{capabilitiesLoading ? (
|
||||||
|
<small>Loading capabilities…</small>
|
||||||
|
) : null}
|
||||||
|
{!capabilitiesLoading && !capabilitySelectionOptions.length ? (
|
||||||
|
<small>No capabilities available.</small>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="settings-form__actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
creatingCapabilitySet
|
||||||
|
|| capabilitySetsLoading
|
||||||
|
|| capabilitiesLoading
|
||||||
|
|| !capabilitySelectionOptions.length
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{creatingCapabilitySet ? 'Creating…' : 'Create capability set'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{capabilitySetFormError ? (
|
||||||
|
<p className="settings-form__error">{capabilitySetFormError}</p>
|
||||||
|
) : null}
|
||||||
|
{capabilitySetsLoading && !hasCapabilitySets ? (
|
||||||
|
<p className="settings-empty">Loading capability sets…</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!capabilitySetsLoading && !hasCapabilitySets ? (
|
||||||
|
<p className="settings-empty">No capability sets yet.</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasCapabilitySets ? (
|
||||||
|
<table className="settings-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Slug</th>
|
||||||
|
{supportsCapabilitySetLabels ? <th scope="col">Label</th> : null}
|
||||||
|
<th scope="col">Capabilities</th>
|
||||||
|
<th scope="col">System</th>
|
||||||
|
<th scope="col">Version</th>
|
||||||
|
<th scope="col">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{capabilitySets.map((set) => {
|
||||||
|
const isSystem = Boolean(set?.is_system);
|
||||||
|
const isEditing = editingCapabilitySetId === set.id;
|
||||||
|
const capabilityList = sortCapabilityValues(
|
||||||
|
Array.isArray(set?.capabilities) ? set.capabilities : [],
|
||||||
|
);
|
||||||
|
const saving = savingCapabilitySetId === set.id;
|
||||||
|
const deleting = deletingCapabilitySetId === set.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={set.id}>
|
||||||
|
<tr className={isSystem ? 'is-system' : undefined}>
|
||||||
|
<td>{set.slug || '—'}</td>
|
||||||
|
{supportsCapabilitySetLabels ? (
|
||||||
|
<td>{set.label || '—'}</td>
|
||||||
|
) : null}
|
||||||
|
<td>
|
||||||
|
{capabilityList.length
|
||||||
|
? capabilityList.map((value) => formatCapabilityLabel(value)).join(', ')
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
<td>{isSystem ? 'Yes' : 'No'}</td>
|
||||||
|
<td>{typeof set.cap_version === 'number' ? set.cap_version : '—'}</td>
|
||||||
|
<td className="settings-table__actions">
|
||||||
|
{isSystem ? (
|
||||||
|
<span className="settings-status">System set</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => handleStartEditCapabilitySet(set)}
|
||||||
|
disabled={
|
||||||
|
saving
|
||||||
|
|| deleting
|
||||||
|
|| capabilitySetsLoading
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isEditing ? 'Editing…' : 'Edit'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger"
|
||||||
|
onClick={() => handleDeleteCapabilitySet(set)}
|
||||||
|
disabled={deleting || saving || capabilitySetsLoading}
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting…' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{isEditing ? (
|
||||||
|
<tr className="settings-table__edit-row">
|
||||||
|
<td colSpan={columnCount}>
|
||||||
|
<form className="settings-form" onSubmit={handleUpdateCapabilitySetSubmit}>
|
||||||
|
<div className="settings-form__field">
|
||||||
|
<label htmlFor="edit-capability-set-slug">Slug</label>
|
||||||
|
<input
|
||||||
|
id="edit-capability-set-slug"
|
||||||
|
type="text"
|
||||||
|
value={editCapabilitySetSlug}
|
||||||
|
onChange={(event) => setEditCapabilitySetSlug(event.target.value)}
|
||||||
|
disabled={saving || capabilitySetsLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{supportsCapabilitySetLabels ? (
|
||||||
|
<div className="settings-form__field">
|
||||||
|
<label htmlFor="edit-capability-set-label">Label</label>
|
||||||
|
<input
|
||||||
|
id="edit-capability-set-label"
|
||||||
|
type="text"
|
||||||
|
value={editCapabilitySetLabel}
|
||||||
|
onChange={(event) => setEditCapabilitySetLabel(event.target.value)}
|
||||||
|
disabled={saving || capabilitySetsLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="settings-form__field settings-form__field--full">
|
||||||
|
<label htmlFor={`edit-capability-set-${set.id}-capabilities`}>
|
||||||
|
Capabilities
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="settings-capability-picker"
|
||||||
|
id={`edit-capability-set-${set.id}-capabilities`}
|
||||||
|
>
|
||||||
|
<CapabilityDropdown
|
||||||
|
id={`edit-capability-set-${set.id}-capabilities`}
|
||||||
|
options={capabilitySelectionOptions}
|
||||||
|
selectedValues={editCapabilitySetCapabilities}
|
||||||
|
onSelect={handleAddCapabilityToEditSet}
|
||||||
|
onDeselect={handleRemoveCapabilityFromEditSet}
|
||||||
|
formatLabel={formatCapabilityLabel}
|
||||||
|
disabled={
|
||||||
|
saving
|
||||||
|
|| capabilitySetsLoading
|
||||||
|
|| capabilitiesLoading
|
||||||
|
|| !capabilitySelectionOptions.length
|
||||||
|
}
|
||||||
|
loading={capabilitiesLoading}
|
||||||
|
summaryLabel="capabilities"
|
||||||
|
/>
|
||||||
|
{editCapabilitySetCapabilities.length ? (
|
||||||
|
<div className="settings-capability-picker__chips">
|
||||||
|
{editCapabilitySetCapabilities.map((value) => (
|
||||||
|
<span key={value} className="badge tag-chip">
|
||||||
|
<span className="tag-chip__label">{formatCapabilityLabel(value)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tag-chip__remove"
|
||||||
|
onClick={() => handleRemoveCapabilityFromEditSet(value)}
|
||||||
|
aria-label={`Remove capability ${formatCapabilityLabel(value)}`}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<IconX className="icon-inline" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="settings-capability-picker__placeholder">
|
||||||
|
No capabilities selected.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{capabilitiesLoading ? (
|
||||||
|
<small>Loading capabilities…</small>
|
||||||
|
) : null}
|
||||||
|
{!capabilitiesLoading && !capabilitySelectionOptions.length ? (
|
||||||
|
<small>No capabilities available.</small>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="settings-form__actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
saving
|
||||||
|
|| capabilitySetsLoading
|
||||||
|
|| capabilitiesLoading
|
||||||
|
|| !capabilitySelectionOptions.length
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={handleCancelEditCapabilitySet}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{capabilitySetEditError ? (
|
||||||
|
<p className="settings-form__error">{capabilitySetEditError}</p>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CAPABILITY_SETS_SECTION = {
|
||||||
|
id: 'capabilitySets',
|
||||||
|
label: 'Capability sets',
|
||||||
|
component: CapabilitySetsSection,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CapabilitySetsSection;
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
const formatDateTime = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
const timestamp = new Date(value);
|
||||||
|
if (Number.isNaN(timestamp.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return timestamp.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const PasskeysSection = ({
|
||||||
|
passkeys = [],
|
||||||
|
passkeysSupported = null,
|
||||||
|
passkeysLoading = false,
|
||||||
|
registeringPasskey = false,
|
||||||
|
revokingPasskeyId = null,
|
||||||
|
onRefreshPasskeys,
|
||||||
|
onRegisterPasskey,
|
||||||
|
onRevokePasskey,
|
||||||
|
}) => {
|
||||||
|
const [newPasskeyNickname, setNewPasskeyNickname] = useState('');
|
||||||
|
|
||||||
|
const hasPasskeys = useMemo(() => Array.isArray(passkeys) && passkeys.length > 0, [passkeys]);
|
||||||
|
|
||||||
|
const handlePasskeyRefresh = useCallback(() => {
|
||||||
|
onRefreshPasskeys?.();
|
||||||
|
}, [onRefreshPasskeys]);
|
||||||
|
|
||||||
|
const handlePasskeyRegister = useCallback(
|
||||||
|
async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const nickname = newPasskeyNickname.trim();
|
||||||
|
const result = await onRegisterPasskey?.({ nickname });
|
||||||
|
if (result?.ok) {
|
||||||
|
setNewPasskeyNickname('');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[newPasskeyNickname, onRegisterPasskey],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePasskeyRevoke = useCallback(
|
||||||
|
async (passkey) => {
|
||||||
|
if (!passkey?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reasonInput = window.prompt('Optional reason for revoking this passkey:', '');
|
||||||
|
const reason = reasonInput ? reasonInput.trim() : undefined;
|
||||||
|
await onRevokePasskey?.(passkey.id, reason);
|
||||||
|
},
|
||||||
|
[onRevokePasskey],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<div className="settings-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={handlePasskeyRefresh}
|
||||||
|
disabled={passkeysLoading}
|
||||||
|
>
|
||||||
|
{passkeysLoading ? 'Refreshing…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{passkeysSupported === false ? (
|
||||||
|
<p className="settings-empty">Passkeys are not enabled for this account.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<form className="settings-form" onSubmit={handlePasskeyRegister}>
|
||||||
|
<div className="settings-form__field">
|
||||||
|
<label htmlFor="passkey-nickname">Nickname (optional)</label>
|
||||||
|
<input
|
||||||
|
id="passkey-nickname"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. MacBook"
|
||||||
|
value={newPasskeyNickname}
|
||||||
|
onChange={(event) => setNewPasskeyNickname(event.target.value)}
|
||||||
|
disabled={registeringPasskey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="settings-form__actions">
|
||||||
|
<button type="submit" disabled={registeringPasskey}>
|
||||||
|
{registeringPasskey ? 'Registering…' : 'Register passkey'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{passkeysLoading && !hasPasskeys ? (
|
||||||
|
<p className="settings-empty">Loading passkeys…</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!passkeysLoading && !hasPasskeys ? (
|
||||||
|
<p className="settings-empty">No passkeys registered yet.</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasPasskeys ? (
|
||||||
|
<table className="settings-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Nickname</th>
|
||||||
|
<th scope="col">Created</th>
|
||||||
|
<th scope="col">Last used</th>
|
||||||
|
<th scope="col">Transports</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{passkeys.map((passkey) => {
|
||||||
|
const createdAt = passkey.created_at || passkey.createdAt;
|
||||||
|
const lastUsedAt = passkey.last_used_at || passkey.lastUsedAt;
|
||||||
|
const revokedAt = passkey.revoked_at || passkey.revokedAt;
|
||||||
|
const revokedReason = passkey.revoked_reason || passkey.revokedReason;
|
||||||
|
const revoked = Boolean(revokedAt);
|
||||||
|
const transports = Array.isArray(passkey.transports)
|
||||||
|
? passkey.transports.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={passkey.id} className={revoked ? 'is-revoked' : undefined}>
|
||||||
|
<td>{passkey.nickname || '—'}</td>
|
||||||
|
<td>{formatDateTime(createdAt)}</td>
|
||||||
|
<td>{formatDateTime(lastUsedAt)}</td>
|
||||||
|
<td>{transports.length ? transports.join(', ') : '—'}</td>
|
||||||
|
<td>
|
||||||
|
{revoked
|
||||||
|
? revokedReason
|
||||||
|
? `Revoked (${revokedReason})`
|
||||||
|
: 'Revoked'
|
||||||
|
: 'Active'}
|
||||||
|
</td>
|
||||||
|
<td className="settings-table__actions">
|
||||||
|
{revoked ? (
|
||||||
|
<span className="settings-status">Revoked</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger"
|
||||||
|
onClick={() => handlePasskeyRevoke(passkey)}
|
||||||
|
disabled={revokingPasskeyId === passkey.id}
|
||||||
|
>
|
||||||
|
{revokingPasskeyId === passkey.id ? 'Revoking…' : 'Revoke'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PASSKEYS_SECTION = {
|
||||||
|
id: 'passkeys',
|
||||||
|
label: 'Passkeys',
|
||||||
|
component: PasskeysSection,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PasskeysSection;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import PasskeysSection, { PASSKEYS_SECTION } from './PasskeysSection';
|
||||||
|
import ApiTokensSection, { API_TOKENS_SECTION } from './ApiTokensSection';
|
||||||
|
import CapabilitySetsSection, { CAPABILITY_SETS_SECTION } from './CapabilitySetsSection';
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS_SECTIONS = [
|
||||||
|
PASSKEYS_SECTION,
|
||||||
|
API_TOKENS_SECTION,
|
||||||
|
CAPABILITY_SETS_SECTION,
|
||||||
|
];
|
||||||
|
|
||||||
|
export {
|
||||||
|
PasskeysSection,
|
||||||
|
ApiTokensSection,
|
||||||
|
CapabilitySetsSection,
|
||||||
|
};
|
||||||
@@ -6,9 +6,7 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => {
|
|||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [deletingId, setDeletingId] = useState(null);
|
const [deletingId, setDeletingId] = useState(null);
|
||||||
const [regeneratingId, setRegeneratingId] = useState(null);
|
const [regeneratingId, setRegeneratingId] = useState(null);
|
||||||
const [updatingId, setUpdatingId] = useState(null);
|
|
||||||
const [createdSecret, setCreatedSecret] = useState(null);
|
const [createdSecret, setCreatedSecret] = useState(null);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return;
|
return;
|
||||||
@@ -25,10 +23,13 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => {
|
|||||||
}, [api, notifyApiError, token]);
|
}, [api, notifyApiError, token]);
|
||||||
|
|
||||||
const create = useCallback(
|
const create = useCallback(
|
||||||
async ({ label, expires_at, capabilities } = {}) => {
|
async ({ label, expires_at, capability_set_id } = {}) => {
|
||||||
if (creating) {
|
if (creating) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!capability_set_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
const payload = {};
|
const payload = {};
|
||||||
@@ -38,9 +39,7 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => {
|
|||||||
if (expires_at) {
|
if (expires_at) {
|
||||||
payload.expires_at = expires_at;
|
payload.expires_at = expires_at;
|
||||||
}
|
}
|
||||||
if (Array.isArray(capabilities) && capabilities.length > 0) {
|
payload.capability_set_id = capability_set_id;
|
||||||
payload.capabilities = capabilities;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data } = await api.post('/profile/api-tokens', payload);
|
const { data } = await api.post('/profile/api-tokens', payload);
|
||||||
if (data?.token_info) {
|
if (data?.token_info) {
|
||||||
@@ -132,46 +131,6 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => {
|
|||||||
[api, notifyApiError, refresh, setStatusMessage],
|
[api, notifyApiError, refresh, setStatusMessage],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateCapabilities = useCallback(
|
|
||||||
async (tokenId, capabilities) => {
|
|
||||||
if (!tokenId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
setUpdatingId(tokenId);
|
|
||||||
try {
|
|
||||||
const { data } = await api.patch(`/profile/api-tokens/${tokenId}`, {
|
|
||||||
capabilities,
|
|
||||||
});
|
|
||||||
if (data) {
|
|
||||||
setTokens((previous) => {
|
|
||||||
let found = false;
|
|
||||||
const next = previous.map((entry) => {
|
|
||||||
if (entry.id === data.id) {
|
|
||||||
found = true;
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
return entry;
|
|
||||||
});
|
|
||||||
if (!found) {
|
|
||||||
return [data, ...previous];
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await refresh();
|
|
||||||
}
|
|
||||||
setStatusMessage?.('API token updated.', 'success');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
notifyApiError?.(error, 'Failed to update API token.');
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
setUpdatingId(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[api, notifyApiError, refresh, setStatusMessage],
|
|
||||||
);
|
|
||||||
|
|
||||||
const dismissSecret = useCallback(() => {
|
const dismissSecret = useCallback(() => {
|
||||||
setCreatedSecret(null);
|
setCreatedSecret(null);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -182,13 +141,11 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => {
|
|||||||
creating,
|
creating,
|
||||||
deletingId,
|
deletingId,
|
||||||
regeneratingId,
|
regeneratingId,
|
||||||
updatingId,
|
|
||||||
createdSecret,
|
createdSecret,
|
||||||
refresh,
|
refresh,
|
||||||
create,
|
create,
|
||||||
revoke,
|
revoke,
|
||||||
regenerate,
|
regenerate,
|
||||||
updateCapabilities,
|
|
||||||
dismissSecret,
|
dismissSecret,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const useCapabilities = ({ api, notifyApiError, token }) => {
|
||||||
|
const [capabilities, setCapabilities] = useState([]);
|
||||||
|
const [capabilitiesLoading, setCapabilitiesLoading] = useState(false);
|
||||||
|
|
||||||
|
const refreshCapabilities = useCallback(async () => {
|
||||||
|
if (!token) {
|
||||||
|
setCapabilities([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCapabilitiesLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/capabilities');
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
setCapabilities(data);
|
||||||
|
} else {
|
||||||
|
setCapabilities([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError?.(error, 'Failed to load capabilities.');
|
||||||
|
setCapabilities([]);
|
||||||
|
} finally {
|
||||||
|
setCapabilitiesLoading(false);
|
||||||
|
}
|
||||||
|
}, [api, notifyApiError, token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token) {
|
||||||
|
refreshCapabilities();
|
||||||
|
} else {
|
||||||
|
setCapabilities([]);
|
||||||
|
}
|
||||||
|
}, [refreshCapabilities, token]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
capabilities,
|
||||||
|
capabilitiesLoading,
|
||||||
|
refreshCapabilities,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useCapabilities;
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }) => {
|
||||||
|
const [capabilitySets, setCapabilitySets] = useState([]);
|
||||||
|
const [capabilitySetsLoading, setCapabilitySetsLoading] = useState(false);
|
||||||
|
const [creatingCapabilitySet, setCreatingCapabilitySet] = useState(false);
|
||||||
|
const [savingCapabilitySetId, setSavingCapabilitySetId] = useState(null);
|
||||||
|
const [deletingCapabilitySetId, setDeletingCapabilitySetId] = useState(null);
|
||||||
|
const [supportsCapabilitySetLabels, setSupportsCapabilitySetLabels] = useState(false);
|
||||||
|
|
||||||
|
const applyCapabilitySets = useCallback((updater) => {
|
||||||
|
setCapabilitySets((previous) => {
|
||||||
|
const base = Array.isArray(previous) ? [...previous] : [];
|
||||||
|
const next = typeof updater === 'function'
|
||||||
|
? updater(base)
|
||||||
|
: (Array.isArray(updater) ? [...updater] : base);
|
||||||
|
const supportsLabels = next.some((item) => Object.prototype.hasOwnProperty.call(item || {}, 'label'));
|
||||||
|
setSupportsCapabilitySetLabels(supportsLabels);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshCapabilitySets = useCallback(async () => {
|
||||||
|
if (!token) {
|
||||||
|
applyCapabilitySets([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCapabilitySetsLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/capability-sets');
|
||||||
|
applyCapabilitySets(Array.isArray(data) ? data : []);
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError?.(error, 'Failed to load capability sets.');
|
||||||
|
} finally {
|
||||||
|
setCapabilitySetsLoading(false);
|
||||||
|
}
|
||||||
|
}, [api, applyCapabilitySets, notifyApiError, token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token) {
|
||||||
|
refreshCapabilitySets();
|
||||||
|
} else {
|
||||||
|
applyCapabilitySets([]);
|
||||||
|
}
|
||||||
|
}, [applyCapabilitySets, refreshCapabilitySets, token]);
|
||||||
|
|
||||||
|
const createCapabilitySet = useCallback(
|
||||||
|
async ({ slug, label, capabilities } = {}) => {
|
||||||
|
if (creatingCapabilitySet) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(capabilities) || capabilities.length === 0) {
|
||||||
|
setStatusMessage?.('Select at least one capability.', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setCreatingCapabilitySet(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
capabilities,
|
||||||
|
};
|
||||||
|
const trimmedSlug = slug?.trim();
|
||||||
|
if (trimmedSlug) {
|
||||||
|
payload.slug = trimmedSlug;
|
||||||
|
}
|
||||||
|
const trimmedLabel = label?.trim();
|
||||||
|
if (trimmedLabel && supportsCapabilitySetLabels) {
|
||||||
|
payload.label = trimmedLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await api.post('/capability-sets', payload);
|
||||||
|
if (data) {
|
||||||
|
applyCapabilitySets((previous) => {
|
||||||
|
const next = previous.filter((entry) => entry?.id !== data.id);
|
||||||
|
next.push(data);
|
||||||
|
next.sort((a, b) => a.slug.localeCompare(b.slug));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await refreshCapabilitySets();
|
||||||
|
}
|
||||||
|
setStatusMessage?.('Capability set created.', 'success');
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError?.(error, 'Failed to create capability set.');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setCreatingCapabilitySet(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
api,
|
||||||
|
applyCapabilitySets,
|
||||||
|
creatingCapabilitySet,
|
||||||
|
notifyApiError,
|
||||||
|
refreshCapabilitySets,
|
||||||
|
setStatusMessage,
|
||||||
|
supportsCapabilitySetLabels,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateCapabilitySet = useCallback(
|
||||||
|
async (capabilitySetId, { slug, label, capabilities } = {}) => {
|
||||||
|
if (!capabilitySetId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setSavingCapabilitySetId(capabilitySetId);
|
||||||
|
try {
|
||||||
|
const payload = {};
|
||||||
|
if (slug !== undefined) {
|
||||||
|
const trimmed = slug?.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
payload.slug = trimmed;
|
||||||
|
} else if (slug === '') {
|
||||||
|
payload.slug = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (label !== undefined && supportsCapabilitySetLabels) {
|
||||||
|
const trimmed = label?.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
payload.label = trimmed;
|
||||||
|
} else if (label === '') {
|
||||||
|
payload.label = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(capabilities)) {
|
||||||
|
payload.capabilities = capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await api.patch(`/capability-sets/${capabilitySetId}`, payload);
|
||||||
|
if (data) {
|
||||||
|
applyCapabilitySets((previous) => {
|
||||||
|
let found = false;
|
||||||
|
const next = previous.map((entry) => {
|
||||||
|
if (entry?.id === data.id) {
|
||||||
|
found = true;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
if (!found) {
|
||||||
|
next.push(data);
|
||||||
|
}
|
||||||
|
next.sort((a, b) => a.slug.localeCompare(b.slug));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await refreshCapabilitySets();
|
||||||
|
}
|
||||||
|
setStatusMessage?.('Capability set updated.', 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError?.(error, 'Failed to update capability set.');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setSavingCapabilitySetId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
api,
|
||||||
|
applyCapabilitySets,
|
||||||
|
notifyApiError,
|
||||||
|
refreshCapabilitySets,
|
||||||
|
setStatusMessage,
|
||||||
|
supportsCapabilitySetLabels,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteCapabilitySet = useCallback(
|
||||||
|
async (capabilitySetId) => {
|
||||||
|
if (!capabilitySetId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setDeletingCapabilitySetId(capabilitySetId);
|
||||||
|
try {
|
||||||
|
await api.delete(`/capability-sets/${capabilitySetId}`);
|
||||||
|
applyCapabilitySets((previous) => previous.filter((entry) => entry?.id !== capabilitySetId));
|
||||||
|
setStatusMessage?.('Capability set deleted.', 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError?.(error, 'Failed to delete capability set.');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setDeletingCapabilitySetId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, applyCapabilitySets, notifyApiError, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
capabilitySets,
|
||||||
|
capabilitySetsLoading,
|
||||||
|
creatingCapabilitySet,
|
||||||
|
savingCapabilitySetId,
|
||||||
|
deletingCapabilitySetId,
|
||||||
|
supportsCapabilitySetLabels,
|
||||||
|
refreshCapabilitySets,
|
||||||
|
createCapabilitySet,
|
||||||
|
updateCapabilitySet,
|
||||||
|
deleteCapabilitySet,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useCapabilitySets;
|
||||||
@@ -165,6 +165,7 @@ const Sidebar = ({
|
|||||||
onCreateFolder,
|
onCreateFolder,
|
||||||
creatingFolder = false,
|
creatingFolder = false,
|
||||||
tags = [],
|
tags = [],
|
||||||
|
untaggedFilterId = null,
|
||||||
activeTagIds = [],
|
activeTagIds = [],
|
||||||
onToggleTagFilter,
|
onToggleTagFilter,
|
||||||
correspondents = [],
|
correspondents = [],
|
||||||
@@ -215,6 +216,7 @@ const Sidebar = ({
|
|||||||
);
|
);
|
||||||
const handleToggleTag = onToggleTagFilter || (() => {});
|
const handleToggleTag = onToggleTagFilter || (() => {});
|
||||||
const activeTagSet = new Set(activeTagIds);
|
const activeTagSet = new Set(activeTagIds);
|
||||||
|
const untaggedActive = untaggedFilterId ? activeTagSet.has(untaggedFilterId) : false;
|
||||||
const handleManageTags = onManageTags || (() => {});
|
const handleManageTags = onManageTags || (() => {});
|
||||||
const handleManageCorrespondents = onManageCorrespondents || (() => {});
|
const handleManageCorrespondents = onManageCorrespondents || (() => {});
|
||||||
const handleCreateTag = useCallback(async () => {
|
const handleCreateTag = useCallback(async () => {
|
||||||
@@ -619,6 +621,20 @@ const Sidebar = ({
|
|||||||
}`}
|
}`}
|
||||||
role="list"
|
role="list"
|
||||||
>
|
>
|
||||||
|
{untaggedFilterId ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="listitem"
|
||||||
|
className={`sidebar-tag-pill sidebar-tag-pill--untagged${
|
||||||
|
untaggedActive ? ' active' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleToggleTag(untaggedFilterId)}
|
||||||
|
aria-pressed={untaggedActive}
|
||||||
|
draggable={false}
|
||||||
|
>
|
||||||
|
No tag assigned
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
{tags.map((tag) => {
|
{tags.map((tag) => {
|
||||||
const isActive = activeTagSet.has(tag.id);
|
const isActive = activeTagSet.has(tag.id);
|
||||||
const style = getTagColorStyle(tag.color);
|
const style = getTagColorStyle(tag.color);
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { TAG_FILTER_UNTAGGED } from '../app/appLayoutUtils';
|
||||||
|
|
||||||
|
const useSidebarProps = ({
|
||||||
|
folderNodes,
|
||||||
|
folderClickHandlers,
|
||||||
|
handleFolderDelete,
|
||||||
|
handleFolderRename,
|
||||||
|
selectedFolder,
|
||||||
|
handleFolderDragStart,
|
||||||
|
handleFolderDragEnd,
|
||||||
|
draggedFolderId,
|
||||||
|
handlePromptCreateFolder,
|
||||||
|
creatingFolder,
|
||||||
|
tags,
|
||||||
|
activeTagFilters,
|
||||||
|
toggleTagFilter,
|
||||||
|
handleTagCreate,
|
||||||
|
correspondents,
|
||||||
|
activeCorrespondentFilters,
|
||||||
|
toggleCorrespondentFilter,
|
||||||
|
handleCorrespondentCreate,
|
||||||
|
appStatus,
|
||||||
|
loading,
|
||||||
|
previewActive,
|
||||||
|
searchQuery,
|
||||||
|
handleSearchChange,
|
||||||
|
handleSearchSubmit,
|
||||||
|
clearFilters,
|
||||||
|
isFilterActive,
|
||||||
|
handleLogout,
|
||||||
|
status,
|
||||||
|
tenantName,
|
||||||
|
tenantOptions,
|
||||||
|
currentTenantId,
|
||||||
|
handleTenantSelect,
|
||||||
|
openSettings,
|
||||||
|
}) =>
|
||||||
|
useMemo(
|
||||||
|
() => ({
|
||||||
|
folderNodes,
|
||||||
|
onToggle: folderClickHandlers.onToggle,
|
||||||
|
onSelect: folderClickHandlers.onSelect,
|
||||||
|
onDrop: folderClickHandlers.onDrop,
|
||||||
|
onDragOver: folderClickHandlers.onDragOver,
|
||||||
|
onDragLeave: folderClickHandlers.onDragLeave,
|
||||||
|
onDeleteFolder: handleFolderDelete,
|
||||||
|
onRenameFolder: handleFolderRename,
|
||||||
|
selectedFolder,
|
||||||
|
onFolderDragStart: handleFolderDragStart,
|
||||||
|
onFolderDragEnd: handleFolderDragEnd,
|
||||||
|
draggedFolderId,
|
||||||
|
onCreateFolder: handlePromptCreateFolder,
|
||||||
|
creatingFolder,
|
||||||
|
tags,
|
||||||
|
untaggedFilterId: TAG_FILTER_UNTAGGED,
|
||||||
|
activeTagIds: activeTagFilters,
|
||||||
|
onToggleTagFilter: toggleTagFilter,
|
||||||
|
onCreateTag: (label) => handleTagCreate({ label }),
|
||||||
|
correspondents,
|
||||||
|
activeCorrespondentIds: activeCorrespondentFilters,
|
||||||
|
onToggleCorrespondentFilter: toggleCorrespondentFilter,
|
||||||
|
onCreateCorrespondent: (name) => handleCorrespondentCreate({ name }),
|
||||||
|
appStatus,
|
||||||
|
loading,
|
||||||
|
previewActive,
|
||||||
|
searchQuery,
|
||||||
|
onSearchChange: handleSearchChange,
|
||||||
|
onSearchSubmit: handleSearchSubmit,
|
||||||
|
onSearchClear: clearFilters,
|
||||||
|
isFilterActive,
|
||||||
|
onLogout: handleLogout,
|
||||||
|
status,
|
||||||
|
tenantName,
|
||||||
|
tenants: tenantOptions,
|
||||||
|
activeTenantId: currentTenantId,
|
||||||
|
onSelectTenant: handleTenantSelect,
|
||||||
|
onOpenSettings: openSettings,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
activeCorrespondentFilters,
|
||||||
|
activeTagFilters,
|
||||||
|
appStatus,
|
||||||
|
clearFilters,
|
||||||
|
correspondents,
|
||||||
|
creatingFolder,
|
||||||
|
currentTenantId,
|
||||||
|
folderClickHandlers,
|
||||||
|
folderNodes,
|
||||||
|
handleCorrespondentCreate,
|
||||||
|
handleFolderDragEnd,
|
||||||
|
handleFolderDragStart,
|
||||||
|
handleFolderDelete,
|
||||||
|
handleFolderRename,
|
||||||
|
handleLogout,
|
||||||
|
handlePromptCreateFolder,
|
||||||
|
handleSearchChange,
|
||||||
|
handleSearchSubmit,
|
||||||
|
handleTagCreate,
|
||||||
|
handleTenantSelect,
|
||||||
|
isFilterActive,
|
||||||
|
loading,
|
||||||
|
openSettings,
|
||||||
|
previewActive,
|
||||||
|
searchQuery,
|
||||||
|
draggedFolderId,
|
||||||
|
selectedFolder,
|
||||||
|
status,
|
||||||
|
tags,
|
||||||
|
tenantName,
|
||||||
|
tenantOptions,
|
||||||
|
toggleCorrespondentFilter,
|
||||||
|
toggleTagFilter,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export default useSidebarProps;
|
||||||
+636
-41
@@ -266,6 +266,10 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.has-main-content {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
@@ -323,7 +327,6 @@ a.button-link {
|
|||||||
|
|
||||||
a.button-link[aria-disabled='true'] {
|
a.button-link[aria-disabled='true'] {
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a.button-link:hover:not([aria-disabled='true']) {
|
a.button-link:hover:not([aria-disabled='true']) {
|
||||||
@@ -393,7 +396,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
.panel-header button,
|
.panel-header button,
|
||||||
.panel-header a.icon-button {
|
.panel-header a.icon-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -413,6 +416,13 @@ button.danger:hover:not([disabled]) {
|
|||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-header .icon-button.active:hover:not([disabled]),
|
||||||
|
.panel-header button.active:hover:not([disabled]),
|
||||||
|
.panel-header a.icon-button.active:hover {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
.panel-header .icon-button.ghost,
|
.panel-header .icon-button.ghost,
|
||||||
.panel-header button.icon-button.ghost {
|
.panel-header button.icon-button.ghost {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -434,7 +444,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
z-index: 3000;
|
z-index: 2000000;
|
||||||
cursor: zoom-out;
|
cursor: zoom-out;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -453,7 +463,9 @@ button.danger:hover:not([disabled]) {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
max-width: 95vw;
|
max-width: 95vw;
|
||||||
max-height: 95vh;
|
max-height: 95vh;
|
||||||
|
z-index: 3000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-zoom__image {
|
.preview-zoom__image {
|
||||||
max-width: 95vw;
|
max-width: 95vw;
|
||||||
max-height: 95vh;
|
max-height: 95vh;
|
||||||
@@ -555,6 +567,16 @@ button.danger:hover:not([disabled]) {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes icon-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon--spin {
|
||||||
|
animation: icon-spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.icon--flip-y {
|
.icon--flip-y {
|
||||||
transform: scaleX(-1);
|
transform: scaleX(-1);
|
||||||
}
|
}
|
||||||
@@ -616,6 +638,80 @@ button.danger:hover:not([disabled]) {
|
|||||||
height: 1.4rem;
|
height: 1.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.documents-actions__sort-group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-sort {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-sort__trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
min-height: 2.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-sort__label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-sort__trigger-content {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-sort__quickmenu .menu__item,
|
||||||
|
.documents-sort__quickmenu .menu__item.active {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-toolbar__toggle {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.3rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-toolbar__toggle:hover:not([disabled]) {
|
||||||
|
color: var(--fg);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-toolbar__toggle[aria-pressed='true'] {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-sort__direction {
|
||||||
|
padding: 0.3rem 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-sort__direction[aria-pressed='true'] {
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-sort__direction svg {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -659,6 +755,10 @@ button.danger:hover:not([disabled]) {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.main-content__header-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.main-content__body {
|
.main-content__body {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
@@ -764,7 +864,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 30em) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 30em) minmax(0, 1fr);
|
||||||
gap: 1.5rem;
|
gap: 1rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 1rem 1rem;
|
padding: 1rem 1rem;
|
||||||
}
|
}
|
||||||
@@ -873,7 +973,14 @@ button.danger:hover:not([disabled]) {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
}
|
||||||
|
|
||||||
|
.document-viewer__details-pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
.document-viewer__tabs-wrapper {
|
.document-viewer__tabs-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -887,7 +994,6 @@ button.danger:hover:not([disabled]) {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1002,6 +1108,11 @@ button.danger:hover:not([disabled]) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.document-viewer__tabpanes--single {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.document-viewer__tabpanel {
|
.document-viewer__tabpanel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -1018,7 +1129,6 @@ button.danger:hover:not([disabled]) {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: auto;
|
|
||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
@@ -1519,6 +1629,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
width: 20em;
|
width: 20em;
|
||||||
max-width: 20em;
|
max-width: 20em;
|
||||||
flex: 0 0 20em;
|
flex: 0 0 20em;
|
||||||
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar__body {
|
.sidebar__body {
|
||||||
@@ -1924,6 +2035,16 @@ button.danger:hover:not([disabled]) {
|
|||||||
box-shadow: 0 0 0 1.5px var(--sidebar-active-pill-border);
|
box-shadow: 0 0 0 1.5px var(--sidebar-active-pill-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-tag-pill--untagged {
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tag-pill--untagged.active {
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-tag-cloud--has-active .sidebar-tag-pill:not(.active) {
|
.sidebar-tag-cloud--has-active .sidebar-tag-pill:not(.active) {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
@@ -1989,6 +2110,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.documents-panel .panel-section__header .header-actions {
|
.documents-panel .panel-section__header .header-actions {
|
||||||
@@ -2019,6 +2141,100 @@ button.danger:hover:not([disabled]) {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-floating {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(50% + 0.25rem);
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: color-mix(in oklch, var(--surface) 100%, transparent);
|
||||||
|
border: 1px solid color-mix(in oklch, var(--border) 95%, transparent);
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--fg);
|
||||||
|
box-shadow: 0 2px 6px color-mix(in oklch, var(--shadow-soft) 60%, transparent);
|
||||||
|
pointer-events: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
z-index: 2000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-floating__label {
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-summary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-summary__token {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-summary__count {
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-summary__icon {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-summary--text {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-summary__separator {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-floating-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-floating-actions .quick-add {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-floating-actions .quick-add__trigger {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-floating-actions .quick-add__trigger[disabled] {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-floating-actions__button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
pointer-events: auto;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-floating-actions__button .icon-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 1.35rem;
|
||||||
|
height: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.documents-panel .documents-scroll {
|
.documents-panel .documents-scroll {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -2168,6 +2384,43 @@ button.danger:hover:not([disabled]) {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.doc-title-edit {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-title-edit input[type='text'] {
|
||||||
|
padding: 0.3rem 0.55rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--fg);
|
||||||
|
min-width: 8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-title-edit input[type='text']:focus-visible {
|
||||||
|
outline: 2px solid var(--selection-ring);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-title-edit .icon-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-panel .doc-name__primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-panel .doc-name__primary-text {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.doc-entry {
|
.doc-entry {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2294,16 +2547,35 @@ button.danger:hover:not([disabled]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.document-card__title {
|
.document-card__title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-card__title-row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-card__title-badge {
|
||||||
padding: 0.2rem 0.7rem;
|
padding: 0.2rem 0.7rem;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
font-size: var(--documents-grid-title-size);
|
font-size: var(--documents-grid-title-size);
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-card.selected .document-card__title,
|
.document-card .doc-correspondent-link {
|
||||||
.folder-card.selected .folder-card__name {
|
font-size: var(--documents-grid-title-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-card.selected .document-card__title-badge {
|
||||||
background-color: var(--accent);
|
background-color: var(--accent);
|
||||||
color: var(--on-accent);
|
color: var(--on-accent);
|
||||||
}
|
}
|
||||||
@@ -2346,6 +2618,14 @@ button.danger:hover:not([disabled]) {
|
|||||||
padding-top: 0.35rem;
|
padding-top: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.folder-card__label-row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.folder-card__name {
|
.folder-card__name {
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -2356,6 +2636,18 @@ button.danger:hover:not([disabled]) {
|
|||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.folder-card__edit {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-card.selected .folder-card__name {
|
||||||
|
background-color: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.doc-name__title {
|
.doc-name__title {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -2539,12 +2831,12 @@ button.danger:hover:not([disabled]) {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--surface);
|
background: var(--bg);
|
||||||
box-shadow: 0 0 24px var(--shadow-soft);
|
box-shadow: 0 0 24px var(--shadow-soft);
|
||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
z-index: 10000000;
|
z-index: 1000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
@@ -2595,7 +2887,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 1.25rem;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-section__header {
|
.detail-section__header {
|
||||||
@@ -2622,6 +2914,11 @@ button.danger:hover:not([disabled]) {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.documents-sort__trigger.quick-add__trigger {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
min-height: 2.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.quick-add__chip {
|
.quick-add__chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2692,9 +2989,168 @@ button.danger:hover:not([disabled]) {
|
|||||||
background: var(--surface-subtle);
|
background: var(--surface-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selection-assignment {
|
||||||
|
display: inline-flex;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-assignment__menu {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 400;
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
max-width: min(22rem, 90vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-assignment__header {
|
||||||
|
padding: 0.4rem 0.75rem 0.3rem;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-assignment__header input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-assignment__header input:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--selection-border);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in oklch, var(--selection) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-assignment__list {
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-assignment__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-assignment__label {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-assignment__spinner {
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-assignment__label--nowrap {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-assignment__indent {
|
||||||
|
display: inline-block;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-assignment__folder-label {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-assignment__folder-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--fg);
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-assignment__folder-path {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-assignment__slash {
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-assignment__segment {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-assignment__item--all .selection-assignment__icon {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-assignment__item--partial .selection-assignment__icon {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-assignment__icon {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-assignment__icon--empty {
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 999px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-assignment__label {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-assignment__count {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-assignment__empty {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-assignment__create {
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.preview-pane {
|
.preview-pane {
|
||||||
margin-top: 0.4rem;
|
margin-top: 0.4rem;
|
||||||
|
background: transparent;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
position: relative;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane__media {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
min-height: 220px;
|
min-height: 220px;
|
||||||
@@ -2705,6 +3161,44 @@ button.danger:hover:not([disabled]) {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-image {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
max-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-image__content {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
outline-color 120ms ease,
|
||||||
|
box-shadow 120ms ease,
|
||||||
|
filter 120ms ease,
|
||||||
|
background-color 120ms ease;
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-image__content:hover,
|
||||||
|
.preview-image__content:focus-visible {
|
||||||
|
outline-color: var(--accent-focus);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 999px var(--accent-elevated),
|
||||||
|
0 6px 18px var(--accent-elevated-strong);
|
||||||
|
}
|
||||||
|
|
||||||
.thumbnail-preview {
|
.thumbnail-preview {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -2942,33 +3436,6 @@ button.danger:hover:not([disabled]) {
|
|||||||
height: 80%;
|
height: 80%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-stack__image {
|
|
||||||
display: block;
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
outline-color 120ms ease,
|
|
||||||
box-shadow 120ms ease,
|
|
||||||
filter 120ms ease,
|
|
||||||
background-color 120ms ease;
|
|
||||||
outline: 2px solid transparent;
|
|
||||||
outline-offset: -2px;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-stack__image:hover,
|
|
||||||
.preview-stack__image:focus-visible {
|
|
||||||
outline-color: var(--accent-focus);
|
|
||||||
box-shadow:
|
|
||||||
inset 0 0 0 999px var(--accent-elevated),
|
|
||||||
0 6px 18px var(--accent-elevated-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-pane__unsupported {
|
.preview-pane__unsupported {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
@@ -3062,7 +3529,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
transform: scale(calc(1 / var(--preview-nav-scale, 1)));
|
transform: scale(calc(1 / var(--preview-nav-scale, 1)));
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-pane--stack:hover .preview-pane__nav--overlay,
|
.preview-pane__media:hover .preview-pane__nav--overlay,
|
||||||
.desk-item__card:hover .preview-pane__nav--overlay {
|
.desk-item__card:hover .preview-pane__nav--overlay {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -3291,7 +3758,7 @@ form.inline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-modal__sidebar button.active {
|
.settings-modal__sidebar button.active {
|
||||||
background: var(--surface-soft);
|
background: var(--accent-soft);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3334,6 +3801,11 @@ form.inline {
|
|||||||
min-width: 14rem;
|
min-width: 14rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-form__field--full {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
fieldset.settings-form__field {
|
fieldset.settings-form__field {
|
||||||
border: 1px solid var(--border-muted, var(--border));
|
border: 1px solid var(--border-muted, var(--border));
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
@@ -3363,6 +3835,129 @@ fieldset.settings-form__field legend {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-capability-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-dropdown {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-dropdown__trigger {
|
||||||
|
width: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-dropdown__trigger:hover:not(:disabled),
|
||||||
|
.capability-dropdown__trigger:focus-visible {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--surface);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-dropdown__trigger:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--surface-muted, var(--surface-soft));
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-dropdown__chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-dropdown__menu {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
max-height: 18rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-dropdown__option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-dropdown__option-icon {
|
||||||
|
width: 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-dropdown__option:not(.is-selected) .capability-dropdown__option-icon {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-dropdown__option-label {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-dropdown__empty {
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-capability-picker__chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-capability-picker__chips--inline {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-capabilities-summary {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-capability-picker__placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-capability-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-capability-list--compact {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-capability-list__item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-choice {
|
.settings-choice {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ const QuickAddMenu = ({
|
|||||||
menuMinWidth = 220,
|
menuMinWidth = 220,
|
||||||
triggerClassName = 'icon-button quick-add__trigger',
|
triggerClassName = 'icon-button quick-add__trigger',
|
||||||
triggerContent = null,
|
triggerContent = null,
|
||||||
|
disabled = false,
|
||||||
|
align = 'start',
|
||||||
|
positionStrategy = 'fixed',
|
||||||
}) => {
|
}) => {
|
||||||
const anchorRef = useRef(null);
|
const anchorRef = useRef(null);
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
@@ -57,8 +60,16 @@ const QuickAddMenu = ({
|
|||||||
anchorRef,
|
anchorRef,
|
||||||
minWidth: menuMinWidth,
|
minWidth: menuMinWidth,
|
||||||
matchAnchorWidth: false,
|
matchAnchorWidth: false,
|
||||||
|
align,
|
||||||
|
positionStrategy,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (disabled && isOpen) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}, [disabled, isOpen, close]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -145,6 +156,7 @@ const QuickAddMenu = ({
|
|||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
aria-label={triggerAriaLabel}
|
aria-label={triggerAriaLabel}
|
||||||
title={triggerTitle}
|
title={triggerTitle}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{triggerContent ?? <PlusIcon />}
|
{triggerContent ?? <PlusIcon />}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import {
|
|||||||
IconWindowMaximize,
|
IconWindowMaximize,
|
||||||
IconTextScan2,
|
IconTextScan2,
|
||||||
IconFolderPlus,
|
IconFolderPlus,
|
||||||
|
IconFolder,
|
||||||
|
IconFolders,
|
||||||
|
IconFoldersOff,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconRestore,
|
IconRestore,
|
||||||
IconMinusVertical,
|
IconMinusVertical,
|
||||||
@@ -30,6 +33,12 @@ import {
|
|||||||
IconLayoutSidebarLeftExpand,
|
IconLayoutSidebarLeftExpand,
|
||||||
IconLayoutSidebarRightCollapse,
|
IconLayoutSidebarRightCollapse,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
|
IconCircleDashedCheck,
|
||||||
|
IconFile,
|
||||||
|
IconLoader,
|
||||||
|
IconSortAscendingLetters,
|
||||||
|
IconSortDescendingLetters,
|
||||||
|
IconFileInfo,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import FolderSvg from '../assets/folder.svg';
|
import FolderSvg from '../assets/folder.svg';
|
||||||
|
|
||||||
@@ -168,6 +177,15 @@ export const InfoIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) =>
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const FileInfoIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconFileInfo
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
export const DetailPanelCollapseIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
export const DetailPanelCollapseIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
<IconLayoutSidebarRightCollapse
|
<IconLayoutSidebarRightCollapse
|
||||||
className={composeClassName('icon', className)}
|
className={composeClassName('icon', className)}
|
||||||
@@ -186,6 +204,24 @@ export const FolderPlusIcon = ({ className, size = '1em', stroke = 1.6, ...rest
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const FoldersIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconFolders
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FoldersOffIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconFoldersOff
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
export const RefreshIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
export const RefreshIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
<IconRefresh
|
<IconRefresh
|
||||||
className={composeClassName('icon', className)}
|
className={composeClassName('icon', className)}
|
||||||
@@ -258,6 +294,24 @@ export const IconFileStack = ({ className, size = 24, stroke = 160, ...rest }) =
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const SortAscendingLettersIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconSortAscendingLetters
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SortDescendingLettersIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconSortDescendingLetters
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
export const IconX = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
export const IconX = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
<TablerIconX
|
<TablerIconX
|
||||||
className={composeClassName('icon', className)}
|
className={composeClassName('icon', className)}
|
||||||
@@ -330,6 +384,33 @@ export const CheckIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) =>
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const CircleDashedCheckIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconCircleDashedCheck
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FileIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconFile
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FolderOutlineIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconFolder
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
export const AnalyzeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
export const AnalyzeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
<IconAnalyze
|
<IconAnalyze
|
||||||
className={composeClassName('icon', className)}
|
className={composeClassName('icon', className)}
|
||||||
@@ -339,6 +420,15 @@ export const AnalyzeIcon = ({ className, size = '1em', stroke = 1.6, ...rest })
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const LoaderIcon = ({ className, size = '1em', stroke = 1.8, ...rest }) => (
|
||||||
|
<IconLoader
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
export const WindowMaximizeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
export const WindowMaximizeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
<IconWindowMaximize
|
<IconWindowMaximize
|
||||||
className={composeClassName('icon', className)}
|
className={composeClassName('icon', className)}
|
||||||
@@ -380,6 +470,7 @@ export default {
|
|||||||
SidebarExpandIcon,
|
SidebarExpandIcon,
|
||||||
DetailPanelCollapseIcon,
|
DetailPanelCollapseIcon,
|
||||||
AnalyzeIcon,
|
AnalyzeIcon,
|
||||||
|
LoaderIcon,
|
||||||
WindowMaximizeIcon,
|
WindowMaximizeIcon,
|
||||||
FolderPlusIcon,
|
FolderPlusIcon,
|
||||||
RefreshIcon,
|
RefreshIcon,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const formatStyle = (metrics) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const style = {
|
const style = {
|
||||||
position: 'fixed',
|
position: metrics.strategy === 'absolute' ? 'absolute' : 'fixed',
|
||||||
top: metrics.top,
|
top: metrics.top,
|
||||||
left: metrics.left,
|
left: metrics.left,
|
||||||
minWidth: metrics.minWidth,
|
minWidth: metrics.minWidth,
|
||||||
@@ -45,6 +45,7 @@ const useFloatingMenu = ({
|
|||||||
align = 'start',
|
align = 'start',
|
||||||
viewportMargin = DEFAULT_VIEWPORT_MARGIN,
|
viewportMargin = DEFAULT_VIEWPORT_MARGIN,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
positionStrategy = 'fixed',
|
||||||
} = {}) => {
|
} = {}) => {
|
||||||
const menuRef = useRef(null);
|
const menuRef = useRef(null);
|
||||||
const [menuMetrics, setMenuMetrics] = useState(null);
|
const [menuMetrics, setMenuMetrics] = useState(null);
|
||||||
@@ -63,22 +64,64 @@ const useFloatingMenu = ({
|
|||||||
|
|
||||||
const rect = anchor.getBoundingClientRect();
|
const rect = anchor.getBoundingClientRect();
|
||||||
const desiredWidth = computeWidth(rect.width, minWidth, matchAnchorWidth);
|
const desiredWidth = computeWidth(rect.width, minWidth, matchAnchorWidth);
|
||||||
|
const menu = menuRef.current;
|
||||||
|
const measuredWidth = menu?.offsetWidth ?? desiredWidth;
|
||||||
|
const widthForAlignment = matchAnchorWidth ? desiredWidth : Math.max(desiredWidth, measuredWidth);
|
||||||
|
|
||||||
|
if (positionStrategy === 'absolute') {
|
||||||
|
const anchor = anchorRef?.current;
|
||||||
|
if (!anchor) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const offsetParent = (menu && menu.offsetParent) || anchor.offsetParent || anchor.parentElement;
|
||||||
|
if (!offsetParent) {
|
||||||
|
// Fall back to fixed positioning if we cannot resolve a relative parent.
|
||||||
|
setMenuMetrics({
|
||||||
|
strategy: 'fixed',
|
||||||
|
top: rect.bottom + offset,
|
||||||
|
left: rect.left,
|
||||||
|
minWidth: desiredWidth,
|
||||||
|
width: matchAnchorWidth ? desiredWidth : undefined,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let left;
|
||||||
|
if (align === 'end') {
|
||||||
|
left = anchor.offsetLeft + anchor.offsetWidth - widthForAlignment;
|
||||||
|
} else if (align === 'center') {
|
||||||
|
left = anchor.offsetLeft + anchor.offsetWidth / 2 - widthForAlignment / 2;
|
||||||
|
} else {
|
||||||
|
left = anchor.offsetLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
const top = anchor.offsetTop + anchor.offsetHeight + offset;
|
||||||
|
|
||||||
|
setMenuMetrics({
|
||||||
|
strategy: 'absolute',
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
minWidth: desiredWidth,
|
||||||
|
width: matchAnchorWidth ? desiredWidth : undefined,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const viewportWidth = resolveViewportWidth();
|
const viewportWidth = resolveViewportWidth();
|
||||||
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 0;
|
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 0;
|
||||||
const safeMargin = viewportMargin ?? DEFAULT_VIEWPORT_MARGIN;
|
const safeMargin = viewportMargin ?? DEFAULT_VIEWPORT_MARGIN;
|
||||||
const menu = menuRef.current;
|
|
||||||
const menuHeight = menu?.offsetHeight ?? 0;
|
const menuHeight = menu?.offsetHeight ?? 0;
|
||||||
|
|
||||||
let left;
|
let left;
|
||||||
if (align === 'end') {
|
if (align === 'end') {
|
||||||
left = rect.right - desiredWidth;
|
left = rect.right - widthForAlignment;
|
||||||
} else if (align === 'center') {
|
} else if (align === 'center') {
|
||||||
left = rect.left + rect.width / 2 - desiredWidth / 2;
|
left = rect.left + rect.width / 2 - widthForAlignment / 2;
|
||||||
} else {
|
} else {
|
||||||
left = rect.left;
|
left = rect.left;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxLeft = viewportWidth > 0 ? viewportWidth - desiredWidth - safeMargin : left;
|
const maxLeft = viewportWidth > 0 ? viewportWidth - widthForAlignment - safeMargin : left;
|
||||||
const clampedLeft = viewportWidth > 0 ? clamp(left, safeMargin, Math.max(maxLeft, safeMargin)) : left;
|
const clampedLeft = viewportWidth > 0 ? clamp(left, safeMargin, Math.max(maxLeft, safeMargin)) : left;
|
||||||
|
|
||||||
let top = rect.bottom + offset;
|
let top = rect.bottom + offset;
|
||||||
@@ -91,6 +134,7 @@ const useFloatingMenu = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setMenuMetrics({
|
setMenuMetrics({
|
||||||
|
strategy: 'fixed',
|
||||||
top,
|
top,
|
||||||
left: clampedLeft,
|
left: clampedLeft,
|
||||||
minWidth: desiredWidth,
|
minWidth: desiredWidth,
|
||||||
@@ -98,7 +142,15 @@ const useFloatingMenu = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}, [anchorRef, align, matchAnchorWidth, minWidth, offset, viewportMargin]);
|
}, [
|
||||||
|
anchorRef,
|
||||||
|
align,
|
||||||
|
matchAnchorWidth,
|
||||||
|
minWidth,
|
||||||
|
offset,
|
||||||
|
positionStrategy,
|
||||||
|
viewportMargin,
|
||||||
|
]);
|
||||||
|
|
||||||
const close = useCallback(() => {
|
const close = useCallback(() => {
|
||||||
setIsOpen((prev) => {
|
setIsOpen((prev) => {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
const defaultFilter = (event) => {
|
||||||
|
if (!event) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { type, button, pointerType, isPrimary } = event;
|
||||||
|
const isPointerUp = type === 'pointerup';
|
||||||
|
const buttonValid =
|
||||||
|
button == null || button === 0 || (isPointerUp && (button === -1 || button === 0));
|
||||||
|
if (!buttonValid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (pointerType === 'touch' && isPrimary === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const usePointerTap = ({
|
||||||
|
onSingle,
|
||||||
|
onDouble,
|
||||||
|
delay = 240,
|
||||||
|
filter = defaultFilter,
|
||||||
|
} = {}) => {
|
||||||
|
const timerRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
(event, metadata = undefined) => {
|
||||||
|
if (!filter(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof event.persist === 'function') {
|
||||||
|
event.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
clientX: event.clientX,
|
||||||
|
clientY: event.clientY,
|
||||||
|
pointerType: event.pointerType,
|
||||||
|
event,
|
||||||
|
data: metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
if (typeof onDouble === 'function') {
|
||||||
|
onDouble(context);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
timerRef.current = null;
|
||||||
|
if (typeof onSingle === 'function') {
|
||||||
|
onSingle(context);
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
},
|
||||||
|
[delay, filter, onDouble, onSingle],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default usePointerTap;
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const {
|
||||||
|
WorkspaceEngine,
|
||||||
|
DESK_CANVAS_PADDING,
|
||||||
|
} = require('../src/desktop/workspaceEngine.js');
|
||||||
|
|
||||||
|
const makeEngine = () => {
|
||||||
|
const engine = new WorkspaceEngine();
|
||||||
|
engine.setEnsureDocumentSize(() => ({ width: 200, height: 200 }));
|
||||||
|
engine.setCanvasSize({ width: 1200, height: 800 });
|
||||||
|
return engine;
|
||||||
|
};
|
||||||
|
|
||||||
|
test('syncLayoutSnapshot clones from layout map', () => {
|
||||||
|
const engine = makeEngine();
|
||||||
|
engine.setItems([{ id: 'doc-1' }]);
|
||||||
|
engine.ensureLayoutForItems();
|
||||||
|
engine.syncLayoutSnapshot();
|
||||||
|
|
||||||
|
const firstSnapshot = engine.getSnapshot();
|
||||||
|
assert(firstSnapshot.layout instanceof Map);
|
||||||
|
assert(firstSnapshot.layout.get('doc-1'));
|
||||||
|
|
||||||
|
engine.layout.set('doc-1', {
|
||||||
|
centerX: DESK_CANVAS_PADDING + 150,
|
||||||
|
centerY: DESK_CANVAS_PADDING + 150,
|
||||||
|
rotation: 0,
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
});
|
||||||
|
engine.syncLayoutSnapshot();
|
||||||
|
|
||||||
|
const secondSnapshot = engine.getSnapshot();
|
||||||
|
assert.notStrictEqual(secondSnapshot.layout, engine.layout);
|
||||||
|
assert.equal(secondSnapshot.layout.get('doc-1').centerX, engine.layout.get('doc-1').centerX);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recalcVisibleDocIds respects viewport bounds', () => {
|
||||||
|
const engine = makeEngine();
|
||||||
|
engine.items = [{ id: 'visible' }, { id: 'hidden' }];
|
||||||
|
engine.setDocumentLookup(new Map([
|
||||||
|
['visible', { id: 'visible' }],
|
||||||
|
['hidden', { id: 'hidden' }],
|
||||||
|
]));
|
||||||
|
|
||||||
|
engine.layout = new Map([
|
||||||
|
['visible', {
|
||||||
|
centerX: DESK_CANVAS_PADDING + 150,
|
||||||
|
centerY: DESK_CANVAS_PADDING + 150,
|
||||||
|
rotation: 0,
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
}],
|
||||||
|
['hidden', {
|
||||||
|
centerX: -500,
|
||||||
|
centerY: -500,
|
||||||
|
rotation: 0,
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
}],
|
||||||
|
]);
|
||||||
|
engine.layoutSnapshot = new Map(engine.layout);
|
||||||
|
|
||||||
|
engine.recalcVisibleDocIds();
|
||||||
|
|
||||||
|
const snapshot = engine.getSnapshot();
|
||||||
|
assert(snapshot.visibleDocIds.has('visible'));
|
||||||
|
assert(!snapshot.visibleDocIds.has('hidden'));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user