9fff5b16fd
Render unlocked albums as adaptive nine-image grids and open the selected source image in the Gallery. Add pointer-driven swipe thresholds, edge resistance, animated snapping, adjacent-image loading, reduced-motion support, and coverage for filtering and navigation.
245 lines
7.4 KiB
TypeScript
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-room";
|
|
|
|
import {
|
|
getResistedGalleryDragDistance,
|
|
resolveGallerySwipeDirection,
|
|
} from "../private-album-gallery-motion";
|
|
import { getPrivateAlbumDisplayImages } from "../private-album-images";
|
|
import styles from "../private-room-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>
|
|
);
|
|
}
|