Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 55 additions & 4 deletions src/components/products/MediaGallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import type { Media } from "@spree/sdk";
import { ZoomIn } from "lucide-react";
import dynamic from "next/dynamic";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useCallback, useRef, useState } from "react";
import { ProductImage } from "@/components/ui/product-image";

const SWIPE_THRESHOLD_PX = 50;
const SWIPE_MAX_VERTICAL_PX = 75;

/** Tiny 10×10 neutral gray PNG used as a blur placeholder while images load. */
const BLUR_PLACEHOLDER =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAIElEQVQYV2P4////MwwMDAxMDAwMDGQJMJCvkGwNZCsEAGebBwVss9lRAAAAAElFTkSuQmCC";
Expand Down Expand Up @@ -63,6 +66,47 @@ function MediaGalleryInner({
null,
);

const safeIndex = Math.max(0, Math.min(selectedIndex, images.length - 1));

// Horizontal swipe on the main image navigates between media. When a
// swipe is detected we suppress the synthetic click so the lightbox
// doesn't open from the same gesture.
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
const suppressClickRef = useRef(false);

const handleTouchStart = useCallback((e: React.TouchEvent) => {
const touch = e.touches[0];
if (!touch) return;
touchStartRef.current = { x: touch.clientX, y: touch.clientY };
suppressClickRef.current = false;
}, []);

const handleTouchEnd = useCallback(
(e: React.TouchEvent) => {
const start = touchStartRef.current;
touchStartRef.current = null;
if (!start || images.length <= 1) return;
const touch = e.changedTouches[0];
if (!touch) return;
const dx = touch.clientX - start.x;
const dy = touch.clientY - start.y;
if (
Math.abs(dx) < SWIPE_THRESHOLD_PX ||
Math.abs(dy) > SWIPE_MAX_VERTICAL_PX
) {
return;
}
suppressClickRef.current = true;
const nextIndex =
dx < 0
? (safeIndex + 1) % images.length
: (safeIndex - 1 + images.length) % images.length;
setSelectedIndex(nextIndex);
setMainImageErrorUrl(null);
},
[images.length, safeIndex],
);

if (images.length === 0) {
return (
<div className="relative aspect-square bg-gray-100 rounded-xl overflow-hidden">
Expand All @@ -81,7 +125,6 @@ function MediaGalleryInner({
setMainImageErrorUrl(null);
};

const safeIndex = Math.max(0, Math.min(selectedIndex, images.length - 1));
const selectedImage = images[safeIndex];
const mainImageUrl = getMainImageUrl(selectedImage);
const showMainImage = mainImageUrl && mainImageErrorUrl !== mainImageUrl;
Expand All @@ -91,8 +134,16 @@ function MediaGalleryInner({
{/* Main Image */}
<button
type="button"
className="relative aspect-square bg-gray-100 rounded-xl overflow-hidden cursor-zoom-in w-full"
onClick={() => showMainImage && setIsZoomed(true)}
className="relative aspect-square bg-gray-100 rounded-xl overflow-hidden cursor-zoom-in w-full touch-pan-y"
onClick={() => {
if (suppressClickRef.current) {
suppressClickRef.current = false;
return;
}
if (showMainImage) setIsZoomed(true);
}}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
aria-label={t("openImageZoom")}
disabled={!showMainImage}
>
Expand Down
71 changes: 63 additions & 8 deletions src/components/products/MediaLightbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import Image from "next/image";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useRef } from "react";

const SWIPE_THRESHOLD_PX = 50;
const SWIPE_MAX_VERTICAL_PX = 75;

interface MediaLightboxProps {
images: Media[];
activeIndex: number;
Expand Down Expand Up @@ -36,6 +39,13 @@ export function MediaLightbox({
const src =
current?.xlarge_url || current?.large_url || current?.original_url || null;
const closeButtonRef = useRef<HTMLButtonElement>(null);
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
// Tracks whether a pointerdown happened on the backdrop itself (vs.
// bubbled up from a child). Without this, the synthetic click fired
// by the opening tap on the parent <button> in MediaGallery lands on
// the freshly-mounted backdrop and immediately closes the lightbox
// on mobile.
const pressedOnBackdropRef = useRef(false);

const goPrev = useCallback(() => {
onNavigate(activeIndex === 0 ? images.length - 1 : activeIndex - 1);
Expand All @@ -45,6 +55,39 @@ export function MediaLightbox({
onNavigate(activeIndex === images.length - 1 ? 0 : activeIndex + 1);
}, [activeIndex, images.length, onNavigate]);

const handleTouchStart = useCallback((e: React.TouchEvent) => {
const touch = e.touches[0];
if (!touch) return;
touchStartRef.current = { x: touch.clientX, y: touch.clientY };
}, []);

const handleTouchEnd = useCallback(
(e: React.TouchEvent) => {
const start = touchStartRef.current;
touchStartRef.current = null;
if (!start || images.length <= 1) return;
const touch = e.changedTouches[0];
if (!touch) return;
const dx = touch.clientX - start.x;
const dy = touch.clientY - start.y;
if (
Math.abs(dx) < SWIPE_THRESHOLD_PX ||
Math.abs(dy) > SWIPE_MAX_VERTICAL_PX
) {
return;
}
// Swallow the synthetic backdrop click that follows touchend so a
// swipe navigates without also dismissing the lightbox.
pressedOnBackdropRef.current = false;
if (dx < 0) {
goNext();
} else {
goPrev();
}
},
[goNext, goPrev, images.length],
);

useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
Expand Down Expand Up @@ -73,14 +116,27 @@ export function MediaLightbox({
role="dialog"
aria-modal="true"
aria-label={t("openImageZoom")}
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
onClick={onClose}
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center touch-pan-y"
onPointerDown={(e) => {
pressedOnBackdropRef.current = e.target === e.currentTarget;
}}
onClick={(e) => {
if (e.target === e.currentTarget && pressedOnBackdropRef.current) {
onClose();
}
pressedOnBackdropRef.current = false;
}}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
<button
ref={closeButtonRef}
type="button"
className="absolute top-4 right-4 text-white p-2 hover:bg-white/10 rounded-lg transition-colors"
onClick={onClose}
className="absolute top-4 right-4 z-10 text-white p-3 hover:bg-white/10 rounded-lg transition-colors"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
aria-label={t("lightboxClose")}
>
<X className="w-8 h-8" />
Expand All @@ -90,7 +146,7 @@ export function MediaLightbox({
<>
<button
type="button"
className="absolute left-4 top-1/2 -translate-y-1/2 text-white p-2 hover:bg-white/10 rounded-lg transition-colors"
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 text-white p-3 hover:bg-white/10 rounded-lg transition-colors"
onClick={(e) => {
e.stopPropagation();
goPrev();
Expand All @@ -101,7 +157,7 @@ export function MediaLightbox({
</button>
<button
type="button"
className="absolute right-4 top-1/2 -translate-y-1/2 text-white p-2 hover:bg-white/10 rounded-lg transition-colors"
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 text-white p-3 hover:bg-white/10 rounded-lg transition-colors"
onClick={(e) => {
e.stopPropagation();
goNext();
Expand All @@ -118,9 +174,8 @@ export function MediaLightbox({
src={src}
alt={current?.alt || productName}
fill
className="object-contain"
className="object-contain pointer-events-none"
sizes="100vw"
onClick={(e) => e.stopPropagation()}
/>
</div>

Expand Down
Loading