"use client"; import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; import { ChevronLeftIcon, ChevronRightIcon, ExternalLinkIcon, RefreshCwIcon, RotateCcwIcon, RotateCwIcon, XIcon, ZoomInIcon, ZoomOutIcon, } from "lucide-react"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import type { ReactZoomPanPinchContentRef } from "react-zoom-pan-pinch"; import { TransformComponent, TransformWrapper, } from "react-zoom-pan-pinch"; import { Button, buttonVariants } from "@/components/ui/button"; import { Dialog, DialogClose, DialogOverlay, DialogPortal, DialogTitle, } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; import { translateCurrentMessage } from "@/i18n/messages"; import { useI18n } from "@/i18n/provider"; export type ImageLightboxItem = { src: string; alt?: string; svg?: string; backgroundColor?: string; }; type ImageLightboxState = { items: ImageLightboxItem[]; index: number; }; export type ImageLightboxContextValue = { open: (src: string, alt?: string) => void; openGallery: (items: ImageLightboxItem[], initialIndex?: number) => void; close: () => void; }; const ImageLightboxContext = createContext( null, ); export function useImageLightbox(): ImageLightboxContextValue { const ctx = useContext(ImageLightboxContext); if (!ctx) { throw new Error(translateCurrentMessage("lightbox.providerError")); } return ctx; } /** Returns null when no provider is mounted, which keeps adoption incremental. */ export function useImageLightboxOptional(): ImageLightboxContextValue | null { return useContext(ImageLightboxContext); } export type ImageLightboxProps = { open: boolean; onOpenChange: (open: boolean) => void; src: string | null; alt?: string; svg?: string; backgroundColor?: string; currentIndex?: number; total?: number; onPrevious?: () => void; onNext?: () => void; }; function canOpenInNewTab(url: string): boolean { if (!url) { return false; } if (url.startsWith("/")) { return true; } try { const parsed = new URL(url); return ( parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.protocol === "blob:" ); } catch { return false; } } function LightboxImageBody({ src, alt, svg, backgroundColor, pinchRef, rotationDeg, }: { src: string; alt?: string; svg?: string; backgroundColor?: string; pinchRef: React.RefObject; rotationDeg: number; }) { const t = useI18n(); const [imageState, setImageState] = useState<{ src: string; status: "loading" | "loaded" | "error"; }>({ src, status: "loading" }); const status = imageState.src === src ? imageState.status : "loading"; const loading = status === "loading"; const error = status === "error"; const showOpenTab = canOpenInNewTab(src); useEffect(() => { requestAnimationFrame(() => { pinchRef.current?.centerView(1, 0); }); }, [rotationDeg, pinchRef]); return (
{!svg && loading && !error ? (
) : null} {!svg && error ? (

{t("lightbox.loadFailed")}

{showOpenTab ? ( {t("lightbox.openInNewTab")} ) : null}
) : ( {svg ? (
) : ( /* eslint-disable-next-line @next/next/no-img-element -- External URLs and arbitrary image sizes need native preview. */ {alt { setImageState({ src, status: "loaded" }); requestAnimationFrame(() => { pinchRef.current?.centerView(1, 0); }); }} onError={() => { setImageState({ src, status: "error" }); }} /> )} )}
); } /** Mounted by src key so rotation resets when switching images. */ function ImageLightboxDialogContent({ src, alt, svg, backgroundColor, currentIndex = 0, total = 1, onPrevious, onNext, }: { src: string; alt?: string; svg?: string; backgroundColor?: string; currentIndex?: number; total?: number; onPrevious?: () => void; onNext?: () => void; }) { const t = useI18n(); const pinchRef = useRef(null); const [rotation, setRotation] = useState({ src, degrees: 0 }); const rotationDeg = rotation.src === src ? rotation.degrees : 0; const showOpenTab = canOpenInNewTab(src); const titleText = alt?.trim() || t("lightbox.imagePreview"); const hasGallery = total > 1; useEffect(() => { requestAnimationFrame(() => { pinchRef.current?.resetTransform(0); }); }, [src]); useEffect(() => { if (!hasGallery) { return; } const onKeyDown = (event: KeyboardEvent) => { if (event.key === "ArrowLeft" && onPrevious) { event.preventDefault(); onPrevious(); } else if (event.key === "ArrowRight" && onNext) { event.preventDefault(); onNext(); } }; window.addEventListener("keydown", onKeyDown, true); return () => window.removeEventListener("keydown", onKeyDown, true); }, [hasGallery, onNext, onPrevious]); const rotateLeft = useCallback(() => { setRotation({ src, degrees: (rotationDeg - 90 + 360) % 360 }); }, [rotationDeg, src]); const rotateRight = useCallback(() => { setRotation({ src, degrees: (rotationDeg + 90) % 360 }); }, [rotationDeg, src]); return (
{titleText} {hasGallery ? ( {t("lightbox.position", { current: currentIndex + 1, total })} ) : null}
{showOpenTab ? ( ) : null} } > {t("lightbox.close")}
{hasGallery ? ( <> ) : null}

{hasGallery ? t("lightbox.galleryHelp") : t("lightbox.help")}

); } export function ImageLightboxView({ open, onOpenChange, src, alt, svg, backgroundColor, currentIndex = 0, total = 1, onPrevious, onNext, }: ImageLightboxProps) { return ( {src ? ( ) : null} ); } export function ImageLightboxProvider({ children }: { children: ReactNode }) { const [state, setState] = useState(null); const open = useCallback((src: string, alt?: string) => { const trimmed = src?.trim(); if (!trimmed) { return; } setState({ items: [{ src: trimmed, alt }], index: 0 }); }, []); const openGallery = useCallback( (items: ImageLightboxItem[], initialIndex = 0) => { const normalizedItems = items .map((item) => ({ ...item, src: item.src?.trim() })) .filter((item): item is ImageLightboxItem => Boolean(item.src)); if (!normalizedItems.length) { return; } const index = Math.min(Math.max(initialIndex, 0), normalizedItems.length - 1); setState({ items: normalizedItems, index }); }, [], ); const close = useCallback(() => { setState(null); }, []); const contextValue = useMemo( () => ({ open, openGallery, close, }), [open, openGallery, close], ); const currentItem = state?.items[state.index] ?? null; return ( {children} { if (!next) { setState(null); } }} src={currentItem?.src ?? null} alt={currentItem?.alt} svg={currentItem?.svg} backgroundColor={currentItem?.backgroundColor} currentIndex={state?.index ?? 0} total={state?.items.length ?? 0} onPrevious={state && state.index > 0 ? () => { setState((current) => current ? { ...current, index: Math.max(0, current.index - 1) } : current); } : undefined} onNext={state && state.index < state.items.length - 1 ? () => { setState((current) => current ? { ...current, index: Math.min(current.items.length - 1, current.index + 1) } : current); } : undefined} /> ); }