refactor(private-zone): use canonical product name
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user