Add minor UX improvements
Build and push Docker image / build-and-push (push) Successful in 3m46s

This commit is contained in:
2026-08-31 05:35:53 +02:00
parent b11366fb4e
commit 7daf2b1783
9 changed files with 547 additions and 241 deletions
+154 -145
View File
@@ -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);
}}
>
&times;
</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);
}
}}>
&#8249;
</button>
{previousImage && (
<button
type="button"
className={`${styles.arrowButton} ${styles.arrowButtonLeft}`}
aria-label="Previous image"
onClick={(event) => {
event.stopPropagation();
setFullScreenImage(previousImage.id);
}}
>
&#8249;
</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);
}
}}>
&#8250;
</button>
{nextImage && (
<button
type="button"
className={`${styles.arrowButton} ${styles.arrowButtonRight}`}
aria-label="Next image"
onClick={(event) => {
event.stopPropagation();
setFullScreenImage(nextImage.id);
}}
>
&#8250;
</button>
)}
</div>
)}
</div>
);
}
}