From f017b3faefc0cb62d0b92f2a6dba0738906e7d8a Mon Sep 17 00:00:00 2001 From: George Cushen Date: Mon, 18 May 2026 00:02:17 +0100 Subject: [PATCH] feat(gallery): justified, carousel, and slideshow layouts Three new design.layout values, all driven by the existing items/resolver infrastructure (no Hugo-side changes). - justified: Flickr-style row-balanced layout. Each tile's flex-basis is derived from its precomputed aspect ratio at the configured row_height, flex-grow proportional to ratio so wider images claim more leftover row space. Pure CSS, no JS layout math. New design.row_height field (clamped 120-640, default 240). Last item capped at max-width: 50% so a lonely last-row image doesn't stretch grotesquely. - carousel: CSS scroll-snap-type: x mandatory horizontal strip. Touch swipe is native; optional Preact prev/next arrows render on md+ only. Per-breakpoint flex-basis driven by design.columns (mobile always shows ~1 + peeking next). Full-bleed scroll via negative margins matched by inner padding so the strip feels native at any width. - slideshow: Single-image presentation with crossfade transitions, dots, arrows, optional autoplay with pause-on-hover, touch swipe, and a click-through to the lightbox at full resolution. New design.autoplay (default false) and design.autoplay_interval (ms, min 1000, default 5000) fields. Aspect defaults to "wide" for presentational feel. Test site adds three new demos pointing at the same album folder so users can compare layouts side-by-side. Block bundle grows from 39KB to 66KB; pages without a gallery still ship 0KB (init.html gating). --- modules/blox/blox/gallery/component.jsx | 447 ++++++++++++++++++++++-- modules/blox/blox/gallery/style.css | 27 ++ test/data/pages/home.yaml | 44 ++- 3 files changed, 485 insertions(+), 33 deletions(-) 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) => ( +
+