Add multi-select to Lumière project card grid
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
484a2e3aac
commit
e19cd6e8ba
+82
-24
@@ -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 (
|
||||
<a
|
||||
href={`/project/${project.id}`}
|
||||
className={`lumiere-card lumiere-card--${variant}`}
|
||||
translate="no"
|
||||
>
|
||||
<div className="lumiere-card-thumb">
|
||||
<span className="lumiere-card-initial">{initial}</span>
|
||||
<div className="lumiere-card-wrapper">
|
||||
<div className="lumiere-card-checkbox">
|
||||
<ProjectCheckbox projectId={project.id} projectName={project.name} />
|
||||
</div>
|
||||
<div className="lumiere-card-body">
|
||||
<span className="lumiere-card-name">{project.name}</span>
|
||||
<div className="lumiere-card-meta">
|
||||
<span
|
||||
className={`lumiere-format-badge lumiere-format-badge--${variant}`}
|
||||
>
|
||||
{getFormatLabel(variant)}
|
||||
</span>
|
||||
{ownerName && (
|
||||
<span className="lumiere-card-owner" translate="yes">
|
||||
{ownerName}
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href={`/project/${project.id}`}
|
||||
className={`lumiere-card lumiere-card--${variant}`}
|
||||
translate="no"
|
||||
>
|
||||
<div className="lumiere-card-thumb">
|
||||
<span className="lumiere-card-initial">{initial}</span>
|
||||
</div>
|
||||
<span className="lumiere-card-date">{date}</span>
|
||||
</div>
|
||||
</a>
|
||||
<div className="lumiere-card-body">
|
||||
<span className="lumiere-card-name">{project.name}</span>
|
||||
<div className="lumiere-card-meta">
|
||||
<span
|
||||
className={`lumiere-format-badge lumiere-format-badge--${variant}`}
|
||||
>
|
||||
{getFormatLabel(variant)}
|
||||
</span>
|
||||
{ownerName && (
|
||||
<span className="lumiere-card-owner" translate="yes">
|
||||
{ownerName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="lumiere-card-date">{date}</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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<HTMLInputElement>(null)
|
||||
useEffect(() => {
|
||||
if (checkAllRef.current) {
|
||||
checkAllRef.current.indeterminate =
|
||||
selectedProjects.length > 0 && !allSelected
|
||||
}
|
||||
}, [selectedProjects, allSelected])
|
||||
|
||||
const handleSelectAll = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{selectedProjects.length > 0 && (
|
||||
<div className="lumiere-selection-bar">
|
||||
<div className="lumiere-selection-bar-left">
|
||||
<OLFormCheckbox
|
||||
autoComplete="off"
|
||||
checked={allSelected}
|
||||
onChange={handleSelectAll}
|
||||
inputRef={checkAllRef}
|
||||
aria-label={t('select_all_projects')}
|
||||
/>
|
||||
<span className="lumiere-selection-count">
|
||||
{t('n_projects_selected', {
|
||||
count: selectedProjects.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<ProjectTools />
|
||||
<button
|
||||
type="button"
|
||||
className="lumiere-selection-deselect"
|
||||
onClick={handleDeselectAll}
|
||||
>
|
||||
{t('deselect_all')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{visibleProjects.length === 0 ? (
|
||||
<p className="lumiere-empty">{t('no_projects')}</p>
|
||||
) : (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user