Quote Block #12

Merged
an.di merged 8 commits from adilallo/component/QuoteBlock into adilallo/component/RuleStack 2025-08-26 21:30:33 +00:00
9 changed files with 550 additions and 21 deletions

No files matched your search

+33
View File
@@ -1,5 +1,29 @@
import "../app/globals.css";
// Import Google Fonts for Storybook
import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
variable: "--font-inter",
display: "swap",
});
const bricolageGrotesque = Bricolage_Grotesque({
subsets: ["latin"],
weight: ["400", "500", "700", "800"],
variable: "--font-bricolage-grotesque",
display: "swap",
});
const spaceGrotesk = Space_Grotesk({
subsets: ["latin"],
weight: ["400", "500", "700"],
variable: "--font-space-grotesk",
display: "swap",
});
/** @type { import('@storybook/react').Preview } */
const preview = {
parameters: {
@@ -11,6 +35,15 @@ const preview = {
},
},
},
decorators: [
(Story) => (
<div
className={`${inter.variable} ${bricolageGrotesque.variable} ${spaceGrotesk.variable} font-sans`}
>
<Story />
</div>
),
],
};
export default preview;
+15 -4
View File
@@ -1,18 +1,27 @@
import "../app/globals.css";
// Import Google Fonts for Storybook
import { Inter, Bricolage_Grotesque } from "next/font/google";
import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
weight: ["400", "500"],
weight: ["400", "500", "600", "700"],
variable: "--font-inter",
display: "swap",
});
const bricolageGrotesque = Bricolage_Grotesque({
subsets: ["latin"],
weight: ["400", "500"],
weight: ["400", "500", "700", "800"],
variable: "--font-bricolage-grotesque",
display: "swap",
});
const spaceGrotesk = Space_Grotesk({
subsets: ["latin"],
weight: ["400", "500", "700"],
variable: "--font-space-grotesk",
display: "swap",
});
/** @type { import('@storybook/react').Preview } */
@@ -28,7 +37,9 @@ const preview = {
},
decorators: [
(Story) => (
<div className={`${inter.variable} ${bricolageGrotesque.variable}`}>
<div
className={`${inter.variable} ${bricolageGrotesque.variable} ${spaceGrotesk.variable} font-sans`}
>
<Story />
</div>
),
+15 -4
View File
@@ -1,18 +1,27 @@
import "../app/globals.css";
// Import Google Fonts for Storybook
import { Inter, Bricolage_Grotesque } from "next/font/google";
import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
weight: ["400", "500"],
weight: ["400", "500", "600", "700"],
variable: "--font-inter",
display: "swap",
});
const bricolageGrotesque = Bricolage_Grotesque({
subsets: ["latin"],
weight: ["400", "500"],
weight: ["400", "500", "700", "800"],
variable: "--font-bricolage-grotesque",
display: "swap",
});
const spaceGrotesk = Space_Grotesk({
subsets: ["latin"],
weight: ["400", "500", "700"],
variable: "--font-space-grotesk",
display: "swap",
});
/** @type { import('@storybook/react').Preview } */
@@ -28,7 +37,9 @@ const preview = {
},
decorators: [
(Story) => (
<div className={`${inter.variable} ${bricolageGrotesque.variable}`}>
<div
className={`${inter.variable} ${bricolageGrotesque.variable} ${spaceGrotesk.variable} font-sans`}
>
<Story />
</div>
),
+238
View File
@@ -0,0 +1,238 @@
"use client";
import React, { useState } from "react";
import Image from "next/image";
import QuoteDecor from "./QuoteDecor";
const QuoteBlock = ({
variant = "standard",
className = "",
quote = "The rules of decision-making must be open and available to everyone, and this can happen only if they are formalized.",
author = "Jo Freeman",
source = "The Tyranny of Structurelessness",
avatarSrc = "assets/Quote_Avatar.svg",
id,
fallbackAvatarSrc = "assets/Quote_Avatar.svg", // Fallback avatar
onError, // Error callback
}) => {
const [imageError, setImageError] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
// Variant configurations
const variants = {
compact: {
container: "py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)]",
card: "py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)] md:py-[var(--spacing-scale-040)] md:px-[var(--spacing-scale-024)] rounded-[var(--radius-measures-radius-small)]",
gap: "gap-[var(--spacing-scale-016)] md:gap-[var(--spacing-scale-024)]",
avatarGap: "gap-[var(--spacing-scale-012)]",
avatar: "w-[48px] h-[48px] md:w-[64px] md:h-[64px]",
quote: "text-[16px] leading-[120%] md:text-[20px] md:leading-[110%]",
author: "text-[10px] leading-[120%] md:text-[12px]",
source: "text-[10px] leading-[120%] md:text-[12px]",
showDecor: false,
},
standard: {
container:
"md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-016)] lg:p-[var(--spacing-scale-064)]",
card: "py-[var(--spacing-scale-064)] px-[var(--spacing-scale-020)] md:py-[var(--spacing-scale-064)] md:px-[var(--spacing-scale-048)] md:rounded-[var(--radius-measures-radius-medium)] lg:py-[var(--spacing-scale-064)] lg:pl-[120px] lg:pr-[320px]",
gap: "gap-[var(--spacing-scale-024)] md:gap-[var(--spacing-scale-048)] lg:gap-[var(--spacing-scale-064)] xl:gap-[105px]",
avatarGap:
"gap-[var(--spacing-scale-020)] lg:gap-[var(--spacing-scale-018)] xl:gap-[var(--spacing-scale-032)]",
avatar:
"md:w-[120px] md:h-[120px] lg:w-[150px] lg:h-[150px] xl:w-[200px] xl:h-[200px]",
quote:
"text-[18px] leading-[120%] md:text-[36px] md:leading-[110%] md:tracking-[0px] lg:text-[52px] xl:text-[64px]",
author:
"text-[12px] leading-[120%] md:text-[18px] md:leading-[120%] md:tracking-[0.24px] lg:text-[24px] xl:text-[32px]",
source:
"text-[12px] leading-[120%] md:text-[18px] md:leading-[120%] md:tracking-[0.24px] lg:text-[24px] xl:text-[32px]",
showDecor: true,
},
extended: {
container:
"py-[var(--spacing-scale-048)] px-[var(--spacing-scale-024)] md:py-[var(--spacing-scale-064)] md:px-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-080)] lg:px-[var(--spacing-scale-048)]",
card: "py-[var(--spacing-scale-080)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-096)] md:px-[var(--spacing-scale-064)] md:rounded-[var(--radius-measures-radius-large)] lg:py-[var(--spacing-scale-112)] lg:pl-[160px] lg:pr-[400px]",
gap: "gap-[var(--spacing-scale-032)] md:gap-[var(--spacing-scale-064)] lg:gap-[var(--spacing-scale-080)] xl:gap-[140px]",
avatarGap:
"gap-[var(--spacing-scale-032)] lg:gap-[var(--spacing-scale-040)] xl:gap-[var(--spacing-scale-048)]",
avatar:
"w-[80px] h-[80px] md:w-[140px] md:h-[140px] lg:w-[180px] lg:h-[180px] xl:w-[240px] xl:h-[240px]",
quote:
"text-[20px] leading-[120%] md:text-[40px] md:leading-[110%] md:tracking-[0px] lg:text-[60px] xl:text-[72px]",
author:
"text-[14px] leading-[120%] md:text-[20px] md:leading-[120%] md:tracking-[0.24px] lg:text-[28px] xl:text-[36px]",
source:
"text-[14px] leading-[120%] md:text-[20px] md:leading-[120%] md:tracking-[0.24px] lg:text-[28px] xl:text-[36px]",
showDecor: true,
},
};
const config = variants[variant] || variants.standard;
// Use provided ID or generate a stable one based on content
const baseId = id || `quote-${author.toLowerCase().replace(/\s+/g, "-")}`;
const quoteId = `${baseId}-content`;
const authorId = `${baseId}-author`;
// Error handling functions
const handleImageError = (error) => {
console.warn(
`QuoteBlock: Failed to load avatar image for ${author}:`,
error
);
setImageError(true);
setImageLoading(false);
// Call error callback if provided
if (onError) {
onError({
type: "image_load_error",
message: `Failed to load avatar for ${author}`,
author,
avatarSrc,
error,
});
}
};
const handleImageLoad = () => {
setImageLoading(false);
setImageError(false);
};
// Validate required props
if (!quote || !author) {
console.error("QuoteBlock: Missing required props (quote or author)");
if (onError) {
onError({
type: "missing_props",
message: "QuoteBlock requires quote and author props",
quote: !!quote,
author: !!author,
});
}
return null; // Don't render if missing required props
}
// Determine which avatar to use
const currentAvatarSrc = imageError ? fallbackAvatarSrc : avatarSrc;
return (
<section
className={`${config.container} ${className}`}
aria-labelledby={quoteId}
role="region"
>
<div
className={`${config.card} bg-[var(--color-surface-default-brand-darker-accent)] relative overflow-hidden`}
>
{/* DECORATIONS (behind content) */}
{config.showDecor && (
<QuoteDecor
className="pointer-events-none absolute z-0
left-0 top-0
w-full h-full"
aria-hidden="true"
/>
)}
<div className={`flex flex-col ${config.gap} relative z-10`}>
<div className={`flex flex-col ${config.avatarGap}`}>
{/* Avatar with error handling */}
<div className="relative">
{!imageError ? (
<Image
src={avatarSrc}
alt={`Portrait of ${author}`}
width={64}
height={64}
className={`filter sepia ${
config.avatar
} transition-opacity duration-300 ${
imageLoading ? "opacity-0" : "opacity-100"
}`}
loading="lazy"
onError={handleImageError}
onLoad={handleImageLoad}
/>
) : null}
{/* Loading state */}
{imageLoading && !imageError && (
<div
className={`absolute inset-0 bg-gray-200 animate-pulse rounded-full ${config.avatar}`}
/>
)}
{/* Error state - show initials */}
{imageError && (
<div
className={`flex items-center justify-center bg-gray-300 rounded-full ${config.avatar} text-gray-600 font-bold`}
>
<span className="text-sm md:text-base lg:text-lg xl:text-xl">
{author
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()}
</span>
</div>
)}
</div>
<blockquote
id={quoteId}
aria-labelledby={authorId}
className="relative"
>
<p
data-qopen="&ldquo;"
data-qclose="&rdquo;"
className={[
"font-bricolage-grotesque font-normal",
config.quote,
"text-[var(--color-content-inverse-primary)]",
// give space for the hanging open-quote so it's not clipped:
"pl-[0.6em] -indent-[0.6em]",
// inject quotes
"relative before:content-[attr(data-qopen)] after:content-[attr(data-qclose)]",
// lock quote glyphs to your display face
"before:[font-family:var(--font-bricolage-grotesque)]",
"after:[font-family:var(--font-bricolage-grotesque)]",
].join(" ")}
>
{quote}
</p>
</blockquote>
</div>
<footer className="flex flex-col gap-[var(--spacing-scale-008)] md:gap-[var(--spacing-scale-012)] xl:gap-[var(--spacing-scale-020)]">
<cite
id={authorId}
className={`font-inter font-normal ${config.author} text-[var(--color-content-inverse-primary)] uppercase not-italic`}
>
{author}
</cite>
{source && (
<p
data-qopen="&ldquo;"
data-qclose="&rdquo;"
className={[
"font-inter font-normal",
config.source,
"text-[var(--color-content-inverse-primary)] uppercase",
"pl-[0.6em] -indent-[0.6em]",
"relative before:content-[attr(data-qopen)] after:content-[attr(data-qclose)]",
"before:[font-family:var(--font-inter)] after:[font-family:var(--font-inter)]",
].join(" ")}
>
{source}
</p>
)}
</footer>
</div>
</div>
</section>
);
};
export default QuoteBlock;
+73
View File
@@ -0,0 +1,73 @@
"use client";
const QuoteDecor = ({ className = "" }) => {
return (
<svg
className={`text-[var(--color-surface-inverse-brand-primary)] opacity-100 w-full h-full md:max-w-[640px] lg:max-w-[850px] xl:max-w-[1100px] ${className}`}
viewBox="400 0 442 163"
aria-hidden="true"
overflow="visible"
preserveAspectRatio="xMinYMin meet"
>
<g fill="currentColor">
{/* Mobile ellipses */}
<g className="md:hidden">
{/* First ellipse - top left */}
<ellipse
cx="490"
cy="80"
rx="300"
ry="100"
transform="rotate(-20 600 90)"
/>
{/* Second ellipse - middle */}
<ellipse
cx="508"
cy="250"
rx="300"
ry="110"
transform="rotate(-25 600 90)"
/>
{/* Third ellipse - bottom right */}
<ellipse
cx="550"
cy="420"
rx="300"
ry="120"
transform="rotate(-25 600 90)"
/>
</g>
{/* MD+ ellipses */}
<g className="hidden md:block">
{/* First ellipse - top left */}
<ellipse
cx="590"
cy="70"
rx="300"
ry="110"
transform="rotate(-30 600 90)"
/>
{/* Second ellipse - middle */}
<ellipse
cx="680"
cy="250"
rx="300"
ry="110"
transform="rotate(-30 600 90)"
/>
{/* Third ellipse - bottom right */}
<ellipse
cx="670"
cy="400"
rx="300"
ry="120"
transform="rotate(-30 600 90)"
/>
</g>
</g>
</svg>
);
};
export default QuoteDecor;
+19 -13
View File
@@ -1,26 +1,32 @@
"use client";
import SectionHeader from "./SectionHeader";
import React from "react";
import Image from "next/image";
import RuleCard from "./RuleCard";
import Button from "./Button";
import Image from "next/image";
const RuleStack = ({ children, className = "" }) => {
const RuleStack = ({ className = "" }) => {
const handleTemplateClick = (templateName) => {
console.log(`Template selected: ${templateName}`);
// This would typically navigate to template details or open a modal
// For now, we'll just log the selection
// Basic analytics tracking
if (typeof window !== "undefined") {
if (window.gtag) {
window.gtag("event", "template_click", {
template_name: templateName,
});
}
if (window.analytics) {
window.analytics.track("Template Clicked", {
templateName: templateName,
});
}
}
console.log(`${templateName} template clicked`);
};
return (
<div
<section
className={`w-full bg-transparent py-[var(--spacing-scale-032)] px-[var(--spacing-scale-020)] md:py-[var(--spacing-scale-048)] md:px-[var(--spacing-scale-032)] xmd:py-[var(--spacing-scale-056)] xmd:px-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)] lg:px-[var(--spacing-scale-064)] xl:py-[var(--spacing-scale-064)] xl:px-[var(--spacing-scale-096)] flex flex-col gap-[var(--spacing-scale-024)] xmd:gap-[var(--spacing-scale-032)] lg:gap-[var(--spacing-scale-040)] ${className}`}
>
<SectionHeader
title="Popular templates"
subtitle="These are popular patterns for making decisions in mutual aid and open source communities. You can use them as they are or as a starting place for customizing your own CommunityRule."
variant="multi-line"
/>
<div className="flex flex-col gap-[18px] xmd:grid xmd:grid-cols-2 lg:gap-[var(--spacing-scale-024)]">
<RuleCard
title="Consensus clusters"
@@ -90,7 +96,7 @@ const RuleStack = ({ children, className = "" }) => {
See all templates
</Button>
</div>
</div>
</section>
);
};
+2
View File
@@ -2,6 +2,7 @@ import NumberedCards from "./components/NumberedCards";
import HeroBanner from "./components/HeroBanner";
import LogoWall from "./components/LogoWall";
import RuleStack from "./components/RuleStack";
import QuoteBlock from "./components/QuoteBlock";
export default function Page() {
const heroBannerData = {
@@ -41,6 +42,7 @@ export default function Page() {
<LogoWall />
<NumberedCards {...numberedCardsData} />
<RuleStack />
<QuoteBlock />
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M0 32C0 14.3269 14.3269 0 32 0C49.6731 0 64 14.3269 64 32C64 49.6731 49.6731 64 32 64C14.3269 64 0 49.6731 0 32Z" fill="url(#pattern0_18633_26043)" style="mix-blend-mode:plus-darker"/>
<defs>
<pattern id="pattern0_18633_26043" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_18633_26043" transform="translate(0 -0.10036) scale(0.00584795)"/>
</pattern>
<image id="image0_18633_26043" width="171" height="256" preserveAspectRatio="none" xlink:href="data:image/png;base64,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 truncated
</defs>
</svg>

After

Width:  |  Height:  |  Size: 41 KiB

+146
View File
@@ -0,0 +1,146 @@
import QuoteBlock from "../app/components/QuoteBlock";
export default {
title: "Components/QuoteBlock",
component: QuoteBlock,
parameters: {
layout: "fullscreen",
docs: {
description: {
component: `
A responsive quote section component that displays inspirational governance quotes with author attribution and decorative geometric elements.
## Features
- **Three variants**: compact, standard, and extended layouts
- **Responsive design**: Adapts across all breakpoints
- **Error handling**: Graceful fallbacks for image loading failures
- **Accessibility**: WCAG 2.1 AA compliant with proper ARIA labels
- **Design system integration**: Uses design tokens for consistent styling
## Usage
\`\`\`jsx
<QuoteBlock
variant="standard"
quote="Your quote text here..."
author="Author Name"
source="Source Title"
avatarSrc="path/to/avatar.jpg"
/>
\`\`\`
`,
},
},
},
argTypes: {
variant: {
control: { type: "select" },
options: ["compact", "standard", "extended"],
description: "Layout variant for different use cases",
},
quote: {
control: { type: "text" },
description: "The quote text to display",
},
author: {
control: { type: "text" },
description: "Author name for attribution",
},
source: {
control: { type: "text" },
description: "Source title (book, article, etc.)",
},
avatarSrc: {
control: { type: "text" },
description: "Path to author avatar image",
},
fallbackAvatarSrc: {
control: { type: "text" },
description: "Fallback avatar image path",
},
onError: {
action: "error",
description: "Error callback function",
},
},
};
// Default story
export const Default = {
args: {
variant: "standard",
quote:
"The rules of decision-making must be open and available to everyone, and this can happen only if they are formalized.",
author: "Jo Freeman",
source: "The Tyranny of Structurelessness",
avatarSrc: "assets/Quote_Avatar.svg",
},
};
// All variants comparison
export const AllVariants = {
render: () => (
<div className="space-y-8 p-4">
<div>
<h3 className="text-lg font-bold mb-4">Compact Variant</h3>
<QuoteBlock
variant="compact"
quote="The rules of decision-making must be open and available to everyone."
author="Jo Freeman"
source="The Tyranny of Structurelessness"
avatarSrc="assets/Quote_Avatar.svg"
/>
</div>
<div>
<h3 className="text-lg font-bold mb-4">Standard Variant</h3>
<QuoteBlock
variant="standard"
quote="The rules of decision-making must be open and available to everyone, and this can happen only if they are formalized."
author="Jo Freeman"
source="The Tyranny of Structurelessness"
avatarSrc="assets/Quote_Avatar.svg"
/>
</div>
<div>
<h3 className="text-lg font-bold mb-4">Extended Variant</h3>
<QuoteBlock
variant="extended"
quote="The rules of decision-making must be open and available to everyone, and this can happen only if they are formalized."
author="Jo Freeman"
source="The Tyranny of Structurelessness"
avatarSrc="assets/Quote_Avatar.svg"
/>
</div>
</div>
),
parameters: {
docs: {
description: {
story:
"Side-by-side comparison of all three variants to show the differences in layout, typography, and spacing.",
},
},
},
};
// Error state simulation
export const ErrorState = {
args: {
variant: "standard",
quote:
"The rules of decision-making must be open and available to everyone, and this can happen only if they are formalized.",
author: "Jo Freeman",
source: "The Tyranny of Structurelessness",
avatarSrc: "invalid-image-path.jpg", // This will trigger error state
onError: (error) => console.log("QuoteBlock error:", error),
},
parameters: {
docs: {
description: {
story:
"Error state when avatar image fails to load. Shows initials fallback and error handling.",
},
},
},
};