fix: lint

This commit is contained in:
George Cushen committed 2026-02-19 14:15:26 +00:00
1 parent 00147612cd
commit a3c21b401e
29 files changed
+667 -797

No files matched your search

+8 -3
View File
@@ -16,13 +16,12 @@
"!**/vendor", "!**/vendor",
"!**/*.html", "!**/*.html",
"!**/layouts/**/*.html", "!**/layouts/**/*.html",
"!**/layouts/**/*.htm",
"!**/*.webmanifest", "!**/*.webmanifest",
"!**/*.backlinks.json", "!**/*.backlinks.json",
"!**/hugo_stats.json", "!**/hugo_stats.json",
"!**/*.code-workspace", "!**/*.code-workspace",
"!**/modules/blox/data/icons/devicon.json", "!**/modules/blox/data/icons/devicon.json",
"!**/templates", "!**/templates/**/*.html",
"!**/test", "!**/test",
"!**/modules/**/package.json", "!**/modules/**/package.json",
"!**/assets/jsconfig.json", "!**/assets/jsconfig.json",
@@ -64,7 +63,13 @@
} }
} }
}, },
"assist": { "actions": { "source": { "organizeImports": "on" } } }, "assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"css": { "css": {
"parser": { "parser": {
"tailwindDirectives": true "tailwindDirectives": true
+5 -7
View File
@@ -90,22 +90,20 @@
line-height: 1.2; line-height: 1.2;
/* Subtle gradient text for premium feel */ /* Subtle gradient text for premium feel */
background: linear-gradient(135deg, background: linear-gradient(135deg, var(--color-gray-500) 0%, var(--color-gray-400) 100%);
var(--color-gray-500) 0%,
var(--color-gray-400) 100%);
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
/* Smooth reveal animation */ /* Smooth reveal animation */
opacity: 0.85; opacity: 0.85;
transition: opacity 0.3s ease, transform 0.3s ease; transition:
opacity 0.3s ease,
transform 0.3s ease;
} }
.dark .hb-ruby-name rt { .dark .hb-ruby-name rt {
background: linear-gradient(135deg, background: linear-gradient(135deg, var(--color-gray-400) 0%, var(--color-gray-300) 100%);
var(--color-gray-400) 0%,
var(--color-gray-300) 100%);
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
+15 -15
View File
@@ -1,25 +1,25 @@
import { render } from "preact"; import {render} from "preact";
import { CtaCardBlock } from "./component.jsx"; import {CtaCardBlock} from "./component.jsx";
function renderCtaCardBlock() { function renderCtaCardBlock() {
const blocks = document.querySelectorAll('.cta-card-block-container[data-preact-render="true"]'); const blocks = document.querySelectorAll('.cta-card-block-container[data-preact-render="true"]');
blocks.forEach((block) => { blocks.forEach((block) => {
try { try {
if (block.dataset.rendered === "true") return; if (block.dataset.rendered === "true") return;
const props = JSON.parse(block.dataset.props); const props = JSON.parse(block.dataset.props);
render(<CtaCardBlock {...props} />, block); render(<CtaCardBlock {...props} />, block);
block.dataset.rendered = "true"; block.dataset.rendered = "true";
} catch (e) { } catch (e) {
console.error("Error rendering CTA Card Block:", e, block); console.error("Error rendering CTA Card Block:", e, block);
} }
}); });
} }
// Initial render // Initial render
if (document.readyState === "loading") { if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", renderCtaCardBlock); document.addEventListener("DOMContentLoaded", renderCtaCardBlock);
} else { } else {
renderCtaCardBlock(); renderCtaCardBlock();
} }
+106 -106
View File
@@ -4,125 +4,125 @@
* Supports glassmorphism, custom backgrounds, and automatic text color adjustment. * Supports glassmorphism, custom backgrounds, and automatic text color adjustment.
*/ */
import { Icon } from "../../shared/components/Icon.jsx"; import {Icon} from "../../shared/components/Icon.jsx";
// Simple markdown renderer // Simple markdown renderer
function renderText(text) { function renderText(text) {
if (!text) return ""; if (!text) return "";
return String(text) return String(text)
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>") .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.*?)\*/g, "<em>$1</em>") .replace(/\*(.*?)\*/g, "<em>$1</em>")
.replace(/`(.*?)`/g, "<code>$1</code>") .replace(/`(.*?)`/g, "<code>$1</code>")
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>'); .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
} }
export const CtaCardBlock = ({ content, design, id, button_icon_svg }) => { export const CtaCardBlock = ({content, design, _id, button_icon_svg}) => {
const card = design?.card || {}; const card = design?.card || {};
const cardClass = card.css_class || ""; const cardClass = card.css_class || "";
const cardStyle = card.css_style || ""; const cardStyle = card.css_style || "";
// Overlay Opacity Logic // Overlay Opacity Logic
let overlayOpacity = 0.15; let overlayOpacity = 0.15;
if (card.overlay_opacity !== undefined) { if (card.overlay_opacity !== undefined) {
const val = parseFloat(card.overlay_opacity); const val = parseFloat(card.overlay_opacity);
if (!isNaN(val)) overlayOpacity = Math.max(0, Math.min(1, val)); if (!Number.isNaN(val)) overlayOpacity = Math.max(0, Math.min(1, val));
}
// Text Color Logic
let textColorMode = "auto";
if (["light", "dark", "auto"].includes(card.text_color?.toLowerCase())) {
textColorMode = card.text_color.toLowerCase();
}
let textClasses = "text-gray-900 dark:text-white";
let buttonClasses = "text-gray-900 dark:text-gray-100";
// Auto-detect based on background gradient
const bg = design?.background || {};
const gradientStart = bg.gradient?.start || "";
const isDarkBg = ["primary-7", "primary-8", "primary-9", "secondary-7", "secondary-8", "secondary-9"].some((p) => gradientStart.startsWith(p));
if (textColorMode === "light") {
textClasses = "text-white";
buttonClasses = "text-gray-900 dark:text-white";
} else if (textColorMode === "dark") {
textClasses = "text-gray-900 dark:text-white";
buttonClasses = "text-gray-900 dark:text-white";
} else if (isDarkBg) {
textClasses = "text-white";
buttonClasses = "text-gray-900";
}
// Button Logic
const button = content?.button || {};
const buttonText = button.text?.trim();
const buttonUrl = button.url?.trim();
const showButton = !!(buttonText && buttonUrl);
let isExternal = false;
let isNewTab = button.new_tab === true;
if (buttonUrl) {
try {
const url = new URL(buttonUrl, window.location.origin);
isExternal = url.origin !== window.location.origin;
} catch {
isExternal = /^https?:\/\//.test(buttonUrl);
} }
if (buttonUrl.endsWith(".pdf")) isNewTab = true;
if (isExternal) isNewTab = true;
}
// Text Color Logic const defaultBgClass = "bg-gradient-to-br from-primary-500/90 via-primary-600/95 to-primary-700/90";
let textColorMode = "auto"; const finalCardClass = cardClass || defaultBgClass;
if (["light", "dark", "auto"].includes(card.text_color?.toLowerCase())) {
textColorMode = card.text_color.toLowerCase();
}
let textClasses = "text-gray-900 dark:text-white"; const style = `--glassmorphism-opacity: ${overlayOpacity}; ${cardStyle}`;
let buttonClasses = "text-gray-900 dark:text-gray-100";
// Auto-detect based on background gradient return (
const bg = design?.background || {}; <div
const gradientStart = bg.gradient?.start || ""; class={`relative overflow-hidden ${finalCardClass} p-8 sm:p-12 lg:p-16 xl:p-20 mx-auto max-w-6xl rounded-3xl shadow-2xl flex flex-col items-center text-center`}
const isDarkBg = ["primary-7", "primary-8", "primary-9", "secondary-7", "secondary-8", "secondary-9"].some(p => gradientStart.startsWith(p)); style={style}
>
{/* Title */}
{content.title && (
<h2
class={`${textClasses} text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-bold tracking-tight leading-tight`}
dangerouslySetInnerHTML={{__html: renderText(content.title)}}
/>
)}
if (textColorMode === "light") { {/* Text */}
textClasses = "text-white"; {content.text && (
buttonClasses = "text-gray-900 dark:text-white";
} else if (textColorMode === "dark") {
textClasses = "text-gray-900 dark:text-white";
buttonClasses = "text-gray-900 dark:text-white";
} else if (isDarkBg) {
textClasses = "text-white";
buttonClasses = "text-gray-900";
}
// Button Logic
const button = content?.button || {};
const buttonText = button.text?.trim();
const buttonUrl = button.url?.trim();
const showButton = !!(buttonText && buttonUrl);
let isExternal = false;
let isNewTab = button.new_tab === true;
if (buttonUrl) {
try {
const url = new URL(buttonUrl, window.location.origin);
isExternal = url.origin !== window.location.origin;
} catch {
isExternal = /^https?:\/\//.test(buttonUrl);
}
if (buttonUrl.endsWith(".pdf")) isNewTab = true;
if (isExternal) isNewTab = true;
}
const defaultBgClass = "bg-gradient-to-br from-primary-500/90 via-primary-600/95 to-primary-700/90";
const finalCardClass = cardClass || defaultBgClass;
const style = `--glassmorphism-opacity: ${overlayOpacity}; ${cardStyle}`;
return (
<div <div
class={`relative overflow-hidden ${finalCardClass} p-8 sm:p-12 lg:p-16 xl:p-20 mx-auto max-w-6xl rounded-3xl shadow-2xl flex flex-col items-center text-center`} class={`${textClasses}/80 mt-6 text-lg sm:text-xl lg:text-2xl max-w-3xl leading-relaxed font-light`}
style={style} dangerouslySetInnerHTML={{__html: renderText(content.text)}}
> />
{/* Title */} )}
{content.title && (
<h2 {/* Button */}
class={`${textClasses} text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-bold tracking-tight leading-tight`} {showButton && (
dangerouslySetInnerHTML={{ __html: renderText(content.title) }} <div class="flex mt-10">
/> <a
href={buttonUrl}
target={isNewTab ? "_blank" : undefined}
rel={isNewTab ? "noopener" : undefined}
class={`group relative inline-flex items-center gap-3 px-8 py-4 text-lg font-semibold ${buttonClasses} transition-all duration-300 ease-out hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-500/50 dark:focus-visible:ring-white/50 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent`}
>
{/* Glassmorphism Background */}
<div class="absolute inset-0 bg-white/95 dark:bg-gray-900/95 backdrop-blur-md rounded-2xl ring-1 ring-gray-200/30 dark:ring-white/30 shadow-lg group-hover:bg-white dark:group-hover:bg-gray-800 group-hover:ring-gray-300/50 dark:group-hover:ring-white/50 group-hover:shadow-2xl transition-all duration-300"></div>
{/* Button Content */}
<span class="relative z-10">{buttonText}</span>
{button_icon_svg && (
<span class="relative z-10 transition-transform duration-300 group-hover:translate-x-1">
<Icon svg={button_icon_svg} attributes={{style: "height: 1.25em", class: "inline-block"}} />
</span>
)} )}
{/* Text */} {/* Glow Effect */}
{content.text && ( <div class="absolute inset-0 bg-white/20 dark:bg-white/10 rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-300 blur-xl"></div>
<div </a>
class={`${textClasses}/80 mt-6 text-lg sm:text-xl lg:text-2xl max-w-3xl leading-relaxed font-light`}
dangerouslySetInnerHTML={{ __html: renderText(content.text) }}
/>
)}
{/* Button */}
{showButton && (
<div class="flex mt-10">
<a
href={buttonUrl}
target={isNewTab ? "_blank" : undefined}
rel={isNewTab ? "noopener" : undefined}
class={`group relative inline-flex items-center gap-3 px-8 py-4 text-lg font-semibold ${buttonClasses} transition-all duration-300 ease-out hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-500/50 dark:focus-visible:ring-white/50 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent`}
>
{/* Glassmorphism Background */}
<div class="absolute inset-0 bg-white/95 dark:bg-gray-900/95 backdrop-blur-md rounded-2xl ring-1 ring-gray-200/30 dark:ring-white/30 shadow-lg group-hover:bg-white dark:group-hover:bg-gray-800 group-hover:ring-gray-300/50 dark:group-hover:ring-white/50 group-hover:shadow-2xl transition-all duration-300"></div>
{/* Button Content */}
<span class="relative z-10">{buttonText}</span>
{button_icon_svg && (
<span class="relative z-10 transition-transform duration-300 group-hover:translate-x-1">
<Icon svg={button_icon_svg} attributes={{ style: "height: 1.25em", class: "inline-block" }} />
</span>
)}
{/* Glow Effect */}
<div class="absolute inset-0 bg-white/20 dark:bg-white/10 rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-300 blur-xl"></div>
</a>
</div>
)}
</div> </div>
); )}
</div>
);
}; };
@@ -2,24 +2,24 @@
* CTA Image Paragraph Block - Client-side Hydration * CTA Image Paragraph Block - Client-side Hydration
*/ */
import { render } from "preact"; import {render} from "preact";
import { CtaImageParagraphBlock } from "./component.jsx"; import {CtaImageParagraphBlock} from "./component.jsx";
function renderCtaImageParagraphBlocks() { function renderCtaImageParagraphBlocks() {
const blocks = document.querySelectorAll('[data-block-type="cta-image-paragraph"]'); const blocks = document.querySelectorAll('[data-block-type="cta-image-paragraph"]');
blocks.forEach((block) => { blocks.forEach((block) => {
const propsData = block.dataset.props; const propsData = block.dataset.props;
if (propsData) { if (propsData) {
try { try {
const props = JSON.parse(propsData); const props = JSON.parse(propsData);
render(<CtaImageParagraphBlock {...props} />, block); render(<CtaImageParagraphBlock {...props} />, block);
console.debug(`✓ CTA Image Paragraph block "${block.id}" rendered with Preact`); console.debug(`✓ CTA Image Paragraph block "${block.id}" rendered with Preact`);
} catch (error) { } catch (error) {
console.error(`Failed to render CTA Image Paragraph block "${block.id}":`, error); console.error(`Failed to render CTA Image Paragraph block "${block.id}":`, error);
} }
} }
}); });
} }
renderCtaImageParagraphBlocks(); renderCtaImageParagraphBlocks();
@@ -4,137 +4,131 @@
* Images are pre-processed by Hugo's responsive image pipeline (srcset) * Images are pre-processed by Hugo's responsive image pipeline (srcset)
*/ */
import { Icon } from "../../shared/components/Icon.jsx"; import {Icon} from "../../shared/components/Icon.jsx";
// Simple markdown renderer // Simple markdown renderer
function renderText(text) { function renderText(text) {
if (!text) return ""; if (!text) return "";
return String(text) return String(text)
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>") .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.*?)\*/g, "<em>$1</em>") .replace(/\*(.*?)\*/g, "<em>$1</em>")
.replace(/`(.*?)`/g, "<code>$1</code>") .replace(/`(.*?)`/g, "<code>$1</code>")
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>'); .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
} }
// Arrow icon for CTA buttons // Arrow icon for CTA buttons
const ArrowIcon = () => ( const ArrowIcon = () => (
<svg class="ml-2 -mr-1 w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"> <svg aria-hidden="true" class="ml-2 -mr-1 w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd" /> <path
</svg> fill-rule="evenodd"
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
); );
function CtaItem({ item, idx, imageData, featureIconSvg }) { function CtaItem({item, idx, imageData, featureIconSvg}) {
const isReversed = idx % 2 === 1; const isReversed = idx % 2 === 1;
const hasImage = !!imageData; const hasImage = !!imageData;
const featureIcon = item.feature_icon || "check"; const _featureIcon = item.feature_icon || "check";
// Resolve button link // Resolve button link
let buttonUrl = item.button?.url || ""; const buttonUrl = item.button?.url || "";
let isExternal = false; let isExternal = false;
if (buttonUrl) { if (buttonUrl) {
try { try {
const url = new URL(buttonUrl, window.location.origin); const url = new URL(buttonUrl, window.location.origin);
isExternal = url.origin !== window.location.origin; isExternal = url.origin !== window.location.origin;
} catch { } catch {
isExternal = /^https?:\/\//.test(buttonUrl); isExternal = /^https?:\/\//.test(buttonUrl);
}
} }
}
const features = Array.isArray(item.features) const features = Array.isArray(item.features) ? item.features : typeof item.features === "string" ? [item.features] : [];
? item.features
: typeof item.features === "string"
? [item.features]
: [];
return ( return (
<div class={`gap-8 items-center py-8 px-4 mx-auto max-w-screen-xl xl:gap-16 ${hasImage ? "md:grid md:grid-cols-2" : ""} sm:py-16 lg:px-6`}> <div class={`gap-8 items-center py-8 px-4 mx-auto max-w-screen-xl xl:gap-16 ${hasImage ? "md:grid md:grid-cols-2" : ""} sm:py-16 lg:px-6`}>
{/* Image */} {/* Image */}
{hasImage && ( {hasImage &&
imageData.srcset ? ( (imageData.srcset ? (
<img <img
class="w-full" class="w-full"
srcset={imageData.srcset} srcset={imageData.srcset}
sizes="(max-width: 768px) 100vw, 50vw" sizes="(max-width: 768px) 100vw, 50vw"
src={imageData.src} src={imageData.src}
width={imageData.width} width={imageData.width}
height={imageData.height} height={imageData.height}
alt={item.title || ""} alt={item.title || ""}
style={isReversed ? { order: 1 } : undefined} style={isReversed ? {order: 1} : undefined}
/> />
) : ( ) : (
<img <img
class="w-full" class="w-full"
src={imageData.src} src={imageData.src}
width={imageData.width} width={imageData.width}
height={imageData.height} height={imageData.height}
alt={item.title || ""} alt={item.title || ""}
style={isReversed ? { order: 1 } : undefined} style={isReversed ? {order: 1} : undefined}
/> />
) ))}
)}
{/* Text Content */} {/* Text Content */}
<div class="mt-4 md:mt-0"> <div class="mt-4 md:mt-0">
{item.title && ( {item.title && (
<h2 <h2
class="mb-4 text-4xl tracking-tight font-extrabold text-gray-900 dark:text-white" class="mb-4 text-4xl tracking-tight font-extrabold text-gray-900 dark:text-white"
dangerouslySetInnerHTML={{ __html: renderText(item.title) }} dangerouslySetInnerHTML={{__html: renderText(item.title)}}
/> />
)} )}
{item.text && ( {item.text && (
<p <p class="mb-6 font-light text-gray-500 md:text-lg dark:text-gray-400" dangerouslySetInnerHTML={{__html: renderText(item.text)}} />
class="mb-6 font-light text-gray-500 md:text-lg dark:text-gray-400" )}
dangerouslySetInnerHTML={{ __html: renderText(item.text) }}
/>
)}
{/* Feature List */} {/* Feature List */}
{features.length > 0 && ( {features.length > 0 && (
<ul> <ul>
{features.map((feature, fIdx) => ( {features.map((feature, fIdx) => (
<li key={fIdx} class="relative mb-4 pl-6"> <li key={fIdx} class="relative mb-4 pl-6">
{featureIconSvg ? ( {featureIconSvg ? <Icon svg={featureIconSvg} attributes={{class: "inline-block pr-5", style: "height: 1em;"}} /> : null}
<Icon svg={featureIconSvg} attributes={{ class: "inline-block pr-5", style: "height: 1em;" }} /> <span dangerouslySetInnerHTML={{__html: renderText(String(feature))}} />
) : null} </li>
<span dangerouslySetInnerHTML={{ __html: renderText(String(feature)) }} /> ))}
</li> </ul>
))} )}
</ul>
)}
{/* CTA Button */} {/* CTA Button */}
{item.button?.text && item.button?.url && ( {item.button?.text && item.button?.url && (
<a <a
href={buttonUrl} href={buttonUrl}
target={isExternal ? "_blank" : undefined} target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener" : undefined} rel={isExternal ? "noopener" : undefined}
class="mt-3 inline-flex items-center text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:focus:ring-primary-900" class="mt-3 inline-flex items-center text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:focus:ring-primary-900"
> >
{item.button.text} {item.button.text}
<ArrowIcon /> <ArrowIcon />
</a> </a>
)} )}
</div> </div>
</div> </div>
); );
} }
export const CtaImageParagraphBlock = ({ content, design, id, item_images, icon_svgs }) => { export const CtaImageParagraphBlock = ({content, _design, _id, item_images, icon_svgs}) => {
const items = Array.isArray(content?.items) ? content.items : []; const items = Array.isArray(content?.items) ? content.items : [];
const imageMap = item_images || {}; const imageMap = item_images || {};
const iconMap = icon_svgs || {}; const iconMap = icon_svgs || {};
return ( return (
<div> <div>
{items.map((item, idx) => ( {items.map((item, idx) => (
<CtaItem <CtaItem
key={idx} key={idx}
item={item} item={item}
idx={idx} idx={idx}
imageData={imageMap[String(idx)] || null} imageData={imageMap[String(idx)] || null}
featureIconSvg={item.feature_icon ? iconMap[item.feature_icon] : iconMap["check"]} featureIconSvg={item.feature_icon ? iconMap[item.feature_icon] : iconMap.check}
/> />
))} ))}
</div> </div>
); );
}; };
+15 -15
View File
@@ -2,24 +2,24 @@
* Features Block - Client-side Hydration * Features Block - Client-side Hydration
*/ */
import { render } from "preact"; import {render} from "preact";
import { FeaturesBlock } from "./component.jsx"; import {FeaturesBlock} from "./component.jsx";
function renderFeaturesBlocks() { function renderFeaturesBlocks() {
const blocks = document.querySelectorAll('[data-block-type="features"]'); const blocks = document.querySelectorAll('[data-block-type="features"]');
blocks.forEach((block) => { blocks.forEach((block) => {
const propsData = block.dataset.props; const propsData = block.dataset.props;
if (propsData) { if (propsData) {
try { try {
const props = JSON.parse(propsData); const props = JSON.parse(propsData);
render(<FeaturesBlock {...props} />, block); render(<FeaturesBlock {...props} />, block);
console.debug(`✓ Features block "${block.id}" rendered with Preact`); console.debug(`✓ Features block "${block.id}" rendered with Preact`);
} catch (error) { } catch (error) {
console.error(`Failed to render Features block "${block.id}":`, error); console.error(`Failed to render Features block "${block.id}":`, error);
} }
} }
}); });
} }
renderFeaturesBlocks(); renderFeaturesBlocks();
+46 -65
View File
@@ -3,79 +3,60 @@
* Responsive grid of feature items with icon, name, and description * Responsive grid of feature items with icon, name, and description
*/ */
import { Icon } from "../../shared/components/Icon.jsx"; import {Icon} from "../../shared/components/Icon.jsx";
// Simple markdown renderer // Simple markdown renderer
function renderText(text) { function renderText(text) {
if (!text) return ""; if (!text) return "";
return String(text) return String(text)
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>") .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.*?)\*/g, "<em>$1</em>") .replace(/\*(.*?)\*/g, "<em>$1</em>")
.replace(/`(.*?)`/g, "<code>$1</code>"); .replace(/`(.*?)`/g, "<code>$1</code>");
} }
function FeatureItem({ item, iconSvg }) { function FeatureItem({item, iconSvg}) {
return ( return (
<div> <div>
{iconSvg && ( {iconSvg && (
<div class="flex justify-center items-center mb-4 w-12 h-12 rounded-full bg-primary-100 lg:h-14 lg:w-14 dark:bg-primary-900 text-primary-600 dark:text-primary-300"> <div class="flex justify-center items-center mb-4 w-12 h-12 rounded-full bg-primary-100 lg:h-14 lg:w-14 dark:bg-primary-900 text-primary-600 dark:text-primary-300">
<Icon svg={iconSvg} attributes={{ class: "flex items-center justify-center w-6 h-6 lg:w-8 lg:h-8" }} /> <Icon svg={iconSvg} attributes={{class: "flex items-center justify-center w-6 h-6 lg:w-8 lg:h-8"}} />
</div>
)}
{item.name && (
<h3
class="mb-2 text-xl font-bold dark:text-white"
dangerouslySetInnerHTML={{ __html: renderText(item.name) }}
/>
)}
{item.description && (
<p
class="text-gray-500 dark:text-gray-400"
dangerouslySetInnerHTML={{ __html: renderText(item.description) }}
/>
)}
</div> </div>
); )}
{item.name && <h3 class="mb-2 text-xl font-bold dark:text-white" dangerouslySetInnerHTML={{__html: renderText(item.name)}} />}
{item.description && <p class="text-gray-500 dark:text-gray-400" dangerouslySetInnerHTML={{__html: renderText(item.description)}} />}
</div>
);
} }
export const FeaturesBlock = ({ content, design, id, icon_svgs }) => { export const FeaturesBlock = ({content, _design, _id, icon_svgs}) => {
const title = content?.title; const title = content?.title;
const text = content?.text; const text = content?.text;
const items = Array.isArray(content?.items) ? content.items : []; const items = Array.isArray(content?.items) ? content.items : [];
const iconMap = icon_svgs || {}; const iconMap = icon_svgs || {};
return ( return (
<section> <section>
<div class="py-8 px-4 mx-auto max-w-screen-xl sm:py-16 lg:px-6"> <div class="py-8 px-4 mx-auto max-w-screen-xl sm:py-16 lg:px-6">
{/* Section Header */} {/* Section Header */}
{(title || text) && ( {(title || text) && (
<div class="max-w-screen-md mb-8 lg:mb-16 flex flex-col justify-center mx-auto"> <div class="max-w-screen-md mb-8 lg:mb-16 flex flex-col justify-center mx-auto">
{title && ( {title && (
<h2 <h2
class="mb-4 text-4xl tracking-tight font-extrabold text-gray-900 dark:text-white text-center" class="mb-4 text-4xl tracking-tight font-extrabold text-gray-900 dark:text-white text-center"
dangerouslySetInnerHTML={{ __html: renderText(title) }} dangerouslySetInnerHTML={{__html: renderText(title)}}
/> />
)} )}
{text && ( {text && <p class="text-gray-500 sm:text-xl dark:text-gray-400 text-center" dangerouslySetInnerHTML={{__html: renderText(text)}} />}
<p </div>
class="text-gray-500 sm:text-xl dark:text-gray-400 text-center" )}
dangerouslySetInnerHTML={{ __html: renderText(text) }}
/>
)}
</div>
)}
{/* Feature Grid */} {/* Feature Grid */}
<div class="space-y-8 md:grid md:grid-cols-2 lg:grid-cols-3 md:gap-12 md:space-y-0"> <div class="space-y-8 md:grid md:grid-cols-2 lg:grid-cols-3 md:gap-12 md:space-y-0">
{items.map((item, idx) => ( {items.map((item, idx) => (
<FeatureItem <FeatureItem key={idx} item={item} iconSvg={item.icon ? iconMap[item.icon] : null} />
key={idx} ))}
item={item} </div>
iconSvg={item.icon ? iconMap[item.icon] : null} </div>
/> </section>
))} );
</div>
</div>
</section>
);
}; };
+7 -7
View File
@@ -3,7 +3,7 @@
* Used for both SSR and client-side hydration * Used for both SSR and client-side hydration
*/ */
import { Icon } from "../../shared/components/Icon.jsx"; import {Icon} from "../../shared/components/Icon.jsx";
// Simple markdown renderer // Simple markdown renderer
export function renderText(text) { export function renderText(text) {
@@ -16,7 +16,7 @@ export function renderText(text) {
// Process URLs // Process URLs
export function processUrl(url) { export function processUrl(url) {
if (!url) return { href: "#" }; if (!url) return {href: "#"};
if (url.startsWith("http://") || url.startsWith("https://")) { if (url.startsWith("http://") || url.startsWith("https://")) {
return { return {
@@ -27,14 +27,14 @@ export function processUrl(url) {
} }
if (url.startsWith("#")) { if (url.startsWith("#")) {
return { href: url }; return {href: url};
} }
return { href: url }; return {href: url};
} }
// Hero Block Component - Single implementation // Hero Block Component - Single implementation
export const HeroBlock = ({ content, design, id, icon_svg }) => { export const HeroBlock = ({content, design, _id, icon_svg}) => {
const paddingClasses = design?.no_padding ? "" : "py-32 sm:py-48 lg:py-56"; const paddingClasses = design?.no_padding ? "" : "py-32 sm:py-48 lg:py-56";
return ( return (
@@ -73,7 +73,7 @@ export const HeroBlock = ({ content, design, id, icon_svg }) => {
{content.title && ( {content.title && (
<h1 <h1
class="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-100 sm:text-6xl" class="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-100 sm:text-6xl"
dangerouslySetInnerHTML={{ __html: renderText(content.title) }} dangerouslySetInnerHTML={{__html: renderText(content.title)}}
/> />
)} )}
@@ -81,7 +81,7 @@ export const HeroBlock = ({ content, design, id, icon_svg }) => {
{content.text && ( {content.text && (
<p <p
class="mt-6 text-lg leading-8 text-gray-600 dark:text-gray-300 max-w-2xl mx-auto" class="mt-6 text-lg leading-8 text-gray-600 dark:text-gray-300 max-w-2xl mx-auto"
dangerouslySetInnerHTML={{ __html: renderText(content.text) }} dangerouslySetInnerHTML={{__html: renderText(content.text)}}
/> />
)} )}
@@ -1,5 +1,5 @@
// biome-ignore lint/correctness/noUnusedImports: Classic Preact JSX runtime may require 'h' for JSX transform // biome-ignore lint/correctness/noUnusedImports: Classic Preact JSX runtime may require 'h' for JSX transform
import { h } from "preact"; import {h} from "preact";
/** /**
* Icon component * Icon component
@@ -13,7 +13,7 @@ import { h } from "preact";
* the SVG fills that container with height:100%;width:auto instead. * the SVG fills that container with height:100%;width:auto instead.
* - If the caller provides explicit w-/h- Tailwind classes, those take precedence. * - If the caller provides explicit w-/h- Tailwind classes, those take precedence.
*/ */
export const Icon = ({ svg, attributes }) => { export const Icon = ({svg, attributes}) => {
if (!svg) return null; if (!svg) return null;
// Clean the SVG string: decode HTML entities and TRIM whitespace // Clean the SVG string: decode HTML entities and TRIM whitespace
@@ -35,9 +35,7 @@ export const Icon = ({ svg, attributes }) => {
// use 100% to fill the container; otherwise default to 1em for self-sizing. // use 100% to fill the container; otherwise default to 1em for self-sizing.
const callerStyle = attributes?.style || ""; const callerStyle = attributes?.style || "";
const hasExplicitHeight = /height\s*:/i.test(String(callerStyle)); const hasExplicitHeight = /height\s*:/i.test(String(callerStyle));
const svgStyle = hasExplicitHeight const svgStyle = hasExplicitHeight ? "height:100%;width:auto" : "height:1em;width:auto";
? "height:100%;width:auto"
: "height:1em;width:auto";
decoded = decoded.replace(/^(<svg\b)/i, `$1 style="${svgStyle}"`); decoded = decoded.replace(/^(<svg\b)/i, `$1 style="${svgStyle}"`);
@@ -47,7 +45,7 @@ export const Icon = ({ svg, attributes }) => {
}; };
// eslint-disable-next-line lint/security/noDangerouslySetInnerHtml // eslint-disable-next-line lint/security/noDangerouslySetInnerHtml
return <span {...wrapperProps} dangerouslySetInnerHTML={{ __html: decoded }} />; return <span {...wrapperProps} dangerouslySetInnerHTML={{__html: decoded}} />;
} }
const finalAttributes = { const finalAttributes = {
@@ -62,5 +60,5 @@ export const Icon = ({ svg, attributes }) => {
.join(" "); .join(" ");
// eslint-disable-next-line lint/security/noDangerouslySetInnerHtml // eslint-disable-next-line lint/security/noDangerouslySetInnerHtml
return <span class="inline-block" dangerouslySetInnerHTML={{ __html: `<svg ${attrs}>${decoded}</svg>` }} />; return <span class="inline-block" dangerouslySetInnerHTML={{__html: `<svg ${attrs}>${decoded}</svg>`}} />;
}; };
+18 -18
View File
@@ -3,28 +3,28 @@
* Uses the shared StatsBlock component for consistency * Uses the shared StatsBlock component for consistency
*/ */
import { render } from "preact"; import {render} from "preact";
import { StatsBlock } from "./component.jsx"; import {StatsBlock} from "./component.jsx";
function renderStatsBlocks() { function renderStatsBlocks() {
const statsBlocks = document.querySelectorAll('[data-block-type="stats"]'); const statsBlocks = document.querySelectorAll('[data-block-type="stats"]');
statsBlocks.forEach((block) => { statsBlocks.forEach((block) => {
const propsData = block.dataset.props; const propsData = block.dataset.props;
if (propsData) { if (propsData) {
try { try {
const props = JSON.parse(propsData); const props = JSON.parse(propsData);
render(<StatsBlock {...props} />, block); render(<StatsBlock {...props} />, block);
console.debug(`✓ Stats block "${block.id}" rendered with Preact`); console.debug(`✓ Stats block "${block.id}" rendered with Preact`);
} catch (error) { } catch (error) {
console.error(`Failed to render Stats block "${block.id}":`, error); console.error(`Failed to render Stats block "${block.id}":`, error);
} }
}
});
if (statsBlocks.length > 0) {
console.debug(`✓ ${statsBlocks.length} Stats blocks initialized with Preact`);
} }
});
if (statsBlocks.length > 0) {
console.debug(`✓ ${statsBlocks.length} Stats blocks initialized with Preact`);
}
} }
// Initialize immediately — script is already deferred // Initialize immediately — script is already deferred
+210 -226
View File
@@ -6,280 +6,264 @@
* Features: counter animation, per-item icons, stagger reveal * Features: counter animation, per-item icons, stagger reveal
*/ */
import { useEffect, useRef } from "preact/hooks"; import {useEffect, useRef} from "preact/hooks";
import { Icon } from "../../shared/components/Icon.jsx"; import {Icon} from "../../shared/components/Icon.jsx";
// Simple markdown renderer (shared with Hero) // Simple markdown renderer (shared with Hero)
function renderText(text) { function renderText(text) {
if (!text) return ""; if (!text) return "";
return text return text
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>") .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.*?)\*/g, "<em>$1</em>") .replace(/\*(.*?)\*/g, "<em>$1</em>")
.replace(/`(.*?)`/g, "<code>$1</code>"); .replace(/`(.*?)`/g, "<code>$1</code>");
} }
// Extract numeric target from a statistic string like "10,000+" → 10000 // Extract numeric target from a statistic string like "10,000+" → 10000
function parseTarget(statistic) { function parseTarget(statistic) {
if (!statistic) return 0; if (!statistic) return 0;
const raw = String(statistic).replace(/[^0-9.]/g, ""); const raw = String(statistic).replace(/[^0-9.]/g, "");
const num = Number(raw); const num = Number(raw);
return isNaN(num) ? 0 : num; return Number.isNaN(num) ? 0 : num;
} }
// Extract prefix, number, suffix from "~10,000+" → { prefix: "~", number: "10,000", suffix: "+" } // Extract prefix, number, suffix from "~10,000+" → { prefix: "~", number: "10,000", suffix: "+" }
function extractParts(text) { function extractParts(text) {
if (!text) return { prefix: "", numberPart: "", suffix: "" }; if (!text) return {prefix: "", numberPart: "", suffix: ""};
const parts = String(text).match(/([^0-9]*)([0-9][0-9,.]*)(.*)/); const parts = String(text).match(/([^0-9]*)([0-9][0-9,.]*)(.*)/);
return { return {
prefix: parts ? parts[1] : "", prefix: parts ? parts[1] : "",
numberPart: parts ? parts[2] : "", numberPart: parts ? parts[2] : "",
suffix: parts ? parts[3] : "", suffix: parts ? parts[3] : "",
}; };
} }
// Counter animation hook // Counter animation hook
function useCounterAnimation(containerRef) { function useCounterAnimation(containerRef) {
useEffect(() => { useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container) return;
const prefersReducedMotion = window.matchMedia( const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
"(prefers-reduced-motion: reduce)"
).matches;
function animateCounters(el) { function animateCounters(el) {
el.classList.add("animate"); el.classList.add("animate");
const counters = el.querySelectorAll(".counter"); const counters = el.querySelectorAll(".counter");
counters.forEach((counter) => { counters.forEach((counter) => {
const target = parseTarget(counter.getAttribute("data-target")); const target = parseTarget(counter.getAttribute("data-target"));
if (target <= 0) return; if (target <= 0) return;
const { prefix, numberPart, suffix } = extractParts( const {prefix, numberPart, suffix} = extractParts(counter.textContent);
counter.textContent const decimals = (numberPart.split(".")[1] || "").length;
); const formatter = new Intl.NumberFormat(undefined, {
const decimals = (numberPart.split(".")[1] || "").length; minimumFractionDigits: decimals,
const formatter = new Intl.NumberFormat(undefined, { maximumFractionDigits: decimals,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
if (prefersReducedMotion) {
counter.textContent = `${prefix}${formatter.format(target)}${suffix}`;
return;
}
const duration = 2000;
const start = Date.now();
function update() {
const now = Date.now();
const progress = Math.min((now - start) / duration, 1);
const easeOut = 1 - Math.pow(1 - progress, 3);
const current = target * easeOut;
counter.textContent = `${prefix}${formatter.format(current)}${suffix}`;
if (progress < 1) requestAnimationFrame(update);
}
requestAnimationFrame(update);
});
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
animateCounters(entry.target);
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.2, rootMargin: "0px 0px -10% 0px" }
);
// Observe each stat item
const items = container.querySelectorAll(".stats-item");
items.forEach((item) => {
const rect = item.getBoundingClientRect();
const vh = window.innerHeight;
if (rect.top < vh * 0.9 && rect.bottom > 0) {
animateCounters(item);
} else {
observer.observe(item);
}
}); });
return () => observer.disconnect(); if (prefersReducedMotion) {
}, []); counter.textContent = `${prefix}${formatter.format(target)}${suffix}`;
return;
}
const duration = 2000;
const start = Date.now();
function update() {
const now = Date.now();
const progress = Math.min((now - start) / duration, 1);
const easeOut = 1 - (1 - progress) ** 3;
const current = target * easeOut;
counter.textContent = `${prefix}${formatter.format(current)}${suffix}`;
if (progress < 1) requestAnimationFrame(update);
}
requestAnimationFrame(update);
});
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
animateCounters(entry.target);
observer.unobserve(entry.target);
}
});
},
{threshold: 0.2, rootMargin: "0px 0px -10% 0px"},
);
// Observe each stat item
const items = container.querySelectorAll(".stats-item");
items.forEach((item) => {
const rect = item.getBoundingClientRect();
const vh = window.innerHeight;
if (rect.top < vh * 0.9 && rect.bottom > 0) {
animateCounters(item);
} else {
observer.observe(item);
}
});
return () => observer.disconnect();
}, []);
} }
// Grid columns class based on item count // Grid columns class based on item count
function gridColsClass(count) { function gridColsClass(count) {
if (count <= 1) return "lg:grid-cols-1"; if (count <= 1) return "lg:grid-cols-1";
if (count === 2) return "lg:grid-cols-2"; if (count === 2) return "lg:grid-cols-2";
if (count >= 4) return "lg:grid-cols-4"; if (count >= 4) return "lg:grid-cols-4";
return "lg:grid-cols-3"; return "lg:grid-cols-3";
} }
// ─── Stat Item (Cards) ─────────────────────────────────────────────── // ─── Stat Item (Cards) ───────────────────────────────────────────────
function StatCard({ item, iconSvg }) { function StatCard({item, iconSvg}) {
return ( return (
<div class="stats-item group relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-500 overflow-hidden border border-gray-100 dark:border-gray-700 hover:border-primary-200 dark:hover:border-primary-800"> <div class="stats-item group relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-500 overflow-hidden border border-gray-100 dark:border-gray-700 hover:border-primary-200 dark:hover:border-primary-800">
{/* Hover gradient */} {/* Hover gradient */}
<div class="absolute inset-0 bg-gradient-to-br from-primary-50 to-transparent dark:from-primary-900/20 dark:to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" /> <div class="absolute inset-0 bg-gradient-to-br from-primary-50 to-transparent dark:from-primary-900/20 dark:to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div class="relative p-8 text-center"> <div class="relative p-8 text-center">
{/* Icon */} {/* Icon */}
{iconSvg && ( {iconSvg && (
<div class="inline-flex items-center justify-center w-16 h-16 mb-6 bg-primary-100 dark:bg-primary-900/50 rounded-xl group-hover:scale-110 transition-transform duration-300"> <div class="inline-flex items-center justify-center w-16 h-16 mb-6 bg-primary-100 dark:bg-primary-900/50 rounded-xl group-hover:scale-110 transition-transform duration-300">
<Icon svg={iconSvg} attributes={{ class: "w-8 h-8 text-primary-600 dark:text-primary-400" }} /> <Icon svg={iconSvg} attributes={{class: "w-8 h-8 text-primary-600 dark:text-primary-400"}} />
</div> </div>
)} )}
{/* Statistic */} {/* Statistic */}
<div class="mb-3"> <div class="mb-3">
<h3 <h3
class="text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-black text-gray-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors duration-300 counter" class="text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-black text-gray-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors duration-300 counter"
data-target={parseTarget(item.statistic)} data-target={parseTarget(item.statistic)}
dangerouslySetInnerHTML={{ __html: renderText(item.statistic) }} dangerouslySetInnerHTML={{__html: renderText(item.statistic)}}
/> />
</div>
{/* Description */}
<p
class="text-sm sm:text-base font-medium text-gray-600 dark:text-gray-300 leading-relaxed group-hover:text-gray-700 dark:group-hover:text-gray-200 transition-colors duration-300"
dangerouslySetInnerHTML={{ __html: renderText(item.description) }}
/>
{/* Sub-metric */}
{item.sub_metric && (
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{item.sub_metric}
</p>
)}
</div>
</div> </div>
);
{/* Description */}
<p
class="text-sm sm:text-base font-medium text-gray-600 dark:text-gray-300 leading-relaxed group-hover:text-gray-700 dark:group-hover:text-gray-200 transition-colors duration-300"
dangerouslySetInnerHTML={{__html: renderText(item.description)}}
/>
{/* Sub-metric */}
{item.sub_metric && <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">{item.sub_metric}</p>}
</div>
</div>
);
} }
// ─── Stat Item (Compact) ───────────────────────────────────────────── // ─── Stat Item (Compact) ─────────────────────────────────────────────
function StatCompact({ item, iconSvg }) { function StatCompact({item, iconSvg}) {
return ( return (
<div class="stats-item group p-8 text-center hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors duration-300"> <div class="stats-item group p-8 text-center hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors duration-300">
{iconSvg && ( {iconSvg && (
<div class="inline-flex items-center justify-center w-12 h-12 mb-4 bg-primary-100 dark:bg-primary-900/50 rounded-lg group-hover:scale-110 transition-transform duration-300"> <div class="inline-flex items-center justify-center w-12 h-12 mb-4 bg-primary-100 dark:bg-primary-900/50 rounded-lg group-hover:scale-110 transition-transform duration-300">
<Icon svg={iconSvg} attributes={{ class: "w-6 h-6 text-primary-600 dark:text-primary-400" }} /> <Icon svg={iconSvg} attributes={{class: "w-6 h-6 text-primary-600 dark:text-primary-400"}} />
</div>
)}
<h3
class="text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-2 counter"
data-target={parseTarget(item.statistic)}
dangerouslySetInnerHTML={{ __html: renderText(item.statistic) }}
/>
<p
class="text-sm font-medium text-gray-600 dark:text-gray-300 leading-relaxed"
dangerouslySetInnerHTML={{ __html: renderText(item.description) }}
/>
</div> </div>
); )}
<h3
class="text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-2 counter"
data-target={parseTarget(item.statistic)}
dangerouslySetInnerHTML={{__html: renderText(item.statistic)}}
/>
<p
class="text-sm font-medium text-gray-600 dark:text-gray-300 leading-relaxed"
dangerouslySetInnerHTML={{__html: renderText(item.description)}}
/>
</div>
);
} }
// ─── Stat Item (Minimal) ───────────────────────────────────────────── // ─── Stat Item (Minimal) ─────────────────────────────────────────────
function StatMinimal({ item, iconSvg }) { function StatMinimal({item, iconSvg}) {
return ( return (
<div class="stats-item text-center group"> <div class="stats-item text-center group">
{iconSvg && ( {iconSvg && (
<div class="inline-flex items-center justify-center w-14 h-14 mb-4 bg-primary-100 dark:bg-primary-900/50 rounded-full group-hover:scale-110 transition-transform duration-300"> <div class="inline-flex items-center justify-center w-14 h-14 mb-4 bg-primary-100 dark:bg-primary-900/50 rounded-full group-hover:scale-110 transition-transform duration-300">
<Icon svg={iconSvg} attributes={{ class: "w-7 h-7 text-primary-600 dark:text-primary-400" }} /> <Icon svg={iconSvg} attributes={{class: "w-7 h-7 text-primary-600 dark:text-primary-400"}} />
</div>
)}
<h3
class="text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-2 counter"
data-target={parseTarget(item.statistic)}
dangerouslySetInnerHTML={{ __html: renderText(item.statistic) }}
/>
<p
class="text-sm font-medium text-gray-600 dark:text-gray-300"
dangerouslySetInnerHTML={{ __html: renderText(item.description) }}
/>
</div> </div>
); )}
<h3
class="text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-2 counter"
data-target={parseTarget(item.statistic)}
dangerouslySetInnerHTML={{__html: renderText(item.statistic)}}
/>
<p class="text-sm font-medium text-gray-600 dark:text-gray-300" dangerouslySetInnerHTML={{__html: renderText(item.description)}} />
</div>
);
} }
// ─── Main Stats Block ──────────────────────────────────────────────── // ─── Main Stats Block ────────────────────────────────────────────────
export const StatsBlock = ({ content, design, id, icon_svgs }) => { export const StatsBlock = ({content, design, _id, icon_svgs}) => {
const containerRef = useRef(null); const containerRef = useRef(null);
useCounterAnimation(containerRef); useCounterAnimation(containerRef);
const title = content?.title; const title = content?.title;
const text = content?.text; const text = content?.text;
const items = Array.isArray(content?.items) ? content.items : []; const items = Array.isArray(content?.items) ? content.items : [];
const layout = (design?.layout || "cards").toLowerCase(); const layout = (design?.layout || "cards").toLowerCase();
const count = Math.max(1, Math.min(items.length, 4)); const count = Math.max(1, Math.min(items.length, 4));
const lgCols = gridColsClass(count); const lgCols = gridColsClass(count);
const baseCols = count === 1 ? "grid-cols-1" : "grid-cols-2"; const baseCols = count === 1 ? "grid-cols-1" : "grid-cols-2";
// icon_svgs is a dict keyed by icon name, provided by preact-wrapper // icon_svgs is a dict keyed by icon name, provided by preact-wrapper
const iconMap = icon_svgs || {}; const iconMap = icon_svgs || {};
return ( return (
<div class="py-8 sm:py-12 lg:py-16" ref={containerRef}> <div class="py-8 sm:py-12 lg:py-16" ref={containerRef}>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Section Header */} {/* Section Header */}
{(title || text) && ( {(title || text) && (
<div class="text-center mb-12 lg:mb-16"> <div class="text-center mb-12 lg:mb-16">
{title && ( {title && (
<h2 <h2
class="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white tracking-tight mb-4" class="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white tracking-tight mb-4"
dangerouslySetInnerHTML={{ __html: renderText(title) }} dangerouslySetInnerHTML={{__html: renderText(title)}}
/> />
)} )}
{text && ( {text && <p class="text-lg text-gray-600 dark:text-gray-400 max-w-3xl mx-auto" dangerouslySetInnerHTML={{__html: renderText(text)}} />}
<p </div>
class="text-lg text-gray-600 dark:text-gray-400 max-w-3xl mx-auto" )}
dangerouslySetInnerHTML={{ __html: renderText(text) }}
/>
)}
</div>
)}
{/* Cards Layout */} {/* Cards Layout */}
{layout === "cards" && ( {layout === "cards" && (
<div class={`grid grid-cols-1 sm:grid-cols-2 ${lgCols} gap-6 lg:gap-8`}> <div class={`grid grid-cols-1 sm:grid-cols-2 ${lgCols} gap-6 lg:gap-8`}>
{items.map((item, idx) => ( {items.map((item, idx) => (
<StatCard key={idx} item={item} iconSvg={item.icon ? iconMap[item.icon] : null} /> <StatCard key={idx} item={item} iconSvg={item.icon ? iconMap[item.icon] : null} />
))} ))}
</div> </div>
)} )}
{/* Compact Layout */} {/* Compact Layout */}
{layout === "compact" && ( {layout === "compact" && (
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-100 dark:border-gray-700 overflow-hidden"> <div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-100 dark:border-gray-700 overflow-hidden">
<div class={`grid grid-cols-1 sm:grid-cols-2 ${lgCols} divide-y sm:divide-y-0 sm:divide-x divide-gray-200 dark:divide-gray-700`}> <div class={`grid grid-cols-1 sm:grid-cols-2 ${lgCols} divide-y sm:divide-y-0 sm:divide-x divide-gray-200 dark:divide-gray-700`}>
{items.map((item, idx) => ( {items.map((item, idx) => (
<StatCompact key={idx} item={item} iconSvg={item.icon ? iconMap[item.icon] : null} /> <StatCompact key={idx} item={item} iconSvg={item.icon ? iconMap[item.icon] : null} />
))} ))}
</div>
</div>
)}
{/* Minimal Layout */}
{layout === "minimal" && (
<div class={`grid ${baseCols} ${lgCols} gap-8 lg:gap-12`}>
{items.map((item, idx) => (
<StatMinimal key={idx} item={item} iconSvg={item.icon ? iconMap[item.icon] : null} />
))}
</div>
)}
</div> </div>
</div>
)}
{/* Animation styles (scoped via container ref) */} {/* Minimal Layout */}
<style>{` {layout === "minimal" && (
<div class={`grid ${baseCols} ${lgCols} gap-8 lg:gap-12`}>
{items.map((item, idx) => (
<StatMinimal key={idx} item={item} iconSvg={item.icon ? iconMap[item.icon] : null} />
))}
</div>
)}
</div>
{/* Animation styles (scoped via container ref) */}
<style>{`
.stats-item { .stats-item {
opacity: 0; opacity: 0;
transform: translateY(30px); transform: translateY(30px);
@@ -295,6 +279,6 @@ export const StatsBlock = ({ content, design, id, icon_svgs }) => {
.stats-item:nth-child(4) { transition-delay: 450ms; } .stats-item:nth-child(4) { transition-delay: 450ms; }
.stats-item:nth-child(5) { transition-delay: 600ms; } .stats-item:nth-child(5) { transition-delay: 600ms; }
`}</style> `}</style>
</div> </div>
); );
}; };
+15 -15
View File
@@ -2,24 +2,24 @@
* Testimonials Block - Client-side Hydration * Testimonials Block - Client-side Hydration
*/ */
import { render } from "preact"; import {render} from "preact";
import { TestimonialsBlock } from "./component.jsx"; import {TestimonialsBlock} from "./component.jsx";
function renderTestimonialsBlocks() { function renderTestimonialsBlocks() {
const blocks = document.querySelectorAll('[data-block-type="testimonials"]'); const blocks = document.querySelectorAll('[data-block-type="testimonials"]');
blocks.forEach((block) => { blocks.forEach((block) => {
const propsData = block.dataset.props; const propsData = block.dataset.props;
if (propsData) { if (propsData) {
try { try {
const props = JSON.parse(propsData); const props = JSON.parse(propsData);
render(<TestimonialsBlock {...props} />, block); render(<TestimonialsBlock {...props} />, block);
console.debug(`✓ Testimonials block "${block.id}" rendered with Preact`); console.debug(`✓ Testimonials block "${block.id}" rendered with Preact`);
} catch (error) { } catch (error) {
console.error(`Failed to render Testimonials block "${block.id}":`, error); console.error(`Failed to render Testimonials block "${block.id}":`, error);
} }
} }
}); });
} }
renderTestimonialsBlocks(); renderTestimonialsBlocks();
+61 -88
View File
@@ -6,103 +6,76 @@
// Quote mark SVG (inline for zero dependencies) // Quote mark SVG (inline for zero dependencies)
const QuoteSvg = () => ( const QuoteSvg = () => (
<svg <svg
class="h-16 mx-auto mb-3 text-gray-400 dark:text-gray-500" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" class="h-16 mx-auto mb-3 text-gray-400 dark:text-gray-500"
fill="currentColor" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32" fill="currentColor"
> viewBox="0 0 32 32"
<path d="M9.563 8.469l-0.813-1.25c-5.625 3.781-8.75 8.375-8.75 12.156 0 3.656 2.688 5.375 4.969 5.375 2.875 0 4.906-2.438 4.906-5 0-2.156-1.375-4-3.219-4.688-0.531-0.188-1.031-0.344-1.031-1.25 0-1.156 0.844-2.875 3.938-5.344zM21.969 8.469l-0.813-1.25c-5.563 3.781-8.75 8.375-8.75 12.156 0 3.656 2.75 5.375 5.031 5.375 2.906 0 4.969-2.438 4.969-5 0-2.156-1.406-4-3.313-4.688-0.531-0.188-1-0.344-1-1.25 0-1.156 0.875-2.875 3.875-5.344z" /> >
</svg> <path d="M9.563 8.469l-0.813-1.25c-5.625 3.781-8.75 8.375-8.75 12.156 0 3.656 2.688 5.375 4.969 5.375 2.875 0 4.906-2.438 4.906-5 0-2.156-1.375-4-3.219-4.688-0.531-0.188-1.031-0.344-1.031-1.25 0-1.156 0.844-2.875 3.938-5.344zM21.969 8.469l-0.813-1.25c-5.563 3.781-8.75 8.375-8.75 12.156 0 3.656 2.75 5.375 5.031 5.375 2.906 0 4.969-2.438 4.969-5 0-2.156-1.406-4-3.313-4.688-0.531-0.188-1-0.344-1-1.25 0-1.156 0.875-2.875 3.875-5.344z" />
</svg>
); );
// Simple markdown renderer // Simple markdown renderer
function renderText(text) { function renderText(text) {
if (!text) return ""; if (!text) return "";
return String(text) return String(text)
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>") .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.*?)\*/g, "<em>$1</em>") .replace(/\*(.*?)\*/g, "<em>$1</em>")
.replace(/`(.*?)`/g, "<code>$1</code>"); .replace(/`(.*?)`/g, "<code>$1</code>");
} }
function TestimonialItem({ item, avatarUrl, isLast }) { function TestimonialItem({item, avatarUrl, isLast}) {
return ( return (
<div class={`max-w-screen-md mx-auto ${isLast ? "" : "mb-12"}`}> <div class={`max-w-screen-md mx-auto ${isLast ? "" : "mb-12"}`}>
<QuoteSvg /> <QuoteSvg />
<blockquote> <blockquote>
<p <p class="text-2xl font-medium text-gray-900 dark:text-white" dangerouslySetInnerHTML={{__html: `"${renderText(item.text)}"`}} />
class="text-2xl font-medium text-gray-900 dark:text-white" </blockquote>
dangerouslySetInnerHTML={{ __html: `"${renderText(item.text)}"` }} <div class="flex items-center justify-center mt-6 space-x-3">
/> {/* Avatar */}
</blockquote> {avatarUrl ? (
<div class="flex items-center justify-center mt-6 space-x-3"> <img class="w-12 h-12 rounded-full" src={avatarUrl} width="48" height="48" alt={item.name || ""} loading="lazy" />
{/* Avatar */} ) : (
{avatarUrl ? ( <div class="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700" />
<img )}
class="w-12 h-12 rounded-full" <div class="flex flex-col items-center">
src={avatarUrl} {item.name && <div class="pr-3 font-medium text-gray-900 dark:text-white">{item.name}</div>}
width="48" {item.role && <div class="pl-3 text-sm font-light text-gray-500 dark:text-gray-400">{item.role}</div>}
height="48"
alt={item.name || ""}
loading="lazy"
/>
) : (
<div class="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700" />
)}
<div class="flex flex-col items-center">
{item.name && (
<div class="pr-3 font-medium text-gray-900 dark:text-white">
{item.name}
</div>
)}
{item.role && (
<div class="pl-3 text-sm font-light text-gray-500 dark:text-gray-400">
{item.role}
</div>
)}
</div>
</div>
</div> </div>
); </div>
</div>
);
} }
export const TestimonialsBlock = ({ content, design, id, item_images }) => { export const TestimonialsBlock = ({content, _design, _id, item_images}) => {
const title = content?.title; const title = content?.title;
const text = content?.text; const text = content?.text;
const items = Array.isArray(content?.items) ? content.items : []; const items = Array.isArray(content?.items) ? content.items : [];
const imageMap = item_images || {}; const imageMap = item_images || {};
return ( return (
<section> <section>
<div class="max-w-screen-xl px-4 py-8 mx-auto text-center lg:py-16 lg:px-6"> <div class="max-w-screen-xl px-4 py-8 mx-auto text-center lg:py-16 lg:px-6">
{/* Section Header */} {/* Section Header */}
{title && ( {title && (
<div class="py-8 px-4 mx-auto max-w-screen-xl sm:py-16 lg:px-6"> <div class="py-8 px-4 mx-auto max-w-screen-xl sm:py-16 lg:px-6">
<div class="max-w-screen-md mb-8 lg:mb-16 flex flex-col justify-center mx-auto"> <div class="max-w-screen-md mb-8 lg:mb-16 flex flex-col justify-center mx-auto">
<h2 <h2
class="mb-4 text-4xl tracking-tight font-extrabold text-gray-900 dark:text-white text-center" class="mb-4 text-4xl tracking-tight font-extrabold text-gray-900 dark:text-white text-center"
dangerouslySetInnerHTML={{ __html: renderText(title) }} dangerouslySetInnerHTML={{__html: renderText(title)}}
/> />
{text && ( {text && <p class="text-gray-500 sm:text-xl dark:text-gray-400 text-center" dangerouslySetInnerHTML={{__html: renderText(text)}} />}
<p
class="text-gray-500 sm:text-xl dark:text-gray-400 text-center"
dangerouslySetInnerHTML={{ __html: renderText(text) }}
/>
)}
</div>
</div>
)}
{/* Testimonial Items */}
{items.map((item, idx) => (
<TestimonialItem
key={idx}
item={item}
avatarUrl={imageMap[String(idx)]?.src || null}
isLast={idx === items.length - 1}
/>
))}
</div> </div>
</section> </div>
); )}
{/* Testimonial Items */}
{items.map((item, idx) => (
<TestimonialItem key={idx} item={item} avatarUrl={imageMap[String(idx)]?.src || null} isLast={idx === items.length - 1} />
))}
</div>
</section>
);
}; };
+3 -6
View File
@@ -34,12 +34,9 @@
"dev": "scripts/view-starter-dev.sh", "dev": "scripts/view-starter-dev.sh",
"dev:stop": "lsof -ti :8081 | xargs kill -9", "dev:stop": "lsof -ti :8081 | xargs kill -9",
"test": "scripts/view-test.sh", "test": "scripts/view-test.sh",
"lint": "biome check --reporter=summary .", "lint": "biome check --reporter=summary . --linter-enabled=true --formatter-enabled=false",
"lint:code": "biome check --reporter=summary . --linter-enabled=true --formatter-enabled=false", "lint:fix": "biome check --write . --linter-enabled=true --formatter-enabled=false",
"lint:code:fix": "biome check --write . --linter-enabled=true --formatter-enabled=false", "format": "biome format --write .",
"lint:style": "biome check --reporter=summary modules/blox/assets/css/ modules/slides/assets/css/ --linter-enabled=true --formatter-enabled=false",
"lint:style:fix": "biome check --write modules/blox/assets/css/ modules/slides/assets/css/ --linter-enabled=true --formatter-enabled=false",
"format": "biome check --write .",
"update:starters": "poetry run python ./scripts/update.py", "update:starters": "poetry run python ./scripts/update.py",
"vendor:libs": "vite build --config vite.config.js", "vendor:libs": "vite build --config vite.config.js",
"vendor:libs:watch": "vite build --config vite.config.js --watch", "vendor:libs:watch": "vite build --config vite.config.js --watch",
@@ -5,18 +5,12 @@
"postCreateCommand": "pnpm --version && hugo version", "postCreateCommand": "pnpm --version && hugo version",
"customizations": { "customizations": {
"vscode": { "vscode": {
"extensions": [ "extensions": ["ownable.ownable"]
"ownable.ownable"
]
} }
}, },
"mounts": [ "mounts": ["source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"],
"source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"
],
"remoteUser": "vscode", "remoteUser": "vscode",
"forwardPorts": [ "forwardPorts": [1313],
1313
],
"portsAttributes": { "portsAttributes": {
"1313": { "1313": {
"label": "Hugo Server" "label": "Hugo Server"
+1 -3
View File
@@ -1,5 +1,3 @@
{ {
"recommendations": [ "recommendations": ["ownable.ownable"]
"ownable.ownable"
]
} }
@@ -5,18 +5,12 @@
"postCreateCommand": "pnpm --version && hugo version", "postCreateCommand": "pnpm --version && hugo version",
"customizations": { "customizations": {
"vscode": { "vscode": {
"extensions": [ "extensions": ["ownable.ownable"]
"ownable.ownable"
]
} }
}, },
"mounts": [ "mounts": ["source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"],
"source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"
],
"remoteUser": "vscode", "remoteUser": "vscode",
"forwardPorts": [ "forwardPorts": [1313],
1313
],
"portsAttributes": { "portsAttributes": {
"1313": { "1313": {
"label": "Hugo Server" "label": "Hugo Server"
+1 -3
View File
@@ -1,5 +1,3 @@
{ {
"recommendations": [ "recommendations": ["ownable.ownable"]
"ownable.ownable"
]
} }
@@ -5,18 +5,12 @@
"postCreateCommand": "pnpm --version && hugo version", "postCreateCommand": "pnpm --version && hugo version",
"customizations": { "customizations": {
"vscode": { "vscode": {
"extensions": [ "extensions": ["ownable.ownable"]
"ownable.ownable"
]
} }
}, },
"mounts": [ "mounts": ["source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"],
"source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"
],
"remoteUser": "vscode", "remoteUser": "vscode",
"forwardPorts": [ "forwardPorts": [1313],
1313
],
"portsAttributes": { "portsAttributes": {
"1313": { "1313": {
"label": "Hugo Server" "label": "Hugo Server"
@@ -5,18 +5,12 @@
"postCreateCommand": "pnpm --version && hugo version", "postCreateCommand": "pnpm --version && hugo version",
"customizations": { "customizations": {
"vscode": { "vscode": {
"extensions": [ "extensions": ["ownable.ownable"]
"ownable.ownable"
]
} }
}, },
"mounts": [ "mounts": ["source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"],
"source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"
],
"remoteUser": "vscode", "remoteUser": "vscode",
"forwardPorts": [ "forwardPorts": [1313],
1313
],
"portsAttributes": { "portsAttributes": {
"1313": { "1313": {
"label": "Hugo Server" "label": "Hugo Server"
+1 -3
View File
@@ -1,5 +1,3 @@
{ {
"recommendations": [ "recommendations": ["ownable.ownable"]
"ownable.ownable"
]
} }
@@ -5,18 +5,12 @@
"postCreateCommand": "pnpm --version && hugo version", "postCreateCommand": "pnpm --version && hugo version",
"customizations": { "customizations": {
"vscode": { "vscode": {
"extensions": [ "extensions": ["ownable.ownable"]
"ownable.ownable"
]
} }
}, },
"mounts": [ "mounts": ["source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"],
"source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"
],
"remoteUser": "vscode", "remoteUser": "vscode",
"forwardPorts": [ "forwardPorts": [1313],
1313
],
"portsAttributes": { "portsAttributes": {
"1313": { "1313": {
"label": "Hugo Server" "label": "Hugo Server"
+1 -3
View File
@@ -1,5 +1,3 @@
{ {
"recommendations": [ "recommendations": ["ownable.ownable"]
"ownable.ownable"
]
} }
@@ -5,18 +5,12 @@
"postCreateCommand": "pnpm --version && hugo version", "postCreateCommand": "pnpm --version && hugo version",
"customizations": { "customizations": {
"vscode": { "vscode": {
"extensions": [ "extensions": ["ownable.ownable"]
"ownable.ownable"
]
} }
}, },
"mounts": [ "mounts": ["source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"],
"source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"
],
"remoteUser": "vscode", "remoteUser": "vscode",
"forwardPorts": [ "forwardPorts": [1313],
1313
],
"portsAttributes": { "portsAttributes": {
"1313": { "1313": {
"label": "Hugo Server" "label": "Hugo Server"
@@ -5,18 +5,12 @@
"postCreateCommand": "pnpm --version && hugo version", "postCreateCommand": "pnpm --version && hugo version",
"customizations": { "customizations": {
"vscode": { "vscode": {
"extensions": [ "extensions": ["ownable.ownable"]
"ownable.ownable"
]
} }
}, },
"mounts": [ "mounts": ["source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"],
"source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"
],
"remoteUser": "vscode", "remoteUser": "vscode",
"forwardPorts": [ "forwardPorts": [1313],
1313
],
"portsAttributes": { "portsAttributes": {
"1313": { "1313": {
"label": "Hugo Server" "label": "Hugo Server"
+1 -3
View File
@@ -1,5 +1,3 @@
{ {
"recommendations": [ "recommendations": ["ownable.ownable"]
"ownable.ownable"
]
} }
@@ -5,18 +5,12 @@
"postCreateCommand": "pnpm --version && hugo version", "postCreateCommand": "pnpm --version && hugo version",
"customizations": { "customizations": {
"vscode": { "vscode": {
"extensions": [ "extensions": ["ownable.ownable"]
"ownable.ownable"
]
} }
}, },
"mounts": [ "mounts": ["source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"],
"source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"
],
"remoteUser": "vscode", "remoteUser": "vscode",
"forwardPorts": [ "forwardPorts": [1313],
1313
],
"portsAttributes": { "portsAttributes": {
"1313": { "1313": {
"label": "Hugo Server" "label": "Hugo Server"
+1 -3
View File
@@ -1,5 +1,3 @@
{ {
"recommendations": [ "recommendations": ["ownable.ownable"]
"ownable.ownable"
]
} }