diff --git a/README.md b/README.md index 814a4d94..7d30e243 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,7 @@ Create Upstash Redis store from storage tab of Vercel dashboard and link to your ### Grid - `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_SHOW_LARGE_THUMBNAILS = 1` ensures large thumbnails on photo grid views (if not configured, density is based on aspect ratio) diff --git a/src/admin/config/AdminAppConfigurationClient.tsx b/src/admin/config/AdminAppConfigurationClient.tsx index a466ec7a..40c71d1a 100644 --- a/src/admin/config/AdminAppConfigurationClient.tsx +++ b/src/admin/config/AdminAppConfigurationClient.tsx @@ -115,6 +115,7 @@ export default function AdminAppConfigurationClient({ showRepoLink, // Grid isGridHomepageEnabled, + isMasonryGridEnabled, gridAspectRatio, hasGridAspectRatio, hasHighGridDensity, @@ -851,6 +852,14 @@ export default function AdminAppConfigurationClient({ on homepage {renderEnvVars(['NEXT_PUBLIC_GRID_HOMEPAGE'])} + + Set environment variable to {'"1"'} to show masonry grid layout + {renderEnvVars(['NEXT_PUBLIC_MASONRY_GRID'])} + } + icon={MASONRY_GRID_ENABLED ? : } href={pathGrid} hrefRef={refHrefGrid} active={currentSelection === 'grid'} diff --git a/src/app/config.ts b/src/app/config.ts index b48a20f7..0a878c1f 100644 --- a/src/app/config.ts +++ b/src/app/config.ts @@ -354,6 +354,8 @@ export const SHOW_REPO_LINK = export const GRID_HOMEPAGE_ENABLED = process.env.NEXT_PUBLIC_GRID_HOMEPAGE === '1'; +export const MASONRY_GRID_ENABLED = + process.env.NEXT_PUBLIC_MASONRY_GRID === '1'; export const GRID_ASPECT_RATIO = 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, // Grid isGridHomepageEnabled: GRID_HOMEPAGE_ENABLED, + isMasonryGridEnabled: MASONRY_GRID_ENABLED, gridAspectRatio: GRID_ASPECT_RATIO, hasGridAspectRatio: Boolean(process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO), hasHighGridDensity: HIGH_DENSITY_GRID, diff --git a/src/components/icons/IconGridMasonry.tsx b/src/components/icons/IconGridMasonry.tsx new file mode 100644 index 00000000..eaf2799c --- /dev/null +++ b/src/components/icons/IconGridMasonry.tsx @@ -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 ( + + + + + + + + + ); +}; diff --git a/src/photo/InfinitePhotoScroll.tsx b/src/photo/InfinitePhotoScroll.tsx index 36d854d0..63374168 100644 --- a/src/photo/InfinitePhotoScroll.tsx +++ b/src/photo/InfinitePhotoScroll.tsx @@ -24,6 +24,7 @@ export type RevalidatePhoto = ( ) => Promise; export default function InfinitePhotoScroll({ + initialPhotos, cacheKey, initialOffset, itemsPerPage, @@ -34,6 +35,7 @@ export default function InfinitePhotoScroll({ year, camera, lens, + album, tag, recipe, film, @@ -44,6 +46,9 @@ export default function InfinitePhotoScroll({ includeHiddenPhotos, children, }: { + // Required for masonry grid: + // initialPhotos necessary to build layout without random gaps + initialPhotos?: Photo[] initialOffset: number itemsPerPage: number sortBy?: SortBy @@ -87,6 +92,7 @@ export default function InfinitePhotoScroll({ year, camera, lens, + album, tag, recipe, film, @@ -104,6 +110,7 @@ export default function InfinitePhotoScroll({ year, camera, lens, + album, tag, recipe, film, @@ -168,18 +175,31 @@ export default function InfinitePhotoScroll({ ; + const flattenedPhotos = initialPhotos + ? initialPhotos.concat(data?.flat() ?? []) + : undefined; + return ( <> - {data?.map((photos, index) => ( - children({ - key: `${cacheKey}-${index}`, - photos, - onLastPhotoVisible: index === data.length - 1 - ? advance - : undefined, + {flattenedPhotos + ? children({ + key: cacheKey, + photos: flattenedPhotos, + onLastPhotoVisible: !isFinished ? advance : undefined, revalidatePhoto, }) - ))} + : ( + data?.map((photos, index) => ( + children({ + key: `${cacheKey}-${index}`, + photos, + onLastPhotoVisible: index === data.length - 1 + ? advance + : undefined, + revalidatePhoto, + }) + )) + )} {!isFinished &&
{wrapMoreButtonInGrid ? diff --git a/src/photo/PhotoGrid.tsx b/src/photo/PhotoGrid.tsx index 8a882e4c..7923abc1 100644 --- a/src/photo/PhotoGrid.tsx +++ b/src/photo/PhotoGrid.tsx @@ -5,13 +5,17 @@ import { PhotoSetCategory } from '../category'; import PhotoMedium from './PhotoMedium'; import { clsx } from 'clsx/lite'; 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 SelectTileOverlay from '@/components/SelectTileOverlay'; import { ReactNode } from 'react'; import { GRID_GAP_CLASSNAME } from '@/components'; import { useSelectPhotosState } from '@/admin/select/SelectPhotosState'; import { DATA_KEY_PHOTO_GRID } from '@/admin/select/SelectPhotosProvider'; +import PhotoGridMasonry from './PhotoGridMasonry'; export default function PhotoGrid({ photos, @@ -54,6 +58,76 @@ export default function PhotoGrid({ togglePhotoSelection, } = useSelectPhotosState(); + const photoNodes = photos.map((photo, index) => { + const isSelected = ( + selectedPhotoIds?.includes(photo.id) || + isSelectingAllPhotos + ) ?? false; + return
+ + {isSelectingPhotos && + togglePhotoSelection?.(photo.id)} + />} +
; + }); + + const allItems = photoNodes.concat( + additionalTile ? [
{additionalTile}
] : [], + ); + + if (MASONRY_GRID_ENABLED) { + return ( + + ); + } + return (
{ - const isSelected = ( - selectedPhotoIds?.includes(photo.id) || - isSelectingAllPhotos - ) ?? false; - return
- - {isSelectingPhotos && - togglePhotoSelection?.(photo.id)} - />} -
; - }).concat(additionalTile ? <>{additionalTile} : [])} + items={allItems} itemKeys={photos.map(photo => photo.id) .concat(additionalTile ? ['more'] : [])} /> diff --git a/src/photo/PhotoGridContainer.tsx b/src/photo/PhotoGridContainer.tsx index ce3be8c5..f3689d2a 100644 --- a/src/photo/PhotoGridContainer.tsx +++ b/src/photo/PhotoGridContainer.tsx @@ -8,6 +8,7 @@ import AnimateItems from '@/components/AnimateItems'; import { ComponentProps, useCallback, useState, ReactNode } from 'react'; import { GRID_SPACE_CLASSNAME } from '@/components'; import { SortBy } from './sort'; +import { MASONRY_GRID_ENABLED } from '@/app/config'; export default function PhotoGridContainer({ cacheKey, @@ -31,6 +32,9 @@ export default function PhotoGridContainer({ sidebar?: ReactNode className?: string } & ComponentProps) { + const shouldRenderInitialGrid = + !MASONRY_GRID_ENABLED || count <= photos.length; + const [ shouldAnimateDynamicItems, setShouldAnimateDynamicItems, @@ -51,15 +55,18 @@ export default function PhotoGridContainer({ animateOnFirstLoadOnly />}
- + {shouldRenderInitialGrid && ( + + )} {count > photos.length && , 'photos'>) { return ( { + 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( +
{additionalTile}
, + ); + colHeights[shortestColIndex] += 1; + } + + return ( +
+
+ {partitionedColumns.map((colItems, i) => ( + `col-${i}-item-${index}`)} + /> + ))} +
+
+ ); +}