Files
cozsweet-frontend-nextjs/src/app/private-zone/components/private-album-gallery.tsx
T

245 lines
7.4 KiB
TypeScript

"use client";
import {
type CSSProperties,
type PointerEvent as ReactPointerEvent,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import Image from "next/image";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
import type { PrivateAlbum } from "@/data/schemas/private-zone";
import {
getResistedGalleryDragDistance,
resolveGallerySwipeDirection,
} from "../private-album-gallery-motion";
import { getPrivateAlbumDisplayImages } from "../private-album-images";
import styles from "../private-zone-screen.module.css";
export interface PrivateAlbumGalleryProps {
album: PrivateAlbum;
imageIndex: number;
onClose: () => void;
onImageIndexChange: (index: number) => void;
}
interface ActivePointerGesture {
pointerId: number;
startX: number;
startTime: number;
startPosition: number;
viewportWidth: number;
}
type GallerySlideStyle = CSSProperties & {
"--gallery-slide-offset": string;
"--gallery-drag-offset": string;
};
export function PrivateAlbumGallery({
album,
imageIndex,
onClose,
onImageIndexChange,
}: PrivateAlbumGalleryProps) {
const displayImages = useMemo(
() => getPrivateAlbumDisplayImages(album),
[album],
);
const [activeSourceIndex, setActiveSourceIndex] = useState(imageIndex);
const [dragOffset, setDragOffset] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const gestureRef = useRef<ActivePointerGesture | null>(null);
const activePosition = displayImages.findIndex(
(image) => image.sourceIndex === activeSourceIndex,
);
const currentImage = displayImages[activePosition];
const hasPrevious = activePosition > 0;
const hasNext =
activePosition >= 0 && activePosition < displayImages.length - 1;
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
if (event.key === "ArrowLeft" && hasPrevious) {
const previousImage = displayImages[activePosition - 1];
if (previousImage) {
setActiveSourceIndex(previousImage.sourceIndex);
onImageIndexChange(previousImage.sourceIndex);
}
}
if (event.key === "ArrowRight" && hasNext) {
const nextImage = displayImages[activePosition + 1];
if (nextImage) {
setActiveSourceIndex(nextImage.sourceIndex);
onImageIndexChange(nextImage.sourceIndex);
}
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [
activePosition,
displayImages,
hasNext,
hasPrevious,
onClose,
onImageIndexChange,
]);
if (!currentImage) return null;
const changeToPosition = (position: number) => {
const nextImage = displayImages[position];
if (!nextImage || position === activePosition) return;
setActiveSourceIndex(nextImage.sourceIndex);
setDragOffset(0);
setIsDragging(false);
onImageIndexChange(nextImage.sourceIndex);
};
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0 || displayImages.length <= 1) return;
const viewportWidth = event.currentTarget.clientWidth || window.innerWidth;
gestureRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startTime: event.timeStamp,
startPosition: activePosition,
viewportWidth,
};
event.currentTarget.setPointerCapture(event.pointerId);
setDragOffset(0);
setIsDragging(true);
};
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const gesture = gestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return;
event.preventDefault();
const distance = event.clientX - gesture.startX;
setDragOffset(
getResistedGalleryDragDistance(
distance,
gesture.startPosition > 0,
gesture.startPosition < displayImages.length - 1,
),
);
};
const finishPointerGesture = (
event: ReactPointerEvent<HTMLDivElement>,
cancelled: boolean,
) => {
const gesture = gestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return;
gestureRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
const distance = event.clientX - gesture.startX;
const direction = cancelled
? 0
: resolveGallerySwipeDirection({
distance,
durationMs: event.timeStamp - gesture.startTime,
viewportWidth: gesture.viewportWidth,
hasPrevious: gesture.startPosition > 0,
hasNext: gesture.startPosition < displayImages.length - 1,
});
setIsDragging(false);
setDragOffset(0);
if (direction !== 0) {
changeToPosition(gesture.startPosition + direction);
}
};
return (
<div
className={styles.galleryOverlay}
role="dialog"
aria-modal="true"
aria-label={album.title || "Private album gallery"}
>
<button
type="button"
className={styles.galleryClose}
onClick={onClose}
aria-label="Close private album gallery"
>
<X size={24} aria-hidden="true" />
</button>
<div
className={styles.galleryViewport}
data-testid="private-album-gallery-viewport"
data-dragging={isDragging ? "true" : "false"}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={(event) => finishPointerGesture(event, false)}
onPointerCancel={(event) => finishPointerGesture(event, true)}
onLostPointerCapture={(event) => finishPointerGesture(event, true)}
>
{displayImages.map((image, position) => {
if (Math.abs(position - activePosition) > 1) return null;
const slideStyle = {
"--gallery-slide-offset": `${(position - activePosition) * 100}%`,
"--gallery-drag-offset": `${dragOffset}px`,
} as GallerySlideStyle;
return (
<div
key={`${image.sourceIndex}:${image.url}`}
className={styles.gallerySlide}
style={slideStyle}
aria-hidden={position !== activePosition}
>
<Image
src={image.url}
alt={`${album.title || "Private album"} photo ${position + 1}`}
fill
loading="eager"
fetchPriority={position === activePosition ? "high" : "auto"}
draggable={false}
sizes="(max-width: 540px) 100vw, 540px"
className={styles.galleryImage}
/>
</div>
);
})}
</div>
<span className={styles.galleryCounter} aria-live="polite">
{activePosition + 1} / {displayImages.length}
</span>
{hasPrevious ? (
<button
type="button"
className={`${styles.galleryNav} ${styles.galleryPrevious}`}
onClick={() => changeToPosition(activePosition - 1)}
aria-label="Previous photo"
>
<ChevronLeft size={30} aria-hidden="true" />
</button>
) : null}
{hasNext ? (
<button
type="button"
className={`${styles.galleryNav} ${styles.galleryNext}`}
onClick={() => changeToPosition(activePosition + 1)}
aria-label="Next photo"
>
<ChevronRight size={30} aria-hidden="true" />
</button>
) : null}
</div>
);
}