From e19cd6e8ba72eef6e4fec000b3baec1a2521b9c9 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 11 Jun 2026 16:03:15 +0000 Subject: [PATCH] =?UTF-8?q?Add=20multi-select=20to=20Lumi=C3=A8re=20projec?= =?UTF-8?q?t=20card=20grid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each project card now has a checkbox (top-left corner, semi-transparent by default, fully opaque on hover). When any card is selected a selection bar slides in above the grid showing: select-all checkbox, count, the existing bulk-action toolbar (archive, trash, tags, delete), and a deselect-all button. :has(input:checked) keeps all checkboxes visible once a selection is active. Co-Authored-By: Claude Sonnet 4.6 --- .../components/project-list-lumiere.tsx | 106 ++++++++++++++---- .../pages/project-list-lumiere.scss | 89 +++++++++++++++ services/web/locales/en.json | 3 + 3 files changed, 174 insertions(+), 24 deletions(-) diff --git a/services/web/frontend/js/features/project-list/components/project-list-lumiere.tsx b/services/web/frontend/js/features/project-list/components/project-list-lumiere.tsx index 6c79e9ef12..ccd47ceb81 100644 --- a/services/web/frontend/js/features/project-list/components/project-list-lumiere.tsx +++ b/services/web/frontend/js/features/project-list/components/project-list-lumiere.tsx @@ -1,4 +1,4 @@ -import { memo } from 'react' +import { memo, useCallback, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useProjectListContext } from '../context/project-list-context' import { Project } from '../../../../../types/project/dashboard/api' @@ -17,6 +17,9 @@ import NewProjectButton from './new-project-button' import ProjectListTitle from './title/project-list-title' import LoadMore from './load-more' import DashApiError from './dash-api-error' +import { ProjectCheckbox } from './table/project-checkbox' +import ProjectTools from './table/project-tools/project-tools' +import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox' type FormatVariant = 'latex' | 'typst' | 'quarto' | 'quarto-slides' @@ -55,31 +58,36 @@ const ProjectCard = memo(function ProjectCard({ const initial = project.name.charAt(0).toUpperCase() || '?' return ( - -
- {initial} +
+
+
-
- {project.name} - - +
+ {project.name} +
+ + {getFormatLabel(variant)} + + {ownerName && ( + + {ownerName} + + )} +
+ {date} +
+ +
) }) @@ -95,9 +103,33 @@ export function ProjectListLumiere() { filter, tags, selectedTagId, + selectedProjects, + selectOrUnselectAllProjects, } = useProjectListContext() const selectedTag = tags.find(tag => tag._id === selectedTagId) + const allSelected = + visibleProjects.length > 0 && + selectedProjects.length === visibleProjects.length + + const checkAllRef = useRef(null) + useEffect(() => { + if (checkAllRef.current) { + checkAllRef.current.indeterminate = + selectedProjects.length > 0 && !allSelected + } + }, [selectedProjects, allSelected]) + + const handleSelectAll = useCallback( + (e: React.ChangeEvent) => { + selectOrUnselectAllProjects(e.target.checked) + }, + [selectOrUnselectAllProjects] + ) + + const handleDeselectAll = useCallback(() => { + selectOrUnselectAllProjects(false) + }, [selectOrUnselectAllProjects]) return ( // Keep project-ds-nav-page + website-redesign so the sidebar and navbar @@ -138,6 +170,32 @@ export function ProjectListLumiere() { />
+ {selectedProjects.length > 0 && ( +
+
+ + + {t('n_projects_selected', { + count: selectedProjects.length, + })} + +
+ + +
+ )} {visibleProjects.length === 0 ? (

{t('no_projects')}

) : ( diff --git a/services/web/frontend/stylesheets/pages/project-list-lumiere.scss b/services/web/frontend/stylesheets/pages/project-list-lumiere.scss index 97b93ee81b..bc47a47dbd 100644 --- a/services/web/frontend/stylesheets/pages/project-list-lumiere.scss +++ b/services/web/frontend/stylesheets/pages/project-list-lumiere.scss @@ -331,6 +331,51 @@ $lum-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' wi margin-top: 2rem; } + // ── Selection bar (appears when projects are selected) ──────────────────── + + .lumiere-selection-bar { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 1rem; + margin-bottom: 1rem; + background: rgba($lum-teal, 0.08); + border: 1px solid rgba($lum-teal, 0.22); + border-radius: 10px; + flex-wrap: wrap; + } + + .lumiere-selection-bar-left { + display: flex; + align-items: center; + gap: 0.6rem; + flex-shrink: 0; + } + + .lumiere-selection-count { + font-size: 0.875rem; + font-weight: 600; + color: $lum-teal; + white-space: nowrap; + } + + .lumiere-selection-deselect { + margin-left: auto; + font-size: 0.8rem; + color: $lum-text-sub; + background: none; + border: none; + padding: 0.2rem 0.4rem; + cursor: pointer; + border-radius: 4px; + flex-shrink: 0; + + &:hover { + color: $lum-text; + background: rgba(0, 0, 0, 0.06); + } + } + // ── Card grid ───────────────────────────────────────────────────────────── .lumiere-card-grid { @@ -340,6 +385,50 @@ $lum-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' wi margin-top: 0.5rem; } + // ── Card wrapper (checkbox + link) ──────────────────────────────────────── + + .lumiere-card-wrapper { + position: relative; + } + + // Checkbox overlay: top-left corner of the card, low opacity by default. + // Becomes fully opaque on hover, on focus, and for all cards when any is + // selected (:has(input:checked) on the grid). + .lumiere-card-checkbox { + position: absolute; + top: 8px; + left: 8px; + z-index: 2; + opacity: 0.35; + transition: opacity 0.15s ease; + + input[type='checkbox'] { + width: 16px; + height: 16px; + cursor: pointer; + accent-color: $lum-teal; + } + } + + .lumiere-card-wrapper:hover .lumiere-card-checkbox, + .lumiere-card-wrapper:focus-within .lumiere-card-checkbox { + opacity: 1; + } + + // When any card in the grid is checked, show all checkboxes fully + .lumiere-card-grid:has(input[type='checkbox']:checked) .lumiere-card-checkbox { + opacity: 1; + } + + // Selected card: teal border + tint + .lumiere-card-wrapper:has(input[type='checkbox']:checked) .lumiere-card { + border-color: rgba($lum-teal, 0.5); + background: rgba($lum-teal, 0.06); + box-shadow: + 0 0 0 2px rgba($lum-teal, 0.18), + 0 2px 8px rgba(0, 0, 0, 0.07); + } + // ── Individual card ─────────────────────────────────────────────────────── .lumiere-card { diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 20853e4e4b..d593d736bb 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -625,6 +625,7 @@ "demonstrating_track_changes_feature": "Demonstrating Track Changes feature", "department": "Department", "descending": "Descending", + "deselect_all": "Deselect all", "description": "Description", "details": "Details", "details_provided_by_google_explanation": "Your details were provided by your Google account. Please check you’re happy with them.", @@ -1725,6 +1726,8 @@ "no_planned_maintenance": "There is currently no planned maintenance", "no_preview_available": "Sorry, no preview is available.", "no_project_notifications_description": "You won’t be notified about this project.", + "n_projects_selected": "__count__ project selected", + "n_projects_selected_plural": "__count__ projects selected", "no_projects": "No projects", "no_resolved_comments": "No resolved comments", "no_search_results": "No Search Results",