grid view
ci / docker (backend, backend/Dockerfile, backend) (push) Has been cancelled
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Has started running

This commit is contained in:
2025-10-16 16:03:51 +02:00
parent 3019172b88
commit 41f17733a5
4 changed files with 445 additions and 9 deletions
+210 -4
View File
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { resolveDocumentAssetUrl } from '../asset_manager';
import { getTagColorStyle } from '../utils/colors';
import { DownloadIcon, FolderIcon, EditIcon } from '../ui/icons';
import { DownloadIcon, FolderIcon, EditIcon, ViewListIcon, ViewGridIcon } from '../ui/icons';
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
@@ -74,6 +74,8 @@ const DocumentsTable = ({
onTagClick,
isSearchLoading = false,
onDocumentTagDrop,
viewMode = 'list',
onViewModeChange,
}) => {
const showingSearchResults = searchResults !== null;
const rows = showingSearchResults ? searchResults : documents;
@@ -91,6 +93,19 @@ const DocumentsTable = ({
[draggingDocumentIds],
);
const scrollRef = useRef(null);
const isGridView = viewMode === 'grid';
const handleSetViewMode = useCallback(
(nextMode) => {
if (!onViewModeChange) {
return;
}
onViewModeChange(nextMode);
if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
},
[onViewModeChange],
);
const isTagDragEvent = useCallback((event) => {
const types = Array.from(event.dataTransfer?.types || []);
return TAG_MIME_TYPES.some((type) => types.includes(type));
@@ -199,7 +214,9 @@ const DocumentsTable = ({
);
return (
<section className="documents-panel column">
<section
className={`documents-panel column documents-panel--view-${isGridView ? 'grid' : 'list'}`}
>
<div className="column-header">
<div className="column-header__titles">
<nav className="breadcrumb" aria-label="Folder breadcrumbs">
@@ -230,6 +247,26 @@ const DocumentsTable = ({
)}
</div>
<div className="header-actions">
<div className="view-toggle" role="group" aria-label="Change view">
<button
type="button"
className={`view-toggle__button${isGridView ? '' : ' active'}`}
onClick={() => handleSetViewMode('list')}
aria-pressed={!isGridView}
title="List view"
>
<ViewListIcon className="view-toggle__icon" size={18} />
</button>
<button
type="button"
className={`view-toggle__button${isGridView ? ' active' : ''}`}
onClick={() => handleSetViewMode('grid')}
aria-pressed={isGridView}
title="Icons view"
>
<ViewGridIcon className="view-toggle__icon" size={18} />
</button>
</div>
<button
type="button"
onClick={onRequestCreateFolder}
@@ -263,12 +300,176 @@ const DocumentsTable = ({
onDocumentListKeyDown(event);
}
}}
aria-activedescendant={activeDescendantId}
aria-activedescendant={isGridView ? undefined : activeDescendantId}
>
{!showingSearchResults && !subfolders.length && rows.length === 0 ? (
<div className="empty-state">
Drop files anywhere or onto a folder to upload documents.
</div>
) : isGridView ? (
<div className="documents-grid" role="list">
{!showingSearchResults &&
subfolders.map((folder) => {
const canDragFolder = folder.id !== 'root';
const isDraggingFolder = draggedFolderId === folder.id;
const isSelectedFolder = selectedFolderSet.has(folder.id);
const classes = ['document-card', 'folder-card'];
if (isDraggingFolder) classes.push('is-dragging');
if (isSelectedFolder) classes.push('selected');
return (
<div
key={folder.id}
className={classes.join(' ')}
role="listitem"
id={`folder-card-${folder.id}`}
draggable={canDragFolder}
onClick={(event) => {
onFolderRowClick?.(folder.id, event);
const shouldNavigate =
!event.defaultPrevented &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey;
if (shouldNavigate) {
onFolderSelect(folder.id);
}
}}
onDoubleClick={(event) => {
event.preventDefault();
onFolderSelect(folder.id);
}}
onDragOver={(event) => onFolderDragOver(event, folder.id)}
onDragLeave={onFolderDragLeave}
onDrop={(event) => onFolderDrop(event, folder.id)}
onDragStart={(event) => {
if (canDragFolder) {
onFolderDragStart(event, folder.id);
}
}}
onDragEnd={(event) => {
if (canDragFolder) {
onFolderDragEnd(event);
}
}}
>
<div className="folder-card__icon">
<FolderIcon className="icon-inline" size={32} />
</div>
<div className="folder-card__meta">
<div className="folder-card__name" title={folder.name}>
{folder.name}
</div>
<div className="folder-card__badge">Folder</div>
</div>
</div>
);
})}
{rows.map((doc) => {
const isSelected = selectedSet.has(doc.id);
const isDraggingDoc = draggingSet.has(doc.id);
const tagList = Array.isArray(doc.tags) ? doc.tags : [];
const visibleTags = tagList.slice(0, 3);
const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0;
const cardClasses = ['document-card', 'document'];
if (isSelected) cardClasses.push('selected');
if (isDraggingDoc) cardClasses.push('is-dragging');
return (
<div
key={doc.id}
className={cardClasses.join(' ')}
role="listitem"
id={`document-card-${doc.id}`}
onClick={(event) => onDocumentRowClick(doc.id, event)}
onDoubleClick={() => onDocumentOpen(doc.id)}
draggable
onDragStart={(event) => onDocumentDragStart(event, doc)}
onDragEnd={onDocumentDragEnd}
onDragOver={(event) => handleDocumentTagDragOver(event)}
onDragOverCapture={(event) => handleDocumentTagDragOver(event)}
onDragLeave={handleDocumentTagDragLeave}
onDragLeaveCapture={handleDocumentTagDragLeave}
onDrop={(event) => handleDocumentTagDrop(event, doc.id)}
onDropCapture={(event) => handleDocumentTagDrop(event, doc.id)}
>
<DocumentThumbnailImage
document={doc}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
alt={`Thumbnail for ${doc.title || doc.original_name}`}
/>
<div className="document-card__meta">
<div
className="document-card__title"
title={doc.title || doc.original_name}
>
{doc.title || doc.original_name}
</div>
{visibleTags.length > 0 && (
<div className="document-card__tags">
{visibleTags.map((tag) => {
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
const style = getTagColorStyle(colorSource);
return (
<span
key={tag.id}
className="badge tag-chip"
style={style || undefined}
title={tag.label}
onClick={(event) => {
event.stopPropagation();
if (onTagClick) {
onTagClick(tag.id);
}
}}
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();
event.stopPropagation();
if (onTagClick) {
onTagClick(tag.id);
}
}
}}
>
{tag.label}
</span>
);
})}
{remainingTagCount > 0 && (
<span className="badge tag-chip tag-chip--more">+{remainingTagCount}</span>
)}
</div>
)}
</div>
</div>
);
})}
</div>
) : (
<table aria-multiselectable="true">
<thead>
@@ -539,7 +740,7 @@ const DocumentsTable = ({
</table>
)}
</div>
{rows.length === 0 && showingSearchResults && !isSearchLoading && (
{rows.length === 0 && showingSearchResults && !isGridView && !isSearchLoading && (
<div className="empty-state">No documents match the current filters.</div>
)}
{showingSearchResults && rows.length > 0 && (
@@ -548,6 +749,11 @@ const DocumentsTable = ({
</div>
)}
</div>
{isGridView && showingSearchResults && rows.length === 0 && !isSearchLoading && (
<div className="empty-state empty-state--global">
No documents match the current filters.
</div>
)}
</section>
);
};