diff --git a/frontend/src/components/Gallery.jsx b/frontend/src/components/Gallery.jsx index 0118138..0f9ba4c 100644 --- a/frontend/src/components/Gallery.jsx +++ b/frontend/src/components/Gallery.jsx @@ -1,42 +1,86 @@ +import { useEffect, useRef, useState } from 'react' import { imgUrl } from '../api' -// Justified gallery: rows are built with flexbox, each tile's flex-grow is -// proportional to its aspect ratio so rows fill the container edge to edge. -// When `selected`/`onToggleSelect` are provided, tiles get a select checkmark; -// selection state lives in the parent so it survives lightbox open/close. +// True justified layout: pack photos greedily into rows at their real aspect +// ratios, then scale each row's height so it fills the container width +// exactly. No cropping, no stretch, and the last row simply renders at the +// 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 }) { + 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 + const gap = 6 + const targetHeight = width < 700 ? 170 : 240 + const rows = width > 0 ? layoutRows(photos, width, targetHeight, gap) : [] const selecting = selected && selected.size > 0 + return ( -
- {photos.map((p, i) => { - const ar = p.width && p.height ? p.width / p.height : 1.5 - const isSelected = selected ? selected.has(p.id) : false - return ( -
onOpen && onOpen(i)} - > - {p.filename} - {onToggleSelect && ( - - )} - {overlay && overlay(p)} -
- ) - })} -
+ {p.filename} + {onToggleSelect && ( + + )} + {overlay && overlay(p)} +
+ ) + })} +
+ ))} ) } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 679e02d..5b7fb44 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -191,15 +191,17 @@ input:focus { /* justified gallery */ .gallery { display: flex; - flex-wrap: wrap; + flex-direction: column; gap: 6px; margin: 1rem 0; } +.g-row { + display: flex; + gap: 6px; +} .g-item { position: relative; - height: 240px; - flex-grow: calc(var(--ar) * 100); - flex-basis: calc(var(--ar) * 240px); + flex: none; border-radius: 4px; overflow: hidden; cursor: pointer; @@ -211,11 +213,6 @@ input:focus { object-fit: cover; display: block; } -.g-spacer { - flex-grow: 1000000; - flex-basis: 0; - height: 0; -} .g-overlay { position: absolute; bottom: 0; @@ -556,10 +553,6 @@ progress { } @media (max-width: 700px) { - .g-item { - height: 160px; - flex-basis: calc(var(--ar) * 160px); - } .lb-stage { padding: 0 0.5rem; }