Add masonry layout for photo grids (#390)

* add masonry layout for photo grids

- NEXT_PUBLIC_MASONRY_GRID env variable to turn masonry layout on and
off
- added PhotoGridMasonry.tsx to handle masonry layout

* fixed albums showing all photos when masonry grid is enabled

* only render infinite photo scroll for masonry grid when total photo count is greater than loaded photos

* fixed masonry grid lcp warnings

* add NEXT_PUBLIC_MASONRY_GRID description to README

* Use custom icon for masonry layout

* Add masonry to in-app config

* Simplify masonry architecture

---------

Co-authored-by: Sam Becker <sam@sambecker.com>
This commit is contained in:
John 2026-05-02 16:01:08 -05:00 committed by GitHub
parent af48b8d6d2
commit 86d4f3dfca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 299 additions and 58 deletions

View File

@ -172,6 +172,7 @@ Create Upstash Redis store from storage tab of Vercel dashboard and link to your
### Grid ### Grid
- `NEXT_PUBLIC_GRID_HOMEPAGE = 1` shows grid layout on homepage - `NEXT_PUBLIC_GRID_HOMEPAGE = 1` shows grid layout on homepage
- `NEXT_PUBLIC_MASONRY_GRID = 1` shows photo grid homepage in masonry layout (keeping photo aspect ratios), also known as 'Pinterest style' or 'grid-lanes'
- `NEXT_PUBLIC_GRID_ASPECT_RATIO = 1.5` sets aspect ratio for grid tiles (defaults to `1`—setting to `0` removes the constraint) - `NEXT_PUBLIC_GRID_ASPECT_RATIO = 1.5` sets aspect ratio for grid tiles (defaults to `1`—setting to `0` removes the constraint)
- `NEXT_PUBLIC_SHOW_LARGE_THUMBNAILS = 1` ensures large thumbnails on photo grid views (if not configured, density is based on aspect ratio) - `NEXT_PUBLIC_SHOW_LARGE_THUMBNAILS = 1` ensures large thumbnails on photo grid views (if not configured, density is based on aspect ratio)

View File

@ -115,6 +115,7 @@ export default function AdminAppConfigurationClient({
showRepoLink, showRepoLink,
// Grid // Grid
isGridHomepageEnabled, isGridHomepageEnabled,
isMasonryGridEnabled,
gridAspectRatio, gridAspectRatio,
hasGridAspectRatio, hasGridAspectRatio,
hasHighGridDensity, hasHighGridDensity,
@ -851,6 +852,14 @@ export default function AdminAppConfigurationClient({
on homepage on homepage
{renderEnvVars(['NEXT_PUBLIC_GRID_HOMEPAGE'])} {renderEnvVars(['NEXT_PUBLIC_GRID_HOMEPAGE'])}
</ChecklistRow> </ChecklistRow>
<ChecklistRow
title="Masonry grid"
status={isMasonryGridEnabled}
optional
>
Set environment variable to {'"1"'} to show masonry grid layout
{renderEnvVars(['NEXT_PUBLIC_MASONRY_GRID'])}
</ChecklistRow>
<ChecklistRow <ChecklistRow
title={`Grid aspect ratio: ${gridAspectRatio}`} title={`Grid aspect ratio: ${gridAspectRatio}`}
status={hasGridAspectRatio} status={hasGridAspectRatio}

View File

@ -14,6 +14,7 @@ import {
SHOW_KEYBOARD_SHORTCUT_TOOLTIPS, SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
NAV_SORT_CONTROL, NAV_SORT_CONTROL,
SHOW_ABOUT_PAGE, SHOW_ABOUT_PAGE,
MASONRY_GRID_ENABLED,
} from './config'; } from './config';
import AdminAppMenu from '@/admin/AdminAppMenu'; import AdminAppMenu from '@/admin/AdminAppMenu';
import Spinner from '@/components/Spinner'; import Spinner from '@/components/Spinner';
@ -29,6 +30,7 @@ import { motion } from 'framer-motion';
import SortMenu from '@/photo/sort/SortMenu'; import SortMenu from '@/photo/sort/SortMenu';
import { SWR_KEYS } from '@/swr'; import { SWR_KEYS } from '@/swr';
import IconAbout from '@/components/icons/IconAbout'; import IconAbout from '@/components/icons/IconAbout';
import IconGridMasonry from '@/components/icons/IconGridMasonry';
export type SwitcherSelection = 'full' | 'grid' | 'about' | 'admin'; export type SwitcherSelection = 'full' | 'grid' | 'about' | 'admin';
@ -126,7 +128,7 @@ export default function AppViewSwitcher({
const renderItemGrid = const renderItemGrid =
<SwitcherItem <SwitcherItem
icon={<IconGrid />} icon={MASONRY_GRID_ENABLED ? <IconGridMasonry /> : <IconGrid />}
href={pathGrid} href={pathGrid}
hrefRef={refHrefGrid} hrefRef={refHrefGrid}
active={currentSelection === 'grid'} active={currentSelection === 'grid'}

View File

@ -354,6 +354,8 @@ export const SHOW_REPO_LINK =
export const GRID_HOMEPAGE_ENABLED = export const GRID_HOMEPAGE_ENABLED =
process.env.NEXT_PUBLIC_GRID_HOMEPAGE === '1'; process.env.NEXT_PUBLIC_GRID_HOMEPAGE === '1';
export const MASONRY_GRID_ENABLED =
process.env.NEXT_PUBLIC_MASONRY_GRID === '1';
export const GRID_ASPECT_RATIO = export const GRID_ASPECT_RATIO =
process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO
? parseFloat(process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO) ? parseFloat(process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO)
@ -514,6 +516,7 @@ export const APP_CONFIGURATION = {
showRepoLink: SHOW_REPO_LINK, showRepoLink: SHOW_REPO_LINK,
// Grid // Grid
isGridHomepageEnabled: GRID_HOMEPAGE_ENABLED, isGridHomepageEnabled: GRID_HOMEPAGE_ENABLED,
isMasonryGridEnabled: MASONRY_GRID_ENABLED,
gridAspectRatio: GRID_ASPECT_RATIO, gridAspectRatio: GRID_ASPECT_RATIO,
hasGridAspectRatio: Boolean(process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO), hasGridAspectRatio: Boolean(process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO),
hasHighGridDensity: HIGH_DENSITY_GRID, hasHighGridDensity: HIGH_DENSITY_GRID,

View File

@ -0,0 +1,31 @@
/* eslint-disable max-len */
const INTRINSIC_WIDTH = 28;
const INTRINSIC_HEIGHT = 24;
export default function IconGridMasonry({
width = INTRINSIC_WIDTH,
className,
}: {
width?: number
className?: string
}) {
return (
<svg
width={width}
height={INTRINSIC_HEIGHT * width / INTRINSIC_WIDTH}
viewBox="0 0 28 24"
fill="none"
stroke="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<rect x="0.625" y="-0.625" width="16.75" height="10.75" rx="2.375" transform="matrix(1 0 0 -1 5 16.75)" strokeWidth="1.25"/>
<line y1="-0.625" x2="11" y2="-0.625" transform="matrix(-4.37114e-08 -1 -1 4.37114e-08 10.75 17)" strokeWidth="1.25"/>
<line y1="-0.625" x2="11" y2="-0.625" transform="matrix(-4.37114e-08 -1 -1 4.37114e-08 16.25 17)" strokeWidth="1.25"/>
<path d="M5 11L11.5 11" strokeWidth="1.25"/>
<path d="M11 13.5L17.5 13.5" strokeWidth="1.25"/>
<path d="M17 11H22.5" strokeWidth="1.25"/>
</svg>
);
};

View File

@ -24,6 +24,7 @@ export type RevalidatePhoto = (
) => Promise<any>; ) => Promise<any>;
export default function InfinitePhotoScroll({ export default function InfinitePhotoScroll({
initialPhotos,
cacheKey, cacheKey,
initialOffset, initialOffset,
itemsPerPage, itemsPerPage,
@ -34,6 +35,7 @@ export default function InfinitePhotoScroll({
year, year,
camera, camera,
lens, lens,
album,
tag, tag,
recipe, recipe,
film, film,
@ -44,6 +46,9 @@ export default function InfinitePhotoScroll({
includeHiddenPhotos, includeHiddenPhotos,
children, children,
}: { }: {
// Required for masonry grid:
// initialPhotos necessary to build layout without random gaps
initialPhotos?: Photo[]
initialOffset: number initialOffset: number
itemsPerPage: number itemsPerPage: number
sortBy?: SortBy sortBy?: SortBy
@ -87,6 +92,7 @@ export default function InfinitePhotoScroll({
year, year,
camera, camera,
lens, lens,
album,
tag, tag,
recipe, recipe,
film, film,
@ -104,6 +110,7 @@ export default function InfinitePhotoScroll({
year, year,
camera, camera,
lens, lens,
album,
tag, tag,
recipe, recipe,
film, film,
@ -168,9 +175,21 @@ export default function InfinitePhotoScroll({
</button> </button>
</div>; </div>;
const flattenedPhotos = initialPhotos
? initialPhotos.concat(data?.flat() ?? [])
: undefined;
return ( return (
<> <>
{data?.map((photos, index) => ( {flattenedPhotos
? children({
key: cacheKey,
photos: flattenedPhotos,
onLastPhotoVisible: !isFinished ? advance : undefined,
revalidatePhoto,
})
: (
data?.map((photos, index) => (
children({ children({
key: `${cacheKey}-${index}`, key: `${cacheKey}-${index}`,
photos, photos,
@ -179,7 +198,8 @@ export default function InfinitePhotoScroll({
: undefined, : undefined,
revalidatePhoto, revalidatePhoto,
}) })
))} ))
)}
{!isFinished && <div className={moreButtonClassName}> {!isFinished && <div className={moreButtonClassName}>
{wrapMoreButtonInGrid {wrapMoreButtonInGrid
? <AppGrid contentMain={renderMoreButton} /> ? <AppGrid contentMain={renderMoreButton} />

View File

@ -5,13 +5,17 @@ import { PhotoSetCategory } from '../category';
import PhotoMedium from './PhotoMedium'; import PhotoMedium from './PhotoMedium';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import AnimateItems from '@/components/AnimateItems'; import AnimateItems from '@/components/AnimateItems';
import { GRID_ASPECT_RATIO } from '@/app/config'; import {
GRID_ASPECT_RATIO,
MASONRY_GRID_ENABLED,
} from '@/app/config';
import { useAppState } from '@/app/AppState'; import { useAppState } from '@/app/AppState';
import SelectTileOverlay from '@/components/SelectTileOverlay'; import SelectTileOverlay from '@/components/SelectTileOverlay';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { GRID_GAP_CLASSNAME } from '@/components'; import { GRID_GAP_CLASSNAME } from '@/components';
import { useSelectPhotosState } from '@/admin/select/SelectPhotosState'; import { useSelectPhotosState } from '@/admin/select/SelectPhotosState';
import { DATA_KEY_PHOTO_GRID } from '@/admin/select/SelectPhotosProvider'; import { DATA_KEY_PHOTO_GRID } from '@/admin/select/SelectPhotosProvider';
import PhotoGridMasonry from './PhotoGridMasonry';
export default function PhotoGrid({ export default function PhotoGrid({
photos, photos,
@ -54,6 +58,76 @@ export default function PhotoGrid({
togglePhotoSelection, togglePhotoSelection,
} = useSelectPhotosState(); } = useSelectPhotosState();
const photoNodes = photos.map((photo, index) => {
const isSelected = (
selectedPhotoIds?.includes(photo.id) ||
isSelectingAllPhotos
) ?? false;
return <div
key={photo.id}
className={clsx(
'flex relative overflow-hidden',
'group',
)}
style={{
...(MASONRY_GRID_ENABLED) ? {
aspectRatio: photo.aspectRatio,
} : (GRID_ASPECT_RATIO !== 0) ? {
aspectRatio: GRID_ASPECT_RATIO,
} : {},
}}
>
<PhotoMedium
className={clsx(
'flex w-full h-full',
// Prevent photo navigation when selecting
isSelectingPhotos && 'pointer-events-none',
classNamePhoto,
)}
{...{
photo,
...categories,
selected: isSelected,
// More priority slots when masonry is on (helps LCP)
priority: prioritizeInitialPhotos
? (MASONRY_GRID_ENABLED ? index < 36 : index < 6)
: undefined,
onVisible: index === photos.length - 1
? onLastPhotoVisible
: undefined,
}}
/>
{isSelectingPhotos &&
<SelectTileOverlay
isSelected={isSelected}
onSelectChange={() => togglePhotoSelection?.(photo.id)}
/>}
</div>;
});
const allItems = photoNodes.concat(
additionalTile ? [<div key="more">{additionalTile}</div>] : [],
);
if (MASONRY_GRID_ENABLED) {
return (
<PhotoGridMasonry
photos={photos}
photoNodes={photoNodes}
additionalTile={additionalTile}
small={small}
isGridHighDensity={isGridHighDensity}
selectable={selectable}
className={className}
animate={animate}
canStart={canStart}
animateOnFirstLoadOnly={animateOnFirstLoadOnly}
staggerOnFirstLoadOnly={staggerOnFirstLoadOnly}
onAnimationCompleteAction={onAnimationComplete}
/>
);
}
return ( return (
<div <div
{...{ [DATA_KEY_PHOTO_GRID]: selectable, className }} {...{ [DATA_KEY_PHOTO_GRID]: selectable, className }}
@ -77,47 +151,7 @@ export default function PhotoGrid({
animateOnFirstLoadOnly={animateOnFirstLoadOnly} animateOnFirstLoadOnly={animateOnFirstLoadOnly}
staggerOnFirstLoadOnly={staggerOnFirstLoadOnly} staggerOnFirstLoadOnly={staggerOnFirstLoadOnly}
onAnimationComplete={onAnimationComplete} onAnimationComplete={onAnimationComplete}
items={photos.map((photo, index) => { items={allItems}
const isSelected = (
selectedPhotoIds?.includes(photo.id) ||
isSelectingAllPhotos
) ?? false;
return <div
key={photo.id}
className={clsx(
'flex relative overflow-hidden',
'group',
)}
style={{
...GRID_ASPECT_RATIO !== 0 && {
aspectRatio: GRID_ASPECT_RATIO,
},
}}
>
<PhotoMedium
className={clsx(
'flex w-full h-full',
// Prevent photo navigation when selecting
isSelectingPhotos && 'pointer-events-none',
classNamePhoto,
)}
{...{
photo,
...categories,
selected: isSelected,
priority: prioritizeInitialPhotos ? index < 6 : undefined,
onVisible: index === photos.length - 1
? onLastPhotoVisible
: undefined,
}}
/>
{isSelectingPhotos &&
<SelectTileOverlay
isSelected={isSelected}
onSelectChange={() => togglePhotoSelection?.(photo.id)}
/>}
</div>;
}).concat(additionalTile ? <>{additionalTile}</> : [])}
itemKeys={photos.map(photo => photo.id) itemKeys={photos.map(photo => photo.id)
.concat(additionalTile ? ['more'] : [])} .concat(additionalTile ? ['more'] : [])}
/> />

View File

@ -8,6 +8,7 @@ import AnimateItems from '@/components/AnimateItems';
import { ComponentProps, useCallback, useState, ReactNode } from 'react'; import { ComponentProps, useCallback, useState, ReactNode } from 'react';
import { GRID_SPACE_CLASSNAME } from '@/components'; import { GRID_SPACE_CLASSNAME } from '@/components';
import { SortBy } from './sort'; import { SortBy } from './sort';
import { MASONRY_GRID_ENABLED } from '@/app/config';
export default function PhotoGridContainer({ export default function PhotoGridContainer({
cacheKey, cacheKey,
@ -31,6 +32,9 @@ export default function PhotoGridContainer({
sidebar?: ReactNode sidebar?: ReactNode
className?: string className?: string
} & ComponentProps<typeof PhotoGrid>) { } & ComponentProps<typeof PhotoGrid>) {
const shouldRenderInitialGrid =
!MASONRY_GRID_ENABLED || count <= photos.length;
const [ const [
shouldAnimateDynamicItems, shouldAnimateDynamicItems,
setShouldAnimateDynamicItems, setShouldAnimateDynamicItems,
@ -51,15 +55,18 @@ export default function PhotoGridContainer({
animateOnFirstLoadOnly animateOnFirstLoadOnly
/>} />}
<div className={GRID_SPACE_CLASSNAME}> <div className={GRID_SPACE_CLASSNAME}>
{shouldRenderInitialGrid && (
<PhotoGrid {...{ <PhotoGrid {...{
photos, photos,
...categories, ...categories,
animateOnFirstLoadOnly, animateOnFirstLoadOnly,
onAnimationComplete, onAnimationComplete,
}} /> }} />
)}
{count > photos.length && {count > photos.length &&
<PhotoGridInfinite {...{ <PhotoGridInfinite {...{
cacheKey, cacheKey,
initialPhotos: MASONRY_GRID_ENABLED ? photos : undefined,
initialOffset: photos.length, initialOffset: photos.length,
sortBy, sortBy,
sortWithPriority, sortWithPriority,

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { INFINITE_SCROLL_GRID_MULTIPLE } from '.'; import { INFINITE_SCROLL_GRID_MULTIPLE, Photo } from '.';
import InfinitePhotoScroll from './InfinitePhotoScroll'; import InfinitePhotoScroll from './InfinitePhotoScroll';
import PhotoGrid from './PhotoGrid'; import PhotoGrid from './PhotoGrid';
import { ComponentProps } from 'react'; import { ComponentProps } from 'react';
@ -9,6 +9,7 @@ import { SortBy } from './sort';
export default function PhotoGridInfinite({ export default function PhotoGridInfinite({
cacheKey, cacheKey,
initialOffset, initialOffset,
initialPhotos,
sortBy, sortBy,
sortWithPriority, sortWithPriority,
excludeFromFeeds, excludeFromFeeds,
@ -18,12 +19,14 @@ export default function PhotoGridInfinite({
}: { }: {
cacheKey: string cacheKey: string
initialOffset: number initialOffset: number
initialPhotos?: Photo[]
sortBy?: SortBy sortBy?: SortBy
sortWithPriority?: boolean sortWithPriority?: boolean
excludeFromFeeds?: boolean excludeFromFeeds?: boolean
} & Omit<ComponentProps<typeof PhotoGrid>, 'photos'>) { } & Omit<ComponentProps<typeof PhotoGrid>, 'photos'>) {
return ( return (
<InfinitePhotoScroll <InfinitePhotoScroll
initialPhotos={initialPhotos}
cacheKey={cacheKey} cacheKey={cacheKey}
initialOffset={initialOffset} initialOffset={initialOffset}
itemsPerPage={INFINITE_SCROLL_GRID_MULTIPLE} itemsPerPage={INFINITE_SCROLL_GRID_MULTIPLE}

View File

@ -0,0 +1,131 @@
'use client';
import { ReactNode, useState, useEffect } from 'react';
import { clsx } from 'clsx/lite';
import AnimateItems from '@/components/AnimateItems';
import { GRID_GAP_CLASSNAME } from '@/components';
import { DATA_KEY_PHOTO_GRID } from '@/admin/select/SelectPhotosProvider';
import { Photo } from '.';
function useMasonryColumns(small?: boolean, isGridHighDensity?: boolean) {
const [columns, setColumns] = useState(
small ? 3 : isGridHighDensity ? 2 : 2, // default mobile
);
useEffect(() => {
const handleResize = () => {
const width = window.innerWidth;
if (small) {
setColumns(width >= 480 ? 6 : 3);
} else if (isGridHighDensity) {
if (width >= 1024) setColumns(6);
else if (width >= 480) setColumns(4);
else setColumns(2);
} else {
if (width >= 1024) setColumns(4);
else if (width >= 768) setColumns(3);
else if (width >= 640) setColumns(4);
else setColumns(2);
}
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [small, isGridHighDensity]);
return columns;
}
export default function PhotoGridMasonry({
photos,
photoNodes,
additionalTile,
small,
isGridHighDensity,
selectable,
className,
animate,
canStart,
animateOnFirstLoadOnly,
staggerOnFirstLoadOnly,
onAnimationCompleteAction,
}: {
photos: Photo[];
photoNodes: ReactNode[];
additionalTile?: ReactNode;
small?: boolean;
isGridHighDensity?: boolean;
selectable?: boolean;
className?: string;
animate?: boolean;
canStart?: boolean;
animateOnFirstLoadOnly?: boolean;
staggerOnFirstLoadOnly?: boolean;
onAnimationCompleteAction?: () => void;
}) {
const masonryColsCount = useMasonryColumns(small, isGridHighDensity);
const partitionedColumns = Array.from(
{ length: masonryColsCount },
() => [] as ReactNode[],
);
const colHeights = new Array(masonryColsCount).fill(0);
photos.forEach((photo, index) => {
let shortestColIndex = 0;
let minHeight = colHeights[0];
for (let i = 1; i < masonryColsCount; i++) {
// Subtract tiny fraction to create tie breaker
// If columns equal in height, ensure photo's placed in left-most column
// (helps maintain left-to-right photo order)
if (colHeights[i] < minHeight - 0.0001) {
minHeight = colHeights[i];
shortestColIndex = i;
}
}
partitionedColumns[shortestColIndex].push(photoNodes[index]);
colHeights[shortestColIndex] += 1 / (photo.aspectRatio || 1);
});
if (additionalTile) {
let shortestColIndex = 0;
let minHeight = colHeights[0];
for (let i = 1; i < masonryColsCount; i++) {
if (colHeights[i] < minHeight - 0.0001) {
minHeight = colHeights[i];
shortestColIndex = i;
}
}
partitionedColumns[shortestColIndex].push(
<div key="more">{additionalTile}</div>,
);
colHeights[shortestColIndex] += 1;
}
return (
<div {...{ [DATA_KEY_PHOTO_GRID]: selectable, className }}>
<div className={clsx('flex flex-row', GRID_GAP_CLASSNAME, 'items-start')}>
{partitionedColumns.map((colItems, i) => (
<AnimateItems
key={`col-${i}`}
className={clsx('flex flex-col flex-1', GRID_GAP_CLASSNAME)}
type={animate === false ? 'none' : undefined}
canStart={canStart}
duration={0.7}
staggerDelay={0.04}
distanceOffset={40}
animateOnFirstLoadOnly={animateOnFirstLoadOnly}
staggerOnFirstLoadOnly={staggerOnFirstLoadOnly}
onAnimationComplete={
i === partitionedColumns.length - 1
? onAnimationCompleteAction
: undefined
}
items={colItems}
itemKeys={colItems.map((_, index) => `col-${i}-item-${index}`)}
/>
))}
</div>
</div>
);
}