tags drags
This commit is contained in:
+311
-145
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user