From 16ac16cd3065d599422b65210176fad662f0cf9e Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sat, 1 Nov 2025 22:49:50 +0100 Subject: [PATCH] ui --- frontend/src/app/AppLayout.jsx | 218 +--- frontend/src/app/SettingsRoute.jsx | 68 +- frontend/src/app/useDetailPanel.js | 86 +- frontend/src/documents/DocumentsTable.jsx | 1142 ++++++++++++--------- frontend/src/settings/SettingsModal.jsx | 152 ++- frontend/src/styles.css | 46 + 6 files changed, 962 insertions(+), 750 deletions(-) diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx index 5534e74..11b6fce 100644 --- a/frontend/src/app/AppLayout.jsx +++ b/frontend/src/app/AppLayout.jsx @@ -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, diff --git a/frontend/src/app/SettingsRoute.jsx b/frontend/src/app/SettingsRoute.jsx index 348a842..ad6b8ac 100644 --- a/frontend/src/app/SettingsRoute.jsx +++ b/frontend/src/app/SettingsRoute.jsx @@ -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 ( { - if (!orderedSelectedDocuments.length) { - setDetailPanelDocIds((prev) => (prev.length ? [] : prev)); - return; - } - - const nextIds = 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; + latestOrderedDocsRef.current = orderedSelectedDocuments; + if (detailPanelOpen && orderedSelectedDocuments.length) { + const snapshotIds = orderedSelectedDocuments + .map((doc) => doc?.id) + .filter((id) => typeof id === 'string' || typeof id === 'number'); + 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(() => { - setDetailPanelOpen(true); - }, []); + 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); diff --git a/frontend/src/documents/DocumentsTable.jsx b/frontend/src/documents/DocumentsTable.jsx index d34f704..567bd87 100644 --- a/frontend/src/documents/DocumentsTable.jsx +++ b/frontend/src/documents/DocumentsTable.jsx @@ -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,12 +540,534 @@ 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 renderFolderGridEntry = 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 classes = ['document-card', 'folder-card']; + if (isDraggingFolder) classes.push('is-dragging'); + if (isSelectedFolder) classes.push('selected'); + + return ( +
handleFolderClick(folder, event)} + onDoubleClick={(event) => { + event.preventDefault(); + onFolderSelect(folder.id); + }} + onDragOver={(event) => onFolderDragOver(event, folder.id)} + onDragLeave={onFolderDragLeave} + onDrop={(event) => onFolderDrop(event, folder.id)} + onDragStart={(event) => { + if (canDragFolder) { + onFolderDragStart(event, folder.id); + } + }} + onDragEnd={(event) => { + if (canDragFolder) { + onFolderDragEnd(event); + } + }} + > +
+ +
+
+
+ {folder.name} +
+
+
+ ); + }, + [ + 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 : []; + const visibleTags = tagList.slice(0, 3); + const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0; + const correspondents = resolveCorrespondents(doc); + const cardClasses = ['document-card', 'document']; + if (isSelected) cardClasses.push('selected'); + if (isDraggingDoc) cardClasses.push('is-dragging'); + const titleText = doc.title || doc.original_name; + + return ( +
handleDocumentClick(doc, event)} + onDoubleClick={() => onDocumentOpen(doc.id)} + draggable + onDragStart={(event) => handleDocumentDragStartLocal(event, doc)} + onDragEnd={handleDocumentDragEndLocal} + onDragOver={(event) => handleDocumentTagDragOver(event)} + onDragOverCapture={(event) => handleDocumentTagDragOver(event)} + onDragLeave={handleDocumentTagDragLeave} + onDragLeaveCapture={handleDocumentTagDragLeave} + onDrop={(event) => handleDocumentTagDrop(event, doc.id)} + onDropCapture={(event) => handleDocumentTagDrop(event, doc.id)} + > + +
+
+ {correspondents.length > 0 ? ( + + {renderCorrespondentLinks(correspondents)} + + ) : null} + {titleText} +
+ {visibleTags.length > 0 && ( +
+ {visibleTags.map((tag) => { + const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color; + const style = getTagColorStyle(colorSource); + return ( + { + event.stopPropagation(); + if (onTagClick) { + onTagClick(tag.id); + } + }} + role="button" + draggable + onDragStart={(event) => { + event.stopPropagation(); + try { + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'copyMove'; + } + const payload = JSON.stringify({ + id: tag.id, + label: tag.label, + sourceDocId: doc.id, + }); + event.dataTransfer?.setData('application/x-papercrate-tag', payload); + event.dataTransfer?.setData('text/papercrate-tag', payload); + event.dataTransfer?.setData('text/plain', tag.label || 'Tag'); + } catch (error) { + console.warn('[documents] Failed to configure tag drag payload', error); + } + }} + onDragEnd={(event) => { + event.stopPropagation(); + }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + event.stopPropagation(); + if (onTagClick) { + onTagClick(tag.id); + } + } + }} + > + {tag.label} + + ); + })} + {remainingTagCount > 0 && ( + +{remainingTagCount} + )} +
+ )} +
+
+ ); + }, + [ + 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 ( + handleFolderClick(folder, event)} + onDoubleClick={(event) => { + event.preventDefault(); + onFolderSelect(folder.id); + }} + onDragOver={(event) => onFolderDragOver(event, folder.id)} + onDragLeave={onFolderDragLeave} + onDrop={(event) => onFolderDrop(event, folder.id)} + draggable={canDragFolder} + onDragStart={(event) => { + if (canDragFolder) { + onFolderDragStart(event, folder.id); + } + }} + onDragEnd={(event) => { + if (canDragFolder) { + onFolderDragEnd(event); + } + }} + > + +
+ +
+ + +
+ {folder.name} +
+ + — + +
+ {folder.id !== 'root' && onFolderRename && ( + + )} + +
+ + + ); + }, + [ + 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']; + if (isSelected) rowClasses.push('selected'); + if (isDraggingDoc) rowClasses.push('is-dragging'); + const downloadHref = getDownloadHref?.(doc) || null; + const correspondents = resolveCorrespondents(doc); + const titleText = doc.title || doc.original_name; + + return ( + handleDocumentClick(doc, event)} + onDoubleClick={() => onDocumentOpen(doc.id)} + draggable + onDragStart={(event) => handleDocumentDragStartLocal(event, doc)} + onDragEnd={handleDocumentDragEndLocal} + onDragOver={handleDocumentTagDragOver} + onDragLeave={handleDocumentTagDragLeave} + onDrop={(event) => handleDocumentTagDrop(event, doc.id)} + > + + + + +
+
+ + {correspondents.length > 0 ? ( + + {renderCorrespondentLinks(correspondents)} + + ) : null} + {titleText} + +
+ {(doc.tags || []).length > 0 && ( +
+ {(doc.tags || []).map((tag) => { + const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color; + const style = getTagColorStyle(colorSource); + return ( + { + event.stopPropagation(); + if (onTagClick) { + onTagClick(tag.id); + } + }} + role="button" + draggable + onDragStart={(event) => { + event.stopPropagation(); + try { + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'copyMove'; + } + const payload = JSON.stringify({ + id: tag.id, + label: tag.label, + sourceDocId: doc.id, + }); + event.dataTransfer?.setData('application/x-papercrate-tag', payload); + event.dataTransfer?.setData('text/papercrate-tag', payload); + event.dataTransfer?.setData('text/plain', tag.label || 'Tag'); + } catch (error) { + console.warn('[documents] Failed to configure tag drag payload', error); + } + }} + onDragEnd={(event) => { + event.stopPropagation(); + }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + event.stopPropagation(); + if (onTagClick) { + onTagClick(tag.id); + } + } + }} + > + {tag.label} + + ); + })} +
+ )} +
+ + + {(() => { + const issuedAt = doc.issued_at || null; + if (!issuedAt) { + return '—'; + } + const timestamp = Date.parse(issuedAt); + if (Number.isNaN(timestamp)) { + return '—'; + } + return new Date(timestamp).toLocaleDateString(); + })()} + + +
+ {onDocumentRename && ( + + )} + {downloadHref ? ( + event.stopPropagation()} + onAuxClick={(event) => event.stopPropagation()} + onContextMenu={(event) => event.stopPropagation()} + > + + + ) : ( + No download + )} + +
+ + + ); + }, + [ + 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(() => { @@ -571,6 +1159,7 @@ const DocumentsTable = ({
{ if (event.target === scrollRef.current) { onDocumentListFocus?.(); @@ -591,489 +1180,48 @@ const DocumentsTable = ({ }} aria-activedescendant={isGridView ? undefined : activeDescendantId} > - {isGridView ? ( -
{ - 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 ( -
{ - onFolderRowClick?.(folder.id, event); - const shouldNavigate = - !event.defaultPrevented && - !event.metaKey && - !event.ctrlKey && - !event.shiftKey; - if (shouldNavigate) { - onFolderSelect(folder.id); - } - }} - onDoubleClick={(event) => { - event.preventDefault(); - onFolderSelect(folder.id); - }} - onDragOver={(event) => onFolderDragOver(event, folder.id)} - onDragLeave={onFolderDragLeave} - onDrop={(event) => onFolderDrop(event, folder.id)} - onDragStart={(event) => { - if (canDragFolder) { - onFolderDragStart(event, folder.id); - } - }} - onDragEnd={(event) => { - if (canDragFolder) { - onFolderDragEnd(event); - } - }} - > -
- -
-
-
- {folder.name} -
-
-
- ); - })} - {rows.map((doc) => { - const isSelected = selectedSet.has(doc.id); - const isDraggingDoc = draggingSet.has(doc.id); - const tagList = Array.isArray(doc.tags) ? doc.tags : []; - const visibleTags = tagList.slice(0, 3); - const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0; - const correspondents = resolveCorrespondents(doc); - const cardClasses = ['document-card', 'document']; - if (isSelected) cardClasses.push('selected'); - if (isDraggingDoc) cardClasses.push('is-dragging'); - const titleText = doc.title || doc.original_name; - - return ( -
handleDocumentClick(doc.id, event)} - onDoubleClick={() => onDocumentOpen(doc.id)} - draggable - onDragStart={(event) => handleDocumentDragStartLocal(event, doc)} - onDragEnd={handleDocumentDragEndLocal} - onDragOver={(event) => handleDocumentTagDragOver(event)} - onDragOverCapture={(event) => handleDocumentTagDragOver(event)} - onDragLeave={handleDocumentTagDragLeave} - onDragLeaveCapture={handleDocumentTagDragLeave} - onDrop={(event) => handleDocumentTagDrop(event, doc.id)} - onDropCapture={(event) => handleDocumentTagDrop(event, doc.id)} - > - -
-
- {correspondents.length > 0 ? ( - - {renderCorrespondentLinks(correspondents)} - - ) : null} - {titleText} -
- {visibleTags.length > 0 && ( -
- {visibleTags.map((tag) => { - const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color; - const style = getTagColorStyle(colorSource); - return ( - { - event.stopPropagation(); - if (onTagClick) { - onTagClick(tag.id); - } - }} - role="button" - draggable - onDragStart={(event) => { - event.stopPropagation(); - try { - if (event.dataTransfer) { - event.dataTransfer.effectAllowed = 'copyMove'; - } - const payload = JSON.stringify({ - id: tag.id, - label: tag.label, - sourceDocId: doc.id, - }); - event.dataTransfer?.setData('application/x-papercrate-tag', payload); - event.dataTransfer?.setData('text/papercrate-tag', payload); - event.dataTransfer?.setData('text/plain', tag.label || 'Tag'); - } catch (error) { - console.warn('[documents] Failed to configure tag drag payload', error); - } - }} - onDragEnd={(event) => { - event.stopPropagation(); - }} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - event.stopPropagation(); - if (onTagClick) { - onTagClick(tag.id); - } - } - }} - > - {tag.label} - - ); - })} - {remainingTagCount > 0 && ( - +{remainingTagCount} - )} -
- )} -
-
- ); - })} -
- ) : !showTableRows ? null : ( - - - - - - - - - - - {!showingSearchResults && - subfolders.map((folder) => { - const canDragFolder = folder.id !== 'root'; - const isDraggingFolder = draggedFolderId === folder.id; - const isSelectedFolder = selectedFolderSet.has(folder.id); - return ( - { - 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) => { - event.preventDefault(); - onFolderSelect(folder.id); - }} - onDragOver={(event) => onFolderDragOver(event, folder.id)} - onDragLeave={onFolderDragLeave} - onDrop={(event) => onFolderDrop(event, folder.id)} - draggable={canDragFolder} - onDragStart={(event) => { - if (canDragFolder) { - onFolderDragStart(event, folder.id); - } - }} - onDragEnd={(event) => { - if (canDragFolder) { - onFolderDragEnd(event); - } - }} - > - - - - - - ); - })} - {rows.map((doc) => { - const isSelected = selectedSet.has(doc.id); - const isDraggingDoc = draggingSet.has(doc.id); - const rowClasses = ['document']; - if (isSelected) rowClasses.push('selected'); - if (isDraggingDoc) rowClasses.push('is-dragging'); - const downloadHref = getDownloadHref?.(doc) || null; - const correspondents = resolveCorrespondents(doc); - const titleText = doc.title || doc.original_name; - - return ( - handleDocumentClick(doc.id, event)} - onDoubleClick={() => onDocumentOpen(doc.id)} - draggable - onDragStart={(event) => handleDocumentDragStartLocal(event, doc)} - onDragEnd={handleDocumentDragEndLocal} - onDragOver={handleDocumentTagDragOver} - onDragLeave={handleDocumentTagDragLeave} - onDrop={(event) => handleDocumentTagDrop(event, doc.id)} - > - - - - - - ); - })} - -
PreviewNameIssuedActions
-
- -
-
-
- {folder.name} -
-
-
- {folder.id !== 'root' && onFolderRename && ( - - )} - -
-
- - -
-
- - {correspondents.length > 0 ? ( - - {renderCorrespondentLinks(correspondents)} - - ) : null} - {titleText} - -
- {(doc.tags || []).length > 0 && ( -
- {(doc.tags || []).map((tag) => { - const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color; - const style = getTagColorStyle(colorSource); - return ( - { - event.stopPropagation(); - if (onTagClick) { - onTagClick(tag.id); - } - }} - role="button" - draggable - onDragStart={(event) => { - event.stopPropagation(); - try { - if (event.dataTransfer) { - event.dataTransfer.effectAllowed = 'copyMove'; - } - const payload = JSON.stringify({ - id: tag.id, - label: tag.label, - sourceDocId: doc.id, - }); - event.dataTransfer?.setData('application/x-papercrate-tag', payload); - event.dataTransfer?.setData('text/papercrate-tag', payload); - event.dataTransfer?.setData('text/plain', tag.label || 'Tag'); - } catch (error) { - console.warn('[documents] Failed to configure tag drag payload', error); - } - }} - onDragEnd={(event) => { - event.stopPropagation(); - }} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - event.stopPropagation(); - if (onTagClick) { - onTagClick(tag.id); - } - } - }} - > - {tag.label} - - ); - })} -
- )} -
-
- {(() => { - const issuedAt = doc.issued_at || null; - if (!issuedAt) { - return '—'; - } - const timestamp = Date.parse(issuedAt); - if (Number.isNaN(timestamp)) { - return '—'; - } - return new Date(timestamp).toLocaleDateString(); - })()} - -
- {onDocumentRename && ( - - )} - {downloadHref ? ( - event.stopPropagation()} - onAuxClick={(event) => event.stopPropagation()} - onContextMenu={(event) => event.stopPropagation()} - > - - - ) : ( - No download - )} - -
-
- )} -
- {showSearchHint && ( -
- Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders. + {isGridView ? ( +
{ + if (event.target === event.currentTarget) { + onClearSelection?.(); + } + }} + style={{ '--documents-grid-icon-size': `${gridIconSize}px` }} + > + {entries.map((entry) => + entry.type === EntryType.folder + ? renderFolderGridEntry(entry) + : renderDocumentGridEntry(entry) + )} +
+ ) : !showTableRows ? null : ( + + + + + + + + + + + {entries.map((entry) => + entry.type === EntryType.folder + ? renderFolderListEntry(entry) + : renderDocumentListEntry(entry) + )} + +
PreviewNameIssuedActions
+ )}
- )} + {showSearchHint && ( +
+ Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders. +
+ )} )} diff --git a/frontend/src/settings/SettingsModal.jsx b/frontend/src/settings/SettingsModal.jsx index 6ff898b..3860394 100644 --- a/frontend/src/settings/SettingsModal.jsx +++ b/frontend/src/settings/SettingsModal.jsx @@ -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 = ({ +

+ 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. +

+ {createdToken ? (

@@ -194,24 +261,44 @@ const SettingsModal = ({

- + setNewTokenLabel(event.target.value)} - placeholder="Personal WebDAV token" + placeholder="Personal API token" />
- + setNewTokenExpires(event.target.value)} />
+
+ Capabilities +
+ {capabilityOptions.map((option) => { + const checked = newTokenCapabilities.includes(option.value); + return ( + + ); + })} +
+