diff --git a/modules/blox/blox/hero/component.jsx b/modules/blox/blox/hero/component.jsx index 47103ba0..c0266be4 100644 --- a/modules/blox/blox/hero/component.jsx +++ b/modules/blox/blox/hero/component.jsx @@ -81,7 +81,7 @@ function StarStrip({stars}) { return (
{Array.from({length: 5}).map((_, i) => ( - + ))}
); @@ -155,58 +155,150 @@ const ALIGN_TEXT = {center: "text-center", left: "text-left"}; const ALIGN_FLEX = {center: "justify-center", left: "justify-start"}; const ALIGN_MX = {center: "mx-auto", left: ""}; +// Layout presets. `centered` is the classic stacked hero. `split-*` puts text and media +// side-by-side (the dominant 2026 SaaS pattern). `stacked` keeps text top-aligned with +// media full-width below — useful for product screenshots that need horizontal real estate. +const LAYOUTS = { + centered: {container: "max-w-2xl", grid: false, stacked: false, reverse: false}, + "split-left": {container: "max-w-7xl", grid: true, stacked: false, reverse: false}, + "split-right": {container: "max-w-7xl", grid: true, stacked: false, reverse: true}, + stacked: {container: "max-w-6xl", grid: false, stacked: true, reverse: false}, +}; + +// Media: image (with optional dark variant) or video. Hero `media.src` is processed +// through Hugo's responsive image pipeline upstream and arrives as `media_image` with +// {src, srcset, width, height}. Videos pass straight through. +function Media({media, mediaImage, mediaImageDark}) { + if (!media) return null; + const type = media.type || "image"; + + if (type === "video") { + return ( +
+
+ ); + } + + // image (default) + if (!mediaImage) return null; + const hasDark = !!mediaImageDark; + return ( +
+ {media.alt + {hasDark && ( + + )} +
+ ); +} + // Hero Block Component - Single implementation -export const HeroBlock = ({content, design, _id, icon_svg, secondary_icon_svg}) => { +export const HeroBlock = ({content, design, _id, icon_svg, secondary_icon_svg, media_image, media_image_dark}) => { // Backward compat: legacy `no_padding: true` maps to size: "none" const sizeKey = SIZE_CLASSES[design?.size] ? design.size : design?.no_padding ? "none" : "default"; const sizeClasses = SIZE_CLASSES[sizeKey]; - const alignKey = design?.alignment === "left" ? "left" : "center"; + const layoutKey = LAYOUTS[design?.layout] ? design.layout : "centered"; + const layout = LAYOUTS[layoutKey]; + + // Auto-align: split layouts read better left-aligned unless explicitly centred + const alignKey = design?.alignment === "left" || design?.alignment === "center" ? design.alignment : layout.grid ? "left" : "center"; const textAlign = ALIGN_TEXT[alignKey]; const flexAlign = ALIGN_FLEX[alignKey]; const mxAuto = ALIGN_MX[alignKey]; - return ( -
-
- + const contentStack = ( + <> + +
+ {content.eyebrow && ( +

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

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

+ )} + {(content.primary_action?.url || content.secondary_action?.url) && ( +

+ + +
+ )} + +

+ + ); -
- {/* Eyebrow */} - {content.eyebrow && ( -

- )} + const mediaEl = content.media ? : null; - {/* Title */} - {content.title && ( -

- )} - - {/* Subtitle/Text */} - {content.text && ( -

- )} - - {/* Action Buttons */} - {(content.primary_action?.url || content.secondary_action?.url) && ( -

- - -
- )} - - + // Split layout: two-column grid, optionally reversed + if (layout.grid) { + return ( +
+
+
+
{contentStack}
+ {mediaEl &&
{mediaEl}
} +
+ ); + } + + // Stacked layout: text top (constrained), media full-width below + if (layout.stacked) { + return ( +
+
+
{contentStack}
+ {mediaEl &&
{mediaEl}
} +
+
+ ); + } + + // Centered (default) + return ( +
+
{contentStack}
); }; diff --git a/modules/blox/layouts/_partials/blox/preact-wrapper.html b/modules/blox/layouts/_partials/blox/preact-wrapper.html index 41ce3d6e..b27df14a 100644 --- a/modules/blox/layouts/_partials/blox/preact-wrapper.html +++ b/modules/blox/layouts/_partials/blox/preact-wrapper.html @@ -33,6 +33,24 @@ {{ end }} {{ end }} +{{/* Resolve hero media (single image, optional dark variant). Videos pass through unprocessed. */}} +{{ with $block.content.media }} + {{ if or (not .type) (eq .type "image") }} + {{ with .src }} + {{ $img_data := partial "functions/process_image_for_props" (dict "path" . "Page" $page) }} + {{ with $img_data.src }} + {{ $props = merge $props (dict "media_image" $img_data) }} + {{ end }} + {{ end }} + {{ with .dark_src }} + {{ $img_data := partial "functions/process_image_for_props" (dict "path" . "Page" $page) }} + {{ with $img_data.src }} + {{ $props = merge $props (dict "media_image_dark" $img_data) }} + {{ end }} + {{ end }} + {{ end }} +{{ end }} + {{/* Resolve icon for single button (cta-card) */}} {{ with $block.content.button.icon }} {{ $icon_data := partial "functions/get_icon_data" (dict "name" .) }} diff --git a/modules/blox/layouts/_partials/functions/process_image_for_props.html b/modules/blox/layouts/_partials/functions/process_image_for_props.html new file mode 100644 index 00000000..67b22a88 --- /dev/null +++ b/modules/blox/layouts/_partials/functions/process_image_for_props.html @@ -0,0 +1,45 @@ +{{/* + process_image_for_props — One-shot helper: resolves an image path and returns a + {src, srcset?, width?, height?} dict ready to merge into a Preact block's props. + SVGs and GIFs pass through unprocessed (preserves vectors / animation frames). + + Inputs: + .path: image path (required) + .Page: current page (recommended for page-resource lookup) + .sizes: slice of widths for srcset generation + (optional, defaults to [600, 900, 1200, 1800]) + + Returns: image data dict {src, srcset?, width?, height?}, or empty dict if the + path is missing or unresolvable. +*/}} +{{ $result := dict }} +{{ $path := .path | default "" }} + +{{ if $path }} + {{ $sizes := .sizes | default (slice 600 900 1200 1800) }} + {{ $resolved := partial "functions/resolve_image_resource" (dict "path" $path "Page" .Page) }} + + {{ with $resolved.resource }} + {{ $canProcess := and (reflect.IsImageResourceProcessable .) (ne .MediaType.SubType "gif") }} + {{ if not $canProcess }} + {{ $result = dict "src" .RelPermalink }} + {{ else }} + {{ $responsive := partial "functions/process_responsive_image.html" (dict + "image" . + "mode" "fit" + "sizes" $sizes + ) }} + {{ $result = dict + "src" $responsive.fallback.RelPermalink + "srcset" $responsive.srcset + "width" $responsive.fallback.Width + "height" $responsive.fallback.Height + }} + {{ end }} + {{ end }} + {{ with $resolved.fallback_url }} + {{ $result = dict "src" . }} + {{ end }} +{{ end }} + +{{ return $result }} diff --git a/modules/blox/layouts/_partials/functions/resolve_image_resource.html b/modules/blox/layouts/_partials/functions/resolve_image_resource.html new file mode 100644 index 00000000..796bbfc8 --- /dev/null +++ b/modules/blox/layouts/_partials/functions/resolve_image_resource.html @@ -0,0 +1,54 @@ +{{/* + resolve_image_resource — Resolves an image path string to either a Hugo image + resource or a fallback URL. Handles remote URLs, page resources, and assets/media + lookups using the same rules as the inline per-item image resolver. + + Inputs: + .path: image path (required) — relative ("dashboard.png", "media/foo.jpg") + or absolute URL ("https://…"). + .Page: current page (optional, recommended for page-resource lookup). + + Returns dict with one of: + .resource: Hugo image resource (use with .Process, srcset, etc.) + .fallback_url: plain URL string (use as ) + Or an empty dict if path is missing. +*/}} +{{ $result := dict }} +{{ $path := strings.TrimSpace (printf "%v" (.path | default "")) }} + +{{ if $path }} + {{ $is_remote := or (strings.HasPrefix $path "http://") (strings.HasPrefix $path "https://") }} + + {{ if $is_remote }} + {{ $remote := try (resources.GetRemote $path) }} + {{ if and $remote (not $remote.Err) }} + {{ $result = dict "resource" $remote.Value }} + {{ else }} + {{ $result = dict "fallback_url" $path }} + {{ end }} + {{ else }} + {{ $normalized := $path }} + {{ $normalized = strings.TrimPrefix "/" $normalized }} + {{ $normalized = strings.TrimPrefix "./" $normalized }} + {{ $normalized = strings.TrimPrefix "assets/" $normalized }} + {{ $normalized = strings.TrimPrefix "media/" $normalized }} + {{ $normalized = path.Clean $normalized }} + + {{ if and $normalized (ne $normalized ".") }} + {{ $resource := false }} + {{ with .Page }} + {{ $resource = (.Resources.ByType "image").GetMatch $normalized }} + {{ end }} + {{ if not $resource }} + {{ $resource = resources.Get (path.Join "media" $normalized) }} + {{ end }} + {{ if $resource }} + {{ $result = dict "resource" $resource }} + {{ else }} + {{ $result = dict "fallback_url" (printf "/media/%s" $normalized) }} + {{ end }} + {{ end }} + {{ end }} +{{ end }} + +{{ return $result }} diff --git a/modules/blox/schemas/blocks.json b/modules/blox/schemas/blocks.json index 755d38d7..39f6919a 100644 --- a/modules/blox/schemas/blocks.json +++ b/modules/blox/schemas/blocks.json @@ -89,7 +89,10 @@ "announcement-badge", "trust-strip", "size-presets", - "alignment" + "alignment", + "media-image", + "media-video", + "split-layouts" ], "complexity": "simple", "preact": true, diff --git a/templates/startup-landing-page/content/_index.md b/templates/startup-landing-page/content/_index.md index 35558fb4..99a9f954 100644 --- a/templates/startup-landing-page/content/_index.md +++ b/templates/startup-landing-page/content/_index.md @@ -30,6 +30,13 @@ sections: trust: stars: 5 text: Loved by **10,000+** developers · Trusted by teams at Google, OpenAI, NVIDIA + # To switch to a split layout with a product mockup, uncomment the media block + # below and set `design.layout: split-left` (or `split-right` / `stacked`): + # media: + # type: image + # src: dashboard.png + # dark_src: dashboard-dark.png # optional dark-mode variant + # alt: Hugo Blox dashboard design: spacing: padding: [0, 0, 0, 0]