"use client"; import { useEffect, useRef } from "react"; type LightboxImage = { id: string; src: string; sizeLabel?: string; dimensions?: string; }; type ImageLightboxProps = { images: LightboxImage[]; currentIndex: number; open: boolean; onOpenChange: (open: boolean) => void; onIndexChange: (index: number) => void; }; type FancyboxItem = { src: string; type: "image"; caption?: string; thumb?: string; }; type FancyboxInstance = { close: () => void; getSlide?: () => { index?: number } | undefined; }; type FancyboxGlobal = { show: ( items: FancyboxItem[], options?: { startIndex?: number; Hash?: false; closeExisting?: boolean; Carousel?: { infinite?: boolean }; on?: { close?: () => void; "Carousel.change"?: (fancyboxRef: FancyboxInstance) => void; }; }, ) => FancyboxInstance; }; declare global { interface Window { Fancybox?: FancyboxGlobal; } } const FANCYBOX_SCRIPT_URL = "https://cdn.jsdelivr.net/npm/@fancyapps/ui@6.1.14/dist/fancybox/fancybox.umd.js"; const FANCYBOX_STYLE_URL = "https://cdn.jsdelivr.net/npm/@fancyapps/ui@6.1.14/dist/fancybox/fancybox.css"; let fancyboxLoader: Promise | null = null; function escapeHtml(value: string) { return value.replace(/[&<>"']/g, (char) => { switch (char) { case "&": return "&"; case "<": return "<"; case ">": return ">"; case "\"": return """; case "'": return "'"; default: return char; } }); } function getCaption(image: LightboxImage) { const parts = [image.sizeLabel, image.dimensions].filter(Boolean); return parts.length ? escapeHtml(parts.join(" ยท ")) : undefined; } function ensureFancyboxStyle() { if (document.querySelector(`link[href="${FANCYBOX_STYLE_URL}"]`)) { return; } const link = document.createElement("link"); link.rel = "stylesheet"; link.href = FANCYBOX_STYLE_URL; document.head.appendChild(link); } function loadFancybox() { if (typeof window === "undefined") { return Promise.reject(new Error("Fancybox can only be loaded in the browser")); } ensureFancyboxStyle(); if (window.Fancybox) { return Promise.resolve(window.Fancybox); } if (fancyboxLoader) { return fancyboxLoader; } fancyboxLoader = new Promise((resolve, reject) => { const existingScript = document.querySelector(`script[src="${FANCYBOX_SCRIPT_URL}"]`); const resolveLoaded = () => { if (window.Fancybox) { resolve(window.Fancybox); } else { reject(new Error("Fancybox loaded but did not expose window.Fancybox")); } }; if (existingScript) { existingScript.addEventListener("load", resolveLoaded, { once: true }); existingScript.addEventListener("error", () => reject(new Error("Failed to load Fancybox")), { once: true }); return; } const script = document.createElement("script"); script.src = FANCYBOX_SCRIPT_URL; script.async = true; script.onload = resolveLoaded; script.onerror = () => reject(new Error("Failed to load Fancybox")); document.body.appendChild(script); }); return fancyboxLoader; } function clampIndex(index: number, length: number) { if (length <= 0) return 0; return Math.max(0, Math.min(index, length - 1)); } export function ImageLightbox({ images, currentIndex, open, onOpenChange, onIndexChange, }: ImageLightboxProps) { const instanceRef = useRef(null); const isClosingRef = useRef(false); useEffect(() => { if (!open) { if (instanceRef.current && !isClosingRef.current) { isClosingRef.current = true; instanceRef.current.close(); instanceRef.current = null; isClosingRef.current = false; } return; } if (images.length === 0) { onOpenChange(false); return; } if (instanceRef.current) { return; } let cancelled = false; void loadFancybox() .then((Fancybox) => { if (cancelled) return; const items = images.map((image) => ({ src: image.src, type: "image" as const, thumb: image.src, caption: getCaption(image), })); instanceRef.current = Fancybox.show(items, { startIndex: clampIndex(currentIndex, images.length), Hash: false, closeExisting: true, Carousel: { infinite: images.length > 1, }, on: { close: () => { instanceRef.current = null; isClosingRef.current = false; onOpenChange(false); }, "Carousel.change": (fancyboxRef) => { const nextIndex = fancyboxRef.getSlide?.()?.index; if (typeof nextIndex === "number") { onIndexChange(clampIndex(nextIndex, images.length)); } }, }, }); }) .catch((error) => { console.error(error); onOpenChange(false); }); return () => { cancelled = true; }; }, [open, images, currentIndex, onOpenChange, onIndexChange]); useEffect(() => { return () => { if (instanceRef.current) { isClosingRef.current = true; instanceRef.current.close(); instanceRef.current = null; } }; }, []); return null; }