This commit is contained in:
+154
-145
@@ -3,185 +3,194 @@
|
||||
import { ImageMeta } from '@/interfaces/image';
|
||||
import styles from '@/styles/Gallery.module.scss';
|
||||
import Image from 'next/image';
|
||||
import { useRouter as useNavigationRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
interface GalleryProps {
|
||||
initialImages: ImageMeta[];
|
||||
}
|
||||
|
||||
export default function Gallery({ initialImages }: GalleryProps) {
|
||||
const HORIZONTAL_ASPECT_RATIO = 1.8;
|
||||
const VERTICAL_ASPECT_RATIO = 0.8;
|
||||
const HORIZONTAL_ASPECT_RATIO = 1.8;
|
||||
const VERTICAL_ASPECT_RATIO = 0.8;
|
||||
|
||||
const navigationRouter = useNavigationRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [images, setImages] = useState<ImageMeta[]>([]);
|
||||
const [columnCount, setColumnCount] = useState(0);
|
||||
const [fullScreenImageId, setFullScreenImageId] = useState<string | null>(null);
|
||||
export default function Gallery({
|
||||
initialImages,
|
||||
}: GalleryProps) {
|
||||
const [fullScreenImageId, setFullScreenImageIdState] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const updateColumns = () => {
|
||||
const imageStack = [...initialImages];
|
||||
const newImages: ImageMeta[] = [];
|
||||
/*
|
||||
* Update the URL without causing a Next.js navigation.
|
||||
*
|
||||
* router.replace() would render the server page again, causing
|
||||
* /api/images and /api/tags to be requested again.
|
||||
*/
|
||||
const setFullScreenImage = useCallback((imageId: string | null) => {
|
||||
setFullScreenImageIdState(imageId);
|
||||
|
||||
if (imageStack.length > 0) {
|
||||
let usedColumnsInRow = 0;
|
||||
let usedRowsInColumn = 0;
|
||||
let index = 0;
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
for (const image of imageStack) {
|
||||
usedColumnsInRow += image.aspect_ratio > HORIZONTAL_ASPECT_RATIO ? 2 : 1;
|
||||
usedRowsInColumn += image.aspect_ratio < VERTICAL_ASPECT_RATIO ? 2 : 1;
|
||||
|
||||
if (usedColumnsInRow > columnCount) {
|
||||
const nextViableImage: ImageMeta | undefined = imageStack.slice(index).find((img) => {
|
||||
const imgAspectRatio = img.aspect_ratio;
|
||||
return (
|
||||
imgAspectRatio <= HORIZONTAL_ASPECT_RATIO &&
|
||||
image.aspect_ratio >= VERTICAL_ASPECT_RATIO &&
|
||||
!newImages.find((i) => i.id === img.id)
|
||||
);
|
||||
});
|
||||
|
||||
if (nextViableImage) {
|
||||
newImages.push(nextViableImage);
|
||||
|
||||
usedColumnsInRow -= nextViableImage.aspect_ratio > HORIZONTAL_ASPECT_RATIO ? 2 : 1;
|
||||
usedRowsInColumn -= nextViableImage.aspect_ratio < VERTICAL_ASPECT_RATIO ? 2 : 1;
|
||||
}
|
||||
} else if (usedColumnsInRow === columnCount) {
|
||||
newImages.push(image);
|
||||
usedColumnsInRow = usedRowsInColumn;
|
||||
usedRowsInColumn = 0;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
|
||||
setImages(imageStack);
|
||||
}
|
||||
};
|
||||
|
||||
const updateColumnCount = () => {
|
||||
const container = document.querySelector(`.${styles.images}`);
|
||||
if (!container) return;
|
||||
|
||||
const newColumnCount = window
|
||||
.getComputedStyle(container)
|
||||
.getPropertyValue('grid-template-columns')
|
||||
.split(' ').length;
|
||||
|
||||
if (newColumnCount !== columnCount) {
|
||||
setColumnCount(newColumnCount);
|
||||
}
|
||||
};
|
||||
|
||||
updateColumns();
|
||||
updateColumnCount();
|
||||
window.addEventListener('resize', updateColumnCount);
|
||||
return () => window.removeEventListener('resize', updateColumnCount);
|
||||
}, [columnCount, initialImages]);
|
||||
|
||||
useEffect(() => {
|
||||
const imageId = searchParams.get('image');
|
||||
setFullScreenImageId(imageId);
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (fullScreenImageId) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('image', fullScreenImageId);
|
||||
navigationRouter.replace(`?${params.toString()}`, { scroll: false });
|
||||
|
||||
document.body.style.overflow = 'hidden';
|
||||
if (imageId) {
|
||||
url.searchParams.set('image', imageId);
|
||||
} else {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('image');
|
||||
navigationRouter.replace(`?${params.toString()}`, { scroll: false });
|
||||
|
||||
document.body.style.overflow = '';
|
||||
url.searchParams.delete('image');
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [fullScreenImageId, searchParams, navigationRouter]);
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
`${url.pathname}${url.search}${url.hash}`,
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Read a fullscreen image from the initial URL.
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
navigationRouter.replace(`?${params.toString()}`, { scroll: false });
|
||||
}, [searchParams, navigationRouter]);
|
||||
const synchronizeWithUrl = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
setFullScreenImageIdState(params.get('image'));
|
||||
};
|
||||
|
||||
synchronizeWithUrl();
|
||||
|
||||
window.addEventListener('popstate', synchronizeWithUrl);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('popstate', synchronizeWithUrl);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Prevent the gallery page from scrolling while the modal is open.
|
||||
useEffect(() => {
|
||||
if (!fullScreenImageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setFullScreenImage(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.body.style.overflow = 'hidden';
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [fullScreenImageId, setFullScreenImage]);
|
||||
|
||||
const currentImageIndex = fullScreenImageId
|
||||
? initialImages.findIndex(
|
||||
(image) => image.id === fullScreenImageId,
|
||||
)
|
||||
: -1;
|
||||
|
||||
const previousImage =
|
||||
currentImageIndex > 0
|
||||
? initialImages[currentImageIndex - 1]
|
||||
: null;
|
||||
|
||||
const nextImage =
|
||||
currentImageIndex >= 0 &&
|
||||
currentImageIndex < initialImages.length - 1
|
||||
? initialImages[currentImageIndex + 1]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={styles.gallery}>
|
||||
<div className={styles.images}>
|
||||
{images.map((image: ImageMeta) => (
|
||||
<div
|
||||
key={uuidv4()}
|
||||
className={`${styles.imagesContainer} ${
|
||||
image.aspect_ratio > HORIZONTAL_ASPECT_RATIO
|
||||
? styles.horizontal
|
||||
: image.aspect_ratio < VERTICAL_ASPECT_RATIO
|
||||
? styles.vertical
|
||||
: ''
|
||||
}`}>
|
||||
<Image
|
||||
width={image.aspect_ratio > HORIZONTAL_ASPECT_RATIO ? image.width : 700}
|
||||
height={image.aspect_ratio < VERTICAL_ASPECT_RATIO ? image.height : 700}
|
||||
loading="lazy"
|
||||
src={`/api/images/${image.id}`}
|
||||
alt={image.aspect_ratio?.toString()}
|
||||
onClick={() => setFullScreenImageId(image.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{initialImages.map((image) => {
|
||||
const isHorizontal =
|
||||
image.aspect_ratio > HORIZONTAL_ASPECT_RATIO;
|
||||
|
||||
const isVertical =
|
||||
image.aspect_ratio < VERTICAL_ASPECT_RATIO;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={image.id}
|
||||
className={`${styles.imagesContainer} ${
|
||||
isHorizontal
|
||||
? styles.horizontal
|
||||
: isVertical
|
||||
? styles.vertical
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={`/api/images/${image.id}`}
|
||||
alt={`Gallery image ${image.id}`}
|
||||
width={isHorizontal ? image.width : 700}
|
||||
height={isVertical ? image.height : 700}
|
||||
loading="lazy"
|
||||
onClick={() => setFullScreenImage(image.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{fullScreenImageId && (
|
||||
<div className={styles.fullscreenModal} onClick={() => setFullScreenImageId(null)}>
|
||||
<div
|
||||
className={styles.fullscreenModal}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Fullscreen image"
|
||||
onClick={() => setFullScreenImage(null)}
|
||||
>
|
||||
<Image
|
||||
src={`/api/images/${fullScreenImageId}`}
|
||||
alt="Full Screen"
|
||||
alt="Fullscreen gallery image"
|
||||
width={1920}
|
||||
height={1080}
|
||||
style={{ objectFit: 'contain' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.closeButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFullScreenImageId(null);
|
||||
}}>
|
||||
aria-label="Close fullscreen image"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setFullScreenImage(null);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`${styles.arrowButton} ${styles.arrowButtonLeft}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const currentIndex = images.findIndex((image) => image.id === fullScreenImageId);
|
||||
if (currentIndex > 0) {
|
||||
setFullScreenImageId(images[currentIndex - 1].id);
|
||||
}
|
||||
}}>
|
||||
‹
|
||||
</button>
|
||||
{previousImage && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.arrowButton} ${styles.arrowButtonLeft}`}
|
||||
aria-label="Previous image"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setFullScreenImage(previousImage.id);
|
||||
}}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={`${styles.arrowButton} ${styles.arrowButtonRight}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const currentIndex = images.findIndex((image) => image.id === fullScreenImageId);
|
||||
if (currentIndex < images.length - 1) {
|
||||
setFullScreenImageId(images[currentIndex + 1].id);
|
||||
}
|
||||
}}>
|
||||
›
|
||||
</button>
|
||||
{nextImage && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.arrowButton} ${styles.arrowButtonRight}`}
|
||||
aria-label="Next image"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setFullScreenImage(nextImage.id);
|
||||
}}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user