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>
);
};
+22 -1
View File
@@ -364,6 +364,13 @@ const AppLayout = () => {
const [currentSubfolders, setCurrentSubfolders] = useState([]);
const [documents, setDocuments] = useState([]);
const [workspaceMode, setWorkspaceMode] = useState('table');
const [documentsViewMode, setDocumentsViewMode] = useState(() => {
if (typeof window === 'undefined') {
return 'list';
}
const stored = window.localStorage.getItem('papercrate_view_mode');
return stored === 'grid' ? 'grid' : 'list';
});
const initialRowSelection = routeDocumentId
? [resolveDocumentRowKey(routeDocumentId)]
: [];
@@ -2514,7 +2521,10 @@ const AppLayout = () => {
if (item.type === 'document') {
const doc = item.payload;
const rowEl = doc?.id ? document.getElementById(`document-row-${doc.id}`) : null;
const rowEl = doc?.id
? document.getElementById(`document-row-${doc.id}`) ||
document.getElementById(`document-card-${doc.id}`)
: null;
const thumbnailEl = rowEl?.querySelector('.document-thumbnail');
const placeholderEl = rowEl?.querySelector('.thumb-placeholder');
@@ -4455,6 +4465,15 @@ const AppLayout = () => {
const showSkeuoWorkspace = useCallback(() => setWorkspaceMode('skeuo'), [setWorkspaceMode]);
const exitSkeuoWorkspace = useCallback(() => setWorkspaceMode('table'), [setWorkspaceMode]);
const handleDocumentsViewModeChange = useCallback((mode) => {
setDocumentsViewMode((previous) => {
const next = mode === 'grid' ? 'grid' : 'list';
if (next !== previous && typeof window !== 'undefined') {
window.localStorage.setItem('papercrate_view_mode', next);
}
return next;
});
}, []);
const documentsTableProps = {
currentFolderName,
@@ -4501,6 +4520,8 @@ const AppLayout = () => {
: null,
onTagClick: toggleTagFilter,
onDocumentTagDrop: handleDocumentTagDrop,
viewMode: documentsViewMode,
onViewModeChange: handleDocumentsViewModeChange,
};
const detailPanelProps = {
+191 -4
View File
@@ -200,6 +200,47 @@ button.icon-button.ghost:hover:not([disabled]) {
fill: currentColor;
}
.view-toggle {
display: inline-flex;
align-items: center;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface-subtle);
margin-right: 0.5rem;
overflow: hidden;
}
.view-toggle__button {
width: 2.5rem;
height: 2.2rem;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--muted);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
padding: 0;
}
.view-toggle__button:hover,
.view-toggle__button:focus-visible {
background: transparent;
color: var(--fg);
outline: none;
}
.view-toggle__button.active {
background: var(--accent-soft, rgba(63, 106, 216, 0.16));
color: var(--accent);
}
.view-toggle__icon {
width: 1.4rem;
height: 1.4rem;
}
.app-shell {
height: 100%;
display: flex;
@@ -1132,6 +1173,10 @@ button.icon-button.ghost:hover:not([disabled]) {
text-align: left;
}
.documents-panel--view-grid .documents-scroll {
padding: 0.35rem 0.5rem 1rem;
}
.documents-panel th.thumb-column,
.documents-panel td.thumb-cell {
width: 54px;
@@ -1141,19 +1186,31 @@ button.icon-button.ghost:hover:not([disabled]) {
.document-thumbnail-wrapper {
width: 48px;
height: 48px;
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
}
.documents-panel--view-grid .document-thumbnail-wrapper {
width: 128px;
height: 128px;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
overflow: hidden;
}
.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);
}
.documents-panel--view-grid .document-thumbnail {
box-shadow: 0 1px 12px var(--shadow-medium);
background: transparent;
}
.thumb-placeholder {
@@ -1171,6 +1228,14 @@ button.icon-button.ghost:hover:not([disabled]) {
box-shadow: 0 1px 3px var(--shadow-medium);
}
.documents-panel--view-grid .thumb-placeholder {
font-size: 1.1rem;
letter-spacing: 0.12em;
display: inline-flex;
width: 100%;
height: 100%;
}
.thumb-icon {
width: 100%;
height: 100%;
@@ -1241,6 +1306,107 @@ button.icon-button.ghost:hover:not([disabled]) {
gap: 0.25rem;
}
.documents-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
gap: 0.2rem 1.3rem;
padding: 0;
}
.document-card {
padding: 0rem;
display: flex;
flex-direction: column;
gap: 0.1rem;
cursor: pointer;
align-items: center;
text-align: center;
}
.document-card.selected .document-thumbnail-wrapper {
background-color: var(--accent-soft);
}
.document-card:hover .document-thumbnail-wrapper,
.document-card:focus-visible .document-thumbnail-wrapper {
background-color: var(--selection-ring);
}
.document-card.is-dragging {
opacity: 0.55;
}
.document-card__meta {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
width: 100%;
}
.document-card__title {
color: var(--fg);
padding: 0.2rem 0.7rem;
border-radius: 1rem;
}
.document-card.selected .document-card__title {
background-color: var(--accent);
color: var(--on-accent);
}
.document-card__subtitle {
font-size: 0.78rem;
color: var(--muted);
}
.document-card__tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.folder-card {
align-items: center;
text-align: center;
}
.folder-card__icon {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
aspect-ratio: 1;
color: var(--accent);
}
.folder-card__meta {
display: flex;
flex-direction: column;
gap: 0.28rem;
padding-top: 0.35rem;
}
.folder-card__name {
font-weight: 600;
font-size: 0.95rem;
color: var(--fg);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-card__badge {
display: inline-flex;
align-self: center;
padding: 0.12rem 0.45rem;
font-size: 0.72rem;
border-radius: 999px;
background: var(--surface-soft);
color: var(--muted);
border: 1px solid var(--border);
}
.doc-name__title {
font-weight: 600;
}
@@ -1260,6 +1426,15 @@ button.icon-button.ghost:hover:not([disabled]) {
box-shadow: inset 0 0 0 2px var(--accent);
}
.document-card.tag-drop-target {
box-shadow: 0 0 0 2px var(--accent);
border-color: var(--accent);
}
.document-card.tag-drop-target .document-card__title {
color: var(--accent);
}
.filter-bar {
display: flex;
flex-wrap: wrap;
@@ -1297,6 +1472,14 @@ button.icon-button.ghost:hover:not([disabled]) {
.tag-chip {
gap: 0.25rem;
border-radius: 1rem;
padding: 0.2rem 0.4rem;
}
.tag-chip--more {
background: transparent;
border-color: var(--border-strong, rgba(15, 23, 42, 0.24));
color: var(--muted);
}
.empty-state {
@@ -1309,6 +1492,10 @@ button.icon-button.ghost:hover:not([disabled]) {
background: var(--surface-subtle);
}
.empty-state--global {
border-style: solid;
}
.detail-panel {
position: relative;
padding: 1.25rem;
+22
View File
@@ -6,6 +6,8 @@ import {
IconTagFilled,
IconUserFilled,
IconTrash,
IconLayoutList,
IconLayoutGrid,
} from '@tabler/icons-react';
const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base);
@@ -73,6 +75,24 @@ export const DownloadIcon = ({ className, size = '1em', stroke = 1.6, ...rest })
/>
);
export const ViewListIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconLayoutList
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const ViewGridIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconLayoutGrid
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export default {
ChevronIcon,
TrashIcon,
@@ -81,4 +101,6 @@ export default {
TagIcon,
CorrespondentIcon,
DownloadIcon,
ViewListIcon,
ViewGridIcon,
};