diff --git a/modules/blox/blox/gallery/client.jsx b/modules/blox/blox/gallery/client.jsx new file mode 100644 index 00000000..cac532ed --- /dev/null +++ b/modules/blox/blox/gallery/client.jsx @@ -0,0 +1,15 @@ +import {render} from "preact"; +import {GalleryBlock} from "./component.jsx"; + +function renderGalleryBlocks() { + const blocks = document.querySelectorAll('[data-block-type="gallery"]'); + blocks.forEach((block) => { + const propsData = block.dataset.props; + if (propsData) { + const props = JSON.parse(propsData); + render(, block); + } + }); +} + +renderGalleryBlocks(); diff --git a/modules/blox/blox/gallery/component.jsx b/modules/blox/blox/gallery/component.jsx new file mode 100644 index 00000000..2d538df4 --- /dev/null +++ b/modules/blox/blox/gallery/component.jsx @@ -0,0 +1,431 @@ +import {useState, useEffect, useRef, useCallback} from "preact/hooks"; +import {createPortal} from "preact/compat"; + +/* + * Gallery block — Slice 1 (grid + masonry layouts, 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 + * alt/caption/credit/link/title. The component never touches image paths. + */ + +const GRID_COLS = { + 1: "grid-cols-1", + 2: "grid-cols-1 sm:grid-cols-2", + 3: "grid-cols-1 sm:grid-cols-2 md:grid-cols-3", + 4: "grid-cols-2 sm:grid-cols-3 md:grid-cols-4", + 5: "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5", + 6: "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6", +}; + +const MASONRY_COLS = { + 1: "columns-1", + 2: "columns-1 sm:columns-2", + 3: "columns-1 sm:columns-2 md:columns-3", + 4: "columns-2 sm:columns-3 md:columns-4", + 5: "columns-2 sm:columns-3 md:columns-4 lg:columns-5", + 6: "columns-2 sm:columns-3 md:columns-4 lg:columns-6", +}; + +const GAP_CLASS = { + sm: "gap-2", + md: "gap-4", + lg: "gap-6", +}; + +const MASONRY_GAP_CLASS = { + sm: "hbx-gallery-masonry-gap-sm", + md: "", + lg: "hbx-gallery-masonry-gap-lg", +}; + +const ASPECT_CLASS = { + square: "aspect-square", + landscape: "aspect-[4/3]", + portrait: "aspect-[3/4]", + wide: "aspect-[16/9]", +}; + +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"; + +function renderInline(text) { + if (!text) return ""; + return String(text) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\*\*(.*?)\*\*/g, "$1") + .replace(/\*(.*?)\*/g, "$1"); +} + +function GridTile({item, 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 MasonryTile({item, onClick, captionPosition, hoverZoom, sizes, lightbox}) { + const [loaded, setLoaded] = useState(false); + const imgClass = loaded + ? "w-full h-auto block transition-opacity duration-500 opacity-100" + : "w-full h-auto block transition-opacity duration-500 opacity-0"; + + const hoverImgClass = hoverZoom ? "transition-transform duration-500 group-hover:scale-105" : ""; + + // Use the precomputed aspect ratio to reserve space — prevents the column + // layout from reflowing as each image loads. + const aspectStyle = item.aspect_ratio ? `aspect-ratio: ${item.aspect_ratio}` : ""; + + const showOverlay = captionPosition === "overlay" && (item.caption || item.title); + const showHoverCaption = captionPosition === "hover" && (item.caption || item.title); + + return ( + + ); +} + +function Lightbox({items, index, onClose, onPrev, onNext}) { + const item = items[index]; + const touchStart = useRef({x: 0, y: 0, t: 0}); + + // Body scroll lock while the lightbox is open + useEffect(() => { + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prev; + }; + }, []); + + // Keyboard navigation + useEffect(() => { + function onKey(e) { + if (e.key === "Escape") onClose(); + else if (e.key === "ArrowLeft") onPrev(); + else if (e.key === "ArrowRight") onNext(); + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose, onPrev, onNext]); + + 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) onNext(); + else onPrev(); + } + } + + if (!item) return null; + + return ( + + ); +} + +export const GalleryBlock = ({content = {}, design = {}, items = []}) => { + const [lightboxIndex, setLightboxIndex] = useState(null); + + const layout = design.layout || "grid"; + const columns = Math.max(1, Math.min(6, design.columns || 3)); + const gap = design.gap || "md"; + const captionPosition = design.caption_position || "below"; + const hoverZoom = design.hover_zoom !== false; + const lightboxEnabled = design.lightbox !== false; + const aspectRatio = design.aspect_ratio || "square"; + + const openLightbox = useCallback( + (i) => { + if (lightboxEnabled) { + setLightboxIndex(i); + } else if (items[i] && items[i].link) { + window.location.href = items[i].link; + } + }, + [lightboxEnabled, items], + ); + + const closeLightbox = useCallback(() => setLightboxIndex(null), []); + const nextLightbox = useCallback(() => setLightboxIndex((i) => (i == null ? null : (i + 1) % items.length)), [items.length]); + const prevLightbox = useCallback(() => setLightboxIndex((i) => (i == null ? null : (i - 1 + items.length) % items.length)), [items.length]); + + if (!items || items.length === 0) { + return ( +
+ Gallery has no images. Add `items[]` or set `album` to a folder under `assets/`. +
+ ); + } + + const sizes = layout === "masonry" ? SIZES_DEFAULT_MASONRY : SIZES_DEFAULT_GRID; + const aspectClass = ASPECT_CLASS[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; + + return ( +
+
+ {(content.title || content.subtitle) && ( +
+ {content.title && ( +

+ )} + {content.subtitle && ( +

+ )} +

+ )} + + {layout === "masonry" ? ( + + ) : ( +
+ {items.map((item, i) => ( + openLightbox(i)} + aspectClass={aspectClass} + captionPosition={captionPosition} + hoverZoom={hoverZoom} + sizes={sizes} + lightbox={lightboxEnabled} + /> + ))} +
+ )} +
+ + {/* Portal the lightbox to so it escapes the page section's + `isolation: isolate` stacking context — without this, the next + section in document order paints over the lightbox. */} + {lightboxIndex !== null && + typeof document !== "undefined" && + createPortal( + , + document.body, + )} +
+ ); +}; diff --git a/modules/blox/blox/gallery/manifest.json b/modules/blox/blox/gallery/manifest.json new file mode 100644 index 00000000..c892d007 --- /dev/null +++ b/modules/blox/blox/gallery/manifest.json @@ -0,0 +1,15 @@ +{ + "id": "gallery", + "name": "Gallery", + "version": "1.0.0", + "license": "MIT", + "category": "media", + "intent": ["showcase", "inform"], + "verticals": ["academic-cv", "event", "agency", "dev-portfolio", "podcaster", "startup", "local-business"], + "tags": ["gallery", "photos", "images", "album", "masonry", "grid", "lightbox"], + "description": "Image gallery with grid and masonry layouts, click-to-zoom lightbox, responsive variants, and lazy-loaded LQIP placeholders. Source images from explicit items, an album folder, or remote URLs.", + "author": "Hugo Blox", + "homepage": "https://hugoblox.com/blocks/", + "repository": "https://github.com/HugoBlox/kit", + "keywords": ["hugo", "gallery", "photos", "lightbox", "masonry", "grid"] +} diff --git a/modules/blox/blox/gallery/style.css b/modules/blox/blox/gallery/style.css new file mode 100644 index 00000000..ed18ba68 --- /dev/null +++ b/modules/blox/blox/gallery/style.css @@ -0,0 +1,40 @@ +/* Gallery block — masonry item spacing, LQIP backdrop, lightbox polish */ + +/* Masonry vertical spacing: each tile needs explicit margin-bottom because + Tailwind's `space-y-*` uses `:not(:last-child)` which doesn't behave inside + CSS multi-column layout. */ +.hbx-gallery-masonry > .hbx-gallery-tile { + margin-bottom: 1rem; + break-inside: avoid; +} + +.hbx-gallery-masonry-gap-sm > .hbx-gallery-tile { + margin-bottom: 0.5rem; +} +.hbx-gallery-masonry-gap-lg > .hbx-gallery-tile { + margin-bottom: 1.5rem; +} + +/* LQIP placeholder — applied as a background-image so the real can + fade in over it without a layout shift. */ +.hbx-gallery-tile-lqip { + background-size: cover; + background-position: center; + background-repeat: no-repeat; +} + +/* Lightbox caption — limit width so long captions wrap rather than spanning + the full viewport. */ +.hbx-gallery-lightbox-caption { + max-width: min(72ch, 90vw); +} + +/* 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, +.hbx-gallery-lightbox-caption a { + color: rgb(243 244 246); +} +.hbx-gallery-lightbox-caption a:hover { + color: rgb(255 255 255); +} diff --git a/modules/blox/layouts/_partials/blox/preact-wrapper.html b/modules/blox/layouts/_partials/blox/preact-wrapper.html index b27df14a..91deb761 100644 --- a/modules/blox/layouts/_partials/blox/preact-wrapper.html +++ b/modules/blox/layouts/_partials/blox/preact-wrapper.html @@ -208,6 +208,17 @@ {{ $props = merge $props (dict "icon_svgs" $icon_svgs) }} {{ end }} +{{/* Gallery block: resolve items list with responsive variants + LQIP placeholders */}} +{{ if eq $block_type "gallery" }} + {{ $gallery := partial "functions/resolve_gallery_items.html" (dict + "content" $block.content + "Page" $page + ) }} + {{ if $gallery.items }} + {{ $props = merge $props (dict "items" $gallery.items) }} + {{ end }} +{{ end }} + {{/* Convert props to JSON for client-side rendering */}} {{ $propsJSON := $props | jsonify }} diff --git a/modules/blox/layouts/_partials/functions/resolve_gallery_items.html b/modules/blox/layouts/_partials/functions/resolve_gallery_items.html new file mode 100644 index 00000000..b99a9370 --- /dev/null +++ b/modules/blox/layouts/_partials/functions/resolve_gallery_items.html @@ -0,0 +1,165 @@ +{{/* + resolve_gallery_items — Resolves gallery block items into a list of fully-processed + image props ready for the Preact component. Handles two source modes: + + 1. content.items[] — explicit per-image dicts with optional caption/alt/credit/link + 2. content.album — folder path relative to assets/, e.g. "media/albums/gallery" + + For each resolved image, produces a dict with: + {src, srcset, width, height, aspect_ratio, lqip, alt, caption, credit, link, title} + + Slice 1 caption resolution: frontmatter only (items[].caption). Sidecar + EXIF/IPTC + resolution is intentionally deferred to Slice 2 — for now, missing captions stay + empty in the UI, with the humanised filename surfaced as the `alt` fallback so + screen readers and search engines have something useful. + + Inputs: + .content: the block's content dict (items / album / sort / limit) + .Page: current page (for page-resource image lookup) + + Returns: dict { items: [...] } or empty if nothing resolves. +*/}} +{{ $page := .Page }} +{{ $content := .content }} +{{ if not (reflect.IsMap $content) }}{{ $content = dict }}{{ end }} + +{{ $allowed_exts := slice ".jpg" ".jpeg" ".png" ".webp" ".gif" ".avif" ".svg" }} +{{ $sort_order := lower ($content.sort | default "asc") }} +{{ $limit := $content.limit | default 0 }} + +{{/* Build a unified list of {resource?, fallback_url?, meta} entries from either source mode */}} +{{ $candidates := slice }} + +{{/* Source A — explicit items */}} +{{ with $content.items }} + {{ range . }} + {{ $src := .src | default "" }} + {{ if $src }} + {{ $resolved := partial "functions/resolve_image_resource" (dict "path" $src "Page" $page) }} + {{ $candidates = $candidates | append (dict + "resource" $resolved.resource + "fallback_url" ($resolved.fallback_url | default "") + "meta" . + ) }} + {{ end }} + {{ end }} +{{ end }} + +{{/* Source B — album folder glob */}} +{{ with $content.album }} + {{ $album := strings.TrimSuffix "/" . }} + {{ $matches := resources.Match (printf "%s/*" $album) }} + {{ $sorted := sort $matches "Name" $sort_order }} + {{ range $sorted }} + {{ if in $allowed_exts (lower (path.Ext .Name)) }} + {{ $candidates = $candidates | append (dict + "resource" . + "fallback_url" "" + "meta" dict + ) }} + {{ end }} + {{ end }} +{{ end }} + +{{/* Apply limit */}} +{{ if gt $limit 0 }} + {{ $candidates = first $limit $candidates }} +{{ end }} + +{{/* Process each candidate into final item props */}} +{{ $out := slice }} +{{ range $candidates }} + {{ $meta := .meta }} + {{ $resource := .resource }} + {{ $fallback_url := .fallback_url }} + + {{/* Per-item text fields. Caption / title / credit are rendered through + Hugo's markdown pipeline at build time (inline mode — no wrapping

) + so users can put **bold**, *italic*, `code`, and [links](url) in any of + them without shipping a JS markdown parser. Alt stays as plain text + since it lives inside an HTML attribute. */}} + {{ $alt := index $meta "alt" | default "" }} + {{ $caption_raw := index $meta "caption" | default "" }} + {{ $title_raw := index $meta "title" | default "" }} + {{ $credit_raw := index $meta "credit" | default "" }} + {{ $link := index $meta "link" | default "" }} + + {{ $caption := $caption_raw }} + {{ if and $caption_raw $page }} + {{ $caption = $page.RenderString (dict "display" "inline") $caption_raw }} + {{ end }} + {{ $title := $title_raw }} + {{ if and $title_raw $page }} + {{ $title = $page.RenderString (dict "display" "inline") $title_raw }} + {{ end }} + {{ $credit := $credit_raw }} + {{ if and $credit_raw $page }} + {{ $credit = $page.RenderString (dict "display" "inline") $credit_raw }} + {{ end }} + + {{ $entry := dict + "alt" $alt + "caption" $caption + "credit" $credit + "link" $link + "title" $title + }} + + {{ if $resource }} + {{ $canProcess := and (reflect.IsImageResourceProcessable $resource) (ne $resource.MediaType.SubType "gif") }} + {{ if $canProcess }} + {{ $responsive := partial "functions/process_responsive_image.html" (dict + "image" $resource + "mode" "fit" + "sizes" (slice 480 768 1024 1366 1920) + ) }} + {{ $entry = merge $entry (dict + "src" $responsive.fallback.RelPermalink + "srcset" $responsive.srcset + "width" $responsive.fallback.Width + "height" $responsive.fallback.Height + "aspect_ratio" (div (float $resource.Width) (float $resource.Height)) + ) }} + + {{/* LQIP — 20px wide WebP, inlined as base64 for instant placeholder paint */}} + {{ $lqip_res := $resource.Resize "20x webp q30" }} + {{ $lqip_b64 := $lqip_res.Content | base64Encode }} + {{ $entry = merge $entry (dict "lqip" (printf "data:image/webp;base64,%s" $lqip_b64)) }} + {{ else }} + {{/* SVG / GIF — pass through unprocessed */}} + {{ $entry = merge $entry (dict + "src" $resource.RelPermalink + "width" $resource.Width + "height" $resource.Height + "aspect_ratio" (cond (gt $resource.Height 0) (div (float $resource.Width) (float $resource.Height)) 1.0) + ) }} + {{ end }} + + {{/* Alt fallback: plainified caption → humanised filename. Caption itself + is NOT filled from filename — empty captions stay empty in the UI + (Slice 1 behaviour). Plainify strips the HTML produced by RenderString + so alt stays attribute-safe. */}} + {{ if not $entry.alt }} + {{ $fallback_alt := "" }} + {{ with $caption_raw }} + {{ $fallback_alt = plainify (printf "%s" $entry.caption) }} + {{ end }} + {{ if not $fallback_alt }} + {{ $base := path.Base $resource.Name }} + {{ $name := strings.TrimSuffix (path.Ext $base) $base }} + {{ $name = replace $name "-" " " }} + {{ $name = replace $name "_" " " }} + {{ $fallback_alt = humanize $name }} + {{ end }} + {{ $entry = merge $entry (dict "alt" $fallback_alt) }} + {{ end }} + {{ else if $fallback_url }} + {{ $entry = merge $entry (dict "src" $fallback_url) }} + {{ end }} + + {{ if $entry.src }} + {{ $out = $out | append $entry }} + {{ end }} +{{ end }} + +{{ return dict "items" $out }} diff --git a/test/assets/media/albums/gallery/01-workspace.jpg b/test/assets/media/albums/gallery/01-workspace.jpg new file mode 100644 index 00000000..b46b3ee4 Binary files /dev/null and b/test/assets/media/albums/gallery/01-workspace.jpg differ diff --git a/test/assets/media/albums/gallery/02-forest.jpg b/test/assets/media/albums/gallery/02-forest.jpg new file mode 100644 index 00000000..7ef143c9 Binary files /dev/null and b/test/assets/media/albums/gallery/02-forest.jpg differ diff --git a/test/assets/media/albums/gallery/03-boat.jpg b/test/assets/media/albums/gallery/03-boat.jpg new file mode 100644 index 00000000..a8af0f66 Binary files /dev/null and b/test/assets/media/albums/gallery/03-boat.jpg differ diff --git a/test/assets/media/albums/gallery/04-cabin.jpg b/test/assets/media/albums/gallery/04-cabin.jpg new file mode 100644 index 00000000..53b5da26 Binary files /dev/null and b/test/assets/media/albums/gallery/04-cabin.jpg differ diff --git a/test/assets/media/albums/gallery/05-castle.jpg b/test/assets/media/albums/gallery/05-castle.jpg new file mode 100644 index 00000000..784543a0 Binary files /dev/null and b/test/assets/media/albums/gallery/05-castle.jpg differ diff --git a/test/assets/media/albums/gallery/06-mountain.jpg b/test/assets/media/albums/gallery/06-mountain.jpg new file mode 100644 index 00000000..0c023143 Binary files /dev/null and b/test/assets/media/albums/gallery/06-mountain.jpg differ diff --git a/test/assets/media/albums/gallery/07-architecture.jpg b/test/assets/media/albums/gallery/07-architecture.jpg new file mode 100644 index 00000000..71674fd9 Binary files /dev/null and b/test/assets/media/albums/gallery/07-architecture.jpg differ diff --git a/test/assets/media/albums/gallery/08-portrait.jpg b/test/assets/media/albums/gallery/08-portrait.jpg new file mode 100644 index 00000000..081eb947 Binary files /dev/null and b/test/assets/media/albums/gallery/08-portrait.jpg differ diff --git a/test/assets/media/albums/gallery/09-shoreline.jpg b/test/assets/media/albums/gallery/09-shoreline.jpg new file mode 100644 index 00000000..555cd189 Binary files /dev/null and b/test/assets/media/albums/gallery/09-shoreline.jpg differ diff --git a/test/config.yaml b/test/config.yaml index 41fdf586..5c666aad 100644 --- a/test/config.yaml +++ b/test/config.yaml @@ -1,4 +1,4 @@ -title: Hugo Blox Test +title: HugoBlox Test baseURL: https://example.com build: buildStats: @@ -14,11 +14,3 @@ markup: _merge: deep security: _merge: deep -params: - header: - navbar: - enable: false - extensions: - cms: - local_backend: true - publish_mode: diff --git a/test/data/pages/home.yaml b/test/data/pages/home.yaml index 0c4a641f..2b9c0538 100644 --- a/test/data/pages/home.yaml +++ b/test/data/pages/home.yaml @@ -6,12 +6,12 @@ date: 2023-11-01 sections: - block: hero content: - title: "**Build Anything** with Hugo Blox" + title: "**Build Anything** with HugoBlox" text: | - Create stunning websites with our Preact-powered components. + Create stunning websites with Hugo-powered components. Fast, flexible, and future-proof. announcement: - text: "🎉 Introducing Preact Components" + text: "🎉 Introducing New Components" link: text: "Learn more" url: "/linked" @@ -90,10 +90,53 @@ sections: height: lg style: light + - block: gallery + content: + title: "Lab tour" + subtitle: "A curated walkthrough — explicit items with captions and credits." + items: + - src: "media/albums/gallery/01-workspace.jpg" + caption: "The **main workspace** where most of our research happens" + credit: "Photo: [Picsum](https://picsum.photos)" + alt: "Open-plan workspace with laptops" + - src: "media/albums/gallery/05-castle.jpg" + title: "Annual retreat" + caption: "*Edinburgh, 2024* — three days of deep work and walks" + credit: "Photo: [Picsum](https://picsum.photos)" + - src: "media/albums/gallery/03-boat.jpg" + caption: "Field trip — coastal data collection on the `Aurora-II`" + credit: "Photo: [Picsum](https://picsum.photos)" + link: "/research/aurora-ii/" + - src: "media/albums/gallery/07-architecture.jpg" + caption: "The new wing opens [in spring](/news/new-wing/)" + credit: "Photo: [Picsum](https://picsum.photos)" + design: + layout: grid + columns: 4 + gap: md + aspect_ratio: square + caption_position: below + hover_zoom: true + lightbox: true + + - block: gallery + content: + title: "Recent expeditions" + subtitle: "Auto-loaded from `assets/media/albums/gallery/` — captions stay empty when not provided (Slice 1 behaviour)." + album: "media/albums/gallery" + sort: asc + design: + layout: masonry + columns: 3 + gap: md + caption_position: overlay + hover_zoom: true + lightbox: true + - block: cta-card content: title: "Ready to get started?" - text: "Join 250,000+ users building with Hugo Blox" + text: "Join 150,000+ users building with HugoBlox" button: text: "Start Building" url: "https://hugoblox.com"