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:
parent
af48b8d6d2
commit
86d4f3dfca
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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'}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
31
src/components/icons/IconGridMasonry.tsx
Normal file
31
src/components/icons/IconGridMasonry.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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} />
|
||||||
|
|||||||
@ -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'] : [])}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
131
src/photo/PhotoGridMasonry.tsx
Normal file
131
src/photo/PhotoGridMasonry.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user