gallery - ditched alpine
This commit is contained in:
parent
adc83d893c
commit
aa7c039163
@ -1,17 +1,17 @@
|
|||||||
---
|
---
|
||||||
import { Img } from "imagetools/components";
|
import { Img } from "imagetools/components";
|
||||||
|
|
||||||
import Translate from "./i18n.astro"
|
import Translate from "./i18n.astro";
|
||||||
import { translate } from "../base/i18n";
|
import { translate } from "../base/i18n";
|
||||||
|
|
||||||
import { I18N_SOURCE_LANGUAGE, IMAGE_SETTINGS } from "../app/config.js"
|
import { I18N_SOURCE_LANGUAGE, IMAGE_SETTINGS } from "../app/config.js";
|
||||||
|
|
||||||
interface Image {
|
interface Image {
|
||||||
alt: string
|
alt: string;
|
||||||
src: string
|
src: string;
|
||||||
title?: string
|
title?: string;
|
||||||
description?: string
|
description?: string;
|
||||||
hash?: string
|
hash?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
@ -37,188 +37,124 @@ export interface Props {
|
|||||||
const { images, gallerySettings = {}, lightboxSettings = {}, s } = Astro.props;
|
const { images, gallerySettings = {}, lightboxSettings = {}, s } = Astro.props;
|
||||||
|
|
||||||
const mergedGallerySettings = {
|
const mergedGallerySettings = {
|
||||||
SIZES_REGULAR: gallerySettings.SIZES_REGULAR || IMAGE_SETTINGS.gallery.sizes_regular,
|
SIZES_REGULAR:
|
||||||
SIZES_THUMB: gallerySettings.SIZES_THUMB || IMAGE_SETTINGS.gallery.sizes_thumb,
|
gallerySettings.SIZES_REGULAR || IMAGE_SETTINGS.gallery.sizes_regular,
|
||||||
|
SIZES_THUMB:
|
||||||
|
gallerySettings.SIZES_THUMB || IMAGE_SETTINGS.gallery.sizes_thumb,
|
||||||
SHOW_TITLE: gallerySettings.SHOW_TITLE ?? IMAGE_SETTINGS.gallery.show_title,
|
SHOW_TITLE: gallerySettings.SHOW_TITLE ?? IMAGE_SETTINGS.gallery.show_title,
|
||||||
SHOW_DESCRIPTION: gallerySettings.SHOW_DESCRIPTION ?? IMAGE_SETTINGS.gallery.show_description,
|
SHOW_DESCRIPTION:
|
||||||
|
gallerySettings.SHOW_DESCRIPTION ?? IMAGE_SETTINGS.gallery.show_description,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mergedLightboxSettings = {
|
const mergedLightboxSettings = {
|
||||||
SIZES_LARGE: lightboxSettings.SIZES_LARGE || IMAGE_SETTINGS.lightbox.sizes_large,
|
SIZES_LARGE:
|
||||||
|
lightboxSettings.SIZES_LARGE || IMAGE_SETTINGS.lightbox.sizes_large,
|
||||||
SHOW_TITLE: lightboxSettings.SHOW_TITLE ?? IMAGE_SETTINGS.lightbox.show_title,
|
SHOW_TITLE: lightboxSettings.SHOW_TITLE ?? IMAGE_SETTINGS.lightbox.show_title,
|
||||||
SHOW_DESCRIPTION: lightboxSettings.SHOW_DESCRIPTION ?? IMAGE_SETTINGS.lightbox.show_description,
|
SHOW_DESCRIPTION:
|
||||||
|
lightboxSettings.SHOW_DESCRIPTION ??
|
||||||
|
IMAGE_SETTINGS.lightbox.show_description,
|
||||||
};
|
};
|
||||||
const locale = Astro.currentLocale || "en";
|
const locale = Astro.currentLocale || "en";
|
||||||
|
|
||||||
|
// Pre-calculate translations to allow await usage (which isn't allowed effectively in synchronous JSX map)
|
||||||
|
const translatedImages = await Promise.all(
|
||||||
|
images.map(async (img) => ({
|
||||||
|
...img,
|
||||||
|
localizedAlt: await translate(img.alt, I18N_SOURCE_LANGUAGE, locale),
|
||||||
|
// We can also pre-calculate description if we wanted to avoid the <Translate> component for consistency,
|
||||||
|
// but <Translate> component is likely handling it fine.
|
||||||
|
// However, for JS safety let's assume raw string access in logic might benefit from it if we moved it to vanilla,
|
||||||
|
// but here it's about the 'attributes' prop in Img.
|
||||||
|
})),
|
||||||
|
);
|
||||||
---
|
---
|
||||||
|
|
||||||
<div
|
<div
|
||||||
x-data={`
|
class="product-gallery bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden"
|
||||||
{
|
data-images={JSON.stringify(translatedImages)}
|
||||||
open: false,
|
data-total={translatedImages.length}
|
||||||
currentIndex: 0,
|
>
|
||||||
total: ${images.length},
|
<div class="flex flex-col h-full p-4">
|
||||||
lightboxLoaded: false,
|
|
||||||
touchStartX: 0,
|
|
||||||
touchEndX: 0,
|
|
||||||
minSwipeDistance: 50,
|
|
||||||
isSwiping: false,
|
|
||||||
images: ${JSON.stringify(images)},
|
|
||||||
lastTapTime: 0,
|
|
||||||
doubleTapDelay: 300,
|
|
||||||
scale: 1,
|
|
||||||
panX: 0,
|
|
||||||
panY: 0,
|
|
||||||
isPanning: false,
|
|
||||||
startPanX: 0,
|
|
||||||
startPanY: 0,
|
|
||||||
handleSwipe() {
|
|
||||||
if (!this.isSwiping || this.scale > 1) return; // Disable swipe when zoomed
|
|
||||||
const swipeDistance = this.touchEndX - this.touchStartX;
|
|
||||||
if (Math.abs(swipeDistance) >= this.minSwipeDistance) {
|
|
||||||
if (swipeDistance > 0 && this.currentIndex > 0) {
|
|
||||||
// Swiped right, show previous image
|
|
||||||
this.currentIndex--;
|
|
||||||
this.resetZoom();
|
|
||||||
} else if (swipeDistance < 0 && this.currentIndex < this.total - 1) {
|
|
||||||
// Swiped left, show next image
|
|
||||||
this.currentIndex++;
|
|
||||||
this.resetZoom();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.isSwiping = false;
|
|
||||||
},
|
|
||||||
preloadAndOpen() {
|
|
||||||
if (this.isSwiping) return;
|
|
||||||
this.lightboxLoaded = false;
|
|
||||||
this.resetZoom();
|
|
||||||
let img = new Image();
|
|
||||||
img.src = this.images[this.currentIndex].src;
|
|
||||||
img.onload = () => {
|
|
||||||
this.lightboxLoaded = true;
|
|
||||||
this.open = true;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
preloadImage(index) {
|
|
||||||
// Preload without affecting lightboxLoaded state (for navigation within lightbox)
|
|
||||||
this.resetZoom();
|
|
||||||
let img = new Image();
|
|
||||||
img.src = this.images[index].src;
|
|
||||||
// No need to wait for load when navigating within lightbox
|
|
||||||
},
|
|
||||||
handleImageInteraction(index) {
|
|
||||||
const currentTime = Date.now();
|
|
||||||
const timeDiff = currentTime - this.lastTapTime;
|
|
||||||
|
|
||||||
if (timeDiff < this.doubleTapDelay) {
|
|
||||||
// Double tap/click detected - open lightbox
|
|
||||||
this.currentIndex = index;
|
|
||||||
this.preloadAndOpen();
|
|
||||||
} else {
|
|
||||||
// Single tap/click - just change current image
|
|
||||||
this.currentIndex = index;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.lastTapTime = currentTime;
|
|
||||||
},
|
|
||||||
resetZoom() {
|
|
||||||
this.scale = 1;
|
|
||||||
this.panX = 0;
|
|
||||||
this.panY = 0;
|
|
||||||
this.isPanning = false;
|
|
||||||
},
|
|
||||||
handleWheel(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const zoomSpeed = 0.1;
|
|
||||||
const newScale = this.scale - Math.sign(e.deltaY) * zoomSpeed;
|
|
||||||
this.scale = Math.min(Math.max(1, newScale), 5); // Limit zoom between 1x and 5x
|
|
||||||
|
|
||||||
if (this.scale === 1) {
|
|
||||||
this.panX = 0;
|
|
||||||
this.panY = 0;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
handlePanStart(e) {
|
|
||||||
if (this.scale <= 1) return;
|
|
||||||
this.isPanning = true;
|
|
||||||
this.startPanX = e.clientX - this.panX;
|
|
||||||
this.startPanY = e.clientY - this.panY;
|
|
||||||
e.preventDefault();
|
|
||||||
},
|
|
||||||
handlePanMove(e) {
|
|
||||||
if (!this.isPanning) return;
|
|
||||||
e.preventDefault();
|
|
||||||
this.panX = e.clientX - this.startPanX;
|
|
||||||
this.panY = e.clientY - this.startPanY;
|
|
||||||
},
|
|
||||||
handlePanEnd(e) {
|
|
||||||
this.isPanning = false;
|
|
||||||
},
|
|
||||||
toggleZoom(e) {
|
|
||||||
if (this.scale > 1) {
|
|
||||||
this.resetZoom();
|
|
||||||
} else {
|
|
||||||
this.scale = 2; // Zoom to 2x on double click
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
@keydown.escape.window="open = false"
|
|
||||||
@keydown.window="if(open){
|
|
||||||
if($event.key === 'ArrowRight' && currentIndex < total - 1){
|
|
||||||
currentIndex++;
|
|
||||||
preloadImage(currentIndex);
|
|
||||||
} else if($event.key === 'ArrowLeft' && currentIndex > 0){
|
|
||||||
currentIndex--;
|
|
||||||
preloadImage(currentIndex);
|
|
||||||
}
|
|
||||||
}"
|
|
||||||
class="product-gallery bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden"><div class="flex flex-col h-full p-4">
|
|
||||||
<!-- Main Image (with swipe functionality) -->
|
<!-- Main Image (with swipe functionality) -->
|
||||||
<div
|
<div
|
||||||
class="flex-1 flex items-center justify-center cursor-pointer rounded-lg touch-manipulation"
|
class="main-image-container flex-1 grid grid-cols-1 grid-rows-1 items-center justify-center cursor-pointer rounded-lg touch-manipulation relative overflow-hidden"
|
||||||
@click="preloadAndOpen()"
|
>
|
||||||
@touchstart="touchStartX = $event.touches[0].clientX; isSwiping = true;"
|
{
|
||||||
@touchend="touchEndX = $event.changedTouches[0].clientX; handleSwipe();"
|
translatedImages.map((image, index) => (
|
||||||
@touchcancel="isSwiping = false;"
|
<div
|
||||||
|
data-index={index}
|
||||||
|
class={`gallery-slide w-full h-full flex items-center justify-center transition-opacity duration-300 col-start-1 row-start-1 ${index === 0 ? "opacity-100 z-10" : "opacity-0 z-0"}`}
|
||||||
>
|
>
|
||||||
{images.map((image, index) => (
|
|
||||||
<div x-show={`currentIndex === ${index}`} key={index} class="w-full h-full flex items-center justify-center">
|
|
||||||
<Img
|
<Img
|
||||||
src={image.src}
|
src={image.src}
|
||||||
alt={image.alt,I18N_SOURCE_LANGUAGE,locale}
|
alt={image.localizedAlt || image.alt || ""}
|
||||||
objectFit="contain"
|
objectFit="contain"
|
||||||
format="avif"
|
format="avif"
|
||||||
placeholder="blurred"
|
placeholder="blurred"
|
||||||
sizes={mergedGallerySettings.SIZES_REGULAR}
|
sizes={mergedGallerySettings.SIZES_REGULAR}
|
||||||
s={s || image.hash}
|
s={s || image.hash}
|
||||||
attributes={{
|
attributes={{
|
||||||
img: { class: "main-image p-4 rounded-lg max-h-[60vh] aspect-square" }
|
img: {
|
||||||
|
class:
|
||||||
|
"product-gallery-main-image p-4 rounded-lg max-h-[60vh] object-contain w-full h-full aspect-square",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Image Info -->
|
<!-- Image Info -->
|
||||||
{ (images.some(img => (mergedGallerySettings.SHOW_TITLE && img.title && img.title.trim().length > 0) || (mergedGallerySettings.SHOW_DESCRIPTION && img.description && img.description.trim().length > 0))) && (
|
{
|
||||||
<div class="text-center py-4 border-t border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-700/50 -mx-4 px-4">
|
translatedImages.some(
|
||||||
{images.map((image, index) => (
|
(img) =>
|
||||||
<div x-show={`currentIndex === ${index}`} key={index}>
|
(mergedGallerySettings.SHOW_TITLE &&
|
||||||
{ mergedGallerySettings.SHOW_TITLE && image.title && image.title.trim().length > 0 && ( <h2 id="imageTitle" class="text-xl font-bold text-gray-900 dark:text-white">{image.title}</h2>)}
|
img.title &&
|
||||||
{ mergedGallerySettings.SHOW_DESCRIPTION && image.description && image.description.trim().length > 0 && (<p id="imageDescription" class="text-gray-600 dark:text-gray-300"><Translate>{ image.description}</Translate></p>)}
|
img.title.trim().length > 0) ||
|
||||||
|
(mergedGallerySettings.SHOW_DESCRIPTION &&
|
||||||
|
img.description &&
|
||||||
|
img.description.trim().length > 0),
|
||||||
|
) && (
|
||||||
|
<div class="gallery-info text-center py-4 border-t border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-700/50 -mx-4 px-4 min-h-[4rem]">
|
||||||
|
{translatedImages.map((image, index) => (
|
||||||
|
<div
|
||||||
|
data-index={index}
|
||||||
|
class={`info-slide ${index === 0 ? "block" : "hidden"}`}
|
||||||
|
>
|
||||||
|
{mergedGallerySettings.SHOW_TITLE &&
|
||||||
|
image.title &&
|
||||||
|
image.title.trim().length > 0 && (
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{image.title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
{mergedGallerySettings.SHOW_DESCRIPTION &&
|
||||||
|
image.description &&
|
||||||
|
image.description.trim().length > 0 && (
|
||||||
|
<p class="text-gray-600 dark:text-gray-300">
|
||||||
|
<Translate>{image.description}</Translate>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Thumbnails -->
|
<!-- Thumbnails -->
|
||||||
<div class="overflow-x-auto scrollbar-thin scrollbar-thumb-gray-400 dark:scrollbar-thumb-gray-500 scrollbar-track-gray-200 dark:scrollbar-track-gray-700 border-t border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-700/30 -mx-4 px-4 pt-3">
|
<div
|
||||||
<div class="grid grid-cols-3 md:grid-cols-4 gap-2 pb-2 items-center justify-center">
|
class="overflow-x-auto scrollbar-thin scrollbar-thumb-gray-400 dark:scrollbar-thumb-gray-500 scrollbar-track-gray-200 dark:scrollbar-track-gray-700 border-t border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-700/30 -mx-4 px-4 pt-3"
|
||||||
{images.map((image, index) => (
|
>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-3 md:grid-cols-4 gap-2 pb-2 items-center justify-center"
|
||||||
|
>
|
||||||
|
{
|
||||||
|
translatedImages.map((image, index) => (
|
||||||
<button
|
<button
|
||||||
key={index}
|
type="button"
|
||||||
x-on:click={`handleImageInteraction(${index})`}
|
data-index={index}
|
||||||
:class={`currentIndex === ${index} ? 'ring-2 ring-orange-500' : ''`}
|
class={`thumbnail-btn rounded-lg touch-manipulation transition-all duration-200 border-2 ${index === 0 ? "border-orange-500" : "border-transparent"}`}
|
||||||
class="thumbnail thumbnail-btn rounded-lg touch-manipulation"
|
|
||||||
>
|
>
|
||||||
<Img
|
<Img
|
||||||
src={image.src}
|
src={image.src}
|
||||||
@ -226,93 +162,495 @@ const locale = Astro.currentLocale || "en";
|
|||||||
format="avif"
|
format="avif"
|
||||||
placeholder="blurred"
|
placeholder="blurred"
|
||||||
sizes={mergedGallerySettings.SIZES_THUMB}
|
sizes={mergedGallerySettings.SIZES_THUMB}
|
||||||
class="w-full h-full p-1 object-contain rounded hover:ring-2 hover:ring-blue-500"
|
alt={image.localizedAlt || image.alt || ""}
|
||||||
alt={translate(image.alt,I18N_SOURCE_LANGUAGE,locale)}
|
class="w-full h-full p-1 object-contain rounded"
|
||||||
attributes={{
|
attributes={{
|
||||||
img: {
|
img: {
|
||||||
class: "w-full h-full rounded-lg hover:ring-2 hover:ring-blue-500 thumbnail-img aspect-square",
|
class:
|
||||||
}
|
"w-full h-full rounded-lg hover:ring-2 hover:ring-blue-500 thumbnail-img aspect-square",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
s={s || image.hash}
|
s={s || image.hash}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Lightbox Modal (with swipe functionality) -->
|
|
||||||
|
<!-- Lightbox Modal -->
|
||||||
<div
|
<div
|
||||||
x-show="open"
|
class="product-gallery-lightbox fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50 hidden opacity-0 transition-opacity duration-300"
|
||||||
x-transition
|
role="dialog"
|
||||||
:class="{ 'lightbox': !lightboxLoaded }"
|
aria-modal="true"
|
||||||
class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center lightbox z-50"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="relative max-w-full max-h-full transition-transform duration-100 ease-out"
|
class="product-gallery-lightbox-content relative w-full h-full max-w-full max-h-full transition-transform duration-100 ease-out"
|
||||||
@touchstart="touchStartX = $event.touches[0].clientX; isSwiping = true;"
|
|
||||||
@touchend="touchEndX = $event.changedTouches[0].clientX; handleSwipe();"
|
|
||||||
@touchcancel="isSwiping = false;"
|
|
||||||
@wheel="handleWheel($event)"
|
|
||||||
@mousedown="handlePanStart($event)"
|
|
||||||
@mousemove.window="handlePanMove($event)"
|
|
||||||
@mouseup.window="handlePanEnd($event)"
|
|
||||||
@dblclick="toggleZoom($event)"
|
|
||||||
>
|
>
|
||||||
{images.map((image, index) => (
|
{
|
||||||
|
translatedImages.map((image, index) => (
|
||||||
<div
|
<div
|
||||||
x-show={`currentIndex === ${index}`}
|
data-index={index}
|
||||||
key={index}
|
class={`lightbox-slide w-full h-full flex items-center justify-center p-4 absolute top-0 left-0 ${index === 0 ? "z-10" : "z-0 hidden"}`}
|
||||||
class="w-full h-full flex items-center justify-center p-4"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
:style="'transform: translate(' + panX + 'px, ' + panY + 'px) scale(' + scale + '); cursor: ' + (scale > 1 ? 'grab' : 'default') + ';'"
|
class="zoom-container transition-transform duration-100 ease-out flex items-center justify-center w-full h-full"
|
||||||
class="transition-transform duration-100 ease-out flex items-center justify-center max-w-full max-h-full"
|
style="transform-origin: center center;"
|
||||||
>
|
>
|
||||||
<Img
|
<Img
|
||||||
src={image.src}
|
src={image.src}
|
||||||
alt={image.alt}
|
alt={image.localizedAlt || image.alt || ""}
|
||||||
placeholder="blurred"
|
placeholder="blurred"
|
||||||
format="avif"
|
format="avif"
|
||||||
objectFit="contain"
|
objectFit="contain"
|
||||||
sizes={IMAGE_SETTINGS.lightbox.sizes_large}
|
sizes={IMAGE_SETTINGS.lightbox.sizes_large}
|
||||||
s={s || image.hash}
|
s={s || image.hash}
|
||||||
attributes={{
|
attributes={{
|
||||||
img: { class: "max-w-[90vw] max-h-[90vh] object-contain rounded-lg lightbox-main select-none" }
|
img: {
|
||||||
|
class:
|
||||||
|
"lightbox-img max-w-[90vw] max-h-[90vh] object-contain rounded-lg select-none",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{ ((mergedLightboxSettings.SHOW_TITLE && image.title && image.title.trim().length > 0) || (mergedLightboxSettings.SHOW_DESCRIPTION && image.description && image.description.trim().length > 0)) && (
|
{((mergedLightboxSettings.SHOW_TITLE &&
|
||||||
<div class="absolute bottom-0 left-1/2 transform -translate-x-1/2 m-[8px] max-h-[32vh] p-2 text-white bg-black/50 rounded-lg" style="width: 90%;">
|
image.title &&
|
||||||
{ mergedLightboxSettings.SHOW_TITLE && image.title && image.title.trim().length > 0 && ( <h3 class="text-xl"><Translate>{image.title}</Translate></h3>)}
|
image.title.trim().length > 0) ||
|
||||||
{ mergedLightboxSettings.SHOW_DESCRIPTION && image.description && image.description.trim().length > 0 && (<p><Translate>{image.description}</Translate></p>)} </div>
|
(mergedLightboxSettings.SHOW_DESCRIPTION &&
|
||||||
|
image.description &&
|
||||||
|
image.description.trim().length > 0)) && (
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 left-1/2 transform -translate-x-1/2 m-[8px] max-h-[32vh] p-2 text-white bg-black/50 rounded-lg pointer-events-none"
|
||||||
|
style="width: 90%;"
|
||||||
|
>
|
||||||
|
{mergedLightboxSettings.SHOW_TITLE &&
|
||||||
|
image.title &&
|
||||||
|
image.title.trim().length > 0 && (
|
||||||
|
<h3 class="text-xl">
|
||||||
|
<Translate>{image.title}</Translate>
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
{mergedLightboxSettings.SHOW_DESCRIPTION &&
|
||||||
|
image.description &&
|
||||||
|
image.description.trim().length > 0 && (
|
||||||
|
<p>
|
||||||
|
<Translate>{image.description}</Translate>
|
||||||
|
</p>
|
||||||
|
)}{" "}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Close Button -->
|
<!-- Close Button -->
|
||||||
<button
|
<button
|
||||||
x-on:click="open = false"
|
type="button"
|
||||||
class="absolute top-0 right-0 text-white text-2xl p-4 m-[8px] bg-gray-800/75 bg-opacity-75 rounded-lg lightbox-nav"
|
class="lightbox-close absolute top-0 right-0 text-white text-2xl p-4 m-[8px] bg-gray-800/75 bg-opacity-75 rounded-lg hover:bg-gray-700/75 z-50"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Navigation Buttons -->
|
<!-- Navigation Buttons -->
|
||||||
<button
|
<button
|
||||||
x-show="currentIndex > 0"
|
type="button"
|
||||||
x-on:click="currentIndex--; preloadImage(currentIndex);"
|
class="lightbox-prev absolute left-0 top-1/2 transform -translate-y-1/2 p-4 m-[8px] text-white text-3xl bg-gray-800/75 bg-opacity-75 rounded-lg hover:bg-gray-700/75 z-50 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
class="absolute left-0 top-1/2 transform -translate-y-1/2 p-4 m-[8px] text-white text-3xl bg-gray-800/75 bg-opacity-75 rounded-lg lightbox-nav"
|
|
||||||
aria-label="Previous"
|
aria-label="Previous"
|
||||||
>
|
>
|
||||||
❮
|
❮
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
x-show="currentIndex < total - 1"
|
type="button"
|
||||||
x-on:click="currentIndex++; preloadImage(currentIndex);"
|
class="lightbox-next absolute right-0 top-1/2 transform -translate-y-1/2 p-4 m-[8px] text-white text-3xl bg-gray-800/75 bg-opacity-75 rounded-lg hover:bg-gray-700/75 z-50 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
class="absolute right-0 top-1/2 transform -translate-y-1/2 p-4 m-[8px] text-white text-3xl bg-gray-800/75 bg-opacity-75 rounded-lg lightbox-nav"
|
|
||||||
aria-label="Next"
|
aria-label="Next"
|
||||||
>
|
>
|
||||||
❯
|
❯
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
class ProductGallery {
|
||||||
|
element: HTMLElement;
|
||||||
|
images: any[];
|
||||||
|
total: number;
|
||||||
|
currentIndex: number;
|
||||||
|
lightboxOpen: boolean;
|
||||||
|
|
||||||
|
// Gestures & Zoom state
|
||||||
|
scale: number;
|
||||||
|
panX: number;
|
||||||
|
panY: number;
|
||||||
|
isPanning: boolean;
|
||||||
|
startPanX: number;
|
||||||
|
startPanY: number;
|
||||||
|
touchStartX: number;
|
||||||
|
touchEndX: number;
|
||||||
|
minSwipeDistance: number;
|
||||||
|
isSwiping: boolean;
|
||||||
|
lastTapTime: number;
|
||||||
|
doubleTapDelay: number;
|
||||||
|
|
||||||
|
// Elements
|
||||||
|
mainImageContainer: HTMLElement;
|
||||||
|
thumbnailBtns: NodeListOf<HTMLElement>;
|
||||||
|
gallerySlides: NodeListOf<HTMLElement>;
|
||||||
|
infoSlides: NodeListOf<HTMLElement>;
|
||||||
|
|
||||||
|
lightbox: HTMLElement;
|
||||||
|
lightboxContent: HTMLElement;
|
||||||
|
lightboxSlides: NodeListOf<HTMLElement>;
|
||||||
|
closeBtn: HTMLButtonElement;
|
||||||
|
prevBtn: HTMLButtonElement;
|
||||||
|
nextBtn: HTMLButtonElement;
|
||||||
|
|
||||||
|
constructor(element: HTMLElement) {
|
||||||
|
this.element = element;
|
||||||
|
this.images = JSON.parse(element.dataset.images || "[]");
|
||||||
|
this.total = this.images.length;
|
||||||
|
this.currentIndex = 0;
|
||||||
|
this.lightboxOpen = false;
|
||||||
|
|
||||||
|
// Gestures & Zoom state
|
||||||
|
this.scale = 1;
|
||||||
|
this.panX = 0;
|
||||||
|
this.panY = 0;
|
||||||
|
this.isPanning = false;
|
||||||
|
this.startPanX = 0;
|
||||||
|
this.startPanY = 0;
|
||||||
|
this.touchStartX = 0;
|
||||||
|
this.touchEndX = 0;
|
||||||
|
this.minSwipeDistance = 50;
|
||||||
|
this.isSwiping = false;
|
||||||
|
this.lastTapTime = 0;
|
||||||
|
this.doubleTapDelay = 300;
|
||||||
|
|
||||||
|
// Elements
|
||||||
|
this.mainImageContainer = element.querySelector(".main-image-container");
|
||||||
|
this.thumbnailBtns = element.querySelectorAll(".thumbnail-btn");
|
||||||
|
this.gallerySlides = element.querySelectorAll(".gallery-slide");
|
||||||
|
this.infoSlides = element.querySelectorAll(".info-slide");
|
||||||
|
|
||||||
|
this.lightbox = element.querySelector(".product-gallery-lightbox");
|
||||||
|
this.lightboxContent = element.querySelector(
|
||||||
|
".product-gallery-lightbox-content",
|
||||||
|
);
|
||||||
|
this.lightboxSlides = element.querySelectorAll(".lightbox-slide");
|
||||||
|
this.closeBtn = element.querySelector(".lightbox-close");
|
||||||
|
this.prevBtn = element.querySelector(".lightbox-prev");
|
||||||
|
this.nextBtn = element.querySelector(".lightbox-next");
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Main Image Interactions
|
||||||
|
this.mainImageContainer.addEventListener("click", () =>
|
||||||
|
this.handleImageClick(),
|
||||||
|
);
|
||||||
|
this.mainImageContainer.addEventListener(
|
||||||
|
"touchstart",
|
||||||
|
(e) => {
|
||||||
|
this.touchStartX = e.touches[0].clientX;
|
||||||
|
this.isSwiping = true;
|
||||||
|
},
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
|
this.mainImageContainer.addEventListener("touchend", (e) => {
|
||||||
|
this.touchEndX = e.changedTouches[0].clientX;
|
||||||
|
this.handleMainSwipe();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Thumbnails
|
||||||
|
this.thumbnailBtns.forEach((btn, idx) => {
|
||||||
|
btn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.setIndex(idx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lightbox Controls
|
||||||
|
this.closeBtn.addEventListener("click", () => this.closeLightbox());
|
||||||
|
this.prevBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.prev();
|
||||||
|
});
|
||||||
|
this.nextBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lightbox Gestures
|
||||||
|
this.lightboxContent.addEventListener(
|
||||||
|
"touchstart",
|
||||||
|
(e) => {
|
||||||
|
if (e.touches.length === 1) {
|
||||||
|
this.touchStartX = e.touches[0].clientX;
|
||||||
|
this.isSwiping = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
|
this.lightboxContent.addEventListener("touchend", (e) => {
|
||||||
|
if (this.isSwiping) {
|
||||||
|
this.touchEndX = e.changedTouches[0].clientX;
|
||||||
|
this.handleLightboxSwipe();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.lightboxContent.addEventListener(
|
||||||
|
"wheel",
|
||||||
|
(e) => this.handleWheel(e),
|
||||||
|
{ passive: false },
|
||||||
|
);
|
||||||
|
this.lightboxContent.addEventListener("dblclick", (e) =>
|
||||||
|
this.toggleZoom(e),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pan events (Mouse)
|
||||||
|
this.lightboxContent.addEventListener("mousedown", (e) =>
|
||||||
|
this.handlePanStart(e),
|
||||||
|
);
|
||||||
|
window.addEventListener("mousemove", (e) => this.handlePanMove(e));
|
||||||
|
window.addEventListener("mouseup", (e) => this.handlePanEnd(e));
|
||||||
|
|
||||||
|
// Keyboard
|
||||||
|
window.addEventListener("keydown", (e) => this.handleKeydown(e));
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
this.updateUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
setIndex(index) {
|
||||||
|
if (index < 0 || index >= this.total) return;
|
||||||
|
this.currentIndex = index;
|
||||||
|
this.resetZoom();
|
||||||
|
this.updateUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
prev() {
|
||||||
|
this.setIndex(this.currentIndex - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
next() {
|
||||||
|
this.setIndex(this.currentIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUI() {
|
||||||
|
// Update Main Gallery
|
||||||
|
this.gallerySlides.forEach((slide, idx) => {
|
||||||
|
if (idx === this.currentIndex) {
|
||||||
|
slide.classList.remove("opacity-0", "z-0");
|
||||||
|
slide.classList.add("opacity-100", "z-10");
|
||||||
|
} else {
|
||||||
|
slide.classList.remove("opacity-100", "z-10");
|
||||||
|
slide.classList.add("opacity-0", "z-0");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Info
|
||||||
|
this.infoSlides.forEach((slide, idx) => {
|
||||||
|
if (idx === this.currentIndex) slide.classList.remove("hidden");
|
||||||
|
else slide.classList.add("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Thumbnails
|
||||||
|
this.thumbnailBtns.forEach((btn, idx) => {
|
||||||
|
if (idx === this.currentIndex) {
|
||||||
|
btn.classList.add("border-orange-500");
|
||||||
|
btn.classList.remove("border-transparent");
|
||||||
|
} else {
|
||||||
|
btn.classList.remove("border-orange-500");
|
||||||
|
btn.classList.add("border-transparent");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Lightbox
|
||||||
|
this.lightboxSlides.forEach((slide, idx) => {
|
||||||
|
if (idx === this.currentIndex) {
|
||||||
|
slide.classList.remove("hidden");
|
||||||
|
// Load image if lazy (though mostly handled by browser) but we might want to trigger load
|
||||||
|
} else {
|
||||||
|
slide.classList.add("hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateZoomTransform();
|
||||||
|
this.updateNavButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateNavButtons() {
|
||||||
|
this.prevBtn.disabled = this.currentIndex === 0;
|
||||||
|
this.nextBtn.disabled = this.currentIndex === this.total - 1;
|
||||||
|
|
||||||
|
// Hide if disabled
|
||||||
|
this.prevBtn.style.display = this.currentIndex === 0 ? "none" : "block";
|
||||||
|
this.nextBtn.style.display =
|
||||||
|
this.currentIndex === this.total - 1 ? "none" : "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Lightbox Logic ---
|
||||||
|
|
||||||
|
handleImageClick() {
|
||||||
|
const currentTime = Date.now();
|
||||||
|
const timeDiff = currentTime - this.lastTapTime;
|
||||||
|
|
||||||
|
if (timeDiff < this.doubleTapDelay) {
|
||||||
|
this.openLightbox();
|
||||||
|
} else {
|
||||||
|
this.openLightbox();
|
||||||
|
}
|
||||||
|
this.lastTapTime = currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
openLightbox() {
|
||||||
|
this.lightboxOpen = true;
|
||||||
|
this.lightbox.classList.remove("hidden");
|
||||||
|
|
||||||
|
// Force direct style manipulation to ensure visibility
|
||||||
|
this.lightbox.style.display = "flex";
|
||||||
|
|
||||||
|
this.lightbox.classList.remove("opacity-0");
|
||||||
|
this.lightbox.style.opacity = "1";
|
||||||
|
|
||||||
|
// Ensure high z-index
|
||||||
|
this.lightbox.style.zIndex = "9999";
|
||||||
|
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
this.resetZoom();
|
||||||
|
this.updateUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
closeLightbox() {
|
||||||
|
this.lightboxOpen = false;
|
||||||
|
this.lightbox.style.display = "none";
|
||||||
|
this.lightbox.classList.add("hidden");
|
||||||
|
this.lightbox.classList.add("opacity-0");
|
||||||
|
this.lightbox.style.opacity = "";
|
||||||
|
this.lightbox.style.zIndex = "";
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeydown(e) {
|
||||||
|
if (!this.lightboxOpen) return;
|
||||||
|
if (e.key === "Escape") this.closeLightbox();
|
||||||
|
if (e.key === "ArrowLeft") this.prev();
|
||||||
|
if (e.key === "ArrowRight") this.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Gestures & Zoom ---
|
||||||
|
|
||||||
|
handleMainSwipe() {
|
||||||
|
if (!this.isSwiping) return;
|
||||||
|
const swipeDistance = this.touchEndX - this.touchStartX;
|
||||||
|
if (Math.abs(swipeDistance) >= this.minSwipeDistance) {
|
||||||
|
if (swipeDistance > 0) this.prev();
|
||||||
|
else this.next();
|
||||||
|
} else {
|
||||||
|
// If it was a very small movement, it might be a tap that wasn't caught by click if preventDefault was involved?
|
||||||
|
// But we are not preventing default on touchstart/move for main image container unless we added it.
|
||||||
|
// We do have touch-manipulation class which usually handles delay.
|
||||||
|
}
|
||||||
|
this.isSwiping = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLightboxSwipe() {
|
||||||
|
if (!this.isSwiping || this.scale > 1) return; // Disable swipe when zoomed
|
||||||
|
const swipeDistance = this.touchEndX - this.touchStartX;
|
||||||
|
if (Math.abs(swipeDistance) >= this.minSwipeDistance) {
|
||||||
|
if (swipeDistance > 0) this.prev();
|
||||||
|
else this.next();
|
||||||
|
}
|
||||||
|
this.isSwiping = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetZoom() {
|
||||||
|
this.scale = 1;
|
||||||
|
this.panX = 0;
|
||||||
|
this.panY = 0;
|
||||||
|
this.isPanning = false;
|
||||||
|
this.updateZoomTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleWheel(e) {
|
||||||
|
if (!this.lightboxOpen) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const zoomSpeed = 0.1;
|
||||||
|
const newScale = this.scale - Math.sign(e.deltaY) * zoomSpeed;
|
||||||
|
this.scale = Math.min(Math.max(1, newScale), 5); // Limit 1x-5x
|
||||||
|
if (this.scale === 1) {
|
||||||
|
this.panX = 0;
|
||||||
|
this.panY = 0;
|
||||||
|
}
|
||||||
|
this.updateZoomTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleZoom(e) {
|
||||||
|
if (this.scale > 1) {
|
||||||
|
this.resetZoom();
|
||||||
|
} else {
|
||||||
|
this.scale = 2;
|
||||||
|
}
|
||||||
|
this.updateZoomTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePanStart(e) {
|
||||||
|
if (this.scale <= 1 || !this.lightboxOpen) return;
|
||||||
|
this.isPanning = true;
|
||||||
|
this.startPanX = e.clientX - this.panX;
|
||||||
|
this.startPanY = e.clientY - this.panY;
|
||||||
|
e.preventDefault();
|
||||||
|
// Add cursor grabbing
|
||||||
|
this.getCurrentZoomContainer().style.cursor = "grabbing";
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePanMove(e) {
|
||||||
|
if (!this.isPanning || !this.lightboxOpen) return;
|
||||||
|
e.preventDefault();
|
||||||
|
this.panX = e.clientX - this.startPanX;
|
||||||
|
this.panY = e.clientY - this.startPanY;
|
||||||
|
this.updateZoomTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePanEnd(e) {
|
||||||
|
this.isPanning = false;
|
||||||
|
const container = this.getCurrentZoomContainer();
|
||||||
|
if (container)
|
||||||
|
container.style.cursor = this.scale > 1 ? "grab" : "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentZoomContainer() {
|
||||||
|
return this.lightboxSlides[this.currentIndex].querySelector(
|
||||||
|
".zoom-container",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateZoomTransform() {
|
||||||
|
const container = this.getCurrentZoomContainer();
|
||||||
|
if (container) {
|
||||||
|
container.style.transform = `translate(${this.panX}px, ${this.panY}px) scale(${this.scale})`;
|
||||||
|
container.style.cursor = this.scale > 1 ? "grab" : "default";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
function initGalleries() {
|
||||||
|
document.querySelectorAll(".product-gallery").forEach((el) => {
|
||||||
|
if (el instanceof HTMLElement && !el.dataset.initialized) {
|
||||||
|
console.log("Initializing gallery");
|
||||||
|
new ProductGallery(el);
|
||||||
|
el.dataset.initialized = "true";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initGalleries();
|
||||||
|
|
||||||
|
// If usage with Astro View Transitions
|
||||||
|
document.addEventListener("astro:page-load", initGalleries);
|
||||||
|
</script>
|
||||||
|
|||||||
@ -315,11 +315,10 @@ const products = [
|
|||||||
<ul
|
<ul
|
||||||
role="list"
|
role="list"
|
||||||
class="grid grid-cols-1 lg:grid-cols-2 gap-2">
|
class="grid grid-cols-1 lg:grid-cols-2 gap-2">
|
||||||
|
{
|
||||||
{
|
{
|
||||||
products.map((product) => (
|
products.map((product) => (
|
||||||
<li
|
<li class="checkout-item">
|
||||||
x-data="{ show: true }"
|
|
||||||
x-show="show">
|
|
||||||
<div class="flex flex-col gap-2 relative p-4 bg-white rounded-xl">
|
<div class="flex flex-col gap-2 relative p-4 bg-white rounded-xl">
|
||||||
<img
|
<img
|
||||||
src={product.imageSrc}
|
src={product.imageSrc}
|
||||||
@ -340,11 +339,10 @@ const products = [
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex ml-auto mt-16">
|
<div class="flex ml-auto mt-16">
|
||||||
<button
|
<button
|
||||||
@click="show = false"
|
type="button"
|
||||||
type="submit"
|
title="Remove item"
|
||||||
title="link to your page"
|
aria-label="Remove item"
|
||||||
aria-label="your label"
|
class="remove-button relative group px-6 justify-center text-xs text-orange-600 uppercase h-8 flex items-center bg-neutral-100 hover:bg-neutral-200 hover:text-orange-600 duration-300 rounded-lg w-full">
|
||||||
class="relative group px-6 justify-center text-xs text-orange-600 uppercase h-8 flex items-center bg-neutral-100 hover:bg-neutral-200 hover:text-orange-600 duration-300 rounded-lg w-full">
|
|
||||||
<span class="sr-only">Remove</span>
|
<span class="sr-only">Remove</span>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
@ -405,3 +403,16 @@ const products = [
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Handle removal of checkout items
|
||||||
|
document.querySelectorAll('.remove-button').forEach(button => {
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
// Find the closest list item and remove it
|
||||||
|
const item = (e.target as Element).closest('.checkout-item');
|
||||||
|
if (item) {
|
||||||
|
item.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user