Skip to content

Commit 3b70b32

Browse files
committed
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.
1 parent 4c13c45 commit 3b70b32

4 files changed

Lines changed: 258 additions & 5 deletions

File tree

modules/blox/blox/logos/client.jsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {render} from "preact";
2+
import {LogosBlock} from "./component.jsx";
3+
4+
function renderLogosBlocks() {
5+
const blocks = document.querySelectorAll('[data-block-type="logos"]');
6+
blocks.forEach((block) => {
7+
const propsData = block.dataset.props;
8+
if (propsData) {
9+
const props = JSON.parse(propsData);
10+
render(<LogosBlock {...props} />, block);
11+
}
12+
});
13+
}
14+
15+
renderLogosBlocks();
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import {Icon} from "../../shared/components/Icon.jsx";
2+
3+
const MARQUEE_CSS = `
4+
@keyframes hbx-logos-marquee {
5+
from { transform: translateX(0); }
6+
to { transform: translateX(-50%); }
7+
}
8+
.hbx-logos-track {
9+
animation: hbx-logos-marquee var(--hbx-marquee-dur, 30s) linear infinite;
10+
will-change: transform;
11+
}
12+
.hbx-logos-track:hover { animation-play-state: paused; }
13+
`;
14+
15+
// Slot widths keep every logo at a consistent visual footprint regardless of SVG aspect ratio
16+
const SLOT_W = {sm: "w-24", md: "w-32", lg: "w-40"};
17+
// Max-width caps very wide SVGs inside their slot (height × ~2.5)
18+
const MAX_W = {sm: "3.5rem", md: "5rem", lg: "7rem"};
19+
// Heights passed as inline style so Icon.jsx's auto-sizing fires correctly
20+
const HEIGHT = {sm: "1.75rem", md: "2.5rem", lg: "3.5rem"};
21+
22+
// Full-string Tailwind filter classes — no dynamic concatenation
23+
const FILTER = {
24+
grayscale: "grayscale opacity-60 hover:grayscale-0 hover:opacity-100 transition-all duration-300",
25+
white: "brightness-0 invert transition-all duration-300",
26+
color: "transition-all duration-300",
27+
};
28+
29+
function renderText(text) {
30+
if (!text) return "";
31+
return String(text)
32+
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
33+
.replace(/\*(.*?)\*/g, "<em>$1</em>");
34+
}
35+
36+
function LogoItem({item, idx, logoStyle, logoSize, icon_svgs, item_images, padY = "py-3"}) {
37+
const iconSvg = item.icon ? (icon_svgs?.[item.icon] ?? null) : null;
38+
const imgData = item_images?.[String(idx)] ?? null;
39+
const height = HEIGHT[logoSize] ?? HEIGHT.md;
40+
const maxW = MAX_W[logoSize] ?? MAX_W.md;
41+
const slotW = SLOT_W[logoSize] ?? SLOT_W.md;
42+
43+
// Per-item overrides: style and scale
44+
const itemStyle = item.style ?? logoStyle;
45+
const filter = FILTER[itemStyle] ?? FILTER.grayscale;
46+
// scale: compensates for SVG intrinsic whitespace or atypical artwork areas.
47+
// e.g. scale: 1.3 enlarges a logo whose SVG has excessive padding baked into its viewBox.
48+
const scale = typeof item.scale === "number" ? item.scale : 1;
49+
const scaleStyle = scale !== 1 ? `;transform:scale(${scale})` : "";
50+
51+
let visual;
52+
if (iconSvg) {
53+
visual = (
54+
<Icon
55+
svg={iconSvg}
56+
attributes={{
57+
class: `inline-block ${filter}`,
58+
style: `height:${height};max-width:${maxW}${scaleStyle}`,
59+
title: item.name || undefined,
60+
}}
61+
/>
62+
);
63+
} else if (imgData) {
64+
visual = (
65+
<img
66+
src={imgData.src}
67+
alt={item.name || ""}
68+
class={`object-contain ${filter}`}
69+
style={`height:${height};width:auto;max-width:${maxW}${scaleStyle}`}
70+
/>
71+
);
72+
} else if (item.name) {
73+
visual = <span class="font-semibold text-gray-400 dark:text-gray-500 text-sm">{item.name}</span>;
74+
} else {
75+
return null;
76+
}
77+
78+
const isExt = item.url && (item.url.startsWith("http://") || item.url.startsWith("https://"));
79+
// Fixed-width slot: every logo occupies the same footprint → consistent visual weight
80+
const cls = `flex items-center justify-center flex-shrink-0 ${slotW} ${padY}`;
81+
82+
return item.url ? (
83+
<a href={item.url} class={cls} aria-label={item.name || undefined}
84+
{...(isExt ? {target: "_blank", rel: "noopener noreferrer"} : {})}>
85+
{visual}
86+
</a>
87+
) : (
88+
<div class={cls} aria-label={item.name || undefined}>{visual}</div>
89+
);
90+
}
91+
92+
function LogoRow({items, logoStyle, logoSize, icon_svgs, item_images}) {
93+
return (
94+
<div class="flex flex-wrap items-center justify-center">
95+
{items.map((item, i) => (
96+
<LogoItem key={i} item={item} idx={i} logoStyle={logoStyle} logoSize={logoSize}
97+
icon_svgs={icon_svgs} item_images={item_images} />
98+
))}
99+
</div>
100+
);
101+
}
102+
103+
function LogoGrid({items, logoStyle, logoSize, icon_svgs, item_images}) {
104+
const cols =
105+
items.length <= 3 ? "grid-cols-3" :
106+
items.length <= 4 ? "grid-cols-2 sm:grid-cols-4" :
107+
items.length <= 6 ? "grid-cols-3 sm:grid-cols-6" :
108+
"grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6";
109+
return (
110+
<div class={`grid ${cols} gap-6 items-center`}>
111+
{items.map((item, i) => (
112+
<LogoItem key={i} item={item} idx={i} logoStyle={logoStyle} logoSize={logoSize}
113+
icon_svgs={icon_svgs} item_images={item_images} />
114+
))}
115+
</div>
116+
);
117+
}
118+
119+
function LogoMarquee({items, logoStyle, logoSize, icon_svgs, item_images, speed}) {
120+
const doubled = [...items, ...items];
121+
return (
122+
<>
123+
<style>{MARQUEE_CSS}</style>
124+
{/*
125+
mask-image fades edges without depending on the section background colour —
126+
works on any bg (white, gray-50, dark, gradient, etc.)
127+
*/}
128+
<div
129+
class="overflow-hidden"
130+
style="mask-image: linear-gradient(to right, transparent, black 8%, black 92%, transparent)"
131+
>
132+
<div
133+
class="hbx-logos-track flex w-max items-center"
134+
style={`--hbx-marquee-dur:${speed ?? 30}s`}
135+
>
136+
{doubled.map((item, i) => (
137+
<LogoItem key={i} item={item} idx={i % items.length} logoStyle={logoStyle}
138+
logoSize={logoSize} icon_svgs={icon_svgs} item_images={item_images} />
139+
))}
140+
</div>
141+
</div>
142+
</>
143+
);
144+
}
145+
146+
export const LogosBlock = ({content = {}, design = {}, icon_svgs = {}, item_images = {}}) => {
147+
const rawItems = Array.isArray(content.items) ? content.items
148+
: Array.isArray(content.logos) ? content.logos
149+
: [];
150+
151+
const {title, subtitle, cta} = content;
152+
const layout = design.layout || design.display_mode || "row";
153+
const logoStyle = design.logo_style || "grayscale";
154+
const logoSize = design.logo_size || "md";
155+
const speed = design.marquee_speed ?? 30;
156+
157+
const isExtCta = cta?.url && (cta.url.startsWith("http://") || cta.url.startsWith("https://"));
158+
159+
return (
160+
<div class="py-12 sm:py-16 px-4 sm:px-6 lg:px-8">
161+
<div class="max-w-7xl mx-auto">
162+
{(title || subtitle) && (
163+
<div class="text-center mb-8">
164+
{title && (
165+
<p
166+
class="text-xs font-semibold uppercase tracking-widest text-gray-400 dark:text-gray-500 mb-1.5"
167+
dangerouslySetInnerHTML={{__html: renderText(title)}}
168+
/>
169+
)}
170+
{subtitle && (
171+
<p
172+
class="text-sm text-gray-400 dark:text-gray-500"
173+
dangerouslySetInnerHTML={{__html: renderText(subtitle)}}
174+
/>
175+
)}
176+
</div>
177+
)}
178+
179+
{layout === "marquee" && (
180+
<LogoMarquee items={rawItems} logoStyle={logoStyle} logoSize={logoSize}
181+
icon_svgs={icon_svgs} item_images={item_images} speed={speed} />
182+
)}
183+
{layout === "grid" && (
184+
<LogoGrid items={rawItems} logoStyle={logoStyle} logoSize={logoSize}
185+
icon_svgs={icon_svgs} item_images={item_images} />
186+
)}
187+
{layout !== "marquee" && layout !== "grid" && (
188+
<LogoRow items={rawItems} logoStyle={logoStyle} logoSize={logoSize}
189+
icon_svgs={icon_svgs} item_images={item_images} />
190+
)}
191+
192+
{cta?.text && cta?.url && (
193+
<div class="mt-6 text-center">
194+
<a
195+
href={cta.url}
196+
{...(isExtCta ? {target: "_blank", rel: "noopener noreferrer"} : {})}
197+
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"
198+
>
199+
{cta.text}
200+
<span aria-hidden="true"></span>
201+
</a>
202+
</div>
203+
)}
204+
</div>
205+
</div>
206+
);
207+
};
Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
{
22
"id": "logos",
33
"name": "Logos",
4-
"version": "1.0.0",
4+
"version": "2.0.0",
55
"license": "MIT",
6-
"category": "content",
7-
"tags": ["logos", "partners", "sponsors", "collaborators", "brands", "clients", "affiliations"],
8-
"description": "Display partner, sponsor, or collaborator logos with interactive effects and multiple display modes",
6+
"category": "marketing",
7+
"intent": ["inform"],
8+
"verticals": ["startup", "saas", "agency", "academic-cv", "lab", "dev-portfolio"],
9+
"tags": ["logos", "social-proof", "partners", "sponsors", "clients", "brands", "affiliations", "marquee"],
10+
"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.",
911
"author": "Hugo Blox",
1012
"homepage": "https://hugoblox.com/blocks/",
1113
"repository": "https://github.com/HugoBlox/kit",
12-
"keywords": ["hugo", "static-site", "logos", "partners", "sponsors", "carousel", "marquee"]
14+
"keywords": ["hugo", "static-site", "logos", "social-proof", "brands", "marquee"]
1315
}

templates/startup-landing-page/content/_index.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,35 @@ sections:
3636
size: cover
3737
position: center
3838
parallax: false
39+
- block: logos
40+
content:
41+
title: Trusted by teams at
42+
items:
43+
- icon: brands/github
44+
name: GitHub
45+
- icon: brands/google
46+
name: Google
47+
- icon: brands/microsoft
48+
name: Microsoft
49+
- icon: brands/nvidia
50+
name: NVIDIA
51+
- icon: brands/openai
52+
name: OpenAI
53+
- icon: brands/anthropic
54+
name: Anthropic
55+
- icon: brands/stripe
56+
name: Stripe
57+
- icon: brands/vercel
58+
name: Vercel
59+
design:
60+
layout: marquee
61+
logo_style: grayscale
62+
logo_size: md
63+
marquee_speed: 35
64+
css_class: "bg-gray-50 dark:bg-gray-900"
65+
spacing:
66+
padding: ["2rem", 0, "2rem", 0]
67+
3968
- block: stats
4069
content:
4170
items:

0 commit comments

Comments
 (0)