Fix mobile ergonomics on Lumière project list page
Build and Deploy Verso / deploy (push) Successful in 14m58s
Build and Deploy Verso / deploy (push) Successful in 14m58s
Compact row (xs): wrap format/owner/date in a lumiere-compact-meta div that uses display:contents on desktop (preserving the 6-column grid) and switches to a 2-row grid layout on mobile — row 1: checkbox/name/actions, row 2: format·owner·date. Actions are always visible on touch devices. Filter access: add a horizontal scrollable filter-pill bar (d-md-none) to the Lumière header so users can switch between All/Yours/Shared/Archived/ Trashed on phone without needing the desktop sidebar. Also: hide zoom control on mobile (d-md-none), auto-force compact view on mobile via useIsMobile hook, fix search form min-width overflow on xs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+67
-14
@@ -1,6 +1,15 @@
|
|||||||
import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
|
import React, {
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useProjectListContext } from '../context/project-list-context'
|
import {
|
||||||
|
Filter,
|
||||||
|
useProjectListContext,
|
||||||
|
} from '../context/project-list-context'
|
||||||
import { Project } from '../../../../../types/project/dashboard/api'
|
import { Project } from '../../../../../types/project/dashboard/api'
|
||||||
import { getOwnerName } from '../util/project'
|
import { getOwnerName } from '../util/project'
|
||||||
import { fromNowDate } from '../../../utils/dates'
|
import { fromNowDate } from '../../../utils/dates'
|
||||||
@@ -35,6 +44,21 @@ import ActionsCell from './table/cells/actions-cell'
|
|||||||
import InlineTags from './table/cells/inline-tags'
|
import InlineTags from './table/cells/inline-tags'
|
||||||
import WelcomePageContent from './welcome-page-content'
|
import WelcomePageContent from './welcome-page-content'
|
||||||
|
|
||||||
|
// ── Mobile breakpoint ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function useIsMobile() {
|
||||||
|
const [mobile, setMobile] = useState(
|
||||||
|
() => window.matchMedia('(max-width: 767px)').matches
|
||||||
|
)
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia('(max-width: 767px)')
|
||||||
|
const handler = (e: MediaQueryListEvent) => setMobile(e.matches)
|
||||||
|
mq.addEventListener('change', handler)
|
||||||
|
return () => mq.removeEventListener('change', handler)
|
||||||
|
}, [])
|
||||||
|
return mobile
|
||||||
|
}
|
||||||
|
|
||||||
// ── Tile zoom ─────────────────────────────────────────────────────────────────
|
// ── Tile zoom ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type ZoomLevel = 0 | 1 | 1.35 | 1.75
|
type ZoomLevel = 0 | 1 | 1.35 | 1.75
|
||||||
@@ -209,14 +233,16 @@ const ProjectCardCompact = memo(function ProjectCardCompact({
|
|||||||
</a>
|
</a>
|
||||||
<InlineTags projectId={project.id} />
|
<InlineTags projectId={project.id} />
|
||||||
</div>
|
</div>
|
||||||
<div className="lumiere-compact-format">
|
<div className="lumiere-compact-meta">
|
||||||
<FormatCell project={project} />
|
<div className="lumiere-compact-format">
|
||||||
</div>
|
<FormatCell project={project} />
|
||||||
<div className="lumiere-compact-owner" translate="no">
|
</div>
|
||||||
<OwnerCell project={project} />
|
<div className="lumiere-compact-owner" translate="no">
|
||||||
</div>
|
<OwnerCell project={project} />
|
||||||
<div className="lumiere-compact-date">
|
</div>
|
||||||
<LastUpdatedCell project={project} />
|
<div className="lumiere-compact-date">
|
||||||
|
<LastUpdatedCell project={project} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="lumiere-compact-actions">
|
<div className="lumiere-compact-actions">
|
||||||
<ActionsCell project={project} />
|
<ActionsCell project={project} />
|
||||||
@@ -230,6 +256,7 @@ export function ProjectListLumiere() {
|
|||||||
const footerProps = getMeta('ol-footer')
|
const footerProps = getMeta('ol-footer')
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [cardScale, setCardScale] = useLumiereCardScale()
|
const [cardScale, setCardScale] = useLumiereCardScale()
|
||||||
|
const isMobile = useIsMobile()
|
||||||
const {
|
const {
|
||||||
error,
|
error,
|
||||||
visibleProjects,
|
visibleProjects,
|
||||||
@@ -241,13 +268,25 @@ export function ProjectListLumiere() {
|
|||||||
selectedTagId,
|
selectedTagId,
|
||||||
selectedProjects,
|
selectedProjects,
|
||||||
selectOrUnselectAllProjects,
|
selectOrUnselectAllProjects,
|
||||||
|
selectFilter,
|
||||||
} = useProjectListContext()
|
} = useProjectListContext()
|
||||||
|
|
||||||
|
// On mobile, always use the compact (XS) row view — cards overflow on small screens
|
||||||
|
const effectiveScale = isMobile ? 0 : cardScale
|
||||||
|
|
||||||
const selectedTag = tags.find(tag => tag._id === selectedTagId)
|
const selectedTag = tags.find(tag => tag._id === selectedTagId)
|
||||||
const allSelected =
|
const allSelected =
|
||||||
visibleProjects.length > 0 &&
|
visibleProjects.length > 0 &&
|
||||||
selectedProjects.length === visibleProjects.length
|
selectedProjects.length === visibleProjects.length
|
||||||
|
|
||||||
|
const MOBILE_FILTERS: { f: Filter; label: string }[] = [
|
||||||
|
{ f: 'all', label: t('all_projects') },
|
||||||
|
{ f: 'owned', label: t('your_projects') },
|
||||||
|
{ f: 'shared', label: t('shared_with_you') },
|
||||||
|
{ f: 'archived', label: t('archived_projects') },
|
||||||
|
{ f: 'trashed', label: t('trashed_projects') },
|
||||||
|
]
|
||||||
|
|
||||||
const checkAllRef = useRef<HTMLInputElement>(null)
|
const checkAllRef = useRef<HTMLInputElement>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (checkAllRef.current) {
|
if (checkAllRef.current) {
|
||||||
@@ -295,7 +334,7 @@ export function ProjectListLumiere() {
|
|||||||
id="lumiere-title"
|
id="lumiere-title"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="lumiere-zoom-control"
|
className="lumiere-zoom-control d-none d-md-flex"
|
||||||
role="group"
|
role="group"
|
||||||
aria-label={t('card_size')}
|
aria-label={t('card_size')}
|
||||||
>
|
>
|
||||||
@@ -312,6 +351,20 @@ export function ProjectListLumiere() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Mobile-only filter pills replacing the hidden sidebar */}
|
||||||
|
<div className="d-flex d-md-none lumiere-mobile-filters" role="group" aria-label={t('filter_projects')}>
|
||||||
|
{MOBILE_FILTERS.map(({ f, label }) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
type="button"
|
||||||
|
className={`lumiere-mobile-filter-pill${filter === f ? ' active' : ''}`}
|
||||||
|
onClick={() => selectFilter(f)}
|
||||||
|
aria-pressed={filter === f}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<div className="lumiere-header-actions">
|
<div className="lumiere-header-actions">
|
||||||
<SearchForm
|
<SearchForm
|
||||||
inputValue={searchText}
|
inputValue={searchText}
|
||||||
@@ -357,12 +410,12 @@ export function ProjectListLumiere() {
|
|||||||
<p className="lumiere-empty">{t('no_projects')}</p>
|
<p className="lumiere-empty">{t('no_projects')}</p>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={`lumiere-card-grid${cardScale === 0 ? ' lumiere-card-grid--compact' : ''}`}
|
className={`lumiere-card-grid${effectiveScale === 0 ? ' lumiere-card-grid--compact' : ''}`}
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
style={cardScale === 0 ? {} : ({ '--lum-card-scale': cardScale } as React.CSSProperties)}
|
style={effectiveScale === 0 ? {} : ({ '--lum-card-scale': effectiveScale } as React.CSSProperties)}
|
||||||
>
|
>
|
||||||
{visibleProjects.map(project =>
|
{visibleProjects.map(project =>
|
||||||
cardScale === 0 ? (
|
effectiveScale === 0 ? (
|
||||||
<ProjectCardCompact key={project.id} project={project} />
|
<ProjectCardCompact key={project.id} project={project} />
|
||||||
) : (
|
) : (
|
||||||
<ProjectCard key={project.id} project={project} />
|
<ProjectCard key={project.id} project={project} />
|
||||||
|
|||||||
@@ -341,9 +341,13 @@ $lum-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' wi
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search bar — wide enough to show the full placeholder
|
// Search bar — wide enough to show the full placeholder (relaxed on mobile)
|
||||||
form.project-search .form-control {
|
form.project-search .form-control {
|
||||||
min-width: 360px;
|
min-width: 360px;
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.lumiere-title {
|
.lumiere-title {
|
||||||
@@ -836,6 +840,12 @@ $lum-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' wi
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On desktop, the meta wrapper is invisible to grid layout — its children
|
||||||
|
// participate as direct grid items, preserving the 6-column alignment.
|
||||||
|
.lumiere-compact-meta {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
.lumiere-compact-actions {
|
.lumiere-compact-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -860,6 +870,96 @@ $lum-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' wi
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Mobile compact row — 2-line layout ────────────────────────────────────
|
||||||
|
// On small screens the 6-column grid overflows. Switch to:
|
||||||
|
// Row 1: [checkbox] [name+tags] [actions]
|
||||||
|
// Row 2: [checkbox] [format · owner · date]
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.lumiere-compact-row {
|
||||||
|
grid-template-columns: 28px 1fr auto;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
row-gap: 0.2rem;
|
||||||
|
|
||||||
|
.lumiere-compact-checkbox {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lumiere-compact-name-cell {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lumiere-compact-actions {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 1;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta wrapper becomes a flex row spanning the second line
|
||||||
|
.lumiere-compact-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
grid-column: 2 / 4;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions always visible — no hover on touch devices
|
||||||
|
@media (hover: none) {
|
||||||
|
.lumiere-compact-actions .action-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mobile filter pills (replaces hidden sidebar on xs/sm) ────────────────
|
||||||
|
|
||||||
|
.lumiere-mobile-filters {
|
||||||
|
overflow-x: auto;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.25rem 0 0.5rem;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.lumiere-mobile-filter-pill {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1.5px solid $lum-border;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
color: $lum-text-sub;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: border-color 0.15s ease, color 0.15s ease, background-color 0.15s ease;
|
||||||
|
line-height: 1.4;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: $lum-teal;
|
||||||
|
border-color: $lum-teal;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(.active),
|
||||||
|
&:focus:not(.active) {
|
||||||
|
border-color: rgba($lum-teal, 0.55);
|
||||||
|
color: $lum-teal;
|
||||||
|
background: rgba($lum-teal, 0.07);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Selection bar — tool buttons ──────────────────────────────────────────
|
// ── Selection bar — tool buttons ──────────────────────────────────────────
|
||||||
|
|
||||||
.lumiere-selection-bar {
|
.lumiere-selection-bar {
|
||||||
|
|||||||
Reference in New Issue
Block a user