Gallery: true justified layout (row-height scaling, no crop, no spacer)
ci / docker (push) Successful in 15s

This commit is contained in:
2026-07-17 14:12:57 +02:00
parent f0238fc625
commit 9be3609cea
2 changed files with 82 additions and 45 deletions
+76 -32
View File
@@ -1,42 +1,86 @@
import { useEffect, useRef, useState } from 'react'
import { imgUrl } from '../api' import { imgUrl } from '../api'
// Justified gallery: rows are built with flexbox, each tile's flex-grow is // True justified layout: pack photos greedily into rows at their real aspect
// proportional to its aspect ratio so rows fill the container edge to edge. // ratios, then scale each row's height so it fills the container width
// When `selected`/`onToggleSelect` are provided, tiles get a select checkmark; // exactly. No cropping, no stretch, and the last row simply renders at the
// selection state lives in the parent so it survives lightbox open/close. // target height instead of being padded by a spacer.
function layoutRows(photos, containerWidth, targetHeight, gap) {
const rows = []
let row = []
let arSum = 0
let index = 0
for (const photo of photos) {
const ar = photo.width && photo.height ? photo.width / photo.height : 1.5
row.push({ photo, ar, index: index++ })
arSum += ar
const gaps = (row.length - 1) * gap
if (arSum * targetHeight + gaps >= containerWidth) {
rows.push({ items: row, height: (containerWidth - gaps) / arSum })
row = []
arSum = 0
}
}
if (row.length > 0) {
const gaps = (row.length - 1) * gap
rows.push({
items: row,
height: Math.min(targetHeight, (containerWidth - gaps) / arSum),
})
}
return rows
}
export default function Gallery({ photos, onOpen, overlay, selected, onToggleSelect }) { export default function Gallery({ photos, onOpen, overlay, selected, onToggleSelect }) {
const containerRef = useRef(null)
const [width, setWidth] = useState(0)
useEffect(() => {
const el = containerRef.current
if (!el) return
const observer = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width))
observer.observe(el)
return () => observer.disconnect()
}, [])
if (photos.length === 0) return null if (photos.length === 0) return null
const gap = 6
const targetHeight = width < 700 ? 170 : 240
const rows = width > 0 ? layoutRows(photos, width, targetHeight, gap) : []
const selecting = selected && selected.size > 0 const selecting = selected && selected.size > 0
return ( return (
<div className={`gallery${selecting ? ' selecting' : ''}`}> <div ref={containerRef} className={`gallery${selecting ? ' selecting' : ''}`}>
{photos.map((p, i) => { {rows.map((row) => (
const ar = p.width && p.height ? p.width / p.height : 1.5 <div key={row.items[0].photo.id} className="g-row" style={{ height: row.height }}>
const isSelected = selected ? selected.has(p.id) : false {row.items.map(({ photo: p, ar, index }) => {
return ( const isSelected = selected ? selected.has(p.id) : false
<div return (
key={p.id} <div
className={`g-item${isSelected ? ' selected' : ''}`} key={p.id}
style={{ '--ar': ar }} className={`g-item${isSelected ? ' selected' : ''}`}
onClick={() => onOpen && onOpen(i)} style={{ width: ar * row.height }}
> onClick={() => onOpen && onOpen(index)}
<img src={imgUrl(p, 'thumb')} loading="lazy" alt={p.filename} />
{onToggleSelect && (
<button
className="g-check"
title={isSelected ? 'Deselect' : 'Select'}
onClick={(e) => {
e.stopPropagation()
onToggleSelect(p.id)
}}
> >
<img src={imgUrl(p, 'thumb')} loading="lazy" alt={p.filename} />
</button> {onToggleSelect && (
)} <button
{overlay && overlay(p)} className="g-check"
</div> title={isSelected ? 'Deselect' : 'Select'}
) onClick={(e) => {
})} e.stopPropagation()
<div className="g-spacer" /> onToggleSelect(p.id)
}}
>
</button>
)}
{overlay && overlay(p)}
</div>
)
})}
</div>
))}
</div> </div>
) )
} }
+6 -13
View File
@@ -191,15 +191,17 @@ input:focus {
/* justified gallery */ /* justified gallery */
.gallery { .gallery {
display: flex; display: flex;
flex-wrap: wrap; flex-direction: column;
gap: 6px; gap: 6px;
margin: 1rem 0; margin: 1rem 0;
} }
.g-row {
display: flex;
gap: 6px;
}
.g-item { .g-item {
position: relative; position: relative;
height: 240px; flex: none;
flex-grow: calc(var(--ar) * 100);
flex-basis: calc(var(--ar) * 240px);
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
@@ -211,11 +213,6 @@ input:focus {
object-fit: cover; object-fit: cover;
display: block; display: block;
} }
.g-spacer {
flex-grow: 1000000;
flex-basis: 0;
height: 0;
}
.g-overlay { .g-overlay {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
@@ -556,10 +553,6 @@ progress {
} }
@media (max-width: 700px) { @media (max-width: 700px) {
.g-item {
height: 160px;
flex-basis: calc(var(--ar) * 160px);
}
.lb-stage { .lb-stage {
padding: 0 0.5rem; padding: 0 0.5rem;
} }