feat(blocks): migrate logos block to Preact with marquee, grid, and per-item overrides

Replace legacy static Go HTML logos block with a Preact component. Adds three
layouts (row, grid, marquee), three filter styles (grayscale/color/white),
and three sizes (sm/md/lg). Fixed-width slots normalise visual weight across
SVG aspect ratios; per-item `scale` compensates for viewBox whitespace and
per-item `style` allows individual colour exceptions.  Add logos block to startup-landing-page template.
This commit is contained in:
George Cushen committed 2026-04-30 22:01:41 +01:00
1 parent 4c13c45536
commit 3b70b325af
4 files changed
+258 -5

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
import {render} from "preact";
import {LogosBlock} from "./component.jsx";
function renderLogosBlocks() {
const blocks = document.querySelectorAll('[data-block-type="logos"]');
blocks.forEach((block) => {
const propsData = block.dataset.props;
if (propsData) {
const props = JSON.parse(propsData);
render(<LogosBlock {...props} />, block);
}
});
}
renderLogosBlocks();
+207
View File
@@ -0,0 +1,207 @@
import {Icon} from "../../shared/components/Icon.jsx";
const MARQUEE_CSS = `
@keyframes hbx-logos-marquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.hbx-logos-track {
animation: hbx-logos-marquee var(--hbx-marquee-dur, 30s) linear infinite;
will-change: transform;
}
.hbx-logos-track:hover { animation-play-state: paused; }
`;
// Slot widths keep every logo at a consistent visual footprint regardless of SVG aspect ratio
const SLOT_W = {sm: "w-24", md: "w-32", lg: "w-40"};
// Max-width caps very wide SVGs inside their slot (height × ~2.5)
const MAX_W = {sm: "3.5rem", md: "5rem", lg: "7rem"};
// Heights passed as inline style so Icon.jsx's auto-sizing fires correctly
const HEIGHT = {sm: "1.75rem", md: "2.5rem", lg: "3.5rem"};
// Full-string Tailwind filter classes — no dynamic concatenation
const FILTER = {
grayscale: "grayscale opacity-60 hover:grayscale-0 hover:opacity-100 transition-all duration-300",
white: "brightness-0 invert transition-all duration-300",
color: "transition-all duration-300",
};
function renderText(text) {
if (!text) return "";
return String(text)
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.*?)\*/g, "<em>$1</em>");
}
function LogoItem({item, idx, logoStyle, logoSize, icon_svgs, item_images, padY = "py-3"}) {
const iconSvg = item.icon ? (icon_svgs?.[item.icon] ?? null) : null;
const imgData = item_images?.[String(idx)] ?? null;
const height = HEIGHT[logoSize] ?? HEIGHT.md;
const maxW = MAX_W[logoSize] ?? MAX_W.md;
const slotW = SLOT_W[logoSize] ?? SLOT_W.md;
// Per-item overrides: style and scale
const itemStyle = item.style ?? logoStyle;
const filter = FILTER[itemStyle] ?? FILTER.grayscale;
// scale: compensates for SVG intrinsic whitespace or atypical artwork areas.
// e.g. scale: 1.3 enlarges a logo whose SVG has excessive padding baked into its viewBox.
const scale = typeof item.scale === "number" ? item.scale : 1;
const scaleStyle = scale !== 1 ? `;transform:scale(${scale})` : "";
let visual;
if (iconSvg) {
visual = (
<Icon
svg={iconSvg}
attributes={{
class: `inline-block ${filter}`,
style: `height:${height};max-width:${maxW}${scaleStyle}`,
title: item.name || undefined,
}}
/>
);
} else if (imgData) {
visual = (
<img
src={imgData.src}
alt={item.name || ""}
class={`object-contain ${filter}`}
style={`height:${height};width:auto;max-width:${maxW}${scaleStyle}`}
/>
);
} else if (item.name) {
visual = <span class="font-semibold text-gray-400 dark:text-gray-500 text-sm">{item.name}</span>;
} else {
return null;
}
const isExt = item.url && (item.url.startsWith("http://") || item.url.startsWith("https://"));
// Fixed-width slot: every logo occupies the same footprint → consistent visual weight
const cls = `flex items-center justify-center flex-shrink-0 ${slotW} ${padY}`;
return item.url ? (
<a href={item.url} class={cls} aria-label={item.name || undefined}
{...(isExt ? {target: "_blank", rel: "noopener noreferrer"} : {})}>
{visual}
</a>
) : (
<div class={cls} aria-label={item.name || undefined}>{visual}</div>
);
}
function LogoRow({items, logoStyle, logoSize, icon_svgs, item_images}) {
return (
<div class="flex flex-wrap items-center justify-center">
{items.map((item, i) => (
<LogoItem key={i} item={item} idx={i} logoStyle={logoStyle} logoSize={logoSize}
icon_svgs={icon_svgs} item_images={item_images} />
))}
</div>
);
}
function LogoGrid({items, logoStyle, logoSize, icon_svgs, item_images}) {
const cols =
items.length <= 3 ? "grid-cols-3" :
items.length <= 4 ? "grid-cols-2 sm:grid-cols-4" :
items.length <= 6 ? "grid-cols-3 sm:grid-cols-6" :
"grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6";
return (
<div class={`grid ${cols} gap-6 items-center`}>
{items.map((item, i) => (
<LogoItem key={i} item={item} idx={i} logoStyle={logoStyle} logoSize={logoSize}
icon_svgs={icon_svgs} item_images={item_images} />
))}
</div>
);
}
function LogoMarquee({items, logoStyle, logoSize, icon_svgs, item_images, speed}) {
const doubled = [...items, ...items];
return (
<>
<style>{MARQUEE_CSS}</style>
{/*
mask-image fades edges without depending on the section background colour —
works on any bg (white, gray-50, dark, gradient, etc.)
*/}
<div
class="overflow-hidden"
style="mask-image: linear-gradient(to right, transparent, black 8%, black 92%, transparent)"
>
<div
class="hbx-logos-track flex w-max items-center"
style={`--hbx-marquee-dur:${speed ?? 30}s`}
>
{doubled.map((item, i) => (
<LogoItem key={i} item={item} idx={i % items.length} logoStyle={logoStyle}
logoSize={logoSize} icon_svgs={icon_svgs} item_images={item_images} />
))}
</div>
</div>
</>
);
}
export const LogosBlock = ({content = {}, design = {}, icon_svgs = {}, item_images = {}}) => {
const rawItems = Array.isArray(content.items) ? content.items
: Array.isArray(content.logos) ? content.logos
: [];
const {title, subtitle, cta} = content;
const layout = design.layout || design.display_mode || "row";
const logoStyle = design.logo_style || "grayscale";
const logoSize = design.logo_size || "md";
const speed = design.marquee_speed ?? 30;
const isExtCta = cta?.url && (cta.url.startsWith("http://") || cta.url.startsWith("https://"));
return (
<div class="py-12 sm:py-16 px-4 sm:px-6 lg:px-8">
<div class="max-w-7xl mx-auto">
{(title || subtitle) && (
<div class="text-center mb-8">
{title && (
<p
class="text-xs font-semibold uppercase tracking-widest text-gray-400 dark:text-gray-500 mb-1.5"
dangerouslySetInnerHTML={{__html: renderText(title)}}
/>
)}
{subtitle && (
<p
class="text-sm text-gray-400 dark:text-gray-500"
dangerouslySetInnerHTML={{__html: renderText(subtitle)}}
/>
)}
</div>
)}
{layout === "marquee" && (
<LogoMarquee items={rawItems} logoStyle={logoStyle} logoSize={logoSize}
icon_svgs={icon_svgs} item_images={item_images} speed={speed} />
)}
{layout === "grid" && (
<LogoGrid items={rawItems} logoStyle={logoStyle} logoSize={logoSize}
icon_svgs={icon_svgs} item_images={item_images} />
)}
{layout !== "marquee" && layout !== "grid" && (
<LogoRow items={rawItems} logoStyle={logoStyle} logoSize={logoSize}
icon_svgs={icon_svgs} item_images={item_images} />
)}
{cta?.text && cta?.url && (
<div class="mt-6 text-center">
<a
href={cta.url}
{...(isExtCta ? {target: "_blank", rel: "noopener noreferrer"} : {})}
class="inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 transition-colors"
>
{cta.text}
<span aria-hidden="true">→</span>
</a>
</div>
)}
</div>
</div>
);
};
+7 -5
View File
@@ -1,13 +1,15 @@
{
"id": "logos",
"name": "Logos",
"version": "1.0.0",
"version": "2.0.0",
"license": "MIT",
"category": "content",
"tags": ["logos", "partners", "sponsors", "collaborators", "brands", "clients", "affiliations"],
"description": "Display partner, sponsor, or collaborator logos with interactive effects and multiple display modes",
"category": "marketing",
"intent": ["inform"],
"verticals": ["startup", "saas", "agency", "academic-cv", "lab", "dev-portfolio"],
"tags": ["logos", "social-proof", "partners", "sponsors", "clients", "brands", "affiliations", "marquee"],
"description": "Social-proof logo row: display partner, client, or sponsor logos via the brands icon pack (or image files). Supports row, grid, and infinite-marquee layouts with grayscale/color/white filter styles.",
"author": "Hugo Blox",
"homepage": "https://hugoblox.com/blocks/",
"repository": "https://github.com/HugoBlox/kit",
"keywords": ["hugo", "static-site", "logos", "partners", "sponsors", "carousel", "marquee"]
"keywords": ["hugo", "static-site", "logos", "social-proof", "brands", "marquee"]
}
@@ -36,6 +36,35 @@ sections:
size: cover
position: center
parallax: false
- block: logos
content:
title: Trusted by teams at
items:
- icon: brands/github
name: GitHub
- icon: brands/google
name: Google
- icon: brands/microsoft
name: Microsoft
- icon: brands/nvidia
name: NVIDIA
- icon: brands/openai
name: OpenAI
- icon: brands/anthropic
name: Anthropic
- icon: brands/stripe
name: Stripe
- icon: brands/vercel
name: Vercel
design:
layout: marquee
logo_style: grayscale
logo_size: md
marquee_speed: 35
css_class: "bg-gray-50 dark:bg-gray-900"
spacing:
padding: ["2rem", 0, "2rem", 0]
- block: stats
content:
items: