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 React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { resolveDocumentAssetUrl } from '../asset_manager'; import { resolveDocumentAssetUrl } from '../asset_manager';
import { getTagColorStyle } from '../utils/colors'; 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']; const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
@@ -74,6 +74,8 @@ const DocumentsTable = ({
onTagClick, onTagClick,
isSearchLoading = false, isSearchLoading = false,
onDocumentTagDrop, onDocumentTagDrop,
viewMode = 'list',
onViewModeChange,
}) => { }) => {
const showingSearchResults = searchResults !== null; const showingSearchResults = searchResults !== null;
const rows = showingSearchResults ? searchResults : documents; const rows = showingSearchResults ? searchResults : documents;
@@ -91,6 +93,19 @@ const DocumentsTable = ({
[draggingDocumentIds], [draggingDocumentIds],
); );
const scrollRef = useRef(null); 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 isTagDragEvent = useCallback((event) => {
const types = Array.from(event.dataTransfer?.types || []); const types = Array.from(event.dataTransfer?.types || []);
return TAG_MIME_TYPES.some((type) => types.includes(type)); return TAG_MIME_TYPES.some((type) => types.includes(type));
@@ -199,7 +214,9 @@ const DocumentsTable = ({
); );
return ( 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">
<div className="column-header__titles"> <div className="column-header__titles">
<nav className="breadcrumb" aria-label="Folder breadcrumbs"> <nav className="breadcrumb" aria-label="Folder breadcrumbs">
@@ -230,6 +247,26 @@ const DocumentsTable = ({
)} )}
</div> </div>
<div className="header-actions"> <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 <button
type="button" type="button"
onClick={onRequestCreateFolder} onClick={onRequestCreateFolder}
@@ -263,12 +300,176 @@ const DocumentsTable = ({
onDocumentListKeyDown(event); onDocumentListKeyDown(event);
} }
}} }}
aria-activedescendant={activeDescendantId} aria-activedescendant={isGridView ? undefined : activeDescendantId}
> >
{!showingSearchResults && !subfolders.length && rows.length === 0 ? ( {!showingSearchResults && !subfolders.length && rows.length === 0 ? (
<div className="empty-state"> <div className="empty-state">
Drop files anywhere or onto a folder to upload documents. Drop files anywhere or onto a folder to upload documents.
</div> </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"> <table aria-multiselectable="true">
<thead> <thead>
@@ -539,7 +740,7 @@ const DocumentsTable = ({
</table> </table>
)} )}
</div> </div>
{rows.length === 0 && showingSearchResults && !isSearchLoading && ( {rows.length === 0 && showingSearchResults && !isGridView && !isSearchLoading && (
<div className="empty-state">No documents match the current filters.</div> <div className="empty-state">No documents match the current filters.</div>
)} )}
{showingSearchResults && rows.length > 0 && ( {showingSearchResults && rows.length > 0 && (
@@ -548,6 +749,11 @@ const DocumentsTable = ({
</div> </div>
)} )}
</div> </div>
{isGridView && showingSearchResults && rows.length === 0 && !isSearchLoading && (
<div className="empty-state empty-state--global">
No documents match the current filters.
</div>
)}
</section> </section>
); );
}; };
+22 -1
View File
@@ -364,6 +364,13 @@ const AppLayout = () => {
const [currentSubfolders, setCurrentSubfolders] = useState([]); const [currentSubfolders, setCurrentSubfolders] = useState([]);
const [documents, setDocuments] = useState([]); const [documents, setDocuments] = useState([]);
const [workspaceMode, setWorkspaceMode] = useState('table'); 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 const initialRowSelection = routeDocumentId
? [resolveDocumentRowKey(routeDocumentId)] ? [resolveDocumentRowKey(routeDocumentId)]
: []; : [];
@@ -2514,7 +2521,10 @@ const AppLayout = () => {
if (item.type === 'document') { if (item.type === 'document') {
const doc = item.payload; 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 thumbnailEl = rowEl?.querySelector('.document-thumbnail');
const placeholderEl = rowEl?.querySelector('.thumb-placeholder'); const placeholderEl = rowEl?.querySelector('.thumb-placeholder');
@@ -4455,6 +4465,15 @@ const AppLayout = () => {
const showSkeuoWorkspace = useCallback(() => setWorkspaceMode('skeuo'), [setWorkspaceMode]); const showSkeuoWorkspace = useCallback(() => setWorkspaceMode('skeuo'), [setWorkspaceMode]);
const exitSkeuoWorkspace = useCallback(() => setWorkspaceMode('table'), [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 = { const documentsTableProps = {
currentFolderName, currentFolderName,
@@ -4501,6 +4520,8 @@ const AppLayout = () => {
: null, : null,
onTagClick: toggleTagFilter, onTagClick: toggleTagFilter,
onDocumentTagDrop: handleDocumentTagDrop, onDocumentTagDrop: handleDocumentTagDrop,
viewMode: documentsViewMode,
onViewModeChange: handleDocumentsViewModeChange,
}; };
const detailPanelProps = { const detailPanelProps = {
+191 -4
View File
@@ -200,6 +200,47 @@ button.icon-button.ghost:hover:not([disabled]) {
fill: currentColor; 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 { .app-shell {
height: 100%; height: 100%;
display: flex; display: flex;
@@ -1132,6 +1173,10 @@ button.icon-button.ghost:hover:not([disabled]) {
text-align: left; text-align: left;
} }
.documents-panel--view-grid .documents-scroll {
padding: 0.35rem 0.5rem 1rem;
}
.documents-panel th.thumb-column, .documents-panel th.thumb-column,
.documents-panel td.thumb-cell { .documents-panel td.thumb-cell {
width: 54px; width: 54px;
@@ -1141,19 +1186,31 @@ button.icon-button.ghost:hover:not([disabled]) {
.document-thumbnail-wrapper { .document-thumbnail-wrapper {
width: 48px; width: 48px;
height: 48px; height: 48px;
background: transparent;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: 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 { .document-thumbnail {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
object-fit: contain; 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 { .thumb-placeholder {
@@ -1171,6 +1228,14 @@ button.icon-button.ghost:hover:not([disabled]) {
box-shadow: 0 1px 3px var(--shadow-medium); 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 { .thumb-icon {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -1241,6 +1306,107 @@ button.icon-button.ghost:hover:not([disabled]) {
gap: 0.25rem; 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 { .doc-name__title {
font-weight: 600; font-weight: 600;
} }
@@ -1260,6 +1426,15 @@ button.icon-button.ghost:hover:not([disabled]) {
box-shadow: inset 0 0 0 2px var(--accent); 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 { .filter-bar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1297,6 +1472,14 @@ button.icon-button.ghost:hover:not([disabled]) {
.tag-chip { .tag-chip {
gap: 0.25rem; 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 { .empty-state {
@@ -1309,6 +1492,10 @@ button.icon-button.ghost:hover:not([disabled]) {
background: var(--surface-subtle); background: var(--surface-subtle);
} }
.empty-state--global {
border-style: solid;
}
.detail-panel { .detail-panel {
position: relative; position: relative;
padding: 1.25rem; padding: 1.25rem;
+22
View File
@@ -6,6 +6,8 @@ import {
IconTagFilled, IconTagFilled,
IconUserFilled, IconUserFilled,
IconTrash, IconTrash,
IconLayoutList,
IconLayoutGrid,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base); 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 { export default {
ChevronIcon, ChevronIcon,
TrashIcon, TrashIcon,
@@ -81,4 +101,6 @@ export default {
TagIcon, TagIcon,
CorrespondentIcon, CorrespondentIcon,
DownloadIcon, DownloadIcon,
ViewListIcon,
ViewGridIcon,
}; };