From 41f17733a5b2aee9bb82d6bff0f8c980771d4713 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Thu, 16 Oct 2025 16:03:51 +0200 Subject: [PATCH] grid view --- frontend/src/documents/DocumentsTable.jsx | 214 +++++++++++++++++++++- frontend/src/index.jsx | 23 ++- frontend/src/styles.css | 195 +++++++++++++++++++- frontend/src/ui/icons.js | 22 +++ 4 files changed, 445 insertions(+), 9 deletions(-) diff --git a/frontend/src/documents/DocumentsTable.jsx b/frontend/src/documents/DocumentsTable.jsx index 95c190c..0e9285f 100644 --- a/frontend/src/documents/DocumentsTable.jsx +++ b/frontend/src/documents/DocumentsTable.jsx @@ -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 ( -
+
+
+ + +
- {rows.length === 0 && showingSearchResults && !isSearchLoading && ( + {rows.length === 0 && showingSearchResults && !isGridView && !isSearchLoading && (
No documents match the current filters.
)} {showingSearchResults && rows.length > 0 && ( @@ -548,6 +749,11 @@ const DocumentsTable = ({
)} + {isGridView && showingSearchResults && rows.length === 0 && !isSearchLoading && ( +
+ No documents match the current filters. +
+ )}
); }; diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index e40e1c1..7c26d12 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -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 = { diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 9ec90f3..948e902 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; diff --git a/frontend/src/ui/icons.js b/frontend/src/ui/icons.js index 568636f..5201e9a 100644 --- a/frontend/src/ui/icons.js +++ b/frontend/src/ui/icons.js @@ -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 }) => ( + +); + +export const ViewGridIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + export default { ChevronIcon, TrashIcon, @@ -81,4 +101,6 @@ export default { TagIcon, CorrespondentIcon, DownloadIcon, + ViewListIcon, + ViewGridIcon, };