fix: load full library + stretch grid to fill row width
Two unrelated bugs surfaced together because the symptom looked
similar ("missing photos in the grid"):
usePhotosQuery only ever fetched page 1 with per_page=500, so any
filter matching more than 500 photos silently truncated. With a
13k-photo library that meant the Timeline only showed ~3.8% of
matches and folders with many descendants looked broken. Walks all
pages now (capped at 200 = 100k photos as a sanity bound), keying
the React Query cache on the full filter set as before.
Timeline grid was leaving an unused horizontal strip on the right.
Two issues in the column math:
- off-by-one: floor((W - 2P) / (T + G)) double-counts gaps. With N
columns there are only N-1 inter-cell gaps, so the correct form
is floor((W - 2P + G) / (T + G)). Reclaims a column whenever the
remainder almost fits.
- the floor remainder was discarded instead of distributed back
into the cells. Treat THUMBNAIL_SIZE as a minimum and stretch
each cell to (available - (cols-1)*gap) / cols so the row fills
the container.
Also swap the resize listener for a ResizeObserver on the scroll
container so the grid re-flows when the sidebar collapses (window
resize alone misses that).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ type TimelineItem =
|
|||||||
function buildItems(
|
function buildItems(
|
||||||
photos: Photo[],
|
photos: Photo[],
|
||||||
columns: number,
|
columns: number,
|
||||||
|
rowHeight: number,
|
||||||
sortBy: string,
|
sortBy: string,
|
||||||
groupBy: 'date' | 'tag'
|
groupBy: 'date' | 'tag'
|
||||||
): TimelineItem[] {
|
): TimelineItem[] {
|
||||||
@@ -50,7 +51,7 @@ function buildItems(
|
|||||||
type: 'row',
|
type: 'row',
|
||||||
key: `${groupKey}::row::${i}`,
|
key: `${groupKey}::row::${i}`,
|
||||||
cells: slice,
|
cells: slice,
|
||||||
height: THUMBNAIL_SIZE + GAP,
|
height: rowHeight + GAP,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,13 +181,27 @@ export function Timeline() {
|
|||||||
const groupBy = useFilterStore((s) => s.groupBy)
|
const groupBy = useFilterStore((s) => s.groupBy)
|
||||||
const viewMode = usePhotoStore((s) => s.viewMode)
|
const viewMode = usePhotoStore((s) => s.viewMode)
|
||||||
|
|
||||||
// Calculate number of columns based on container width.
|
// Calculate number of columns + actual cell size based on container
|
||||||
const columns = useMemo(() => {
|
// width. Treat THUMBNAIL_SIZE as a *minimum* and let cells grow to
|
||||||
if (containerWidth === 0) return 4
|
// fill the remaining space, so we never leave a horizontal gap on
|
||||||
return Math.max(
|
// the right side of the grid.
|
||||||
|
//
|
||||||
|
// Column math: with N columns there are N-1 inter-cell gaps, so the
|
||||||
|
// width needed is N*T + (N-1)*G. Solving for the largest N that fits
|
||||||
|
// in the available width gives N = floor((available + G) / (T + G)).
|
||||||
|
// The previous formula floor((available) / (T + G)) was off-by-one
|
||||||
|
// and lost a whole column whenever the remainder almost fit.
|
||||||
|
const { columns, cellSize } = useMemo(() => {
|
||||||
|
if (containerWidth === 0) {
|
||||||
|
return { columns: 4, cellSize: THUMBNAIL_SIZE }
|
||||||
|
}
|
||||||
|
const available = containerWidth - PADDING * 2
|
||||||
|
const cols = Math.max(
|
||||||
1,
|
1,
|
||||||
Math.floor((containerWidth - PADDING * 2) / (THUMBNAIL_SIZE + GAP))
|
Math.floor((available + GAP) / (THUMBNAIL_SIZE + GAP))
|
||||||
)
|
)
|
||||||
|
const cell = Math.floor((available - (cols - 1) * GAP) / cols)
|
||||||
|
return { columns: cols, cellSize: cell }
|
||||||
}, [containerWidth])
|
}, [containerWidth])
|
||||||
|
|
||||||
// Shared photos query — both Timeline and PreviewView use the same hook so
|
// Shared photos query — both Timeline and PreviewView use the same hook so
|
||||||
@@ -203,8 +218,8 @@ export function Timeline() {
|
|||||||
// photos. Date headers appear when sorted by a date field; tag headers
|
// photos. Date headers appear when sorted by a date field; tag headers
|
||||||
// appear when groupBy === 'tag' (overrides date grouping).
|
// appear when groupBy === 'tag' (overrides date grouping).
|
||||||
const items = useMemo(
|
const items = useMemo(
|
||||||
() => buildItems(photos, columns, sortBy, groupBy),
|
() => buildItems(photos, columns, cellSize, sortBy, groupBy),
|
||||||
[photos, columns, sortBy, groupBy]
|
[photos, columns, cellSize, sortBy, groupBy]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Pre-computed offset of every header in the virtualizer's coordinate
|
// Pre-computed offset of every header in the virtualizer's coordinate
|
||||||
@@ -225,7 +240,7 @@ export function Timeline() {
|
|||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: items.length,
|
count: items.length,
|
||||||
getScrollElement: () => parentRef.current,
|
getScrollElement: () => parentRef.current,
|
||||||
estimateSize: (index) => items[index]?.height ?? THUMBNAIL_SIZE,
|
estimateSize: (index) => items[index]?.height ?? cellSize,
|
||||||
overscan: 5,
|
overscan: 5,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -264,16 +279,21 @@ export function Timeline() {
|
|||||||
return current
|
return current
|
||||||
}, [headerOffsets, scrollTop])
|
}, [headerOffsets, scrollTop])
|
||||||
|
|
||||||
// Measure container width on mount and resize.
|
// Measure container width on mount, window resize, and any layout
|
||||||
|
// change driven by the sidebar collapse / right panel toggle. Plain
|
||||||
|
// window.resize wouldn't catch those — ResizeObserver does.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const measureWidth = () => {
|
const el = parentRef.current
|
||||||
if (parentRef.current) {
|
if (!el) return
|
||||||
setContainerWidth(parentRef.current.clientWidth)
|
const measure = () => setContainerWidth(el.clientWidth)
|
||||||
}
|
measure()
|
||||||
|
const ro = new ResizeObserver(measure)
|
||||||
|
ro.observe(el)
|
||||||
|
window.addEventListener('resize', measure)
|
||||||
|
return () => {
|
||||||
|
ro.disconnect()
|
||||||
|
window.removeEventListener('resize', measure)
|
||||||
}
|
}
|
||||||
measureWidth()
|
|
||||||
window.addEventListener('resize', measureWidth)
|
|
||||||
return () => window.removeEventListener('resize', measureWidth)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Photo rows in visual order — drops the header items so navigation
|
// Photo rows in visual order — drops the header items so navigation
|
||||||
@@ -508,7 +528,7 @@ export function Timeline() {
|
|||||||
<PhotoThumbnail
|
<PhotoThumbnail
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
photo={photo}
|
photo={photo}
|
||||||
size={THUMBNAIL_SIZE}
|
size={cellSize}
|
||||||
isSelected={selectedPhotos.includes(photo.id)}
|
isSelected={selectedPhotos.includes(photo.id)}
|
||||||
isInActiveHeap={activeHeapMembers.has(photo.id)}
|
isInActiveHeap={activeHeapMembers.has(photo.id)}
|
||||||
activeHeapName={activeHeapName}
|
activeHeapName={activeHeapName}
|
||||||
|
|||||||
@@ -53,17 +53,33 @@ export function usePhotosQuery() {
|
|||||||
// Goes through the shared axios instance so it inherits the
|
// Goes through the shared axios instance so it inherits the
|
||||||
// relative /api/v1 baseURL — same-origin behind the nginx / vite
|
// relative /api/v1 baseURL — same-origin behind the nginx / vite
|
||||||
// proxy, no CORS dance required from another machine.
|
// proxy, no CORS dance required from another machine.
|
||||||
const response = await api.get<{ photos: Photo[]; total: number }>(
|
//
|
||||||
'/photos',
|
// The Timeline and grid views virtualize, so we load every match
|
||||||
{
|
// up-front rather than paginating in the UI. Backend caps per_page
|
||||||
|
// at 500, so for libraries / folders with more matches we walk
|
||||||
|
// pages until we have everything. Capped at 200 pages (= 100k
|
||||||
|
// photos) as a sanity bound.
|
||||||
|
const PER_PAGE = 500
|
||||||
|
const MAX_PAGES = 200
|
||||||
|
const all: Photo[] = []
|
||||||
|
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||||
|
const response = await api.get<{
|
||||||
|
photos: Photo[]
|
||||||
|
total: number
|
||||||
|
pages: number
|
||||||
|
}>('/photos', {
|
||||||
params: {
|
params: {
|
||||||
page: 1,
|
page,
|
||||||
per_page: 500,
|
per_page: PER_PAGE,
|
||||||
...filterParams,
|
...filterParams,
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
)
|
const photos = response.data.photos || []
|
||||||
return response.data.photos || []
|
all.push(...photos)
|
||||||
|
const totalPages = response.data.pages ?? 1
|
||||||
|
if (page >= totalPages || photos.length < PER_PAGE) break
|
||||||
|
}
|
||||||
|
return all
|
||||||
},
|
},
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user