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 (
+
+ {items.map((item, i) => (
+ openLightbox(i)}
+ captionPosition={design.caption_position || "below"}
+ hoverZoom={design.hover_zoom !== false}
+ sizes={sizes}
+ lightbox={design.lightbox !== false}
+ />
+ ))}
+
+ );
+}
+
+/* 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 (
+
+
+ {captionPosition === "below" && (item.caption || item.title) && (
+
+ {item.title && (
+
+ )}
+ {item.title && item.caption && " — "}
+ {item.caption && }
+
+ )}
+
+ );
+}
+
+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.map((item, i) => (
+ openLightbox(i)}
+ captionPosition={design.caption_position || "below"}
+ hoverZoom={design.hover_zoom !== false}
+ sizes={sizes}
+ lightbox={design.lightbox !== false}
+ />
+ ))}
+
+ {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) => (
+
+
+
+ {captionPosition === "overlay" && (item.caption || item.title) && i === index && (
+
+
+
+ {it.title && it.caption && (
+
+ )}
+
+
+ )}
+
+ ))}
+
+ {items.length > 1 && (
+ <>
+
+
+ >
+ )}
+
+
+ {items.length > 1 && (
+
+ {items.map((_, i) => (
+
+ )}
+
+ {captionPosition === "below" && (item.caption || item.title) && (
+
+ {item.title && (
+
+ )}
+ {item.caption &&
}
+
+ )}
+
+ );
+}
+
function Lightbox({items, index, onClose, onPrev, onNext}) {
const item = items[index];
const touchStart = useRef({x: 0, y: 0, t: 0});
@@ -362,12 +719,67 @@ export const GalleryBlock = ({content = {}, design = {}, items = []}) => {
}
const sizes = layout === "masonry" ? SIZES_DEFAULT_MASONRY : SIZES_DEFAULT_GRID;
- const aspectClass = ASPECT_CLASS[aspectRatio] || ASPECT_CLASS.square;
+ // Slideshow defaults to a wide aspect when the user hasn't picked one — it
+ // looks more presentational than the square grid default.
+ const slideshowAspect = design.aspect_ratio || (layout === "slideshow" ? "wide" : "square");
+ const aspectClass = ASPECT_CLASS[layout === "slideshow" ? slideshowAspect : aspectRatio] || ASPECT_CLASS.square;
const gridClass = GRID_COLS[columns] || GRID_COLS[3];
const masonryClass = MASONRY_COLS[columns] || MASONRY_COLS[3];
const gapClass = GAP_CLASS[gap] || GAP_CLASS.md;
const masonryGapClass = MASONRY_GAP_CLASS[gap] || MASONRY_GAP_CLASS.md;
+ let layoutEl;
+ if (layout === "masonry") {
+ layoutEl = (
+
+ {items.map((item, i) => (
+ openLightbox(i)}
+ captionPosition={captionPosition}
+ hoverZoom={hoverZoom}
+ sizes={sizes}
+ lightbox={lightboxEnabled}
+ />
+ ))}
+
+ );
+ } else if (layout === "justified") {
+ layoutEl = ;
+ } else if (layout === "carousel") {
+ layoutEl = (
+
+ );
+ } else if (layout === "slideshow") {
+ layoutEl = ;
+ } else {
+ layoutEl = (
+
+ {items.map((item, i) => (
+ openLightbox(i)}
+ aspectClass={aspectClass}
+ captionPosition={captionPosition}
+ hoverZoom={hoverZoom}
+ sizes={sizes}
+ lightbox={lightboxEnabled}
+ />
+ ))}
+
+ );
+ }
+
return (
@@ -385,36 +797,7 @@ export const GalleryBlock = ({content = {}, design = {}, items = []}) => {
)}
- {layout === "masonry" ? (
-
- {items.map((item, i) => (
- openLightbox(i)}
- captionPosition={captionPosition}
- hoverZoom={hoverZoom}
- sizes={sizes}
- lightbox={lightboxEnabled}
- />
- ))}
-
- ) : (
-
- {items.map((item, i) => (
- openLightbox(i)}
- aspectClass={aspectClass}
- captionPosition={captionPosition}
- hoverZoom={hoverZoom}
- sizes={sizes}
- lightbox={lightboxEnabled}
- />
- ))}
-
- )}
+ {layoutEl}
{/* Portal the lightbox to so it escapes the page section's
diff --git a/modules/blox/blox/gallery/style.css b/modules/blox/blox/gallery/style.css
index ed18ba68..5ff2cb9b 100644
--- a/modules/blox/blox/gallery/style.css
+++ b/modules/blox/blox/gallery/style.css
@@ -29,6 +29,33 @@
max-width: min(72ch, 90vw);
}
+/* Justified — cap the last item's growth so a lonely last-row image doesn't
+ stretch grotesquely across the full row width. */
+.hbx-gallery-justified > .hbx-gallery-justified-item:last-child {
+ max-width: 50%;
+}
+
+/* Carousel — smooth programmatic scroll + slim styled scrollbar so users still
+ get the scroll affordance without it dominating the strip. */
+.hbx-gallery-carousel {
+ scroll-behavior: smooth;
+ scrollbar-width: thin;
+ scrollbar-color: rgb(156 163 175 / 0.5) transparent;
+}
+.hbx-gallery-carousel::-webkit-scrollbar {
+ height: 6px;
+}
+.hbx-gallery-carousel::-webkit-scrollbar-track {
+ background: transparent;
+}
+.hbx-gallery-carousel::-webkit-scrollbar-thumb {
+ background: rgb(156 163 175 / 0.5);
+ border-radius: 9999px;
+}
+.hbx-gallery-carousel::-webkit-scrollbar-thumb:hover {
+ background: rgb(107 114 128 / 0.7);
+}
+
/* Prevent backdrop-blurred caption from picking up site text colour in dark
mode (where body text is white on a black backdrop already). */
.hbx-gallery-lightbox-caption,
diff --git a/test/data/pages/home.yaml b/test/data/pages/home.yaml
index 2b9c0538..50e3358b 100644
--- a/test/data/pages/home.yaml
+++ b/test/data/pages/home.yaml
@@ -122,7 +122,7 @@ sections:
- block: gallery
content:
title: "Recent expeditions"
- subtitle: "Auto-loaded from `assets/media/albums/gallery/` — captions stay empty when not provided (Slice 1 behaviour)."
+ subtitle: "Auto-loaded from `assets/media/albums/gallery/` — sidecar files supply captions for cabin and mountain."
album: "media/albums/gallery"
sort: asc
design:
@@ -133,6 +133,48 @@ sections:
hover_zoom: true
lightbox: true
+ - block: gallery
+ content:
+ title: "Justified — Flickr-style"
+ subtitle: "Each row is the same height; widths follow the natural image aspect ratio."
+ album: "media/albums/gallery"
+ sort: asc
+ design:
+ layout: justified
+ row_height: 220
+ gap: sm
+ caption_position: none
+ lightbox: true
+
+ - block: gallery
+ content:
+ title: "Carousel — horizontal scroll-snap"
+ subtitle: "Swipe on touch devices, arrow buttons on desktop. Three across at large widths."
+ album: "media/albums/gallery"
+ sort: asc
+ design:
+ layout: carousel
+ columns: 3
+ gap: md
+ aspect_ratio: landscape
+ caption_position: below
+ hover_zoom: true
+ lightbox: true
+
+ - block: gallery
+ content:
+ title: "Slideshow — autoplay"
+ subtitle: "Hover anywhere over the slide to pause. Click to open the lightbox at full size."
+ album: "media/albums/gallery"
+ sort: asc
+ design:
+ layout: slideshow
+ aspect_ratio: wide
+ caption_position: overlay
+ autoplay: true
+ autoplay_interval: 4000
+ lightbox: true
+
- block: cta-card
content:
title: "Ready to get started?"