Rule Stack #11
+111
-12
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import QuoteDecor from "./QuoteDecor";
|
import QuoteDecor from "./QuoteDecor";
|
||||||
|
|
||||||
@@ -11,7 +11,13 @@ const QuoteBlock = ({
|
|||||||
author = "Jo Freeman",
|
author = "Jo Freeman",
|
||||||
source = "The Tyranny of Structurelessness",
|
source = "The Tyranny of Structurelessness",
|
||||||
avatarSrc = "assets/Quote_Avatar.svg",
|
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
|
// Variant configurations
|
||||||
const variants = {
|
const variants = {
|
||||||
compact: {
|
compact: {
|
||||||
@@ -63,8 +69,60 @@ const QuoteBlock = ({
|
|||||||
|
|
||||||
const config = variants[variant] || variants.standard;
|
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 (
|
return (
|
||||||
<div className={`${config.container} ${className}`}>
|
<section
|
||||||
|
className={`${config.container} ${className}`}
|
||||||
|
aria-labelledby={quoteId}
|
||||||
|
role="region"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={`${config.card} bg-[var(--color-surface-default-brand-darker-accent)] relative overflow-hidden`}
|
className={`${config.card} bg-[var(--color-surface-default-brand-darker-accent)] relative overflow-hidden`}
|
||||||
>
|
>
|
||||||
@@ -74,19 +132,57 @@ const QuoteBlock = ({
|
|||||||
className="pointer-events-none absolute z-0
|
className="pointer-events-none absolute z-0
|
||||||
left-0 top-0
|
left-0 top-0
|
||||||
w-full h-full"
|
w-full h-full"
|
||||||
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`flex flex-col ${config.gap} relative z-10`}>
|
<div className={`flex flex-col ${config.gap} relative z-10`}>
|
||||||
<div className={`flex flex-col ${config.avatarGap}`}>
|
<div className={`flex flex-col ${config.avatarGap}`}>
|
||||||
|
{/* Avatar with error handling */}
|
||||||
|
<div className="relative">
|
||||||
<Image
|
<Image
|
||||||
src={avatarSrc}
|
src={currentAvatarSrc}
|
||||||
alt={`${author} Avatar`}
|
alt={`Portrait of ${author}`}
|
||||||
width={64}
|
width={64}
|
||||||
height={64}
|
height={64}
|
||||||
className={`filter sepia ${config.avatar}`}
|
className={`filter sepia ${
|
||||||
|
config.avatar
|
||||||
|
} transition-opacity duration-300 ${
|
||||||
|
imageLoading ? "opacity-0" : "opacity-100"
|
||||||
|
}`}
|
||||||
|
loading="lazy"
|
||||||
|
onError={handleImageError}
|
||||||
|
onLoad={handleImageLoad}
|
||||||
/>
|
/>
|
||||||
<blockquote>
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{imageLoading && (
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 bg-gray-200 animate-pulse rounded-full ${config.avatar}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state - show initials */}
|
||||||
|
{imageError && !imageLoading && (
|
||||||
|
<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
|
<p
|
||||||
className={`font-bricolage-grotesque font-normal ${config.quote} text-[var(--color-content-inverse-primary)] -indent-[0.5em] relative before:content-['\\201C'] after:content-['\\201D'] before:mr-[0.05em] after:ml-[0.05em] before:[font-family:var(--font-bricolage-grotesque)] after:[font-family:var(--font-bricolage-grotesque)]`}
|
className={`font-bricolage-grotesque font-normal ${config.quote} text-[var(--color-content-inverse-primary)] -indent-[0.5em] relative before:content-['\\201C'] after:content-['\\201D'] before:mr-[0.05em] after:ml-[0.05em] before:[font-family:var(--font-bricolage-grotesque)] after:[font-family:var(--font-bricolage-grotesque)]`}
|
||||||
>
|
>
|
||||||
@@ -94,21 +190,24 @@ const QuoteBlock = ({
|
|||||||
</p>
|
</p>
|
||||||
</blockquote>
|
</blockquote>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-[var(--spacing-scale-008)] md:gap-[var(--spacing-scale-012)] xl:gap-[var(--spacing-scale-020)]">
|
<footer className="flex flex-col gap-[var(--spacing-scale-008)] md:gap-[var(--spacing-scale-012)] xl:gap-[var(--spacing-scale-020)]">
|
||||||
<p
|
<cite
|
||||||
className={`font-inter font-normal ${config.author} text-[var(--color-content-inverse-primary)] uppercase`}
|
id={authorId}
|
||||||
|
className={`font-inter font-normal ${config.author} text-[var(--color-content-inverse-primary)] uppercase not-italic`}
|
||||||
>
|
>
|
||||||
{author}
|
{author}
|
||||||
</p>
|
</cite>
|
||||||
|
{source && (
|
||||||
<p
|
<p
|
||||||
className={`font-inter font-normal ${config.source} text-[var(--color-content-inverse-primary)] uppercase -indent-[0.5em] relative before:content-['\\201C'] after:content-['\\201D'] before:mr-[0.05em] after:ml-[0.05em] before:[font-family:var(--font-inter)] after:[font-family:var(--font-inter)]`}
|
className={`font-inter font-normal ${config.source} text-[var(--color-content-inverse-primary)] uppercase -indent-[0.5em] relative before:content-['\\201C'] after:content-['\\201D'] before:mr-[0.05em] after:ml-[0.05em] before:[font-family:var(--font-inter)] after:[font-family:var(--font-inter)]`}
|
||||||
>
|
>
|
||||||
{source}
|
{source}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user