tags drags
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 21s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 47s

This commit is contained in:
2025-10-16 13:51:12 +02:00
parent e45574779b
commit b741c181e0
4 changed files with 420 additions and 146 deletions
+85
View File
@@ -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 (
<section className="documents-panel column">
<div className="column-header">
@@ -336,6 +395,9 @@ const DocumentsTable = ({
draggable
onDragStart={(event) => onDocumentDragStart(event, doc)}
onDragEnd={onDocumentDragEnd}
onDragOver={handleDocumentTagDragOver}
onDragLeave={handleDocumentTagDragLeave}
onDrop={(event) => handleDocumentTagDrop(event, doc.id)}
>
<td className="thumb-cell">
<DocumentThumbnailImage
@@ -393,6 +455,29 @@ const DocumentsTable = ({
}}
role="button"
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) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
+307 -141
View File
@@ -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;
}
updateDocumentCaches(documentId, (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;
});
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}`);
applyTagRemovalToCaches(documentId, tagId);
if (refreshTagList) {
await refreshTags();
}
if (showMessage) {
setStatusMessage('Tag removed.', 'success');
await Promise.all([refreshCurrentFolder(), refreshTags()]);
}
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 = {
+19 -1
View File
@@ -209,7 +209,9 @@ const Sidebar = ({
<span className="meta">{tags.length}</span>
</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"
>
{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}
</button>
+5
View File
@@ -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;