3 Commits
Author SHA1 Message Date
nils b741c181e0 tags drags
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 21s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 47s
2025-10-16 13:51:12 +02:00
nils e45574779b fixes 2025-10-16 12:32:03 +02:00
nils fecb7af3f6 sidebar tags style 2025-10-16 12:22:19 +02:00
4 changed files with 445 additions and 156 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();
+326 -153
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,15 +1777,13 @@ const AppLayout = () => {
refreshTags,
notifyApiError,
setStatusMessage,
setDocuments,
setSearchResults,
setFolderContents,
mapDocumentCaches,
setActiveTagFilters,
],
);
const loadFolder = useCallback(
async (folderId, { showLoading = true } = {}) => {
async (folderId, { showLoading = true, preserveSearch = false } = {}) => {
const targetId = folderId || 'root';
setSelectedFolder(targetId);
if (showLoading) setLoading(true);
@@ -1722,7 +1797,9 @@ const AppLayout = () => {
}
}
applySelectedFolder(targetId, contents);
setSearchResults(null);
if (!preserveSearch) {
setSearchResults(null);
}
} catch (error) {
notifyApiError(error, 'Failed to load folder contents.');
} finally {
@@ -1759,14 +1836,14 @@ const AppLayout = () => {
const targetId = folderId && folderId !== 'root' ? folderId : 'root';
if (!navigate || immediate) {
await loadFolder(targetId);
await loadFolder(targetId, { preserveSearch: isFilterActive });
return;
}
const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
navigate(path, { replace });
},
[loadFolder, navigate],
[loadFolder, navigate, isFilterActive],
);
const initializeAfterLogin = useCallback(async () => {
@@ -1799,7 +1876,10 @@ const AppLayout = () => {
const hasData = folderContents.has(targetParam);
if (targetParam !== selectedFolder || !hasData) {
loadFolder(targetParam, { showLoading: !routeDocumentId });
loadFolder(targetParam, {
showLoading: !routeDocumentId,
preserveSearch: isFilterActive,
});
}
}, [
token,
@@ -1809,6 +1889,7 @@ const AppLayout = () => {
selectedFolder,
loadFolder,
folderContents,
isFilterActive,
]);
const refreshCurrentFolder = useCallback(async () => {
@@ -3173,14 +3254,14 @@ const AppLayout = () => {
const targetId = folderId || selectedFolder || 'root';
if (!navigate) {
loadFolder(targetId, { showLoading: false });
loadFolder(targetId, { showLoading: false, preserveSearch: isFilterActive });
return;
}
const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
navigate(path, { replace: false });
},
[navigate, selectedFolder, loadFolder],
[navigate, selectedFolder, loadFolder, isFilterActive],
);
useEffect(() => {
@@ -3218,7 +3299,7 @@ const AppLayout = () => {
const targetFolder = doc?.folder_id || routeFolderId || 'root';
if (targetFolder && targetFolder !== selectedFolder) {
await loadFolder(targetFolder, { showLoading: false });
await loadFolder(targetFolder, { showLoading: false, preserveSearch: isFilterActive });
}
if (!cancelled) {
await openDocumentPreview(routeDocumentId, { replace: true, skipNavigate: true });
@@ -3248,6 +3329,7 @@ const AppLayout = () => {
documents,
searchResults,
notifyApiError,
isFilterActive,
]);
const handleDocumentTitleUpdate = useCallback(
@@ -3264,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');
@@ -3326,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(
@@ -3385,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) {
@@ -3811,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();
@@ -4046,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)) {
@@ -4108,9 +4282,7 @@ const AppLayout = () => {
token,
documentLookup,
setStatusMessage,
setDocuments,
setSearchResults,
setFolderContents,
removeDocumentFromCaches,
setPreviewEntries,
previewInflightRef,
previewDocumentId,
@@ -4328,6 +4500,7 @@ const AppLayout = () => {
? resolveApiPath(doc.current_version.download_path)
: null,
onTagClick: toggleTagFilter,
onDocumentTagDrop: handleDocumentTagDrop,
};
const detailPanelProps = {
+22 -1
View File
@@ -208,7 +208,12 @@ const Sidebar = ({
<h3>Tags</h3>
<span className="meta">{tags.length}</span>
</div>
<div className="sidebar-tag-cloud" role="list">
<div
className={`sidebar-tag-cloud${
activeTagSet.size ? ' sidebar-tag-cloud--has-active' : ''
}`}
role="list"
>
{tags.length ? (
tags.map((tag) => {
const isActive = activeTagSet.has(tag.id);
@@ -223,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>
+12 -2
View File
@@ -993,7 +993,8 @@ button.icon-button.ghost:hover:not([disabled]) {
cursor: pointer;
background: var(--surface-soft);
color: var(--fg);
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease,
opacity 0.12s ease;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
}
@@ -1010,7 +1011,11 @@ button.icon-button.ghost:hover:not([disabled]) {
.sidebar-tag-pill.active {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
box-shadow: 0 0 0 1.5px var(--accent);
}
.sidebar-tag-cloud--has-active .sidebar-tag-pill:not(.active) {
opacity: 0.45;
}
.sidebar-correspondent-list {
@@ -1250,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;