tags drags
This commit is contained in:
@@ -3,6 +3,8 @@ import { resolveDocumentAssetUrl } from '../asset_manager';
|
|||||||
import { getTagColorStyle } from '../utils/colors';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
import { DownloadIcon, FolderIcon, EditIcon } from '../ui/icons';
|
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 DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, alt }) => {
|
||||||
const url = useMemo(
|
const url = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -71,6 +73,7 @@ const DocumentsTable = ({
|
|||||||
getDownloadHref,
|
getDownloadHref,
|
||||||
onTagClick,
|
onTagClick,
|
||||||
isSearchLoading = false,
|
isSearchLoading = false,
|
||||||
|
onDocumentTagDrop,
|
||||||
}) => {
|
}) => {
|
||||||
const showingSearchResults = searchResults !== null;
|
const showingSearchResults = searchResults !== null;
|
||||||
const rows = showingSearchResults ? searchResults : documents;
|
const rows = showingSearchResults ? searchResults : documents;
|
||||||
@@ -88,6 +91,10 @@ const DocumentsTable = ({
|
|||||||
[draggingDocumentIds],
|
[draggingDocumentIds],
|
||||||
);
|
);
|
||||||
const scrollRef = useRef(null);
|
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(() => {
|
const ensureFocusedRowVisible = useCallback(() => {
|
||||||
if (!focusedRowKey) return;
|
if (!focusedRowKey) return;
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
@@ -139,6 +146,58 @@ const DocumentsTable = ({
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [focusedRowKey]);
|
}, [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 (
|
return (
|
||||||
<section className="documents-panel column">
|
<section className="documents-panel column">
|
||||||
<div className="column-header">
|
<div className="column-header">
|
||||||
@@ -336,6 +395,9 @@ const DocumentsTable = ({
|
|||||||
draggable
|
draggable
|
||||||
onDragStart={(event) => onDocumentDragStart(event, doc)}
|
onDragStart={(event) => onDocumentDragStart(event, doc)}
|
||||||
onDragEnd={onDocumentDragEnd}
|
onDragEnd={onDocumentDragEnd}
|
||||||
|
onDragOver={handleDocumentTagDragOver}
|
||||||
|
onDragLeave={handleDocumentTagDragLeave}
|
||||||
|
onDrop={(event) => handleDocumentTagDrop(event, doc.id)}
|
||||||
>
|
>
|
||||||
<td className="thumb-cell">
|
<td className="thumb-cell">
|
||||||
<DocumentThumbnailImage
|
<DocumentThumbnailImage
|
||||||
@@ -393,6 +455,29 @@ const DocumentsTable = ({
|
|||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
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 (
|
||||||
|
// eslint-disable-next-line no-empty
|
||||||
|
error
|
||||||
|
) {}
|
||||||
|
}}
|
||||||
|
onDragEnd={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
+311
-145
@@ -41,6 +41,7 @@ const runtimeApiBase =
|
|||||||
|
|
||||||
const DEFAULT_DEV_API = 'http://127.0.0.1:3000';
|
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 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(/\/$/, '');
|
const API_ROOT = (runtimeApiBase || process.env.API_BASE_URL || DEFAULT_DEV_API).replace(/\/$/, '');
|
||||||
|
|
||||||
@@ -375,6 +376,25 @@ const AppLayout = () => {
|
|||||||
const tokenRef = useRef(token);
|
const tokenRef = useRef(token);
|
||||||
const refreshPromiseRef = useRef(null);
|
const refreshPromiseRef = useRef(null);
|
||||||
const breadcrumbFetchRef = useRef(new Set());
|
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 () => {
|
const refreshAccessToken = useCallback(async () => {
|
||||||
console.log('[Auth] Attempting to refresh access token…');
|
console.log('[Auth] Attempting to refresh access token…');
|
||||||
appDispatch({ type: 'TOKEN_REFRESH_START' });
|
appDispatch({ type: 'TOKEN_REFRESH_START' });
|
||||||
@@ -868,6 +888,122 @@ const AppLayout = () => {
|
|||||||
return map;
|
return map;
|
||||||
}, [documents, searchResults]);
|
}, [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(
|
const applySelection = useCallback(
|
||||||
(rowKeys, { anchor, interactedKeys = [] } = {}) => {
|
(rowKeys, { anchor, interactedKeys = [] } = {}) => {
|
||||||
const unique = [];
|
const unique = [];
|
||||||
@@ -1488,32 +1624,7 @@ const AppLayout = () => {
|
|||||||
await api.delete(`/correspondents/${correspondentId}`);
|
await api.delete(`/correspondents/${correspondentId}`);
|
||||||
await refreshCorrespondents();
|
await refreshCorrespondents();
|
||||||
|
|
||||||
setDocuments((prev) => prev.map((doc) => stripFromDoc(doc)));
|
mapDocumentCaches(stripFromDoc);
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
setStatusMessage('Correspondent deleted.', 'success');
|
setStatusMessage('Correspondent deleted.', 'success');
|
||||||
return true;
|
return true;
|
||||||
@@ -1523,15 +1634,7 @@ const AppLayout = () => {
|
|||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[api, refreshCorrespondents, notifyApiError, setStatusMessage, mapDocumentCaches],
|
||||||
api,
|
|
||||||
refreshCorrespondents,
|
|
||||||
notifyApiError,
|
|
||||||
setStatusMessage,
|
|
||||||
setDocuments,
|
|
||||||
setSearchResults,
|
|
||||||
setFolderContents,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
async function handleDocumentCorrespondentAttach(
|
async function handleDocumentCorrespondentAttach(
|
||||||
@@ -1658,33 +1761,7 @@ const AppLayout = () => {
|
|||||||
return { ...doc, tags: nextTags };
|
return { ...doc, tags: nextTags };
|
||||||
};
|
};
|
||||||
|
|
||||||
setDocuments((prev) => prev.map((doc) => stripTagFromDoc(doc)));
|
mapDocumentCaches(stripTagFromDoc);
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
await refreshTags();
|
await refreshTags();
|
||||||
setStatusMessage('Tag deleted.', 'success');
|
setStatusMessage('Tag deleted.', 'success');
|
||||||
@@ -1700,9 +1777,7 @@ const AppLayout = () => {
|
|||||||
refreshTags,
|
refreshTags,
|
||||||
notifyApiError,
|
notifyApiError,
|
||||||
setStatusMessage,
|
setStatusMessage,
|
||||||
setDocuments,
|
mapDocumentCaches,
|
||||||
setSearchResults,
|
|
||||||
setFolderContents,
|
|
||||||
setActiveTagFilters,
|
setActiveTagFilters,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -3271,56 +3346,11 @@ const AppLayout = () => {
|
|||||||
const hydratedDetail = assetManager.hydrateDetail(data);
|
const hydratedDetail = assetManager.hydrateDetail(data);
|
||||||
const hydratedDocument = hydratedDetail?.document || data.document || data;
|
const hydratedDocument = hydratedDetail?.document || data.document || data;
|
||||||
|
|
||||||
setDocuments((prev) =>
|
updateDocumentCaches(documentId, (doc) => {
|
||||||
prev.map((doc) => {
|
if (hydratedDocument) {
|
||||||
if (doc.id !== documentId) {
|
return { ...doc, ...hydratedDocument };
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
return next;
|
return { ...doc, title: trimmed };
|
||||||
});
|
});
|
||||||
|
|
||||||
setStatusMessage('Document title updated.', 'success');
|
setStatusMessage('Document title updated.', 'success');
|
||||||
@@ -3333,20 +3363,52 @@ const AppLayout = () => {
|
|||||||
setLoading(false);
|
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(
|
const handleTagRemove = useCallback(
|
||||||
async (documentId, tagId) => {
|
async (documentId, tagId, { refreshTagList = true, showMessage = true } = {}) => {
|
||||||
|
if (!documentId || !tagId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.delete(`/documents/${documentId}/tags/${tagId}`);
|
await api.delete(`/documents/${documentId}/tags/${tagId}`);
|
||||||
setStatusMessage('Tag removed.', 'success');
|
applyTagRemovalToCaches(documentId, tagId);
|
||||||
await Promise.all([refreshCurrentFolder(), refreshTags()]);
|
if (refreshTagList) {
|
||||||
|
await refreshTags();
|
||||||
|
}
|
||||||
|
if (showMessage) {
|
||||||
|
setStatusMessage('Tag removed.', 'success');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} 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(
|
const handleTagAdd = useCallback(
|
||||||
@@ -3392,6 +3454,31 @@ const AppLayout = () => {
|
|||||||
[api, refreshCurrentFolder, notifyApiError, setStatusMessage],
|
[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(
|
const handleFolderDelete = useCallback(
|
||||||
async (folderId) => {
|
async (folderId) => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -3818,6 +3905,107 @@ const AppLayout = () => {
|
|||||||
};
|
};
|
||||||
}, [token, handleFileDrop, currentFolderName, selectedFolder]);
|
}, [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(
|
const handleLogin = useCallback(
|
||||||
async (event) => {
|
async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -4053,28 +4241,7 @@ const AppLayout = () => {
|
|||||||
try {
|
try {
|
||||||
await api.delete(`/documents/${documentId}`);
|
await api.delete(`/documents/${documentId}`);
|
||||||
|
|
||||||
setDocuments((prev) => prev.filter((item) => item.id !== documentId));
|
removeDocumentFromCaches(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;
|
|
||||||
});
|
|
||||||
|
|
||||||
setPreviewEntries((prev) => {
|
setPreviewEntries((prev) => {
|
||||||
if (!prev.has(documentId)) {
|
if (!prev.has(documentId)) {
|
||||||
@@ -4115,9 +4282,7 @@ const AppLayout = () => {
|
|||||||
token,
|
token,
|
||||||
documentLookup,
|
documentLookup,
|
||||||
setStatusMessage,
|
setStatusMessage,
|
||||||
setDocuments,
|
removeDocumentFromCaches,
|
||||||
setSearchResults,
|
|
||||||
setFolderContents,
|
|
||||||
setPreviewEntries,
|
setPreviewEntries,
|
||||||
previewInflightRef,
|
previewInflightRef,
|
||||||
previewDocumentId,
|
previewDocumentId,
|
||||||
@@ -4335,6 +4500,7 @@ const AppLayout = () => {
|
|||||||
? resolveApiPath(doc.current_version.download_path)
|
? resolveApiPath(doc.current_version.download_path)
|
||||||
: null,
|
: null,
|
||||||
onTagClick: toggleTagFilter,
|
onTagClick: toggleTagFilter,
|
||||||
|
onDocumentTagDrop: handleDocumentTagDrop,
|
||||||
};
|
};
|
||||||
|
|
||||||
const detailPanelProps = {
|
const detailPanelProps = {
|
||||||
|
|||||||
@@ -209,7 +209,9 @@ const Sidebar = ({
|
|||||||
<span className="meta">{tags.length}</span>
|
<span className="meta">{tags.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`sidebar-tag-cloud${activeTagSet.size ? ' sidebar-tag-cloud--has-active' : ''}`}
|
className={`sidebar-tag-cloud${
|
||||||
|
activeTagSet.size ? ' sidebar-tag-cloud--has-active' : ''
|
||||||
|
}`}
|
||||||
role="list"
|
role="list"
|
||||||
>
|
>
|
||||||
{tags.length ? (
|
{tags.length ? (
|
||||||
@@ -226,6 +228,22 @@ const Sidebar = ({
|
|||||||
style={style || undefined}
|
style={style || undefined}
|
||||||
onClick={() => handleToggleTag(tag.id)}
|
onClick={() => handleToggleTag(tag.id)}
|
||||||
aria-pressed={isActive}
|
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}
|
{tag.label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1255,6 +1255,11 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
opacity: 0.4;
|
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 {
|
.filter-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
Reference in New Issue
Block a user