diff --git a/frontend/src/documents/DocumentsTable.jsx b/frontend/src/documents/DocumentsTable.jsx index cb37f18..95c190c 100644 --- a/frontend/src/documents/DocumentsTable.jsx +++ b/frontend/src/documents/DocumentsTable.jsx @@ -3,6 +3,8 @@ import { resolveDocumentAssetUrl } from '../asset_manager'; import { getTagColorStyle } from '../utils/colors'; import { DownloadIcon, FolderIcon, EditIcon } from '../ui/icons'; +const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag']; + const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, alt }) => { const url = useMemo( () => @@ -71,6 +73,7 @@ const DocumentsTable = ({ getDownloadHref, onTagClick, isSearchLoading = false, + onDocumentTagDrop, }) => { const showingSearchResults = searchResults !== null; const rows = showingSearchResults ? searchResults : documents; @@ -88,6 +91,10 @@ const DocumentsTable = ({ [draggingDocumentIds], ); const scrollRef = useRef(null); + const isTagDragEvent = useCallback((event) => { + const types = Array.from(event.dataTransfer?.types || []); + return TAG_MIME_TYPES.some((type) => types.includes(type)); + }, []); const ensureFocusedRowVisible = useCallback(() => { if (!focusedRowKey) return; const container = scrollRef.current; @@ -139,6 +146,58 @@ const DocumentsTable = ({ return undefined; }, [focusedRowKey]); + const handleDocumentTagDragOver = useCallback( + (event) => { + if (!isTagDragEvent(event)) { + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + event.currentTarget.classList.add('tag-drop-target'); + }, + [isTagDragEvent], + ); + + const handleDocumentTagDragLeave = useCallback( + (event) => { + if (!isTagDragEvent(event)) { + return; + } + if (event.relatedTarget && event.currentTarget.contains(event.relatedTarget)) { + return; + } + event.currentTarget.classList.remove('tag-drop-target'); + }, + [isTagDragEvent], + ); + + const handleDocumentTagDrop = useCallback( + (event, documentId) => { + if (!isTagDragEvent(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.classList.remove('tag-drop-target'); + const payload = + event.dataTransfer.getData('application/x-papercrate-tag') || + event.dataTransfer.getData('text/papercrate-tag'); + if (!payload) { + return; + } + try { + const parsed = JSON.parse(payload); + if (parsed?.id && onDocumentTagDrop) { + onDocumentTagDrop(documentId, parsed); + } + } catch ( + // eslint-disable-next-line no-empty + error + ) {} + }, + [isTagDragEvent, onDocumentTagDrop], + ); + return (
@@ -336,6 +395,9 @@ const DocumentsTable = ({ draggable onDragStart={(event) => onDocumentDragStart(event, doc)} onDragEnd={onDocumentDragEnd} + onDragOver={handleDocumentTagDragOver} + onDragLeave={handleDocumentTagDragLeave} + onDrop={(event) => handleDocumentTagDrop(event, doc.id)} > { + 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 ( + // eslint-disable-next-line no-empty + error + ) {} + }} + onDragEnd={(event) => { + event.stopPropagation(); + }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 327b051..e40e1c1 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -41,6 +41,7 @@ const runtimeApiBase = const DEFAULT_DEV_API = 'http://127.0.0.1:3000'; const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early +const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag']; const API_ROOT = (runtimeApiBase || process.env.API_BASE_URL || DEFAULT_DEV_API).replace(/\/$/, ''); @@ -375,6 +376,25 @@ const AppLayout = () => { const tokenRef = useRef(token); const refreshPromiseRef = useRef(null); const breadcrumbFetchRef = useRef(new Set()); + const tagRemovalCursorActiveRef = useRef(false); + const setTagRemovalCursor = useCallback((active) => { + if (typeof document === 'undefined') { + return; + } + if (tagRemovalCursorActiveRef.current === active) { + return; + } + const body = document.body; + if (!body) { + return; + } + tagRemovalCursorActiveRef.current = active; + if (active) { + body.classList.add('skeuo-cursor-remove'); + } else { + body.classList.remove('skeuo-cursor-remove'); + } + }, []); const refreshAccessToken = useCallback(async () => { console.log('[Auth] Attempting to refresh access token…'); appDispatch({ type: 'TOKEN_REFRESH_START' }); @@ -868,6 +888,122 @@ const AppLayout = () => { return map; }, [documents, searchResults]); + const mapDocumentCaches = useCallback( + (mapper) => { + if (typeof mapper !== 'function') { + return; + } + + const applyToList = (list) => { + let changed = false; + const next = list.map((doc) => { + const updated = mapper(doc); + if (updated === undefined || updated === doc) { + return doc; + } + changed = true; + return updated; + }); + return changed ? next : list; + }; + + setDocuments((prev) => applyToList(prev)); + setSearchResults((prev) => { + if (!Array.isArray(prev)) { + return prev; + } + return applyToList(prev); + }); + setFolderContents((prev) => { + if (!prev.size) { + return prev; + } + let changed = false; + const next = new Map(); + prev.forEach((contents, key) => { + const docs = Array.isArray(contents?.documents) ? contents.documents : null; + if (!docs || docs.length === 0) { + next.set(key, contents); + return; + } + let docsChanged = false; + const updatedDocs = docs.map((doc) => { + const updated = mapper(doc); + if (updated === undefined || updated === doc) { + return doc; + } + docsChanged = true; + return updated; + }); + if (docsChanged) { + changed = true; + next.set(key, { ...contents, documents: updatedDocs }); + } else { + next.set(key, contents); + } + }); + return changed ? next : prev; + }); + }, + [setDocuments, setSearchResults, setFolderContents], + ); + + const updateDocumentCaches = useCallback( + (documentId, updater) => { + if (!documentId || typeof updater !== 'function') { + return; + } + + mapDocumentCaches((doc) => { + if (!doc || doc.id !== documentId) { + return doc; + } + const updated = updater(doc); + return updated === undefined ? doc : updated; + }); + }, + [mapDocumentCaches], + ); + + const removeDocumentFromCaches = useCallback( + (documentId) => { + if (!documentId) { + return; + } + + const removeFromList = (list) => { + const next = list.filter((doc) => doc.id !== documentId); + return next.length === list.length ? list : next; + }; + + setDocuments((prev) => removeFromList(prev)); + setSearchResults((prev) => (Array.isArray(prev) ? removeFromList(prev) : prev)); + setFolderContents((prev) => { + if (!prev.size) { + return prev; + } + let changed = false; + const next = new Map(); + prev.forEach((contents, key) => { + const docs = Array.isArray(contents?.documents) ? contents.documents : null; + if (!docs || docs.length === 0) { + next.set(key, contents); + return; + } + const filteredDocs = docs.filter((doc) => doc.id !== documentId); + if (filteredDocs.length !== docs.length) { + changed = true; + next.set(key, { ...contents, documents: filteredDocs }); + } else { + next.set(key, contents); + } + }); + return changed ? next : prev; + }); + }, + [setDocuments, setSearchResults, setFolderContents], + ); + const applySelection = useCallback( (rowKeys, { anchor, interactedKeys = [] } = {}) => { const unique = []; @@ -1488,32 +1624,7 @@ const AppLayout = () => { await api.delete(`/correspondents/${correspondentId}`); await refreshCorrespondents(); - setDocuments((prev) => prev.map((doc) => stripFromDoc(doc))); - setSearchResults((prev) => - Array.isArray(prev) ? prev.map((doc) => stripFromDoc(doc)) : prev, - ); - setFolderContents((prev) => { - if (!prev.size) { - return prev; - } - let changed = false; - const next = new Map(); - prev.forEach((contents, key) => { - if (!Array.isArray(contents?.documents)) { - next.set(key, contents); - return; - } - const updatedDocs = contents.documents.map((doc) => stripFromDoc(doc)); - const mutated = updatedDocs.some((doc, index) => doc !== contents.documents[index]); - if (mutated) { - changed = true; - next.set(key, { ...contents, documents: updatedDocs }); - } else { - next.set(key, contents); - } - }); - return changed ? next : prev; - }); + mapDocumentCaches(stripFromDoc); setStatusMessage('Correspondent deleted.', 'success'); return true; @@ -1523,15 +1634,7 @@ const AppLayout = () => { throw new Error(message); } }, - [ - api, - refreshCorrespondents, - notifyApiError, - setStatusMessage, - setDocuments, - setSearchResults, - setFolderContents, - ], + [api, refreshCorrespondents, notifyApiError, setStatusMessage, mapDocumentCaches], ); async function handleDocumentCorrespondentAttach( @@ -1658,33 +1761,7 @@ const AppLayout = () => { return { ...doc, tags: nextTags }; }; - setDocuments((prev) => prev.map((doc) => stripTagFromDoc(doc))); - setSearchResults((prev) => - Array.isArray(prev) ? prev.map((doc) => stripTagFromDoc(doc)) : prev, - ); - - setFolderContents((prev) => { - if (!prev.size) { - return prev; - } - let changed = false; - const next = new Map(); - prev.forEach((contents, key) => { - if (!Array.isArray(contents?.documents)) { - next.set(key, contents); - return; - } - const updatedDocs = contents.documents.map((doc) => stripTagFromDoc(doc)); - const mutated = updatedDocs.some((doc, index) => doc !== contents.documents[index]); - if (mutated) { - changed = true; - next.set(key, { ...contents, documents: updatedDocs }); - } else { - next.set(key, contents); - } - }); - return changed ? next : prev; - }); + mapDocumentCaches(stripTagFromDoc); await refreshTags(); setStatusMessage('Tag deleted.', 'success'); @@ -1700,9 +1777,7 @@ const AppLayout = () => { refreshTags, notifyApiError, setStatusMessage, - setDocuments, - setSearchResults, - setFolderContents, + mapDocumentCaches, setActiveTagFilters, ], ); @@ -3271,56 +3346,11 @@ const AppLayout = () => { const hydratedDetail = assetManager.hydrateDetail(data); const hydratedDocument = hydratedDetail?.document || data.document || data; - setDocuments((prev) => - prev.map((doc) => { - if (doc.id !== documentId) { - return doc; - } - if (hydratedDocument) { - return { ...doc, ...hydratedDocument }; - } - return { ...doc, title: trimmed }; - }), - ); - - setSearchResults((prev) => - prev - ? prev.map((doc) => { - if (doc.id !== documentId) { - return doc; - } - if (hydratedDocument) { - return { ...doc, ...hydratedDocument }; - } - return { ...doc, title: trimmed }; - }) - : null, - ); - - setFolderContents((prev) => { - let changed = false; - const next = new Map(); - prev.forEach((contents, key) => { - if (contents?.documents?.some((doc) => doc.id === documentId)) { - changed = true; - next.set(key, { - ...contents, - documents: contents.documents.map((doc) => - doc.id === documentId - ? hydratedDocument - ? { ...doc, ...hydratedDocument } - : { ...doc, title: trimmed } - : doc, - ), - }); - } else { - next.set(key, contents); - } - }); - if (!changed) { - return prev; + updateDocumentCaches(documentId, (doc) => { + if (hydratedDocument) { + return { ...doc, ...hydratedDocument }; } - return next; + return { ...doc, title: trimmed }; }); setStatusMessage('Document title updated.', 'success'); @@ -3333,20 +3363,52 @@ const AppLayout = () => { setLoading(false); } }, - [assetManager, notifyApiError, setStatusMessage, setDocuments, setSearchResults, setFolderContents], + [assetManager, notifyApiError, setStatusMessage, updateDocumentCaches], + ); + + const applyTagRemovalToCaches = useCallback( + (documentId, tagId) => { + if (!documentId || !tagId) { + return; + } + + updateDocumentCaches(documentId, (doc) => { + if (!Array.isArray(doc.tags)) { + return doc; + } + const nextTags = doc.tags.filter((tag) => tag.id !== tagId); + if (nextTags.length === doc.tags.length) { + return doc; + } + return { ...doc, tags: nextTags }; + }); + }, + [updateDocumentCaches], ); const handleTagRemove = useCallback( - async (documentId, tagId) => { + async (documentId, tagId, { refreshTagList = true, showMessage = true } = {}) => { + if (!documentId || !tagId) { + return false; + } + try { await api.delete(`/documents/${documentId}/tags/${tagId}`); - setStatusMessage('Tag removed.', 'success'); - await Promise.all([refreshCurrentFolder(), refreshTags()]); + applyTagRemovalToCaches(documentId, tagId); + if (refreshTagList) { + await refreshTags(); + } + if (showMessage) { + setStatusMessage('Tag removed.', 'success'); + } + return true; } catch (error) { - notifyApiError(error, 'Failed to remove tag.'); + const message = error.response?.data?.error || 'Failed to remove tag.'; + notifyApiError(error, message); + return false; } }, - [refreshCurrentFolder, refreshTags, notifyApiError, setStatusMessage], + [api, refreshTags, notifyApiError, setStatusMessage, applyTagRemovalToCaches], ); const handleTagAdd = useCallback( @@ -3392,6 +3454,31 @@ const AppLayout = () => { [api, refreshCurrentFolder, notifyApiError, setStatusMessage], ); + const handleDocumentTagDrop = useCallback( + async (documentId, tag) => { + if (!documentId || !tag?.id) { + return; + } + + if (tag.sourceDocId && tag.sourceDocId === documentId) { + return; + } + + const attached = await handleDocumentTagAttach({ documentId, tagId: tag.id }); + if (!attached) { + return; + } + + if (tag.sourceDocId && tag.sourceDocId !== documentId) { + await handleTagRemove(tag.sourceDocId, tag.id, { + refreshTagList: false, + showMessage: false, + }); + } + }, + [handleDocumentTagAttach, handleTagRemove], + ); + const handleFolderDelete = useCallback( async (folderId) => { if (!token) { @@ -3818,6 +3905,107 @@ const AppLayout = () => { }; }, [token, handleFileDrop, currentFolderName, selectedFolder]); + useEffect( + () => () => { + setTagRemovalCursor(false); + }, + [setTagRemovalCursor], + ); + + useEffect(() => { + const host = shellRef.current; + if (!host) { + return undefined; + } + + const isTagTransfer = (event) => { + const types = event?.dataTransfer?.types; + if (!types) { + return false; + } + if (typeof types.includes === 'function') { + return TAG_MIME_TYPES.some((type) => types.includes(type)); + } + return TAG_MIME_TYPES.some((type) => Array.from(types).includes(type)); + }; + + const isDocumentRowTarget = (target) => + target instanceof Element ? Boolean(target.closest('tr.document')) : false; + + const handleTagDragOver = (event) => { + if (!isTagTransfer(event)) { + return; + } + if (isDocumentRowTarget(event.target)) { + setTagRemovalCursor(false); + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + setTagRemovalCursor(true); + }; + + const handleTagDragLeave = (event) => { + if (!isTagTransfer(event)) { + return; + } + const related = event.relatedTarget; + if (related instanceof Element && host.contains(related)) { + if (isDocumentRowTarget(related)) { + setTagRemovalCursor(false); + } + return; + } + setTagRemovalCursor(false); + }; + + const handleTagDrop = async (event) => { + if (!isTagTransfer(event)) { + return; + } + setTagRemovalCursor(false); + if (isDocumentRowTarget(event.target) || event.defaultPrevented) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const raw = + event.dataTransfer.getData('application/x-papercrate-tag') || + event.dataTransfer.getData('text/papercrate-tag'); + if (!raw) { + return; + } + try { + const payload = JSON.parse(raw); + if (payload?.sourceDocId && payload?.id) { + await handleTagRemove(payload.sourceDocId, payload.id, { + refreshTagList: false, + showMessage: true, + }); + } + } catch (error) { + console.warn('Failed to remove tag from drop target', error); + } + }; + + const handleTagDragEnd = () => { + setTagRemovalCursor(false); + }; + + host.addEventListener('dragover', handleTagDragOver, true); + host.addEventListener('dragleave', handleTagDragLeave, true); + host.addEventListener('drop', handleTagDrop, true); + window.addEventListener('dragend', handleTagDragEnd, true); + + return () => { + host.removeEventListener('dragover', handleTagDragOver, true); + host.removeEventListener('dragleave', handleTagDragLeave, true); + host.removeEventListener('drop', handleTagDrop, true); + window.removeEventListener('dragend', handleTagDragEnd, true); + setTagRemovalCursor(false); + }; + }, [handleTagRemove, setTagRemovalCursor]); + const handleLogin = useCallback( async (event) => { event.preventDefault(); @@ -4053,28 +4241,7 @@ const AppLayout = () => { try { await api.delete(`/documents/${documentId}`); - setDocuments((prev) => prev.filter((item) => item.id !== documentId)); - - setSearchResults((prev) => (prev ? prev.filter((item) => item.id !== documentId) : null)); - - setFolderContents((prev) => { - let changed = false; - const next = new Map(); - prev.forEach((contents, key) => { - if (!contents?.documents) { - next.set(key, contents); - return; - } - const filteredDocs = contents.documents.filter((item) => item.id !== documentId); - if (filteredDocs.length !== contents.documents.length) { - changed = true; - next.set(key, { ...contents, documents: filteredDocs }); - } else { - next.set(key, contents); - } - }); - return changed ? next : prev; - }); + removeDocumentFromCaches(documentId); setPreviewEntries((prev) => { if (!prev.has(documentId)) { @@ -4115,9 +4282,7 @@ const AppLayout = () => { token, documentLookup, setStatusMessage, - setDocuments, - setSearchResults, - setFolderContents, + removeDocumentFromCaches, setPreviewEntries, previewInflightRef, previewDocumentId, @@ -4335,6 +4500,7 @@ const AppLayout = () => { ? resolveApiPath(doc.current_version.download_path) : null, onTagClick: toggleTagFilter, + onDocumentTagDrop: handleDocumentTagDrop, }; const detailPanelProps = { diff --git a/frontend/src/sidebar/Sidebar.jsx b/frontend/src/sidebar/Sidebar.jsx index 58e5c57..c373a88 100644 --- a/frontend/src/sidebar/Sidebar.jsx +++ b/frontend/src/sidebar/Sidebar.jsx @@ -209,7 +209,9 @@ const Sidebar = ({ {tags.length}
{tags.length ? ( @@ -226,6 +228,22 @@ const Sidebar = ({ style={style || undefined} onClick={() => handleToggleTag(tag.id)} aria-pressed={isActive} + draggable + onDragStart={(event) => { + try { + const payload = JSON.stringify({ + id: tag.id, + label: tag.label, + color: tag.color || null, + }); + event.dataTransfer.effectAllowed = 'copy'; + event.dataTransfer.setData('application/x-papercrate-tag', payload); + event.dataTransfer.setData('text/papercrate-tag', payload); + } catch ( + // eslint-disable-next-line no-empty + error + ) {} + }} > {tag.label} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 11d1e99..9ec90f3 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1255,6 +1255,11 @@ button.icon-button.ghost:hover:not([disabled]) { opacity: 0.4; } +.documents-panel tbody tr.document.tag-drop-target { + background: var(--accent-soft, rgba(63, 106, 216, 0.12)); + box-shadow: inset 0 0 0 2px var(--accent); +} + .filter-bar { display: flex; flex-wrap: wrap;