3 Commits
Author SHA1 Message Date
nils 43d27cfd27 thumbnails
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 18s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 45s
2025-10-15 16:56:34 +02:00
nils 2d2e046c9d dragging 2025-10-15 16:50:45 +02:00
nils abee49a335 fixes 2025-10-15 16:16:37 +02:00
3 changed files with 240 additions and 73 deletions
+8 -4
View File
@@ -67,17 +67,21 @@ const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, al
[document, ensureAssetUrl, getDocumentAsset], [document, ensureAssetUrl, getDocumentAsset],
); );
if (url) {
return ( return (
<div className="document-thumbnail-wrapper">
{url ? (
<img <img
src={url} src={url}
alt={alt || ''} alt={alt || ''}
className="document-thumbnail" className="document-thumbnail"
draggable={false}
onDragStart={(event) => event.preventDefault()}
/> />
) : (
<div className="thumb-placeholder">DOC</div>
)}
</div>
); );
}
return <div className="thumb-placeholder">DOC</div>;
}; };
const DocumentsTable = ({ const DocumentsTable = ({
+143 -55
View File
@@ -859,9 +859,6 @@ const DetailPanel = ({
return ( return (
<aside className="detail-panel column"> <aside className="detail-panel column">
<div className="column-header">
<h2>Details</h2>
</div>
<div className="column-body scrollable"> <div className="column-body scrollable">
{selectedCount <= 1 ? renderSingle() : renderBulk()} {selectedCount <= 1 ? renderSingle() : renderBulk()}
</div> </div>
@@ -1876,6 +1873,23 @@ const AppLayout = () => {
[visibleDocuments], [visibleDocuments],
); );
const documentLookup = useMemo(() => {
const map = new Map();
const push = (items) => {
(items || []).forEach((doc) => {
if (doc?.id) {
map.set(doc.id, doc);
}
});
};
push(documents);
if (Array.isArray(searchResults)) {
push(searchResults);
}
return map;
}, [documents, searchResults]);
const applySelection = useCallback( const applySelection = useCallback(
(ids, { anchor, interactedIds = [] } = {}) => { (ids, { anchor, interactedIds = [] } = {}) => {
const visibleSet = new Set(visibleDocumentIds); const visibleSet = new Set(visibleDocumentIds);
@@ -2104,40 +2118,6 @@ const AppLayout = () => {
}, [focusedRowKey, navigableRowKeys, focusedDocumentId]); }, [focusedRowKey, navigableRowKeys, focusedDocumentId]);
const handleDocumentDragStart = useCallback(
(event, documentId) => {
const selection = selectedDocumentIds.includes(documentId)
? selectedDocumentIds
: [documentId];
if (!selectedDocumentIds.includes(documentId)) {
applySelection([documentId], {
anchor: documentId,
interactedIds: [documentId],
});
}
setDraggedDocumentIds(selection);
event.dataTransfer.effectAllowed = 'move';
try {
event.dataTransfer.setData(
'application/x-papercrate-doc-list',
JSON.stringify(selection),
);
} catch (
// eslint-disable-next-line no-empty
error
) {}
event.currentTarget.classList.add('dragging');
},
[selectedDocumentIds, applySelection],
);
const handleDocumentDragEnd = useCallback((event) => {
setDraggedDocumentIds([]);
event.currentTarget.classList.remove('dragging');
}, []);
const ensureFolderData = useCallback( const ensureFolderData = useCallback(
async (folderId, { force = false } = {}) => { async (folderId, { force = false } = {}) => {
if (!force && folderContents.has(folderId)) { if (!force && folderContents.has(folderId)) {
@@ -3263,6 +3243,131 @@ const AppLayout = () => {
], ],
); );
const dragPreviewRef = useRef(null);
const destroyDragPreview = useCallback(() => {
const node = dragPreviewRef.current;
if (node && node.parentNode) {
node.parentNode.removeChild(node);
}
dragPreviewRef.current = null;
}, []);
useEffect(() => destroyDragPreview, [destroyDragPreview]);
const createDragPreview = useCallback(
(documentsForPreview) => {
destroyDragPreview();
const docs = (documentsForPreview || []).filter(Boolean);
if (!docs.length) {
return null;
}
const maxVisible = 4;
const size = 64;
const canvasSize = Math.round(size * 1.6);
const visibleDocs = docs.slice(0, maxVisible);
const wrapper = document.createElement('div');
wrapper.className = 'document-drag-preview';
wrapper.style.setProperty('--drag-preview-size', `${canvasSize}px`);
visibleDocs.forEach((doc, index) => {
const layer = document.createElement('div');
layer.className = 'document-drag-preview__thumb';
const rotationMagnitude = Math.random() * 8 + 2; // 2..10 degrees
const rotation = (index % 2 === 0 ? 1 : -1) * rotationMagnitude;
layer.style.setProperty('--rotation-deg', `${rotation}deg`);
const rowEl = doc?.id ? document.getElementById(`document-row-${doc.id}`) : null;
const thumbnailEl = rowEl?.querySelector('.document-thumbnail');
const placeholderEl = rowEl?.querySelector('.thumb-placeholder');
let content = null;
if (thumbnailEl instanceof HTMLImageElement) {
content = thumbnailEl.cloneNode(true);
content.draggable = false;
content.style.pointerEvents = 'none';
} else if (placeholderEl instanceof HTMLElement) {
content = placeholderEl.cloneNode(true);
content.style.pointerEvents = 'none';
}
if (content) {
layer.appendChild(content);
} else {
layer.textContent = 'DOC';
}
wrapper.appendChild(layer);
});
if (docs.length > 1) {
const badge = document.createElement('div');
badge.className = 'document-drag-preview__count';
badge.textContent = docs.length > maxVisible ? `+${docs.length - maxVisible}` : `${docs.length}`;
wrapper.appendChild(badge);
}
document.body.appendChild(wrapper);
dragPreviewRef.current = wrapper;
return wrapper;
},
[destroyDragPreview],
);
const handleDocumentDragStart = useCallback(
(event, documentOrId) => {
const documentId = typeof documentOrId === 'string' ? documentOrId : documentOrId?.id;
if (!documentId) {
return;
}
const isAlreadySelected = selectedDocumentIds.includes(documentId);
const selection = isAlreadySelected ? [...selectedDocumentIds] : [documentId];
if (!isAlreadySelected) {
applySelection([documentId], {
anchor: documentId,
interactedIds: [documentId],
});
}
const previewDocs = selection
.map((id) => documentLookup.get(id) || null)
.filter(Boolean);
const previewNode = createDragPreview(previewDocs);
setDraggedDocumentIds(selection);
event.dataTransfer.effectAllowed = 'move';
try {
event.dataTransfer.setData(
'application/x-papercrate-doc-list',
JSON.stringify(selection),
);
} catch (
// eslint-disable-next-line no-empty
error
) {}
if (previewNode) {
const width = previewNode.offsetWidth || 96;
const height = previewNode.offsetHeight || 96;
event.dataTransfer.setDragImage(previewNode, width / 2, height / 2);
}
event.currentTarget.classList.add('dragging');
},
[selectedDocumentIds, applySelection, documentLookup, createDragPreview],
);
const handleDocumentDragEnd = useCallback(
(event) => {
setDraggedDocumentIds([]);
event.currentTarget.classList.remove('dragging');
destroyDragPreview();
},
[destroyDragPreview],
);
const ensurePreviewUrl = useCallback( const ensurePreviewUrl = useCallback(
async (documentId, { force = false } = {}) => { async (documentId, { force = false } = {}) => {
if (!documentId) return null; if (!documentId) return null;
@@ -3509,7 +3614,7 @@ const AppLayout = () => {
setLoading(true); setLoading(true);
try { try {
if (unique.length === 1) { if (unique.length === 1) {
await api.patch(`/documents/${unique[0]}/folder`, { folder_id: target }); await api.patch(`/documents/${unique[0].id}/folder`, { folder_id: target });
} else { } else {
await api.post('/documents/bulk/move', { await api.post('/documents/bulk/move', {
document_ids: unique, document_ids: unique,
@@ -4597,23 +4702,6 @@ const AppLayout = () => {
return list.find((doc) => doc.id === focusedDocumentId) || null; return list.find((doc) => doc.id === focusedDocumentId) || null;
}, [searchResults, documents, focusedDocumentId]); }, [searchResults, documents, focusedDocumentId]);
const documentLookup = useMemo(() => {
const map = new Map();
documents.forEach((doc) => {
if (doc?.id) {
map.set(doc.id, doc);
}
});
if (Array.isArray(searchResults)) {
searchResults.forEach((doc) => {
if (doc?.id) {
map.set(doc.id, doc);
}
});
}
return map;
}, [documents, searchResults]);
const handleDocumentDelete = useCallback( const handleDocumentDelete = useCallback(
async (documentId) => { async (documentId) => {
if (!documentId) return; if (!documentId) return;
+82 -7
View File
@@ -313,6 +313,69 @@ button.icon-button.ghost:hover:not([disabled]) {
color: var(--muted); color: var(--muted);
} }
.document-drag-preview {
position: fixed;
pointer-events: none;
top: -9999px;
left: -9999px;
width: var(--drag-preview-size, 96px);
height: var(--drag-preview-size, 96px);
z-index: 9999;
}
.document-drag-preview__thumb {
position: absolute;
top: 50%;
left: 50%;
width: 64px;
height: 64px;
border-radius: 6px;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.25);
overflow: hidden;
background-color: rgba(32, 34, 40, 0.2);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
transform: translate(-50%, -50%) rotate(var(--rotation-deg, 0deg));
transform-origin: center;
}
.document-drag-preview__thumb .document-thumbnail,
.document-drag-preview__thumb img {
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
}
.document-drag-preview__thumb .thumb-placeholder,
.document-drag-preview__thumb .thumb-placeholder * {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.document-drag-preview__count {
position: absolute;
bottom: 4px;
right: 4px;
background-color: var(--accent);
color: #fff;
border-radius: 999px;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 600;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.25);
pointer-events: none;
}
.preview-workspace__actions { .preview-workspace__actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -856,18 +919,29 @@ button.icon-button.ghost:hover:not([disabled]) {
text-align: center; text-align: center;
} }
.document-thumbnail { .document-thumbnail-wrapper {
width: 36px; width: 48px;
height: 48px; height: 48px;
object-fit: cover; background: transparent;
border-radius: 3px; display: inline-flex;
align-items: center;
justify-content: center;
}
.document-thumbnail {
max-width: 100%;
max-height: 100%;
object-fit: contain;
background: var(--surface-subtle);
border-radius: 1px;
box-shadow: 0 1px 3px var(--shadow-medium); box-shadow: 0 1px 3px var(--shadow-medium);
} }
.thumb-placeholder { .thumb-placeholder {
width: 36px; width: 100%;
height: 48px; height: 100%;
border-radius: 1px; border-radius: 1px;
background: var(--surface-subtle);
color: var(--muted); color: var(--muted);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -875,6 +949,7 @@ button.icon-button.ghost:hover:not([disabled]) {
font-size: 0.68rem; font-size: 0.68rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.08em; letter-spacing: 0.08em;
box-shadow: 0 1px 3px var(--shadow-medium);
} }
.thumb-placeholder.icon { .thumb-placeholder.icon {
@@ -1144,7 +1219,7 @@ button.icon-button.ghost:hover:not([disabled]) {
.preview-stack--stacked { .preview-stack--stacked {
width: 100%; width: 100%;
min-height: 420px; min-height: 320px;
flex-shrink: 0; flex-shrink: 0;
} }