ui
This commit is contained in:
+31
-187
@@ -257,12 +257,6 @@ const AppLayout = () => {
|
|||||||
const previewReturnPathRef = useRef(null);
|
const previewReturnPathRef = useRef(null);
|
||||||
const [tags, setTags] = useState([]);
|
const [tags, setTags] = useState([]);
|
||||||
const [correspondents, setCorrespondents] = useState([]);
|
const [correspondents, setCorrespondents] = useState([]);
|
||||||
const [webdavTokens, setWebdavTokens] = useState([]);
|
|
||||||
const [webdavTokensLoading, setWebdavTokensLoading] = useState(false);
|
|
||||||
const [creatingWebdavToken, setCreatingWebdavToken] = useState(false);
|
|
||||||
const [deletingWebdavTokenId, setDeletingWebdavTokenId] = useState(null);
|
|
||||||
const [regeneratingWebdavTokenId, setRegeneratingWebdavTokenId] = useState(null);
|
|
||||||
const [webdavTokenSecret, setWebdavTokenSecret] = useState(null);
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [activeTagFilters, setActiveTagFilters] = useState([]);
|
const [activeTagFilters, setActiveTagFilters] = useState([]);
|
||||||
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]);
|
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]);
|
||||||
@@ -425,11 +419,6 @@ const AppLayout = () => {
|
|||||||
setSearchResults(null);
|
setSearchResults(null);
|
||||||
setTags([]);
|
setTags([]);
|
||||||
setCorrespondents([]);
|
setCorrespondents([]);
|
||||||
setWebdavTokens([]);
|
|
||||||
setWebdavTokensLoading(false);
|
|
||||||
setCreatingWebdavToken(false);
|
|
||||||
setDeletingWebdavTokenId(null);
|
|
||||||
setWebdavTokenSecret(null);
|
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setActiveTagFilters([]);
|
setActiveTagFilters([]);
|
||||||
setActiveCorrespondentFilters([]);
|
setActiveCorrespondentFilters([]);
|
||||||
@@ -927,32 +916,10 @@ const AppLayout = () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDocumentRowClick = useCallback(
|
|
||||||
(documentId, event) => {
|
|
||||||
const rowKey = resolveDocumentRowKey(documentId);
|
|
||||||
if (!rowKey) return;
|
|
||||||
const hadSelection = selectionCount > 0;
|
|
||||||
handleRowSelection(rowKey, event);
|
|
||||||
if (!hadSelection) {
|
|
||||||
detailPanelControlRef.current.open();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleRowSelection, selectionCount],
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearDocumentSelection = useCallback(() => {
|
const clearDocumentSelection = useCallback(() => {
|
||||||
clearSelectionInternal();
|
clearSelectionInternal();
|
||||||
}, [clearSelectionInternal]);
|
}, [clearSelectionInternal]);
|
||||||
|
|
||||||
const handleFolderRowClick = useCallback(
|
|
||||||
(folderId, event) => {
|
|
||||||
const rowKey = resolveFolderRowKey(folderId);
|
|
||||||
if (!rowKey) return;
|
|
||||||
handleRowSelection(rowKey, event);
|
|
||||||
},
|
|
||||||
[handleRowSelection],
|
|
||||||
);
|
|
||||||
|
|
||||||
const prevFocusedDocIdRef = useRef(focusedDocumentId);
|
const prevFocusedDocIdRef = useRef(focusedDocumentId);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previous = prevFocusedDocIdRef.current;
|
const previous = prevFocusedDocIdRef.current;
|
||||||
@@ -968,23 +935,39 @@ const AppLayout = () => {
|
|||||||
}, [focusedDocumentId, setFocusedRowKey]);
|
}, [focusedDocumentId, setFocusedRowKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!focusedRowKey) {
|
if (!navigableRowKeys.length) {
|
||||||
|
if (focusedRowKey) {
|
||||||
|
setFocusedRowKey(null);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (navigableRowKeys.includes(focusedRowKey)) {
|
|
||||||
|
if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const docKey = focusedDocumentId ? resolveDocumentRowKey(focusedDocumentId) : null;
|
const docKey = focusedDocumentId ? resolveDocumentRowKey(focusedDocumentId) : null;
|
||||||
if (docKey && navigableRowKeys.includes(docKey)) {
|
if (docKey && navigableRowKeys.includes(docKey)) {
|
||||||
setFocusedRowKey(docKey);
|
setFocusedRowKey(docKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (navigableRowKeys.length) {
|
|
||||||
setFocusedRowKey(navigableRowKeys[0]);
|
const selectedKey = selectedRowKeys.find((key) => navigableRowKeys.includes(key));
|
||||||
} else {
|
if (selectedKey) {
|
||||||
|
setFocusedRowKey(selectedKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusedRowKey) {
|
||||||
setFocusedRowKey(null);
|
setFocusedRowKey(null);
|
||||||
}
|
}
|
||||||
}, [focusedRowKey, navigableRowKeys, focusedDocumentId, setFocusedRowKey]);
|
}, [
|
||||||
|
focusedRowKey,
|
||||||
|
focusedDocumentId,
|
||||||
|
navigableRowKeys,
|
||||||
|
selectedRowKeys,
|
||||||
|
setFocusedRowKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
const ensureFolderData = useCallback(
|
const ensureFolderData = useCallback(
|
||||||
@@ -1303,125 +1286,6 @@ const AppLayout = () => {
|
|||||||
}
|
}
|
||||||
}, [notifyApiError]);
|
}, [notifyApiError]);
|
||||||
|
|
||||||
const refreshWebdavTokens = useCallback(async () => {
|
|
||||||
if (!token) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setWebdavTokensLoading(true);
|
|
||||||
try {
|
|
||||||
const { data } = await api.get('/profile/webdav-tokens');
|
|
||||||
setWebdavTokens(Array.isArray(data) ? data : []);
|
|
||||||
} catch (error) {
|
|
||||||
notifyApiError(error, 'Failed to load WebDAV tokens.');
|
|
||||||
} finally {
|
|
||||||
setWebdavTokensLoading(false);
|
|
||||||
}
|
|
||||||
}, [notifyApiError, token]);
|
|
||||||
|
|
||||||
const createWebdavToken = useCallback(
|
|
||||||
async ({ label, expires_at } = {}) => {
|
|
||||||
if (creatingWebdavToken) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
setCreatingWebdavToken(true);
|
|
||||||
try {
|
|
||||||
const payload = {};
|
|
||||||
if (label) {
|
|
||||||
payload.label = label;
|
|
||||||
}
|
|
||||||
if (expires_at) {
|
|
||||||
payload.expires_at = expires_at;
|
|
||||||
}
|
|
||||||
const { data } = await api.post('/profile/webdav-tokens', payload);
|
|
||||||
if (data?.token_info) {
|
|
||||||
setWebdavTokens((previous) => {
|
|
||||||
const filtered = previous.filter((entry) => entry.id !== data.token_info.id);
|
|
||||||
return [data.token_info, ...filtered];
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await refreshWebdavTokens();
|
|
||||||
}
|
|
||||||
if (data?.token) {
|
|
||||||
setWebdavTokenSecret(data.token);
|
|
||||||
}
|
|
||||||
setStatusMessage('WebDAV token created.', 'success');
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
notifyApiError(error, 'Failed to create WebDAV token.');
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
setCreatingWebdavToken(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[creatingWebdavToken, notifyApiError, refreshWebdavTokens, setStatusMessage],
|
|
||||||
);
|
|
||||||
|
|
||||||
const deleteWebdavToken = useCallback(
|
|
||||||
async (tokenId) => {
|
|
||||||
if (!tokenId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
setDeletingWebdavTokenId(tokenId);
|
|
||||||
try {
|
|
||||||
await api.delete(`/profile/webdav-tokens/${tokenId}`);
|
|
||||||
await refreshWebdavTokens();
|
|
||||||
setStatusMessage('WebDAV token revoked.', 'success');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
notifyApiError(error, 'Failed to revoke WebDAV token.');
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
setDeletingWebdavTokenId(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[refreshWebdavTokens, notifyApiError, setStatusMessage],
|
|
||||||
);
|
|
||||||
|
|
||||||
const regenerateWebdavToken = useCallback(
|
|
||||||
async (tokenId) => {
|
|
||||||
if (!tokenId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
setRegeneratingWebdavTokenId(tokenId);
|
|
||||||
try {
|
|
||||||
const { data } = await api.post(`/profile/webdav-tokens/${tokenId}/regenerate`);
|
|
||||||
if (data?.token_info) {
|
|
||||||
setWebdavTokens((previous) => {
|
|
||||||
let found = false;
|
|
||||||
const next = previous.map((entry) => {
|
|
||||||
if (entry.id === data.token_info.id) {
|
|
||||||
found = true;
|
|
||||||
return data.token_info;
|
|
||||||
}
|
|
||||||
return entry;
|
|
||||||
});
|
|
||||||
if (!found) {
|
|
||||||
return [data.token_info, ...previous];
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await refreshWebdavTokens();
|
|
||||||
}
|
|
||||||
if (data?.token) {
|
|
||||||
setWebdavTokenSecret(data.token);
|
|
||||||
}
|
|
||||||
setStatusMessage('WebDAV token regenerated.', 'success');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
notifyApiError(error, 'Failed to regenerate WebDAV token.');
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
setRegeneratingWebdavTokenId(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[notifyApiError, refreshWebdavTokens, setStatusMessage],
|
|
||||||
);
|
|
||||||
|
|
||||||
const dismissCreatedWebdavToken = useCallback(() => {
|
|
||||||
setWebdavTokenSecret(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
passkeys,
|
passkeys,
|
||||||
passkeysSupported,
|
passkeysSupported,
|
||||||
@@ -3208,9 +3072,13 @@ const AppLayout = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedRowKeys.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setFocusedRowKey(resolvedKey);
|
setFocusedRowKey(resolvedKey);
|
||||||
|
|
||||||
if (!selectedRowKeys.includes(resolvedKey)) {
|
if (!selectedRowKeys.includes(resolvedKey) && selectedRowKeys.length > 0) {
|
||||||
applySelection([resolvedKey], { anchor: resolvedKey, interactedKeys: [resolvedKey] });
|
applySelection([resolvedKey], { anchor: resolvedKey, interactedKeys: [resolvedKey] });
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@@ -4699,15 +4567,12 @@ const AppLayout = () => {
|
|||||||
onFolderDragEnd: handleFolderDragEnd,
|
onFolderDragEnd: handleFolderDragEnd,
|
||||||
draggedFolderId,
|
draggedFolderId,
|
||||||
onFolderDelete: handleFolderDelete,
|
onFolderDelete: handleFolderDelete,
|
||||||
onFolderRowClick: handleFolderRowClick,
|
|
||||||
onFolderRename: handleFolderRename,
|
onFolderRename: handleFolderRename,
|
||||||
onDocumentRowClick: handleDocumentRowClick,
|
|
||||||
onDocumentOpen: openDocumentPreview,
|
onDocumentOpen: openDocumentPreview,
|
||||||
onDocumentDelete: handleDocumentDelete,
|
onDocumentDelete: handleDocumentDelete,
|
||||||
onDocumentRename: handleDocumentTitleUpdate,
|
onDocumentRename: handleDocumentTitleUpdate,
|
||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
selectedFolderIds,
|
selectedFolderIds,
|
||||||
focusedDocumentId,
|
|
||||||
focusedRowKey,
|
focusedRowKey,
|
||||||
draggingDocumentIds: draggedDocumentIds,
|
draggingDocumentIds: draggedDocumentIds,
|
||||||
onDocumentDragStart: handleDocumentDragStart,
|
onDocumentDragStart: handleDocumentDragStart,
|
||||||
@@ -4730,6 +4595,8 @@ const AppLayout = () => {
|
|||||||
viewMode: documentsViewMode,
|
viewMode: documentsViewMode,
|
||||||
onViewModeChange: handleDocumentsViewModeChange,
|
onViewModeChange: handleDocumentsViewModeChange,
|
||||||
onClearSelection: clearDocumentSelection,
|
onClearSelection: clearDocumentSelection,
|
||||||
|
onRowSelection: handleRowSelection,
|
||||||
|
onOpenDetailPanel: openDetailPanel,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
activeCorrespondentFilters,
|
activeCorrespondentFilters,
|
||||||
@@ -4741,7 +4608,6 @@ const AppLayout = () => {
|
|||||||
documentsViewMode,
|
documentsViewMode,
|
||||||
draggedDocumentIds,
|
draggedDocumentIds,
|
||||||
draggedFolderId,
|
draggedFolderId,
|
||||||
focusedDocumentId,
|
|
||||||
focusedRowKey,
|
focusedRowKey,
|
||||||
folderClickHandlers,
|
folderClickHandlers,
|
||||||
handleDocumentDelete,
|
handleDocumentDelete,
|
||||||
@@ -4749,7 +4615,6 @@ const AppLayout = () => {
|
|||||||
handleDocumentDragStart,
|
handleDocumentDragStart,
|
||||||
handleDocumentListFocus,
|
handleDocumentListFocus,
|
||||||
handleDocumentListKeyDown,
|
handleDocumentListKeyDown,
|
||||||
handleDocumentRowClick,
|
|
||||||
handleDocumentTagDrop,
|
handleDocumentTagDrop,
|
||||||
handleDocumentTitleUpdate,
|
handleDocumentTitleUpdate,
|
||||||
handleDocumentsViewModeChange,
|
handleDocumentsViewModeChange,
|
||||||
@@ -4757,8 +4622,9 @@ const AppLayout = () => {
|
|||||||
handleFolderDragEnd,
|
handleFolderDragEnd,
|
||||||
handleFolderDragStart,
|
handleFolderDragStart,
|
||||||
handleFolderRename,
|
handleFolderRename,
|
||||||
handleFolderRowClick,
|
handleRowSelection,
|
||||||
isFilterActive,
|
isFilterActive,
|
||||||
|
openDetailPanel,
|
||||||
openDocumentPreview,
|
openDocumentPreview,
|
||||||
refreshCurrentFolder,
|
refreshCurrentFolder,
|
||||||
searchLoading,
|
searchLoading,
|
||||||
@@ -4975,17 +4841,6 @@ const AppLayout = () => {
|
|||||||
handleDocumentCorrespondentAttach,
|
handleDocumentCorrespondentAttach,
|
||||||
handleCorrespondentRemove,
|
handleCorrespondentRemove,
|
||||||
handleCorrespondentAdd,
|
handleCorrespondentAdd,
|
||||||
webdavTokens,
|
|
||||||
webdavTokensLoading,
|
|
||||||
creatingWebdavToken,
|
|
||||||
deletingWebdavTokenId,
|
|
||||||
regeneratingWebdavTokenId,
|
|
||||||
refreshWebdavTokens,
|
|
||||||
createWebdavToken,
|
|
||||||
deleteWebdavToken,
|
|
||||||
regenerateWebdavToken,
|
|
||||||
webdavTokenSecret,
|
|
||||||
dismissCreatedWebdavToken,
|
|
||||||
passkeys,
|
passkeys,
|
||||||
passkeysSupported,
|
passkeysSupported,
|
||||||
passkeysLoading,
|
passkeysLoading,
|
||||||
@@ -5036,17 +4891,6 @@ const AppLayout = () => {
|
|||||||
handleDocumentCorrespondentAttach,
|
handleDocumentCorrespondentAttach,
|
||||||
handleCorrespondentRemove,
|
handleCorrespondentRemove,
|
||||||
handleCorrespondentAdd,
|
handleCorrespondentAdd,
|
||||||
webdavTokens,
|
|
||||||
webdavTokensLoading,
|
|
||||||
creatingWebdavToken,
|
|
||||||
deletingWebdavTokenId,
|
|
||||||
regeneratingWebdavTokenId,
|
|
||||||
refreshWebdavTokens,
|
|
||||||
createWebdavToken,
|
|
||||||
deleteWebdavToken,
|
|
||||||
regenerateWebdavToken,
|
|
||||||
webdavTokenSecret,
|
|
||||||
dismissCreatedWebdavToken,
|
|
||||||
passkeys,
|
passkeys,
|
||||||
passkeysSupported,
|
passkeysSupported,
|
||||||
passkeysLoading,
|
passkeysLoading,
|
||||||
|
|||||||
@@ -2,21 +2,15 @@ import React, { useEffect, useCallback } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
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 { api } from './appState';
|
||||||
|
|
||||||
const SettingsRoute = () => {
|
const SettingsRoute = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const {
|
const {
|
||||||
webdavTokens,
|
token,
|
||||||
webdavTokensLoading,
|
notifyApiError,
|
||||||
creatingWebdavToken,
|
setStatusMessage,
|
||||||
deletingWebdavTokenId,
|
|
||||||
regeneratingWebdavTokenId,
|
|
||||||
refreshWebdavTokens,
|
|
||||||
createWebdavToken,
|
|
||||||
deleteWebdavToken,
|
|
||||||
regenerateWebdavToken,
|
|
||||||
webdavTokenSecret,
|
|
||||||
dismissCreatedWebdavToken,
|
|
||||||
passkeys,
|
passkeys,
|
||||||
passkeysSupported,
|
passkeysSupported,
|
||||||
passkeysLoading,
|
passkeysLoading,
|
||||||
@@ -27,15 +21,31 @@ const SettingsRoute = () => {
|
|||||||
revokePasskey,
|
revokePasskey,
|
||||||
} = useAppShell();
|
} = useAppShell();
|
||||||
|
|
||||||
|
const {
|
||||||
|
tokens,
|
||||||
|
loading,
|
||||||
|
creating,
|
||||||
|
deletingId,
|
||||||
|
regeneratingId,
|
||||||
|
updatingId,
|
||||||
|
createdSecret,
|
||||||
|
refresh,
|
||||||
|
create,
|
||||||
|
revoke,
|
||||||
|
regenerate,
|
||||||
|
updateCapabilities,
|
||||||
|
dismissSecret,
|
||||||
|
} = useApiTokens({ api, token, notifyApiError, setStatusMessage });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshWebdavTokens();
|
refresh();
|
||||||
refreshPasskeys();
|
refreshPasskeys();
|
||||||
}, [refreshWebdavTokens, refreshPasskeys]);
|
}, [refresh, refreshPasskeys]);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
dismissCreatedWebdavToken();
|
dismissSecret();
|
||||||
navigate(-1);
|
navigate(-1);
|
||||||
}, [dismissCreatedWebdavToken, navigate]);
|
}, [dismissSecret, navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event) => {
|
const handleKeyDown = (event) => {
|
||||||
@@ -49,24 +59,26 @@ const SettingsRoute = () => {
|
|||||||
}, [handleClose]);
|
}, [handleClose]);
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
dismissCreatedWebdavToken();
|
dismissSecret();
|
||||||
}, [dismissCreatedWebdavToken]);
|
}, [dismissSecret]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsModal
|
<SettingsModal
|
||||||
open
|
open
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
tokens={webdavTokens}
|
tokens={tokens}
|
||||||
loading={webdavTokensLoading}
|
loading={loading}
|
||||||
creating={creatingWebdavToken}
|
creating={creating}
|
||||||
deletingId={deletingWebdavTokenId}
|
deletingId={deletingId}
|
||||||
regeneratingId={regeneratingWebdavTokenId}
|
regeneratingId={regeneratingId}
|
||||||
onRefresh={refreshWebdavTokens}
|
updatingId={updatingId}
|
||||||
onCreate={createWebdavToken}
|
onRefresh={refresh}
|
||||||
onDelete={deleteWebdavToken}
|
onCreate={create}
|
||||||
onRegenerate={regenerateWebdavToken}
|
onDelete={revoke}
|
||||||
createdToken={webdavTokenSecret}
|
onRegenerate={regenerate}
|
||||||
onDismissCreatedToken={dismissCreatedWebdavToken}
|
onUpdateCapabilities={updateCapabilities}
|
||||||
|
createdToken={createdSecret}
|
||||||
|
onDismissCreatedToken={dismissSecret}
|
||||||
passkeys={passkeys}
|
passkeys={passkeys}
|
||||||
passkeysSupported={passkeysSupported}
|
passkeysSupported={passkeysSupported}
|
||||||
passkeysLoading={passkeysLoading}
|
passkeysLoading={passkeysLoading}
|
||||||
|
|||||||
@@ -12,24 +12,32 @@ export const useDetailPanel = ({
|
|||||||
const [detailPanelDocIds, setDetailPanelDocIds] = useState([]);
|
const [detailPanelDocIds, setDetailPanelDocIds] = useState([]);
|
||||||
const [detailPanelDocs, setDetailPanelDocs] = useState([]);
|
const [detailPanelDocs, setDetailPanelDocs] = useState([]);
|
||||||
const lastScrolledDetailDocRef = useRef(null);
|
const lastScrolledDetailDocRef = useRef(null);
|
||||||
|
const latestOrderedDocsRef = useRef([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!orderedSelectedDocuments.length) {
|
latestOrderedDocsRef.current = orderedSelectedDocuments;
|
||||||
setDetailPanelDocIds((prev) => (prev.length ? [] : prev));
|
if (detailPanelOpen && orderedSelectedDocuments.length) {
|
||||||
return;
|
const snapshotIds = orderedSelectedDocuments
|
||||||
}
|
|
||||||
|
|
||||||
const nextIds = orderedSelectedDocuments
|
|
||||||
.map((doc) => doc?.id)
|
.map((doc) => doc?.id)
|
||||||
.filter((id) => typeof id === 'string' || typeof id === 'number');
|
.filter((id) => typeof id === 'string' || typeof id === 'number');
|
||||||
|
if (snapshotIds.length) {
|
||||||
setDetailPanelDocIds((prev) => {
|
setDetailPanelDocIds(snapshotIds);
|
||||||
if (prev.length === nextIds.length && prev.every((id, index) => id === nextIds[index])) {
|
|
||||||
return prev;
|
|
||||||
}
|
}
|
||||||
return nextIds;
|
}
|
||||||
});
|
}, [orderedSelectedDocuments, detailPanelOpen]);
|
||||||
}, [orderedSelectedDocuments]);
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (!detailPanelDocIds.length) {
|
if (!detailPanelDocIds.length) {
|
||||||
@@ -38,17 +46,13 @@ export const useDetailPanel = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setDetailPanelDocs((prevDocs) => {
|
setDetailPanelDocs((prevDocs) => {
|
||||||
const prevMap = new Map((prevDocs || []).map((doc) => [doc.id, doc]));
|
const resolved = resolveDocsForIds(detailPanelDocIds, prevDocs);
|
||||||
const next = detailPanelDocIds
|
if (resolved.length === prevDocs.length && resolved.every((doc, index) => doc === prevDocs[index])) {
|
||||||
.map((id) => documentLookup.get(id) || prevMap.get(id) || null)
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
if (next.length === prevDocs.length && next.every((doc, index) => doc === prevDocs[index])) {
|
|
||||||
return prevDocs;
|
return prevDocs;
|
||||||
}
|
}
|
||||||
return next;
|
return resolved;
|
||||||
});
|
});
|
||||||
}, [detailPanelDocIds, documentLookup]);
|
}, [detailPanelDocIds, resolveDocsForIds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!detailPanelOpen) {
|
if (!detailPanelOpen) {
|
||||||
@@ -108,9 +112,39 @@ export const useDetailPanel = ({
|
|||||||
|
|
||||||
const detailPanelSelectedDocuments = detailPanelDocs;
|
const detailPanelSelectedDocuments = detailPanelDocs;
|
||||||
|
|
||||||
const openDetailPanel = useCallback(() => {
|
const openDetailPanel = useCallback(
|
||||||
|
({ documentIds: explicitIds, documents: explicitDocs } = {}) => {
|
||||||
|
let sourceDocs = Array.isArray(explicitDocs) ? explicitDocs : null;
|
||||||
|
let snapshotIds = Array.isArray(explicitIds)
|
||||||
|
? explicitIds.filter((id) => typeof id === 'string' || typeof id === 'number')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!snapshotIds?.length) {
|
||||||
|
if (!sourceDocs || !sourceDocs.length) {
|
||||||
|
sourceDocs = latestOrderedDocsRef.current;
|
||||||
|
}
|
||||||
|
snapshotIds = Array.isArray(sourceDocs)
|
||||||
|
? sourceDocs
|
||||||
|
.map((doc) => doc?.id)
|
||||||
|
.filter((id) => typeof id === 'string' || typeof id === 'number')
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueIds = [];
|
||||||
|
snapshotIds.forEach((id) => {
|
||||||
|
if (!uniqueIds.includes(id)) {
|
||||||
|
uniqueIds.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolvedDocs = resolveDocsForIds(uniqueIds, sourceDocs || latestOrderedDocsRef.current);
|
||||||
|
|
||||||
|
setDetailPanelDocIds(uniqueIds);
|
||||||
|
setDetailPanelDocs(resolvedDocs);
|
||||||
setDetailPanelOpen(true);
|
setDetailPanelOpen(true);
|
||||||
}, []);
|
},
|
||||||
|
[resolveDocsForIds],
|
||||||
|
);
|
||||||
|
|
||||||
const closeDetailPanel = useCallback(() => {
|
const closeDetailPanel = useCallback(() => {
|
||||||
setDetailPanelOpen(false);
|
setDetailPanelOpen(false);
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
|||||||
const DEFAULT_GRID_ICON_SIZE = 144;
|
const DEFAULT_GRID_ICON_SIZE = 144;
|
||||||
const LIST_ICON_SIZE = 48;
|
const LIST_ICON_SIZE = 48;
|
||||||
|
|
||||||
|
const EntryType = {
|
||||||
|
folder: 'folder',
|
||||||
|
document: 'document',
|
||||||
|
};
|
||||||
|
|
||||||
const getPageCount = (doc) =>
|
const getPageCount = (doc) =>
|
||||||
Number.isFinite(doc?.current_version?.metadata?.page_count)
|
Number.isFinite(doc?.current_version?.metadata?.page_count)
|
||||||
? doc.current_version.metadata.page_count
|
? doc.current_version.metadata.page_count
|
||||||
@@ -212,18 +217,18 @@ const DocumentsTable = ({
|
|||||||
onFolderDragEnd,
|
onFolderDragEnd,
|
||||||
draggedFolderId,
|
draggedFolderId,
|
||||||
onFolderDelete,
|
onFolderDelete,
|
||||||
|
onFolderRename,
|
||||||
selectedFolderIds = [],
|
selectedFolderIds = [],
|
||||||
onFolderRowClick,
|
|
||||||
onDocumentRowClick,
|
|
||||||
onDocumentOpen,
|
onDocumentOpen,
|
||||||
selectedDocumentIds,
|
selectedDocumentIds = [],
|
||||||
focusedRowKey,
|
focusedRowKey,
|
||||||
draggingDocumentIds = [],
|
draggingDocumentIds = [],
|
||||||
onDocumentDragStart,
|
onDocumentDragStart,
|
||||||
onDocumentDragEnd,
|
onDocumentDragEnd,
|
||||||
onDocumentDelete,
|
onDocumentDelete,
|
||||||
onFolderRename,
|
|
||||||
onDocumentRename,
|
onDocumentRename,
|
||||||
|
onRowSelection = null,
|
||||||
|
onOpenDetailPanel = null,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
activeCorrespondentIds = [],
|
activeCorrespondentIds = [],
|
||||||
onDocumentListFocus,
|
onDocumentListFocus,
|
||||||
@@ -244,6 +249,25 @@ const DocumentsTable = ({
|
|||||||
const showingSearchResults = searchResults !== null;
|
const showingSearchResults = searchResults !== null;
|
||||||
const rows = showingSearchResults ? searchResults : documents;
|
const rows = showingSearchResults ? searchResults : documents;
|
||||||
|
|
||||||
|
const entries = useMemo(() => {
|
||||||
|
const list = [];
|
||||||
|
if (!showingSearchResults) {
|
||||||
|
subfolders.forEach((folder) => {
|
||||||
|
if (!folder || !folder.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.push({ type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
rows.forEach((doc) => {
|
||||||
|
if (!doc || !doc.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.push({ type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc });
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}, [showingSearchResults, subfolders, rows]);
|
||||||
|
|
||||||
const selectedSet = useMemo(
|
const selectedSet = useMemo(
|
||||||
() => new Set(selectedDocumentIds),
|
() => new Set(selectedDocumentIds),
|
||||||
[selectedDocumentIds],
|
[selectedDocumentIds],
|
||||||
@@ -401,14 +425,56 @@ const DocumentsTable = ({
|
|||||||
[isTagDragEvent, onDocumentTagDrop],
|
[isTagDragEvent, onDocumentTagDrop],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDocumentClick = useCallback(
|
const handleEntryClick = useCallback(
|
||||||
(documentId, event) => {
|
(entry, event) => {
|
||||||
if (suppressDocumentClickRef.current) {
|
if (!entry || !entry.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onDocumentRowClick?.(documentId, event);
|
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) {
|
||||||
|
if (typeof onOpenDetailPanel === 'function') {
|
||||||
|
onOpenDetailPanel();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.type === EntryType.folder) {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
onFocusedRowChange?.(rowKey);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[onDocumentRowClick],
|
[onRowSelection, onOpenDetailPanel, onFocusedRowChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDocumentClick = useCallback(
|
||||||
|
(doc, event) => {
|
||||||
|
if (!doc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleEntryClick({ type: EntryType.document, id: doc.id, document: doc }, event);
|
||||||
|
},
|
||||||
|
[handleEntryClick],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFolderClick = useCallback(
|
||||||
|
(folder, event) => {
|
||||||
|
if (!folder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleEntryClick({ type: EntryType.folder, id: folder.id, folder }, event);
|
||||||
|
},
|
||||||
|
[handleEntryClick],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDocumentDragStartLocal = useCallback(
|
const handleDocumentDragStartLocal = useCallback(
|
||||||
@@ -474,160 +540,27 @@ const DocumentsTable = ({
|
|||||||
[activeCorrespondentIdSet, onCorrespondentClick],
|
[activeCorrespondentIdSet, onCorrespondentClick],
|
||||||
);
|
);
|
||||||
|
|
||||||
const noDocumentRows = rows.length === 0;
|
const renderFolderGridEntry = useCallback(
|
||||||
const hasFolderRows = !showingSearchResults && subfolders.length > 0;
|
(entry) => {
|
||||||
const showTableRows = hasFolderRows || !noDocumentRows;
|
const folder = entry.folder;
|
||||||
const showDefaultEmptyState = !showingSearchResults && !isFilterActive && !hasFolderRows && noDocumentRows;
|
if (!folder) {
|
||||||
const showListSearchEmptyState = showingSearchResults && noDocumentRows && !isGridView && !isSearchLoading;
|
return null;
|
||||||
const showGridSearchEmptyState = isGridView && showingSearchResults && noDocumentRows && !isSearchLoading;
|
|
||||||
const showSearchHint = showingSearchResults && rows.length > 0;
|
|
||||||
const breadcrumbEntries = useMemo(() => (Array.isArray(breadcrumbs) ? breadcrumbs.filter(Boolean) : []), [breadcrumbs]);
|
|
||||||
const trailEntries = useMemo(() => {
|
|
||||||
if (!breadcrumbEntries.length) {
|
|
||||||
return [{ id: 'current-folder', label: currentFolderName }];
|
|
||||||
}
|
}
|
||||||
const lastIndex = breadcrumbEntries.length - 1;
|
|
||||||
return breadcrumbEntries.map((crumb, index) => ({
|
|
||||||
id: crumb.id ?? index,
|
|
||||||
label: crumb.name ?? crumb.label ?? crumb.title ?? '',
|
|
||||||
onClick: index < lastIndex && onFolderSelect
|
|
||||||
? () => onFolderSelect(crumb.id)
|
|
||||||
: null,
|
|
||||||
}));
|
|
||||||
}, [breadcrumbEntries, currentFolderName, onFolderSelect]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className={`documents-panel documents-panel--view-${isGridView ? 'grid' : 'list'}`}
|
|
||||||
>
|
|
||||||
{showHeader ? (
|
|
||||||
<div className="panel-section__header">
|
|
||||||
<div className="panel-section__titles">
|
|
||||||
<h2 className="documents-panel__title">
|
|
||||||
<BreadcrumbTrail
|
|
||||||
entries={trailEntries}
|
|
||||||
className="documents-panel__breadcrumbs"
|
|
||||||
separator="/"
|
|
||||||
/>
|
|
||||||
</h2>
|
|
||||||
{showingSearchResults && (
|
|
||||||
<div className="panel-section__subtitle">Search results</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="header-actions">
|
|
||||||
<div className="view-toggle" role="group" aria-label="Change view">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`view-toggle__button${isListView ? ' active' : ''}`}
|
|
||||||
onClick={() => handleSetViewMode('list')}
|
|
||||||
aria-pressed={isListView}
|
|
||||||
title="List view"
|
|
||||||
>
|
|
||||||
<ViewListIcon className="view-toggle__icon" size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`view-toggle__button${isGridView ? ' active' : ''}`}
|
|
||||||
onClick={() => handleSetViewMode('grid')}
|
|
||||||
aria-pressed={isGridView}
|
|
||||||
title="Icons view"
|
|
||||||
>
|
|
||||||
<ViewGridIcon className="view-toggle__icon" size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`view-toggle__button${isDeskView ? ' active' : ''}`}
|
|
||||||
onClick={() => handleSetViewMode('desk')}
|
|
||||||
aria-pressed={isDeskView}
|
|
||||||
title="Desk view"
|
|
||||||
>
|
|
||||||
<IconFileStack className="view-toggle__icon" size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button className="secondary" onClick={onRefresh}>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{showDefaultEmptyState ? (
|
|
||||||
<div className="panel-section__body">
|
|
||||||
<div className="empty-state">
|
|
||||||
Drop files anywhere or onto a folder to upload documents.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : showGridSearchEmptyState ? (
|
|
||||||
<div className="panel-section__body">
|
|
||||||
<div className="empty-state empty-state--global">
|
|
||||||
No documents match the current filters.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : showListSearchEmptyState ? (
|
|
||||||
<div className="panel-section__body">
|
|
||||||
<div className="empty-state">No documents match the current filters.</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="panel-section__body">
|
|
||||||
<div
|
|
||||||
ref={assignScrollRef}
|
|
||||||
className="documents-scroll"
|
|
||||||
onFocus={(event) => {
|
|
||||||
if (event.target === scrollRef.current) {
|
|
||||||
onDocumentListFocus?.();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.target !== scrollRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (onDocumentListKeyDown) {
|
|
||||||
onDocumentListKeyDown(event);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={(event) => {
|
|
||||||
if (event.target === event.currentTarget) {
|
|
||||||
onClearSelection?.();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
aria-activedescendant={isGridView ? undefined : activeDescendantId}
|
|
||||||
>
|
|
||||||
{isGridView ? (
|
|
||||||
<div
|
|
||||||
className="documents-grid"
|
|
||||||
role="list"
|
|
||||||
onClick={(event) => {
|
|
||||||
if (event.target === event.currentTarget) {
|
|
||||||
onClearSelection?.();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
|
|
||||||
>
|
|
||||||
{!showingSearchResults &&
|
|
||||||
subfolders.map((folder) => {
|
|
||||||
const canDragFolder = folder.id !== 'root';
|
const canDragFolder = folder.id !== 'root';
|
||||||
const isDraggingFolder = draggedFolderId === folder.id;
|
const isDraggingFolder = draggedFolderId === folder.id;
|
||||||
const isSelectedFolder = selectedFolderSet.has(folder.id);
|
const isSelectedFolder = selectedFolderSet.has(folder.id);
|
||||||
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');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={folder.id}
|
key={entry.key}
|
||||||
className={classes.join(' ')}
|
className={classes.join(' ')}
|
||||||
role="listitem"
|
role="listitem"
|
||||||
id={`folder-card-${folder.id}`}
|
id={`folder-card-${folder.id}`}
|
||||||
draggable={canDragFolder}
|
draggable={canDragFolder}
|
||||||
onClick={(event) => {
|
onClick={(event) => handleFolderClick(folder, event)}
|
||||||
onFolderRowClick?.(folder.id, event);
|
|
||||||
const shouldNavigate =
|
|
||||||
!event.defaultPrevented &&
|
|
||||||
!event.metaKey &&
|
|
||||||
!event.ctrlKey &&
|
|
||||||
!event.shiftKey;
|
|
||||||
if (shouldNavigate) {
|
|
||||||
onFolderSelect(folder.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDoubleClick={(event) => {
|
onDoubleClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onFolderSelect(folder.id);
|
onFolderSelect(folder.id);
|
||||||
@@ -659,8 +592,27 @@ const DocumentsTable = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
},
|
||||||
{rows.map((doc) => {
|
[
|
||||||
|
draggedFolderId,
|
||||||
|
gridIconSize,
|
||||||
|
handleFolderClick,
|
||||||
|
onFolderDragEnd,
|
||||||
|
onFolderDragLeave,
|
||||||
|
onFolderDragOver,
|
||||||
|
onFolderDragStart,
|
||||||
|
onFolderDrop,
|
||||||
|
onFolderSelect,
|
||||||
|
selectedFolderSet,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderDocumentGridEntry = useCallback(
|
||||||
|
(entry) => {
|
||||||
|
const doc = entry.document;
|
||||||
|
if (!doc) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const isSelected = selectedSet.has(doc.id);
|
const isSelected = selectedSet.has(doc.id);
|
||||||
const isDraggingDoc = draggingSet.has(doc.id);
|
const isDraggingDoc = draggingSet.has(doc.id);
|
||||||
const tagList = Array.isArray(doc.tags) ? doc.tags : [];
|
const tagList = Array.isArray(doc.tags) ? doc.tags : [];
|
||||||
@@ -674,12 +626,12 @@ const DocumentsTable = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={doc.id}
|
key={entry.key}
|
||||||
className={cardClasses.join(' ')}
|
className={cardClasses.join(' ')}
|
||||||
role="listitem"
|
role="listitem"
|
||||||
id={`document-card-${doc.id}`}
|
id={`document-card-${doc.id}`}
|
||||||
data-doc-id={doc.id}
|
data-doc-id={doc.id}
|
||||||
onClick={(event) => handleDocumentClick(doc.id, event)}
|
onClick={(event) => handleDocumentClick(doc, event)}
|
||||||
onDoubleClick={() => onDocumentOpen(doc.id)}
|
onDoubleClick={() => onDocumentOpen(doc.id)}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(event) => handleDocumentDragStartLocal(event, doc)}
|
onDragStart={(event) => handleDocumentDragStartLocal(event, doc)}
|
||||||
@@ -773,46 +725,46 @@ const DocumentsTable = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
},
|
||||||
</div>
|
[
|
||||||
) : !showTableRows ? null : (
|
draggingSet,
|
||||||
<table aria-multiselectable="true">
|
ensureAssetUrl,
|
||||||
<thead>
|
getDocumentAsset,
|
||||||
<tr>
|
gridIconSize,
|
||||||
<th className="thumb-column">Preview</th>
|
handleDocumentClick,
|
||||||
<th>Name</th>
|
handleDocumentDragEndLocal,
|
||||||
<th>Issued</th>
|
handleDocumentDragStartLocal,
|
||||||
<th className="actions-column">Actions</th>
|
handleDocumentTagDragLeave,
|
||||||
</tr>
|
handleDocumentTagDragOver,
|
||||||
</thead>
|
handleDocumentTagDrop,
|
||||||
<tbody>
|
onDocumentOpen,
|
||||||
{!showingSearchResults &&
|
onTagClick,
|
||||||
subfolders.map((folder) => {
|
renderCorrespondentLinks,
|
||||||
|
scrollRef,
|
||||||
|
selectedSet,
|
||||||
|
tagLookupById,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderFolderListEntry = useCallback(
|
||||||
|
(entry) => {
|
||||||
|
const folder = entry.folder;
|
||||||
|
if (!folder) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const canDragFolder = folder.id !== 'root';
|
const canDragFolder = folder.id !== 'root';
|
||||||
const isDraggingFolder = draggedFolderId === folder.id;
|
const isDraggingFolder = draggedFolderId === folder.id;
|
||||||
const isSelectedFolder = selectedFolderSet.has(folder.id);
|
const isSelectedFolder = selectedFolderSet.has(folder.id);
|
||||||
|
const rowKey = `folder:${folder.id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={folder.id}
|
key={entry.key}
|
||||||
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${
|
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${
|
||||||
focusedRowKey === `folder:${folder.id}` ? ' focused' : ''
|
focusedRowKey === rowKey ? ' focused' : ''
|
||||||
}${isSelectedFolder ? ' selected' : ''}`}
|
}${isSelectedFolder ? ' selected' : ''}`}
|
||||||
id={`folder-row-${folder.id}`}
|
id={`folder-row-${folder.id}`}
|
||||||
onClick={(event) => {
|
onClick={(event) => handleFolderClick(folder, event)}
|
||||||
onFolderRowClick?.(folder.id, event);
|
|
||||||
const shouldNavigate =
|
|
||||||
!event.defaultPrevented &&
|
|
||||||
!event.metaKey &&
|
|
||||||
!event.ctrlKey &&
|
|
||||||
!event.shiftKey;
|
|
||||||
if (shouldNavigate) {
|
|
||||||
onFolderSelect(folder.id);
|
|
||||||
}
|
|
||||||
if (scrollRef.current) {
|
|
||||||
scrollRef.current.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
onFocusedRowChange?.(`folder:${folder.id}`);
|
|
||||||
}}
|
|
||||||
onDoubleClick={(event) => {
|
onDoubleClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onFolderSelect(folder.id);
|
onFolderSelect(folder.id);
|
||||||
@@ -886,8 +838,29 @@ const DocumentsTable = ({
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
},
|
||||||
{rows.map((doc) => {
|
[
|
||||||
|
draggedFolderId,
|
||||||
|
focusedRowKey,
|
||||||
|
handleFolderClick,
|
||||||
|
onFolderDelete,
|
||||||
|
onFolderDragEnd,
|
||||||
|
onFolderDragLeave,
|
||||||
|
onFolderDragOver,
|
||||||
|
onFolderDragStart,
|
||||||
|
onFolderDrop,
|
||||||
|
onFolderRename,
|
||||||
|
onFolderSelect,
|
||||||
|
selectedFolderSet,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderDocumentListEntry = useCallback(
|
||||||
|
(entry) => {
|
||||||
|
const doc = entry.document;
|
||||||
|
if (!doc) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const isSelected = selectedSet.has(doc.id);
|
const isSelected = selectedSet.has(doc.id);
|
||||||
const isDraggingDoc = draggingSet.has(doc.id);
|
const isDraggingDoc = draggingSet.has(doc.id);
|
||||||
const rowClasses = ['document'];
|
const rowClasses = ['document'];
|
||||||
@@ -899,11 +872,11 @@ const DocumentsTable = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={doc.id}
|
key={entry.key}
|
||||||
className={rowClasses.join(' ')}
|
className={rowClasses.join(' ')}
|
||||||
id={`document-row-${doc.id}`}
|
id={`document-row-${doc.id}`}
|
||||||
data-doc-id={doc.id}
|
data-doc-id={doc.id}
|
||||||
onClick={(event) => handleDocumentClick(doc.id, event)}
|
onClick={(event) => handleDocumentClick(doc, event)}
|
||||||
onDoubleClick={() => onDocumentOpen(doc.id)}
|
onDoubleClick={() => onDocumentOpen(doc.id)}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(event) => handleDocumentDragStartLocal(event, doc)}
|
onDragStart={(event) => handleDocumentDragStartLocal(event, doc)}
|
||||||
@@ -1064,7 +1037,182 @@ const DocumentsTable = ({
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
},
|
||||||
|
[
|
||||||
|
draggingSet,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
getDownloadHref,
|
||||||
|
handleDocumentClick,
|
||||||
|
handleDocumentDragEndLocal,
|
||||||
|
handleDocumentDragStartLocal,
|
||||||
|
handleDocumentTagDragLeave,
|
||||||
|
handleDocumentTagDragOver,
|
||||||
|
handleDocumentTagDrop,
|
||||||
|
onDocumentDelete,
|
||||||
|
onDocumentOpen,
|
||||||
|
onDocumentRename,
|
||||||
|
onTagClick,
|
||||||
|
renderCorrespondentLinks,
|
||||||
|
scrollRef,
|
||||||
|
selectedSet,
|
||||||
|
tagLookupById,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasDocumentEntries = useMemo(
|
||||||
|
() => entries.some((entry) => entry.type === EntryType.document),
|
||||||
|
[entries],
|
||||||
|
);
|
||||||
|
const showTableRows = entries.length > 0;
|
||||||
|
const showDefaultEmptyState = !showingSearchResults && !isFilterActive && entries.length === 0;
|
||||||
|
const showListSearchEmptyState = showingSearchResults && !hasDocumentEntries && !isGridView && !isSearchLoading;
|
||||||
|
const showGridSearchEmptyState = isGridView && showingSearchResults && !hasDocumentEntries && !isSearchLoading;
|
||||||
|
const showSearchHint = showingSearchResults && rows.length > 0;
|
||||||
|
const breadcrumbEntries = useMemo(() => (Array.isArray(breadcrumbs) ? breadcrumbs.filter(Boolean) : []), [breadcrumbs]);
|
||||||
|
const trailEntries = useMemo(() => {
|
||||||
|
if (!breadcrumbEntries.length) {
|
||||||
|
return [{ id: 'current-folder', label: currentFolderName }];
|
||||||
|
}
|
||||||
|
const lastIndex = breadcrumbEntries.length - 1;
|
||||||
|
return breadcrumbEntries.map((crumb, index) => ({
|
||||||
|
id: crumb.id ?? index,
|
||||||
|
label: crumb.name ?? crumb.label ?? crumb.title ?? '',
|
||||||
|
onClick: index < lastIndex && onFolderSelect
|
||||||
|
? () => onFolderSelect(crumb.id)
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
}, [breadcrumbEntries, currentFolderName, onFolderSelect]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`documents-panel documents-panel--view-${isGridView ? 'grid' : 'list'}`}
|
||||||
|
>
|
||||||
|
{showHeader ? (
|
||||||
|
<div className="panel-section__header">
|
||||||
|
<div className="panel-section__titles">
|
||||||
|
<h2 className="documents-panel__title">
|
||||||
|
<BreadcrumbTrail
|
||||||
|
entries={trailEntries}
|
||||||
|
className="documents-panel__breadcrumbs"
|
||||||
|
separator="/"
|
||||||
|
/>
|
||||||
|
</h2>
|
||||||
|
{showingSearchResults && (
|
||||||
|
<div className="panel-section__subtitle">Search results</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="header-actions">
|
||||||
|
<div className="view-toggle" role="group" aria-label="Change view">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`view-toggle__button${isListView ? ' active' : ''}`}
|
||||||
|
onClick={() => handleSetViewMode('list')}
|
||||||
|
aria-pressed={isListView}
|
||||||
|
title="List view"
|
||||||
|
>
|
||||||
|
<ViewListIcon className="view-toggle__icon" size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`view-toggle__button${isGridView ? ' active' : ''}`}
|
||||||
|
onClick={() => handleSetViewMode('grid')}
|
||||||
|
aria-pressed={isGridView}
|
||||||
|
title="Icons view"
|
||||||
|
>
|
||||||
|
<ViewGridIcon className="view-toggle__icon" size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`view-toggle__button${isDeskView ? ' active' : ''}`}
|
||||||
|
onClick={() => handleSetViewMode('desk')}
|
||||||
|
aria-pressed={isDeskView}
|
||||||
|
title="Desk view"
|
||||||
|
>
|
||||||
|
<IconFileStack className="view-toggle__icon" size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button className="secondary" onClick={onRefresh}>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{showDefaultEmptyState ? (
|
||||||
|
<div className="panel-section__body">
|
||||||
|
<div className="empty-state">
|
||||||
|
Drop files anywhere or onto a folder to upload documents.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : showGridSearchEmptyState ? (
|
||||||
|
<div className="panel-section__body">
|
||||||
|
<div className="empty-state empty-state--global">
|
||||||
|
No documents match the current filters.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : showListSearchEmptyState ? (
|
||||||
|
<div className="panel-section__body">
|
||||||
|
<div className="empty-state">No documents match the current filters.</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="panel-section__body">
|
||||||
|
<div
|
||||||
|
ref={assignScrollRef}
|
||||||
|
className="documents-scroll"
|
||||||
|
tabIndex={0}
|
||||||
|
onFocus={(event) => {
|
||||||
|
if (event.target === scrollRef.current) {
|
||||||
|
onDocumentListFocus?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.target !== scrollRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (onDocumentListKeyDown) {
|
||||||
|
onDocumentListKeyDown(event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
onClearSelection?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-activedescendant={isGridView ? undefined : activeDescendantId}
|
||||||
|
>
|
||||||
|
{isGridView ? (
|
||||||
|
<div
|
||||||
|
className="documents-grid"
|
||||||
|
role="list"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
onClearSelection?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
|
||||||
|
>
|
||||||
|
{entries.map((entry) =>
|
||||||
|
entry.type === EntryType.folder
|
||||||
|
? renderFolderGridEntry(entry)
|
||||||
|
: renderDocumentGridEntry(entry)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : !showTableRows ? null : (
|
||||||
|
<table aria-multiselectable="true">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="thumb-column">Preview</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Issued</th>
|
||||||
|
<th className="actions-column">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{entries.map((entry) =>
|
||||||
|
entry.type === EntryType.folder
|
||||||
|
? renderFolderListEntry(entry)
|
||||||
|
: renderDocumentListEntry(entry)
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ const SECTIONS = [
|
|||||||
label: 'Passkeys',
|
label: 'Passkeys',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'webdav',
|
id: 'apiTokens',
|
||||||
label: 'WebDAV',
|
label: 'API tokens',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -20,10 +20,12 @@ const SettingsModal = ({
|
|||||||
creating = false,
|
creating = false,
|
||||||
deletingId = null,
|
deletingId = null,
|
||||||
regeneratingId = null,
|
regeneratingId = null,
|
||||||
|
updatingId = null,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onCreate,
|
onCreate,
|
||||||
onDelete,
|
onDelete,
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
|
onUpdateCapabilities,
|
||||||
createdToken = null,
|
createdToken = null,
|
||||||
onDismissCreatedToken,
|
onDismissCreatedToken,
|
||||||
passkeys = [],
|
passkeys = [],
|
||||||
@@ -39,6 +41,7 @@ const SettingsModal = ({
|
|||||||
const [activeSection, setActiveSection] = useState(defaultSection);
|
const [activeSection, setActiveSection] = useState(defaultSection);
|
||||||
const [newTokenLabel, setNewTokenLabel] = useState('');
|
const [newTokenLabel, setNewTokenLabel] = useState('');
|
||||||
const [newTokenExpires, setNewTokenExpires] = useState('');
|
const [newTokenExpires, setNewTokenExpires] = useState('');
|
||||||
|
const [newTokenCapabilities, setNewTokenCapabilities] = useState([]);
|
||||||
const [formError, setFormError] = useState(null);
|
const [formError, setFormError] = useState(null);
|
||||||
const [newPasskeyNickname, setNewPasskeyNickname] = useState('');
|
const [newPasskeyNickname, setNewPasskeyNickname] = useState('');
|
||||||
|
|
||||||
@@ -55,6 +58,7 @@ const SettingsModal = ({
|
|||||||
setActiveSection(defaultSection);
|
setActiveSection(defaultSection);
|
||||||
setNewTokenLabel('');
|
setNewTokenLabel('');
|
||||||
setNewTokenExpires('');
|
setNewTokenExpires('');
|
||||||
|
setNewTokenCapabilities([]);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setNewPasskeyNickname('');
|
setNewPasskeyNickname('');
|
||||||
}
|
}
|
||||||
@@ -116,6 +120,57 @@ const SettingsModal = ({
|
|||||||
[onRevokePasskey],
|
[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(
|
const handleCreateToken = useCallback(
|
||||||
async (event) => {
|
async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -135,18 +190,25 @@ const SettingsModal = ({
|
|||||||
normalizedExpires = parsed.toISOString();
|
normalizedExpires = parsed.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!newTokenCapabilities.length) {
|
||||||
|
setFormError('Select at least one capability.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await onCreate?.({
|
const result = await onCreate?.({
|
||||||
label: normalizedLabel,
|
label: normalizedLabel,
|
||||||
expires_at: normalizedExpires,
|
expires_at: normalizedExpires,
|
||||||
|
capabilities: newTokenCapabilities,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result !== false) {
|
if (result !== false) {
|
||||||
setNewTokenLabel('');
|
setNewTokenLabel('');
|
||||||
setNewTokenExpires('');
|
setNewTokenExpires('');
|
||||||
|
setNewTokenCapabilities([]);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[newTokenExpires, newTokenLabel, onCreate],
|
[newTokenExpires, newTokenLabel, newTokenCapabilities, onCreate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRegenerateToken = useCallback(
|
const handleRegenerateToken = useCallback(
|
||||||
@@ -159,7 +221,7 @@ const SettingsModal = ({
|
|||||||
[onRegenerate],
|
[onRegenerate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderWebdavSection = useMemo(() => {
|
const renderApiTokensSection = useMemo(() => {
|
||||||
const hasTokens = Array.isArray(tokens) && tokens.length > 0;
|
const hasTokens = Array.isArray(tokens) && tokens.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -175,6 +237,11 @@ const SettingsModal = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 ? (
|
{createdToken ? (
|
||||||
<div className="settings-notice">
|
<div className="settings-notice">
|
||||||
<p>
|
<p>
|
||||||
@@ -194,24 +261,44 @@ const SettingsModal = ({
|
|||||||
|
|
||||||
<form className="settings-form" onSubmit={handleCreateToken}>
|
<form className="settings-form" onSubmit={handleCreateToken}>
|
||||||
<div className="settings-form__field">
|
<div className="settings-form__field">
|
||||||
<label htmlFor="webdav-token-label">Label</label>
|
<label htmlFor="api-token-label">Label</label>
|
||||||
<input
|
<input
|
||||||
id="webdav-token-label"
|
id="api-token-label"
|
||||||
type="text"
|
type="text"
|
||||||
value={newTokenLabel}
|
value={newTokenLabel}
|
||||||
onChange={(event) => setNewTokenLabel(event.target.value)}
|
onChange={(event) => setNewTokenLabel(event.target.value)}
|
||||||
placeholder="Personal WebDAV token"
|
placeholder="Personal API token"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-form__field">
|
<div className="settings-form__field">
|
||||||
<label htmlFor="webdav-token-expires">Expires at</label>
|
<label htmlFor="api-token-expires">Expires at</label>
|
||||||
<input
|
<input
|
||||||
id="webdav-token-expires"
|
id="api-token-expires"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={newTokenExpires}
|
value={newTokenExpires}
|
||||||
onChange={(event) => setNewTokenExpires(event.target.value)}
|
onChange={(event) => setNewTokenExpires(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="settings-form__actions">
|
||||||
<button type="submit" disabled={creating}>
|
<button type="submit" disabled={creating}>
|
||||||
{creating ? 'Creating…' : 'Create token'}
|
{creating ? 'Creating…' : 'Create token'}
|
||||||
@@ -225,7 +312,7 @@ const SettingsModal = ({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!loading && !hasTokens ? (
|
{!loading && !hasTokens ? (
|
||||||
<p className="settings-empty">No WebDAV tokens yet.</p>
|
<p className="settings-empty">No API tokens yet.</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{hasTokens ? (
|
{hasTokens ? (
|
||||||
@@ -236,18 +323,54 @@ const SettingsModal = ({
|
|||||||
<th scope="col">Created</th>
|
<th scope="col">Created</th>
|
||||||
<th scope="col">Last used</th>
|
<th scope="col">Last used</th>
|
||||||
<th scope="col">Expires</th>
|
<th scope="col">Expires</th>
|
||||||
|
<th scope="col">Capabilities</th>
|
||||||
<th scope="col">Actions</th>
|
<th scope="col">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{tokens.map((token) => {
|
{tokens.map((token) => {
|
||||||
const isRevoked = Boolean(token?.revoked_at);
|
const isRevoked = Boolean(token?.revoked_at);
|
||||||
|
const capabilitySet = Array.isArray(token?.capabilities)
|
||||||
|
? token.capabilities
|
||||||
|
: [];
|
||||||
return (
|
return (
|
||||||
<tr key={token.id} className={isRevoked ? 'is-revoked' : undefined}>
|
<tr key={token.id} className={isRevoked ? 'is-revoked' : undefined}>
|
||||||
<td>{token.label || '—'}</td>
|
<td>{token.label || '—'}</td>
|
||||||
<td>{formatDateTime(token.created_at)}</td>
|
<td>{formatDateTime(token.created_at)}</td>
|
||||||
<td>{formatDateTime(token.last_used_at)}</td>
|
<td>{formatDateTime(token.last_used_at)}</td>
|
||||||
<td>{formatDateTime(token.expires_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">
|
<td className="settings-table__actions">
|
||||||
{isRevoked ? (
|
{isRevoked ? (
|
||||||
<span className="settings-status">Revoked</span>
|
<span className="settings-status">Revoked</span>
|
||||||
@@ -291,9 +414,12 @@ const SettingsModal = ({
|
|||||||
creating,
|
creating,
|
||||||
deletingId,
|
deletingId,
|
||||||
regeneratingId,
|
regeneratingId,
|
||||||
|
updatingId,
|
||||||
newTokenLabel,
|
newTokenLabel,
|
||||||
newTokenExpires,
|
newTokenExpires,
|
||||||
|
newTokenCapabilities,
|
||||||
formError,
|
formError,
|
||||||
|
capabilityOptions,
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
handleCopyToken,
|
handleCopyToken,
|
||||||
handleCreateToken,
|
handleCreateToken,
|
||||||
@@ -301,6 +427,8 @@ const SettingsModal = ({
|
|||||||
handleRefresh,
|
handleRefresh,
|
||||||
onDelete,
|
onDelete,
|
||||||
handleDismissSecret,
|
handleDismissSecret,
|
||||||
|
handleNewCapabilityChange,
|
||||||
|
handleToggleTokenCapability,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const renderPasskeysSection = useMemo(() => {
|
const renderPasskeysSection = useMemo(() => {
|
||||||
@@ -465,8 +593,8 @@ const SettingsModal = ({
|
|||||||
</nav>
|
</nav>
|
||||||
<div className="settings-modal__content">
|
<div className="settings-modal__content">
|
||||||
{activeSection === 'passkeys' ? renderPasskeysSection : null}
|
{activeSection === 'passkeys' ? renderPasskeysSection : null}
|
||||||
{activeSection === 'webdav' ? renderWebdavSection : null}
|
{activeSection === 'apiTokens' ? renderApiTokensSection : null}
|
||||||
{activeSection !== 'passkeys' && activeSection !== 'webdav' ? (
|
{activeSection !== 'passkeys' && activeSection !== 'apiTokens' ? (
|
||||||
<p>Select a settings section.</p>
|
<p>Select a settings section.</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1883,6 +1883,12 @@ button.danger:hover:not([disabled]) {
|
|||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.documents-panel .documents-scroll:focus-visible {
|
||||||
|
outline: 2px solid var(--selection-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.documents-panel table {
|
.documents-panel table {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -3129,6 +3135,19 @@ form.inline {
|
|||||||
min-width: 14rem;
|
min-width: 14rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fieldset.settings-form__field {
|
||||||
|
border: 1px solid var(--border-muted, var(--border));
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.75rem 0.9rem 0.85rem;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset.settings-form__field legend {
|
||||||
|
padding: 0 0.35rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted-strong, inherit);
|
||||||
|
}
|
||||||
|
|
||||||
.settings-form__field input[type='text'],
|
.settings-form__field input[type='text'],
|
||||||
.settings-form__field input[type='datetime-local'] {
|
.settings-form__field input[type='datetime-local'] {
|
||||||
padding: 0.45rem 0.6rem;
|
padding: 0.45rem 0.6rem;
|
||||||
@@ -3138,6 +3157,33 @@ form.inline {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-form__choices {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-choice {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-choice input[type='checkbox'] {
|
||||||
|
width: 1.05rem;
|
||||||
|
height: 1.05rem;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-choice input[type='checkbox']:disabled + span {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.settings-form__actions {
|
.settings-form__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user