mirror of
https://github.com/tiennm99/HugoBlox-kit.git
synced 2026-10-11 03:12:56 +00:00
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).
This commit is contained in:
1 parent
bdd3820f27
commit
f017b3faef
3 files changed
+485
-33
No files matched your search
@@ -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 (
|
||||
<figure class="hbx-gallery-justified-item group relative overflow-hidden rounded-xl bg-gray-100 dark:bg-gray-800" style={itemStyle}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
class="absolute inset-0 w-full h-full block cursor-zoom-in focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
||||
aria-label={lightbox ? `Open image: ${item.alt || item.caption || ""}` : item.alt || item.caption || "Image"}
|
||||
>
|
||||
<span
|
||||
class="hbx-gallery-tile-lqip absolute inset-0 w-full h-full"
|
||||
style={item.lqip ? `background-image: url("${item.lqip}")` : ""}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<img
|
||||
src={item.src}
|
||||
srcset={item.srcset}
|
||||
sizes={sizes}
|
||||
width={item.width}
|
||||
height={item.height}
|
||||
alt={item.alt || ""}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class={`${imgClass} ${hoverImgClass}`}
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
{showOverlay && (
|
||||
<span class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4 pt-12 text-left">
|
||||
<span class="block text-sm font-semibold text-white" dangerouslySetInnerHTML={{__html: item.title || item.caption}} />
|
||||
{item.title && item.caption && (
|
||||
<span class="block text-xs text-gray-200 mt-1" dangerouslySetInnerHTML={{__html: item.caption}} />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{showHoverCaption && (
|
||||
<span class="absolute inset-0 flex items-end p-4 bg-black/0 group-hover:bg-black/60 transition-colors duration-300">
|
||||
<span class="block text-sm font-semibold text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300" dangerouslySetInnerHTML={{__html: item.caption || item.title}} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
function JustifiedView({items, design, openLightbox, gapClass, sizes}) {
|
||||
const rowHeight = Math.max(120, Math.min(640, design.row_height || 240));
|
||||
return (
|
||||
<div class={`hbx-gallery-justified flex flex-wrap ${gapClass}`}>
|
||||
{items.map((item, i) => (
|
||||
<JustifiedTile
|
||||
key={i}
|
||||
item={item}
|
||||
rowHeight={rowHeight}
|
||||
onClick={() => openLightbox(i)}
|
||||
captionPosition={design.caption_position || "below"}
|
||||
hoverZoom={design.hover_zoom !== false}
|
||||
sizes={sizes}
|
||||
lightbox={design.lightbox !== false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* 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 (
|
||||
<figure class={`hbx-gallery-carousel-item snap-start shrink-0 ${basisClass} group`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
class={`relative block w-full overflow-hidden rounded-xl bg-gray-100 dark:bg-gray-800 cursor-zoom-in focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 ${aspectClass}`}
|
||||
aria-label={lightbox ? `Open image: ${item.alt || item.caption || ""}` : item.alt || item.caption || "Image"}
|
||||
>
|
||||
<span
|
||||
class="hbx-gallery-tile-lqip absolute inset-0 w-full h-full"
|
||||
style={item.lqip ? `background-image: url("${item.lqip}")` : ""}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<img
|
||||
src={item.src}
|
||||
srcset={item.srcset}
|
||||
sizes={sizes}
|
||||
width={item.width}
|
||||
height={item.height}
|
||||
alt={item.alt || ""}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class={`${imgClass} ${hoverImgClass}`}
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
{showOverlay && (
|
||||
<span class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4 pt-12 text-left">
|
||||
<span class="block text-sm font-semibold text-white" dangerouslySetInnerHTML={{__html: item.title || item.caption}} />
|
||||
{item.title && item.caption && (
|
||||
<span class="block text-xs text-gray-200 mt-1" dangerouslySetInnerHTML={{__html: item.caption}} />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{showHoverCaption && (
|
||||
<span class="absolute inset-0 flex items-end p-4 bg-black/0 group-hover:bg-black/60 transition-colors duration-300">
|
||||
<span class="block text-sm font-semibold text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300" dangerouslySetInnerHTML={{__html: item.caption || item.title}} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{captionPosition === "below" && (item.caption || item.title) && (
|
||||
<figcaption class="pt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{item.title && (
|
||||
<span class="font-semibold text-gray-900 dark:text-white" dangerouslySetInnerHTML={{__html: item.title}} />
|
||||
)}
|
||||
{item.title && item.caption && " — "}
|
||||
{item.caption && <span dangerouslySetInnerHTML={{__html: item.caption}} />}
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div class="relative">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
class={`hbx-gallery-carousel flex overflow-x-auto snap-x snap-mandatory scroll-pl-4 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 pb-2 ${gapClass}`}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<CarouselTile
|
||||
key={i}
|
||||
item={item}
|
||||
basisClass={basisClass}
|
||||
aspectClass={aspectClass}
|
||||
onClick={() => openLightbox(i)}
|
||||
captionPosition={design.caption_position || "below"}
|
||||
hoverZoom={design.hover_zoom !== false}
|
||||
sizes={sizes}
|
||||
lightbox={design.lightbox !== false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{items.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollByItem(-1)}
|
||||
class="hidden md:flex absolute left-2 top-1/2 -translate-y-1/2 z-10 h-12 w-12 items-center justify-center rounded-full bg-white/90 dark:bg-gray-800/90 text-gray-900 dark:text-white shadow-lg hover:bg-white dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors"
|
||||
aria-label="Scroll previous"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollByItem(1)}
|
||||
class="hidden md:flex absolute right-2 top-1/2 -translate-y-1/2 z-10 h-12 w-12 items-center justify-center rounded-full bg-white/90 dark:bg-gray-800/90 text-gray-900 dark:text-white shadow-lg hover:bg-white dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors"
|
||||
aria-label="Scroll next"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* 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 (
|
||||
<div
|
||||
class="relative"
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
<div class={`relative overflow-hidden rounded-xl bg-gray-100 dark:bg-gray-800 ${aspectClass}`}>
|
||||
{items.map((it, i) => (
|
||||
<div
|
||||
key={i}
|
||||
class={i === index ? "absolute inset-0 transition-opacity duration-500 opacity-100" : "absolute inset-0 transition-opacity duration-500 opacity-0 pointer-events-none"}
|
||||
aria-hidden={i === index ? "false" : "true"}
|
||||
>
|
||||
<span
|
||||
class="hbx-gallery-tile-lqip absolute inset-0 w-full h-full"
|
||||
style={it.lqip ? `background-image: url("${it.lqip}")` : ""}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLightbox(i)}
|
||||
class="absolute inset-0 w-full h-full block cursor-zoom-in focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-inset"
|
||||
aria-label={lightbox ? `Open image: ${it.alt || it.caption || ""}` : it.alt || it.caption || "Image"}
|
||||
>
|
||||
<img
|
||||
src={it.src}
|
||||
srcset={it.srcset}
|
||||
sizes={sizes}
|
||||
width={it.width}
|
||||
height={it.height}
|
||||
alt={it.alt || ""}
|
||||
loading={i === 0 ? "eager" : "lazy"}
|
||||
decoding="async"
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
{captionPosition === "overlay" && (item.caption || item.title) && i === index && (
|
||||
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-6 pt-16 pointer-events-none">
|
||||
<div class="max-w-3xl mx-auto text-center">
|
||||
<div class="text-base font-semibold text-white" dangerouslySetInnerHTML={{__html: it.title || it.caption}} />
|
||||
{it.title && it.caption && (
|
||||
<div class="text-sm text-gray-200 mt-1" dangerouslySetInnerHTML={{__html: it.caption}} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{items.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={prev}
|
||||
class="absolute left-2 sm:left-4 top-1/2 -translate-y-1/2 z-10 flex h-12 w-12 items-center justify-center rounded-full bg-white/90 dark:bg-gray-800/90 text-gray-900 dark:text-white shadow-lg hover:bg-white dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors"
|
||||
aria-label="Previous slide"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
class="absolute right-2 sm:right-4 top-1/2 -translate-y-1/2 z-10 flex h-12 w-12 items-center justify-center rounded-full bg-white/90 dark:bg-gray-800/90 text-gray-900 dark:text-white shadow-lg hover:bg-white dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors"
|
||||
aria-label="Next slide"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length > 1 && (
|
||||
<div class="flex items-center justify-center gap-2 mt-4">
|
||||
{items.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setIndex(i)}
|
||||
class={i === index ? "h-2 w-8 rounded-full bg-primary-600 dark:bg-primary-400 transition-all duration-300" : "h-2 w-2 rounded-full bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-all duration-300"}
|
||||
aria-label={`Go to slide ${i + 1}`}
|
||||
aria-current={i === index ? "true" : "false"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{captionPosition === "below" && (item.caption || item.title) && (
|
||||
<div class="mt-4 text-center text-sm text-gray-600 dark:text-gray-400 max-w-3xl mx-auto">
|
||||
{item.title && (
|
||||
<div class="font-semibold text-gray-900 dark:text-white" dangerouslySetInnerHTML={{__html: item.title}} />
|
||||
)}
|
||||
{item.caption && <div dangerouslySetInnerHTML={{__html: item.caption}} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<div class={`hbx-gallery-masonry ${masonryClass} ${gapClass} ${masonryGapClass}`}>
|
||||
{items.map((item, i) => (
|
||||
<MasonryTile
|
||||
key={i}
|
||||
item={item}
|
||||
onClick={() => openLightbox(i)}
|
||||
captionPosition={captionPosition}
|
||||
hoverZoom={hoverZoom}
|
||||
sizes={sizes}
|
||||
lightbox={lightboxEnabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} else if (layout === "justified") {
|
||||
layoutEl = <JustifiedView items={items} design={design} openLightbox={openLightbox} gapClass={gapClass} sizes={sizes} />;
|
||||
} else if (layout === "carousel") {
|
||||
layoutEl = (
|
||||
<CarouselView
|
||||
items={items}
|
||||
design={design}
|
||||
openLightbox={openLightbox}
|
||||
gapClass={gapClass}
|
||||
aspectClass={aspectClass}
|
||||
sizes={sizes}
|
||||
columns={columns}
|
||||
/>
|
||||
);
|
||||
} else if (layout === "slideshow") {
|
||||
layoutEl = <SlideshowView items={items} design={design} openLightbox={openLightbox} aspectClass={aspectClass} sizes={sizes} />;
|
||||
} else {
|
||||
layoutEl = (
|
||||
<div class={`grid ${gridClass} ${gapClass}`}>
|
||||
{items.map((item, i) => (
|
||||
<GridTile
|
||||
key={i}
|
||||
item={item}
|
||||
onClick={() => openLightbox(i)}
|
||||
aspectClass={aspectClass}
|
||||
captionPosition={captionPosition}
|
||||
hoverZoom={hoverZoom}
|
||||
sizes={sizes}
|
||||
lightbox={lightboxEnabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="py-12 sm:py-16 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
@@ -385,36 +797,7 @@ export const GalleryBlock = ({content = {}, design = {}, items = []}) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{layout === "masonry" ? (
|
||||
<div class={`hbx-gallery-masonry ${masonryClass} ${gapClass} ${masonryGapClass}`}>
|
||||
{items.map((item, i) => (
|
||||
<MasonryTile
|
||||
key={i}
|
||||
item={item}
|
||||
onClick={() => openLightbox(i)}
|
||||
captionPosition={captionPosition}
|
||||
hoverZoom={hoverZoom}
|
||||
sizes={sizes}
|
||||
lightbox={lightboxEnabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class={`grid ${gridClass} ${gapClass}`}>
|
||||
{items.map((item, i) => (
|
||||
<GridTile
|
||||
key={i}
|
||||
item={item}
|
||||
onClick={() => openLightbox(i)}
|
||||
aspectClass={aspectClass}
|
||||
captionPosition={captionPosition}
|
||||
hoverZoom={hoverZoom}
|
||||
sizes={sizes}
|
||||
lightbox={lightboxEnabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{layoutEl}
|
||||
</div>
|
||||
|
||||
{/* Portal the lightbox to <body> so it escapes the page section's
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user