This commit is contained in:
2025-11-01 22:49:50 +01:00
parent 326a6042ec
commit 16ac16cd30
6 changed files with 962 additions and 750 deletions
+31 -187
View File
@@ -257,12 +257,6 @@ const AppLayout = () => {
const previewReturnPathRef = useRef(null);
const [tags, setTags] = 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 [activeTagFilters, setActiveTagFilters] = useState([]);
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]);
@@ -425,11 +419,6 @@ const AppLayout = () => {
setSearchResults(null);
setTags([]);
setCorrespondents([]);
setWebdavTokens([]);
setWebdavTokensLoading(false);
setCreatingWebdavToken(false);
setDeletingWebdavTokenId(null);
setWebdavTokenSecret(null);
setSearchQuery('');
setActiveTagFilters([]);
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(() => {
clearSelectionInternal();
}, [clearSelectionInternal]);
const handleFolderRowClick = useCallback(
(folderId, event) => {
const rowKey = resolveFolderRowKey(folderId);
if (!rowKey) return;
handleRowSelection(rowKey, event);
},
[handleRowSelection],
);
const prevFocusedDocIdRef = useRef(focusedDocumentId);
useEffect(() => {
const previous = prevFocusedDocIdRef.current;
@@ -968,23 +935,39 @@ const AppLayout = () => {
}, [focusedDocumentId, setFocusedRowKey]);
useEffect(() => {
if (!focusedRowKey) {
if (!navigableRowKeys.length) {
if (focusedRowKey) {
setFocusedRowKey(null);
}
return;
}
if (navigableRowKeys.includes(focusedRowKey)) {
if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) {
return;
}
const docKey = focusedDocumentId ? resolveDocumentRowKey(focusedDocumentId) : null;
if (docKey && navigableRowKeys.includes(docKey)) {
setFocusedRowKey(docKey);
return;
}
if (navigableRowKeys.length) {
setFocusedRowKey(navigableRowKeys[0]);
} else {
const selectedKey = selectedRowKeys.find((key) => navigableRowKeys.includes(key));
if (selectedKey) {
setFocusedRowKey(selectedKey);
return;
}
if (focusedRowKey) {
setFocusedRowKey(null);
}
}, [focusedRowKey, navigableRowKeys, focusedDocumentId, setFocusedRowKey]);
}, [
focusedRowKey,
focusedDocumentId,
navigableRowKeys,
selectedRowKeys,
setFocusedRowKey,
]);
const ensureFolderData = useCallback(
@@ -1303,125 +1286,6 @@ const AppLayout = () => {
}
}, [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 {
passkeys,
passkeysSupported,
@@ -3208,9 +3072,13 @@ const AppLayout = () => {
return;
}
if (selectedRowKeys.length === 0) {
return;
}
setFocusedRowKey(resolvedKey);
if (!selectedRowKeys.includes(resolvedKey)) {
if (!selectedRowKeys.includes(resolvedKey) && selectedRowKeys.length > 0) {
applySelection([resolvedKey], { anchor: resolvedKey, interactedKeys: [resolvedKey] });
}
}, [
@@ -4699,15 +4567,12 @@ const AppLayout = () => {
onFolderDragEnd: handleFolderDragEnd,
draggedFolderId,
onFolderDelete: handleFolderDelete,
onFolderRowClick: handleFolderRowClick,
onFolderRename: handleFolderRename,
onDocumentRowClick: handleDocumentRowClick,
onDocumentOpen: openDocumentPreview,
onDocumentDelete: handleDocumentDelete,
onDocumentRename: handleDocumentTitleUpdate,
selectedDocumentIds,
selectedFolderIds,
focusedDocumentId,
focusedRowKey,
draggingDocumentIds: draggedDocumentIds,
onDocumentDragStart: handleDocumentDragStart,
@@ -4730,6 +4595,8 @@ const AppLayout = () => {
viewMode: documentsViewMode,
onViewModeChange: handleDocumentsViewModeChange,
onClearSelection: clearDocumentSelection,
onRowSelection: handleRowSelection,
onOpenDetailPanel: openDetailPanel,
}),
[
activeCorrespondentFilters,
@@ -4741,7 +4608,6 @@ const AppLayout = () => {
documentsViewMode,
draggedDocumentIds,
draggedFolderId,
focusedDocumentId,
focusedRowKey,
folderClickHandlers,
handleDocumentDelete,
@@ -4749,7 +4615,6 @@ const AppLayout = () => {
handleDocumentDragStart,
handleDocumentListFocus,
handleDocumentListKeyDown,
handleDocumentRowClick,
handleDocumentTagDrop,
handleDocumentTitleUpdate,
handleDocumentsViewModeChange,
@@ -4757,8 +4622,9 @@ const AppLayout = () => {
handleFolderDragEnd,
handleFolderDragStart,
handleFolderRename,
handleFolderRowClick,
handleRowSelection,
isFilterActive,
openDetailPanel,
openDocumentPreview,
refreshCurrentFolder,
searchLoading,
@@ -4975,17 +4841,6 @@ const AppLayout = () => {
handleDocumentCorrespondentAttach,
handleCorrespondentRemove,
handleCorrespondentAdd,
webdavTokens,
webdavTokensLoading,
creatingWebdavToken,
deletingWebdavTokenId,
regeneratingWebdavTokenId,
refreshWebdavTokens,
createWebdavToken,
deleteWebdavToken,
regenerateWebdavToken,
webdavTokenSecret,
dismissCreatedWebdavToken,
passkeys,
passkeysSupported,
passkeysLoading,
@@ -5036,17 +4891,6 @@ const AppLayout = () => {
handleDocumentCorrespondentAttach,
handleCorrespondentRemove,
handleCorrespondentAdd,
webdavTokens,
webdavTokensLoading,
creatingWebdavToken,
deletingWebdavTokenId,
regeneratingWebdavTokenId,
refreshWebdavTokens,
createWebdavToken,
deleteWebdavToken,
regenerateWebdavToken,
webdavTokenSecret,
dismissCreatedWebdavToken,
passkeys,
passkeysSupported,
passkeysLoading,
+40 -28
View File
@@ -2,21 +2,15 @@ import React, { useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import SettingsModal from '../settings/SettingsModal';
import { useAppShell } from '../appShellContext';
import useApiTokens from '../settings/useApiTokens';
import { api } from './appState';
const SettingsRoute = () => {
const navigate = useNavigate();
const {
webdavTokens,
webdavTokensLoading,
creatingWebdavToken,
deletingWebdavTokenId,
regeneratingWebdavTokenId,
refreshWebdavTokens,
createWebdavToken,
deleteWebdavToken,
regenerateWebdavToken,
webdavTokenSecret,
dismissCreatedWebdavToken,
token,
notifyApiError,
setStatusMessage,
passkeys,
passkeysSupported,
passkeysLoading,
@@ -27,15 +21,31 @@ const SettingsRoute = () => {
revokePasskey,
} = useAppShell();
const {
tokens,
loading,
creating,
deletingId,
regeneratingId,
updatingId,
createdSecret,
refresh,
create,
revoke,
regenerate,
updateCapabilities,
dismissSecret,
} = useApiTokens({ api, token, notifyApiError, setStatusMessage });
useEffect(() => {
refreshWebdavTokens();
refresh();
refreshPasskeys();
}, [refreshWebdavTokens, refreshPasskeys]);
}, [refresh, refreshPasskeys]);
const handleClose = useCallback(() => {
dismissCreatedWebdavToken();
dismissSecret();
navigate(-1);
}, [dismissCreatedWebdavToken, navigate]);
}, [dismissSecret, navigate]);
useEffect(() => {
const handleKeyDown = (event) => {
@@ -49,24 +59,26 @@ const SettingsRoute = () => {
}, [handleClose]);
useEffect(() => () => {
dismissCreatedWebdavToken();
}, [dismissCreatedWebdavToken]);
dismissSecret();
}, [dismissSecret]);
return (
<SettingsModal
open
onClose={handleClose}
tokens={webdavTokens}
loading={webdavTokensLoading}
creating={creatingWebdavToken}
deletingId={deletingWebdavTokenId}
regeneratingId={regeneratingWebdavTokenId}
onRefresh={refreshWebdavTokens}
onCreate={createWebdavToken}
onDelete={deleteWebdavToken}
onRegenerate={regenerateWebdavToken}
createdToken={webdavTokenSecret}
onDismissCreatedToken={dismissCreatedWebdavToken}
tokens={tokens}
loading={loading}
creating={creating}
deletingId={deletingId}
regeneratingId={regeneratingId}
updatingId={updatingId}
onRefresh={refresh}
onCreate={create}
onDelete={revoke}
onRegenerate={regenerate}
onUpdateCapabilities={updateCapabilities}
createdToken={createdSecret}
onDismissCreatedToken={dismissSecret}
passkeys={passkeys}
passkeysSupported={passkeysSupported}
passkeysLoading={passkeysLoading}
+57 -23
View File
@@ -12,24 +12,32 @@ export const useDetailPanel = ({
const [detailPanelDocIds, setDetailPanelDocIds] = useState([]);
const [detailPanelDocs, setDetailPanelDocs] = useState([]);
const lastScrolledDetailDocRef = useRef(null);
const latestOrderedDocsRef = useRef([]);
useEffect(() => {
if (!orderedSelectedDocuments.length) {
setDetailPanelDocIds((prev) => (prev.length ? [] : prev));
return;
}
const nextIds = orderedSelectedDocuments
latestOrderedDocsRef.current = orderedSelectedDocuments;
if (detailPanelOpen && orderedSelectedDocuments.length) {
const snapshotIds = orderedSelectedDocuments
.map((doc) => doc?.id)
.filter((id) => typeof id === 'string' || typeof id === 'number');
setDetailPanelDocIds((prev) => {
if (prev.length === nextIds.length && prev.every((id, index) => id === nextIds[index])) {
return prev;
if (snapshotIds.length) {
setDetailPanelDocIds(snapshotIds);
}
return nextIds;
});
}, [orderedSelectedDocuments]);
}
}, [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) {
@@ -38,17 +46,13 @@ export const useDetailPanel = ({
}
setDetailPanelDocs((prevDocs) => {
const prevMap = new Map((prevDocs || []).map((doc) => [doc.id, doc]));
const next = detailPanelDocIds
.map((id) => documentLookup.get(id) || prevMap.get(id) || null)
.filter(Boolean);
if (next.length === prevDocs.length && next.every((doc, index) => doc === prevDocs[index])) {
const resolved = resolveDocsForIds(detailPanelDocIds, prevDocs);
if (resolved.length === prevDocs.length && resolved.every((doc, index) => doc === prevDocs[index])) {
return prevDocs;
}
return next;
return resolved;
});
}, [detailPanelDocIds, documentLookup]);
}, [detailPanelDocIds, resolveDocsForIds]);
useEffect(() => {
if (!detailPanelOpen) {
@@ -108,9 +112,39 @@ export const useDetailPanel = ({
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);
}, []);
},
[resolveDocsForIds],
);
const closeDetailPanel = useCallback(() => {
setDetailPanelOpen(false);
+339 -191
View File
@@ -20,6 +20,11 @@ const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
const DEFAULT_GRID_ICON_SIZE = 144;
const LIST_ICON_SIZE = 48;
const EntryType = {
folder: 'folder',
document: 'document',
};
const getPageCount = (doc) =>
Number.isFinite(doc?.current_version?.metadata?.page_count)
? doc.current_version.metadata.page_count
@@ -212,18 +217,18 @@ const DocumentsTable = ({
onFolderDragEnd,
draggedFolderId,
onFolderDelete,
onFolderRename,
selectedFolderIds = [],
onFolderRowClick,
onDocumentRowClick,
onDocumentOpen,
selectedDocumentIds,
selectedDocumentIds = [],
focusedRowKey,
draggingDocumentIds = [],
onDocumentDragStart,
onDocumentDragEnd,
onDocumentDelete,
onFolderRename,
onDocumentRename,
onRowSelection = null,
onOpenDetailPanel = null,
tagLookupById,
activeCorrespondentIds = [],
onDocumentListFocus,
@@ -244,6 +249,25 @@ const DocumentsTable = ({
const showingSearchResults = searchResults !== null;
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(
() => new Set(selectedDocumentIds),
[selectedDocumentIds],
@@ -401,14 +425,56 @@ const DocumentsTable = ({
[isTagDragEvent, onDocumentTagDrop],
);
const handleDocumentClick = useCallback(
(documentId, event) => {
if (suppressDocumentClickRef.current) {
const handleEntryClick = useCallback(
(entry, event) => {
if (!entry || !entry.id) {
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(
@@ -474,160 +540,27 @@ const DocumentsTable = ({
[activeCorrespondentIdSet, onCorrespondentClick],
);
const noDocumentRows = rows.length === 0;
const hasFolderRows = !showingSearchResults && subfolders.length > 0;
const showTableRows = hasFolderRows || !noDocumentRows;
const showDefaultEmptyState = !showingSearchResults && !isFilterActive && !hasFolderRows && noDocumentRows;
const showListSearchEmptyState = showingSearchResults && noDocumentRows && !isGridView && !isSearchLoading;
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 renderFolderGridEntry = useCallback(
(entry) => {
const folder = entry.folder;
if (!folder) {
return null;
}
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 isDraggingFolder = draggedFolderId === folder.id;
const isSelectedFolder = selectedFolderSet.has(folder.id);
const classes = ['document-card', 'folder-card'];
if (isDraggingFolder) classes.push('is-dragging');
if (isSelectedFolder) classes.push('selected');
return (
<div
key={folder.id}
key={entry.key}
className={classes.join(' ')}
role="listitem"
id={`folder-card-${folder.id}`}
draggable={canDragFolder}
onClick={(event) => {
onFolderRowClick?.(folder.id, event);
const shouldNavigate =
!event.defaultPrevented &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey;
if (shouldNavigate) {
onFolderSelect(folder.id);
}
}}
onClick={(event) => handleFolderClick(folder, event)}
onDoubleClick={(event) => {
event.preventDefault();
onFolderSelect(folder.id);
@@ -659,8 +592,27 @@ const DocumentsTable = ({
</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 isDraggingDoc = draggingSet.has(doc.id);
const tagList = Array.isArray(doc.tags) ? doc.tags : [];
@@ -674,12 +626,12 @@ const DocumentsTable = ({
return (
<div
key={doc.id}
key={entry.key}
className={cardClasses.join(' ')}
role="listitem"
id={`document-card-${doc.id}`}
data-doc-id={doc.id}
onClick={(event) => handleDocumentClick(doc.id, event)}
onClick={(event) => handleDocumentClick(doc, event)}
onDoubleClick={() => onDocumentOpen(doc.id)}
draggable
onDragStart={(event) => handleDocumentDragStartLocal(event, doc)}
@@ -773,46 +725,46 @@ const DocumentsTable = ({
</div>
</div>
);
})}
</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>
{!showingSearchResults &&
subfolders.map((folder) => {
},
[
draggingSet,
ensureAssetUrl,
getDocumentAsset,
gridIconSize,
handleDocumentClick,
handleDocumentDragEndLocal,
handleDocumentDragStartLocal,
handleDocumentTagDragLeave,
handleDocumentTagDragOver,
handleDocumentTagDrop,
onDocumentOpen,
onTagClick,
renderCorrespondentLinks,
scrollRef,
selectedSet,
tagLookupById,
],
);
const renderFolderListEntry = useCallback(
(entry) => {
const folder = entry.folder;
if (!folder) {
return null;
}
const canDragFolder = folder.id !== 'root';
const isDraggingFolder = draggedFolderId === folder.id;
const isSelectedFolder = selectedFolderSet.has(folder.id);
const rowKey = `folder:${folder.id}`;
return (
<tr
key={folder.id}
key={entry.key}
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${
focusedRowKey === `folder:${folder.id}` ? ' focused' : ''
focusedRowKey === rowKey ? ' focused' : ''
}${isSelectedFolder ? ' selected' : ''}`}
id={`folder-row-${folder.id}`}
onClick={(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}`);
}}
onClick={(event) => handleFolderClick(folder, event)}
onDoubleClick={(event) => {
event.preventDefault();
onFolderSelect(folder.id);
@@ -886,8 +838,29 @@ const DocumentsTable = ({
</td>
</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 isDraggingDoc = draggingSet.has(doc.id);
const rowClasses = ['document'];
@@ -899,11 +872,11 @@ const DocumentsTable = ({
return (
<tr
key={doc.id}
key={entry.key}
className={rowClasses.join(' ')}
id={`document-row-${doc.id}`}
data-doc-id={doc.id}
onClick={(event) => handleDocumentClick(doc.id, event)}
onClick={(event) => handleDocumentClick(doc, event)}
onDoubleClick={() => onDocumentOpen(doc.id)}
draggable
onDragStart={(event) => handleDocumentDragStartLocal(event, doc)}
@@ -1064,7 +1037,182 @@ const DocumentsTable = ({
</td>
</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>
</table>
)}
+140 -12
View File
@@ -7,8 +7,8 @@ const SECTIONS = [
label: 'Passkeys',
},
{
id: 'webdav',
label: 'WebDAV',
id: 'apiTokens',
label: 'API tokens',
},
];
@@ -20,10 +20,12 @@ const SettingsModal = ({
creating = false,
deletingId = null,
regeneratingId = null,
updatingId = null,
onRefresh,
onCreate,
onDelete,
onRegenerate,
onUpdateCapabilities,
createdToken = null,
onDismissCreatedToken,
passkeys = [],
@@ -39,6 +41,7 @@ const SettingsModal = ({
const [activeSection, setActiveSection] = useState(defaultSection);
const [newTokenLabel, setNewTokenLabel] = useState('');
const [newTokenExpires, setNewTokenExpires] = useState('');
const [newTokenCapabilities, setNewTokenCapabilities] = useState([]);
const [formError, setFormError] = useState(null);
const [newPasskeyNickname, setNewPasskeyNickname] = useState('');
@@ -55,6 +58,7 @@ const SettingsModal = ({
setActiveSection(defaultSection);
setNewTokenLabel('');
setNewTokenExpires('');
setNewTokenCapabilities([]);
setFormError(null);
setNewPasskeyNickname('');
}
@@ -116,6 +120,57 @@ const SettingsModal = ({
[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();
@@ -135,18 +190,25 @@ const SettingsModal = ({
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, onCreate],
[newTokenExpires, newTokenLabel, newTokenCapabilities, onCreate],
);
const handleRegenerateToken = useCallback(
@@ -159,7 +221,7 @@ const SettingsModal = ({
[onRegenerate],
);
const renderWebdavSection = useMemo(() => {
const renderApiTokensSection = useMemo(() => {
const hasTokens = Array.isArray(tokens) && tokens.length > 0;
return (
@@ -175,6 +237,11 @@ const SettingsModal = ({
</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>
@@ -194,24 +261,44 @@ const SettingsModal = ({
<form className="settings-form" onSubmit={handleCreateToken}>
<div className="settings-form__field">
<label htmlFor="webdav-token-label">Label</label>
<label htmlFor="api-token-label">Label</label>
<input
id="webdav-token-label"
id="api-token-label"
type="text"
value={newTokenLabel}
onChange={(event) => setNewTokenLabel(event.target.value)}
placeholder="Personal WebDAV token"
placeholder="Personal API token"
/>
</div>
<div className="settings-form__field">
<label htmlFor="webdav-token-expires">Expires at</label>
<label htmlFor="api-token-expires">Expires at</label>
<input
id="webdav-token-expires"
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'}
@@ -225,7 +312,7 @@ const SettingsModal = ({
) : null}
{!loading && !hasTokens ? (
<p className="settings-empty">No WebDAV tokens yet.</p>
<p className="settings-empty">No API tokens yet.</p>
) : null}
{hasTokens ? (
@@ -236,18 +323,54 @@ const SettingsModal = ({
<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>
@@ -291,9 +414,12 @@ const SettingsModal = ({
creating,
deletingId,
regeneratingId,
updatingId,
newTokenLabel,
newTokenExpires,
newTokenCapabilities,
formError,
capabilityOptions,
formatDateTime,
handleCopyToken,
handleCreateToken,
@@ -301,6 +427,8 @@ const SettingsModal = ({
handleRefresh,
onDelete,
handleDismissSecret,
handleNewCapabilityChange,
handleToggleTokenCapability,
]);
const renderPasskeysSection = useMemo(() => {
@@ -465,8 +593,8 @@ const SettingsModal = ({
</nav>
<div className="settings-modal__content">
{activeSection === 'passkeys' ? renderPasskeysSection : null}
{activeSection === 'webdav' ? renderWebdavSection : null}
{activeSection !== 'passkeys' && activeSection !== 'webdav' ? (
{activeSection === 'apiTokens' ? renderApiTokensSection : null}
{activeSection !== 'passkeys' && activeSection !== 'apiTokens' ? (
<p>Select a settings section.</p>
) : null}
</div>
+46
View File
@@ -1883,6 +1883,12 @@ button.danger:hover:not([disabled]) {
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 {
max-width: 100%;
border-collapse: collapse;
@@ -3129,6 +3135,19 @@ form.inline {
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='datetime-local'] {
padding: 0.45rem 0.6rem;
@@ -3138,6 +3157,33 @@ form.inline {
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 {
display: flex;
gap: 0.5rem;