diff --git a/modules/blox/blox/gallery/component.jsx b/modules/blox/blox/gallery/component.jsx index 2d538df4..8190a626 100644 --- a/modules/blox/blox/gallery/component.jsx +++ b/modules/blox/blox/gallery/component.jsx @@ -2,7 +2,8 @@ import {useState, useEffect, useRef, useCallback} from "preact/hooks"; import {createPortal} from "preact/compat"; /* - * Gallery block — Slice 1 (grid + masonry layouts, Preact lightbox). + * Gallery block — grid / masonry / justified / carousel / slideshow + Preact + * lightbox. * * Hugo's resolve_gallery_items.html partial supplies a fully-processed `items` * prop: each item has src/srcset/width/height/aspect_ratio/lqip plus @@ -46,6 +47,15 @@ const ASPECT_CLASS = { wide: "aspect-[16/9]", }; +const CAROUSEL_BASIS = { + 1: "basis-[90%] sm:basis-[80%]", + 2: "basis-[85%] sm:basis-1/2", + 3: "basis-[85%] sm:basis-1/2 md:basis-1/3", + 4: "basis-[85%] sm:basis-1/2 md:basis-1/3 lg:basis-1/4", + 5: "basis-[85%] sm:basis-1/2 md:basis-1/3 lg:basis-1/5", + 6: "basis-[85%] sm:basis-1/2 md:basis-1/3 lg:basis-1/6", +}; + const SIZES_DEFAULT_GRID = "(min-width: 1024px) 25vw, (min-width: 640px) 33vw, 100vw"; const SIZES_DEFAULT_MASONRY = "(min-width: 1024px) 25vw, (min-width: 640px) 33vw, 100vw"; @@ -190,6 +200,353 @@ function MasonryTile({item, onClick, captionPosition, hoverZoom, sizes, lightbox ); } +/* Justified — Flickr-style row-packed layout. Each item's flex-basis is derived + from its precomputed aspect ratio so widths reflect image proportions; flex-grow + takes care of filling each row exactly (the browser does the row-balancing math). */ +function JustifiedTile({item, rowHeight, onClick, captionPosition, hoverZoom, sizes, lightbox}) { + const [loaded, setLoaded] = useState(false); + const ar = item.aspect_ratio || 1; + const imgClass = loaded + ? "absolute inset-0 w-full h-full object-cover transition-opacity duration-500 opacity-100" + : "absolute inset-0 w-full h-full object-cover transition-opacity duration-500 opacity-0"; + const hoverImgClass = hoverZoom ? "transition-transform duration-500 group-hover:scale-105" : ""; + + const showOverlay = captionPosition === "overlay" && (item.caption || item.title); + const showHoverCaption = captionPosition === "hover" && (item.caption || item.title); + + // Inline style: width derived from aspect ratio at the configured row height; + // flex-grow proportional to aspect ratio so wider images claim more leftover row space. + const itemStyle = `flex-basis: ${ar * rowHeight}px; flex-grow: ${ar}; height: ${rowHeight}px; min-width: 0;`; + + return ( + + ); +} + +function JustifiedView({items, design, openLightbox, gapClass, sizes}) { + const rowHeight = Math.max(120, Math.min(640, design.row_height || 240)); + return ( + + ); +} + +/* Carousel — horizontal scroll-snap strip. CSS does the snap, optional prev/next + buttons (desktop only — touch users swipe natively). */ +function CarouselTile({item, basisClass, onClick, aspectClass, captionPosition, hoverZoom, sizes, lightbox}) { + const [loaded, setLoaded] = useState(false); + const imgClass = loaded + ? "absolute inset-0 w-full h-full object-cover transition-opacity duration-500 opacity-100" + : "absolute inset-0 w-full h-full object-cover transition-opacity duration-500 opacity-0"; + const hoverImgClass = hoverZoom ? "transition-transform duration-500 group-hover:scale-105" : ""; + + const showOverlay = captionPosition === "overlay" && (item.caption || item.title); + const showHoverCaption = captionPosition === "hover" && (item.caption || item.title); + + return ( + + ); +} + +function CarouselView({items, design, openLightbox, gapClass, aspectClass, sizes, columns}) { + const scrollRef = useRef(null); + const basisClass = CAROUSEL_BASIS[columns] || CAROUSEL_BASIS[3]; + + const scrollByItem = useCallback((dir) => { + const el = scrollRef.current; + if (!el) return; + const tile = el.querySelector(".hbx-gallery-carousel-item"); + const step = tile ? tile.clientWidth + 16 : el.clientWidth * 0.8; + el.scrollBy({left: dir * step, behavior: "smooth"}); + }, []); + + return ( +
+ + {items.length > 1 && ( + <> + + + + )} +
+ ); +} + +/* Slideshow — single image at a time, dots + arrows + optional autoplay with + pause-on-hover. Touch swipe reuses the lightbox's gesture handling. */ +function SlideshowView({items, design, openLightbox, aspectClass, sizes}) { + const [index, setIndex] = useState(0); + const [paused, setPaused] = useState(false); + const touchStart = useRef({x: 0, y: 0, t: 0}); + + const autoplay = !!design.autoplay; + const interval = Math.max(1000, design.autoplay_interval || 5000); + + const next = useCallback(() => setIndex((i) => (i + 1) % items.length), [items.length]); + const prev = useCallback(() => setIndex((i) => (i - 1 + items.length) % items.length), [items.length]); + + useEffect(() => { + if (!autoplay || paused || items.length <= 1) return; + const t = setTimeout(next, interval); + return () => clearTimeout(t); + }, [autoplay, paused, interval, index, next, items.length]); + + function onTouchStart(e) { + const t = e.touches[0]; + touchStart.current = {x: t.clientX, y: t.clientY, t: Date.now()}; + } + function onTouchEnd(e) { + const t = e.changedTouches[0]; + const dx = t.clientX - touchStart.current.x; + const dy = t.clientY - touchStart.current.y; + const dt = Date.now() - touchStart.current.t; + if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) && dt < 600) { + if (dx < 0) next(); + else prev(); + } + } + + const item = items[index]; + const lightbox = design.lightbox !== false; + const captionPosition = design.caption_position || "below"; + + return ( +
setPaused(true)} + onMouseLeave={() => setPaused(false)} + onTouchStart={onTouchStart} + onTouchEnd={onTouchEnd} + > +
+ {items.map((it, i) => ( +
+