migration wip
This commit is contained in:
243
components/content/Breadcrumbs.tsx
Normal file
243
components/content/Breadcrumbs.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Container } from '../ui/Container';
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface BreadcrumbsProps {
|
||||
items: BreadcrumbItem[];
|
||||
separator?: React.ReactNode;
|
||||
className?: string;
|
||||
showHome?: boolean;
|
||||
homeLabel?: string;
|
||||
homeHref?: string;
|
||||
collapseMobile?: boolean;
|
||||
}
|
||||
|
||||
export const Breadcrumbs: React.FC<BreadcrumbsProps> = ({
|
||||
items,
|
||||
separator = '/',
|
||||
className = '',
|
||||
showHome = true,
|
||||
homeLabel = 'Home',
|
||||
homeHref = '/',
|
||||
collapseMobile = true,
|
||||
}) => {
|
||||
// Generate schema.org structured data
|
||||
const generateSchema = () => {
|
||||
const breadcrumbs = [
|
||||
...(showHome ? [{ label: homeLabel, href: homeHref }] : []),
|
||||
...items,
|
||||
].map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: item.label,
|
||||
...(item.href && { item: item.href }),
|
||||
}));
|
||||
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: breadcrumbs,
|
||||
};
|
||||
};
|
||||
|
||||
// Render individual breadcrumb item
|
||||
const renderItem = (item: BreadcrumbItem, index: number, isLast: boolean) => {
|
||||
const isHome = showHome && index === 0 && item.href === homeHref;
|
||||
|
||||
const content = (
|
||||
<span className="flex items-center gap-2">
|
||||
{isHome && (
|
||||
<span className="inline-flex items-center justify-center w-4 h-4">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{item.icon && <span className="inline-flex items-center">{item.icon}</span>}
|
||||
<span className={cn(
|
||||
'transition-colors duration-200',
|
||||
isLast ? 'font-semibold text-gray-900' : 'text-gray-600 hover:text-gray-900'
|
||||
)}>
|
||||
{item.label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
if (!isLast && item.href) {
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2',
|
||||
'transition-colors duration-200',
|
||||
'hover:text-primary'
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
const allItems = [
|
||||
...(showHome ? [{ label: homeLabel, href: homeHref }] : []),
|
||||
...items,
|
||||
];
|
||||
|
||||
// Schema.org JSON-LD
|
||||
const schema = generateSchema();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Schema.org structured data */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||
/>
|
||||
|
||||
{/* Breadcrumbs navigation */}
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
className={cn('w-full bg-gray-50 py-3', className)}
|
||||
>
|
||||
<Container maxWidth="6xl" padding="md">
|
||||
<ol className={cn(
|
||||
'flex items-center flex-wrap gap-2 text-sm',
|
||||
// Mobile: show only current and parent, hide others
|
||||
collapseMobile && 'max-w-full overflow-hidden'
|
||||
)}>
|
||||
{allItems.map((item, index) => {
|
||||
const isLast = index === allItems.length - 1;
|
||||
const isFirst = index === 0;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={index}
|
||||
className={cn(
|
||||
'flex items-center gap-2',
|
||||
// On mobile, hide intermediate items but keep structure
|
||||
collapseMobile && !isFirst && !isLast && 'hidden sm:flex'
|
||||
)}
|
||||
>
|
||||
{renderItem(item, index, isLast)}
|
||||
|
||||
{!isLast && (
|
||||
<span className={cn(
|
||||
'text-gray-400 select-none',
|
||||
// Use different separator for mobile vs desktop
|
||||
'hidden sm:inline'
|
||||
)}>
|
||||
{separator}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</Container>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Sub-components for variations
|
||||
export const BreadcrumbsCompact: React.FC<Omit<BreadcrumbsProps, 'collapseMobile'>> = ({
|
||||
items,
|
||||
separator = '›',
|
||||
className = '',
|
||||
showHome = true,
|
||||
homeLabel = 'Home',
|
||||
homeHref = '/',
|
||||
}) => {
|
||||
const allItems = [
|
||||
...(showHome ? [{ label: homeLabel, href: homeHref }] : []),
|
||||
...items,
|
||||
];
|
||||
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className={cn('w-full', className)}>
|
||||
<Container maxWidth="6xl" padding="md">
|
||||
<ol className="flex items-center gap-2 text-xs md:text-sm">
|
||||
{allItems.map((item, index) => {
|
||||
const isLast = index === allItems.length - 1;
|
||||
|
||||
return (
|
||||
<li key={index} className="flex items-center gap-2">
|
||||
{item.href && !isLast ? (
|
||||
<Link
|
||||
href={item.href}
|
||||
className="text-gray-500 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={cn(
|
||||
isLast ? 'font-medium text-gray-900' : 'text-gray-500'
|
||||
)}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!isLast && (
|
||||
<span className="text-gray-400">{separator}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</Container>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export const BreadcrumbsSimple: React.FC<{
|
||||
items: BreadcrumbItem[];
|
||||
className?: string;
|
||||
}> = ({ items, className = '' }) => {
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className={cn('w-full', className)}>
|
||||
<ol className="flex items-center gap-2 text-sm">
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1;
|
||||
|
||||
return (
|
||||
<li key={index} className="flex items-center gap-2">
|
||||
{item.href && !isLast ? (
|
||||
<Link
|
||||
href={item.href}
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={cn(
|
||||
isLast ? 'font-semibold text-gray-900' : 'text-gray-600'
|
||||
)}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!isLast && <span className="text-gray-400">/</span>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Breadcrumbs;
|
||||
402
components/content/ContentComponentsExample.tsx
Normal file
402
components/content/ContentComponentsExample.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* Content Components Example
|
||||
* Demonstrates how to use the content components with WordPress data
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Hero,
|
||||
Section,
|
||||
SectionHeader,
|
||||
SectionContent,
|
||||
SectionGrid,
|
||||
FeaturedImage,
|
||||
ContentRenderer,
|
||||
Breadcrumbs,
|
||||
ContentBlock,
|
||||
RichText,
|
||||
Avatar,
|
||||
ImageGallery,
|
||||
} from './index';
|
||||
import { Button, Card, CardHeader, CardBody, CardFooter, Grid } from '../ui';
|
||||
import { Page, Post, Product, getMediaById } from '../../lib/data';
|
||||
|
||||
// Example: Hero component with WordPress data
|
||||
export const ExampleHero: React.FC<{
|
||||
page: Page;
|
||||
}> = ({ page }) => {
|
||||
const featuredMedia = page.featuredImage ? getMediaById(page.featuredImage) : null;
|
||||
|
||||
return (
|
||||
<Hero
|
||||
title={page.title}
|
||||
subtitle={page.excerptHtml ? page.excerptHtml.replace(/<[^>]*>/g, '') : undefined}
|
||||
backgroundImage={featuredMedia?.localPath}
|
||||
backgroundAlt={featuredMedia?.alt || page.title}
|
||||
height="lg"
|
||||
variant="dark"
|
||||
overlay={true}
|
||||
overlayOpacity={0.6}
|
||||
ctaText="Learn More"
|
||||
ctaLink="#content"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Example: Section component for content blocks
|
||||
export const ExampleSection: React.FC<{
|
||||
title: string;
|
||||
content: string;
|
||||
background?: 'default' | 'light' | 'dark';
|
||||
}> = ({ title, content, background = 'default' }) => {
|
||||
return (
|
||||
<Section background={background} padding="xl">
|
||||
<SectionHeader title={title} align="center" />
|
||||
<SectionContent>
|
||||
<ContentRenderer content={content} />
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
// Example: Featured content grid
|
||||
export const ExampleContentGrid: React.FC<{
|
||||
posts: Post[];
|
||||
}> = ({ posts }) => {
|
||||
return (
|
||||
<Section background="light" padding="xl">
|
||||
<SectionHeader
|
||||
title="Latest News"
|
||||
subtitle="Stay updated with our latest developments"
|
||||
align="center"
|
||||
/>
|
||||
<SectionGrid cols={3} gap="md">
|
||||
{posts.map((post) => {
|
||||
const featuredMedia = post.featuredImage ? getMediaById(post.featuredImage) : null;
|
||||
return (
|
||||
<Card key={post.id} variant="elevated">
|
||||
{featuredMedia && (
|
||||
<div className="relative h-48">
|
||||
<FeaturedImage
|
||||
src={featuredMedia.localPath}
|
||||
alt={featuredMedia.alt || post.title}
|
||||
size="full"
|
||||
aspectRatio="16:9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader>
|
||||
<h3 className="text-xl font-bold">{post.title}</h3>
|
||||
<small className="text-gray-500">
|
||||
{new Date(post.datePublished).toLocaleDateString()}
|
||||
</small>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<RichText
|
||||
html={post.excerptHtml}
|
||||
className="text-gray-600 line-clamp-3"
|
||||
/>
|
||||
</CardBody>
|
||||
<CardFooter>
|
||||
<Button variant="primary" size="sm">
|
||||
Read More
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SectionGrid>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
// Example: Product showcase
|
||||
export const ExampleProductShowcase: React.FC<{
|
||||
product: Product;
|
||||
}> = ({ product }) => {
|
||||
const images = product.images.map((img) => ({
|
||||
src: img,
|
||||
alt: product.name,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Section padding="xl">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Product Images */}
|
||||
<div>
|
||||
{product.featuredImage && (
|
||||
<FeaturedImage
|
||||
src={product.featuredImage}
|
||||
alt={product.name}
|
||||
size="full"
|
||||
aspectRatio="4:3"
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
{images.length > 1 && (
|
||||
<div className="mt-4">
|
||||
<ImageGallery images={images.slice(1)} cols={3} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Product Info */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-2">{product.name}</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl font-bold text-primary">
|
||||
{product.regularPrice} {product.currency}
|
||||
</span>
|
||||
{product.salePrice && (
|
||||
<span className="text-lg text-gray-500 line-through">
|
||||
{product.salePrice} {product.currency}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RichText html={product.descriptionHtml} />
|
||||
|
||||
{product.categories.length > 0 && (
|
||||
<div>
|
||||
<strong>Categories:</strong>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{product.categories.map((cat) => (
|
||||
<span key={cat.id} className="px-3 py-1 bg-gray-100 rounded-full text-sm">
|
||||
{cat.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button variant="primary" size="lg">
|
||||
Add to Cart
|
||||
</Button>
|
||||
<Button variant="outline" size="lg">
|
||||
Contact Sales
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
// Example: Breadcrumbs with WordPress page structure
|
||||
export const ExampleBreadcrumbs: React.FC<{
|
||||
page: Page;
|
||||
ancestors?: Page[];
|
||||
}> = ({ page, ancestors = [] }) => {
|
||||
const items = [
|
||||
...ancestors.map((p) => ({
|
||||
label: p.title,
|
||||
href: `/${p.locale}${p.path}`,
|
||||
})),
|
||||
{
|
||||
label: page.title,
|
||||
},
|
||||
];
|
||||
|
||||
return <Breadcrumbs items={items} />;
|
||||
};
|
||||
|
||||
// Example: Full page layout with all components
|
||||
export const ExamplePageLayout: React.FC<{
|
||||
page: Page;
|
||||
relatedPosts?: Post[];
|
||||
}> = ({ page, relatedPosts = [] }) => {
|
||||
const featuredMedia = page.featuredImage ? getMediaById(page.featuredImage) : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Hero Section */}
|
||||
<Hero
|
||||
title={page.title}
|
||||
subtitle={page.excerptHtml ? page.excerptHtml.replace(/<[^>]*>/g, '') : undefined}
|
||||
backgroundImage={featuredMedia?.localPath}
|
||||
backgroundAlt={featuredMedia?.alt || page.title}
|
||||
height="md"
|
||||
variant="dark"
|
||||
overlay
|
||||
overlayOpacity={0.5}
|
||||
/>
|
||||
|
||||
{/* Breadcrumbs */}
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: page.title },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<Section padding="xl">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<ContentRenderer content={page.contentHtml} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Related Posts */}
|
||||
{relatedPosts.length > 0 && (
|
||||
<Section background="light" padding="xl">
|
||||
<SectionHeader title="Related Content" align="center" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{relatedPosts.map((post) => (
|
||||
<Card key={post.id} variant="bordered">
|
||||
<CardBody>
|
||||
<h3 className="text-lg font-bold mb-2">{post.title}</h3>
|
||||
<RichText
|
||||
html={post.excerptHtml}
|
||||
className="text-gray-600 text-sm line-clamp-2"
|
||||
/>
|
||||
</CardBody>
|
||||
<CardFooter>
|
||||
<Button variant="ghost" size="sm">
|
||||
Read More
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* CTA Section */}
|
||||
<Section background="primary" padding="xl">
|
||||
<div className="text-center text-white">
|
||||
<h2 className="text-3xl font-bold mb-4">Ready to Get Started?</h2>
|
||||
<p className="text-xl mb-6 opacity-90">
|
||||
Contact us today for more information about our products and services.
|
||||
</p>
|
||||
<div className="flex justify-center gap-3">
|
||||
<Button variant="secondary" size="lg">
|
||||
Contact Us
|
||||
</Button>
|
||||
<Button variant="ghost" size="lg" className="text-white border-white hover:bg-white/10">
|
||||
View Products
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Example: Blog post layout
|
||||
export const ExampleBlogPost: React.FC<{
|
||||
post: Post;
|
||||
author?: {
|
||||
name: string;
|
||||
avatar?: string;
|
||||
};
|
||||
}> = ({ post, author }) => {
|
||||
const featuredMedia = post.featuredImage ? getMediaById(post.featuredImage) : null;
|
||||
|
||||
return (
|
||||
<article className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<header className="mb-8 text-center">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">{post.title}</h1>
|
||||
<div className="flex items-center justify-center gap-4 text-gray-600">
|
||||
<time dateTime={post.datePublished}>
|
||||
{new Date(post.datePublished).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
{author && (
|
||||
<div className="flex items-center gap-2">
|
||||
{author.avatar && <Avatar src={author.avatar} alt={author.name} size="sm" />}
|
||||
<span>{author.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Featured Image */}
|
||||
{featuredMedia && (
|
||||
<div className="mb-8">
|
||||
<FeaturedImage
|
||||
src={featuredMedia.localPath}
|
||||
alt={featuredMedia.alt || post.title}
|
||||
size="full"
|
||||
aspectRatio="16:9"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="prose prose-lg max-w-none">
|
||||
<ContentRenderer content={post.contentHtml} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
// Example: Product category grid
|
||||
export const ExampleProductGrid: React.FC<{
|
||||
products: Product[];
|
||||
category?: string;
|
||||
}> = ({ products, category }) => {
|
||||
return (
|
||||
<Section padding="xl">
|
||||
<SectionHeader
|
||||
title={category ? `${category} Products` : 'Our Products'}
|
||||
subtitle="Explore our range of high-quality products"
|
||||
align="center"
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{products.map((product) => {
|
||||
const image = product.featuredImage || product.images[0];
|
||||
return (
|
||||
<Card key={product.id} variant="elevated" className="flex flex-col">
|
||||
{image && (
|
||||
<div className="relative">
|
||||
<FeaturedImage
|
||||
src={image}
|
||||
alt={product.name}
|
||||
size="full"
|
||||
aspectRatio="4:3"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-bold">{product.name}</h3>
|
||||
<div className="text-primary font-semibold text-lg">
|
||||
{product.regularPrice} {product.currency}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<RichText
|
||||
html={product.shortDescriptionHtml}
|
||||
className="text-gray-600 text-sm line-clamp-2"
|
||||
/>
|
||||
</CardBody>
|
||||
<CardFooter className="mt-auto">
|
||||
<Button variant="primary" size="sm" fullWidth>
|
||||
View Details
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
ExampleHero,
|
||||
ExampleSection,
|
||||
ExampleContentGrid,
|
||||
ExampleProductShowcase,
|
||||
ExampleBreadcrumbs,
|
||||
ExamplePageLayout,
|
||||
ExampleBlogPost,
|
||||
ExampleProductGrid,
|
||||
};
|
||||
667
components/content/ContentRenderer.tsx
Normal file
667
components/content/ContentRenderer.tsx
Normal file
@@ -0,0 +1,667 @@
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { processHTML } from '../../lib/html-compat';
|
||||
import { getMediaByUrl, getMediaById, getAssetMap } from '../../lib/data';
|
||||
|
||||
interface ContentRendererProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
sanitize?: boolean;
|
||||
processAssets?: boolean;
|
||||
convertClasses?: boolean;
|
||||
}
|
||||
|
||||
interface ProcessedImage {
|
||||
src: string;
|
||||
alt: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* ContentRenderer Component
|
||||
* Handles rendering of WordPress HTML content with proper sanitization
|
||||
* and conversion to modern React components
|
||||
*/
|
||||
export const ContentRenderer: React.FC<ContentRendererProps> = ({
|
||||
content,
|
||||
className = '',
|
||||
sanitize = true,
|
||||
processAssets = true,
|
||||
convertClasses = true,
|
||||
}) => {
|
||||
// Process the HTML content
|
||||
const processedContent = React.useMemo(() => {
|
||||
let html = content;
|
||||
|
||||
if (sanitize) {
|
||||
html = processHTML(html);
|
||||
}
|
||||
|
||||
if (processAssets) {
|
||||
html = replaceWordPressAssets(html);
|
||||
}
|
||||
|
||||
if (convertClasses) {
|
||||
html = convertWordPressClasses(html);
|
||||
}
|
||||
|
||||
return html;
|
||||
}, [content, sanitize, processAssets, convertClasses]);
|
||||
|
||||
// Parse and render the HTML
|
||||
const renderContent = () => {
|
||||
if (!processedContent) return null;
|
||||
|
||||
// Use a parser to convert HTML to React elements
|
||||
// For security, we'll use a custom parser that only allows safe elements
|
||||
return parseHTMLToReact(processedContent);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'prose prose-lg max-w-none',
|
||||
'prose-headings:font-bold prose-headings:tracking-tight',
|
||||
'prose-h1:text-3xl prose-h1:md:text-4xl prose-h1:mb-4',
|
||||
'prose-h2:text-2xl prose-h2:md:text-3xl prose-h2:mb-3',
|
||||
'prose-h3:text-xl prose-h3:md:text-2xl prose-h3:mb-2',
|
||||
'prose-p:text-gray-700 prose-p:leading-relaxed prose-p:mb-4',
|
||||
'prose-a:text-primary prose-a:hover:text-primary-dark prose-a:underline',
|
||||
'prose-ul:list-disc prose-ul:pl-6 prose-ul:mb-4',
|
||||
'prose-ol:list-decimal prose-ol:pl-6 prose-ol:mb-4',
|
||||
'prose-li:mb-2 prose-li:marker:text-primary',
|
||||
'prose-strong:font-bold prose-strong:text-gray-900',
|
||||
'prose-em:italic prose-em:text-gray-700',
|
||||
'prose-table:w-full prose-table:border-collapse prose-table:my-4',
|
||||
'prose-th:bg-gray-100 prose-th:font-bold prose-th:p-2 prose-th:text-left',
|
||||
'prose-td:p-2 prose-td:border prose-td:border-gray-200',
|
||||
'prose-img:rounded-lg prose-img:shadow-md prose-img:my-4',
|
||||
'prose-blockquote:border-l-4 prose-blockquote:border-primary prose-blockquote:pl-4 prose-blockquote:italic prose-blockquote:bg-gray-50 prose-blockquote:py-2 prose-blockquote:my-4',
|
||||
className
|
||||
)}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse HTML string to React elements
|
||||
* This is a safe parser that only allows specific tags and attributes
|
||||
*/
|
||||
function parseHTMLToReact(html: string): React.ReactNode {
|
||||
// Define allowed tags and their properties
|
||||
const allowedTags = {
|
||||
div: ['className', 'id', 'style'],
|
||||
p: ['className', 'style'],
|
||||
h1: ['className', 'style'],
|
||||
h2: ['className', 'style'],
|
||||
h3: ['className', 'style'],
|
||||
h4: ['className', 'style'],
|
||||
h5: ['className', 'style'],
|
||||
h6: ['className', 'style'],
|
||||
span: ['className', 'style'],
|
||||
a: ['href', 'target', 'rel', 'className', 'title', 'style'],
|
||||
ul: ['className', 'style'],
|
||||
ol: ['className', 'style'],
|
||||
li: ['className', 'style'],
|
||||
strong: ['className', 'style'],
|
||||
b: ['className', 'style'],
|
||||
em: ['className', 'style'],
|
||||
i: ['className', 'style'],
|
||||
br: [],
|
||||
hr: ['className', 'style'],
|
||||
img: ['src', 'alt', 'width', 'height', 'className', 'style'],
|
||||
table: ['className', 'style'],
|
||||
thead: ['className', 'style'],
|
||||
tbody: ['className', 'style'],
|
||||
tr: ['className', 'style'],
|
||||
th: ['className', 'style'],
|
||||
td: ['className', 'style'],
|
||||
blockquote: ['className', 'style'],
|
||||
code: ['className', 'style'],
|
||||
pre: ['className', 'style'],
|
||||
small: ['className', 'style'],
|
||||
section: ['className', 'id', 'style'],
|
||||
article: ['className', 'id', 'style'],
|
||||
figure: ['className', 'style'],
|
||||
figcaption: ['className', 'style'],
|
||||
video: ['className', 'style', 'autoPlay', 'loop', 'muted', 'playsInline', 'poster'],
|
||||
source: ['src', 'type'],
|
||||
};
|
||||
|
||||
// Create a temporary DOM element to parse the HTML
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const body = doc.body;
|
||||
|
||||
// Recursive function to convert DOM nodes to React elements
|
||||
function convertNode(node: Node, index: number): React.ReactNode {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node.textContent;
|
||||
}
|
||||
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const element = node as HTMLElement;
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
|
||||
// Check if tag is allowed
|
||||
if (!allowedTags[tagName as keyof typeof allowedTags]) {
|
||||
// For unknown tags, just render their children
|
||||
return Array.from(node.childNodes).map((child, i) => convertNode(child, i));
|
||||
}
|
||||
|
||||
// Build props
|
||||
const props: any = { key: index };
|
||||
const allowedProps = allowedTags[tagName as keyof typeof allowedTags];
|
||||
|
||||
// Helper function to convert style string to object
|
||||
const parseStyleString = (styleStr: string): React.CSSProperties => {
|
||||
const styles: React.CSSProperties = {};
|
||||
if (!styleStr) return styles;
|
||||
|
||||
styleStr.split(';').forEach(style => {
|
||||
const [key, value] = style.split(':').map(s => s.trim());
|
||||
if (key && value) {
|
||||
// Convert camelCase for React
|
||||
const camelKey = key.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
(styles as any)[camelKey] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return styles;
|
||||
};
|
||||
|
||||
// Handle special cases for different element types
|
||||
if (tagName === 'a' && element.getAttribute('href')) {
|
||||
const href = element.getAttribute('href')!;
|
||||
const isExternal = href.startsWith('http') && !href.includes(window?.location?.hostname || '');
|
||||
|
||||
if (isExternal) {
|
||||
props.href = href;
|
||||
props.target = '_blank';
|
||||
props.rel = 'noopener noreferrer';
|
||||
} else {
|
||||
// For internal links, use Next.js Link
|
||||
const children = Array.from(node.childNodes).map((child, i) => convertNode(child, i));
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
key={index}
|
||||
className={element.className}
|
||||
style={parseStyleString(element.style.cssText)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (tagName === 'img') {
|
||||
const src = element.getAttribute('src') || '';
|
||||
const alt = element.getAttribute('alt') || '';
|
||||
const widthAttr = element.getAttribute('width');
|
||||
const heightAttr = element.getAttribute('height');
|
||||
const dataWpImageId = element.getAttribute('data-wp-image-id');
|
||||
|
||||
// Handle WordPress image IDs
|
||||
if (dataWpImageId) {
|
||||
const media = getMediaById(parseInt(dataWpImageId));
|
||||
if (media) {
|
||||
const width = widthAttr ? parseInt(widthAttr) : (media.width || 800);
|
||||
const height = heightAttr ? parseInt(heightAttr) : (media.height || 600);
|
||||
|
||||
return (
|
||||
<Image
|
||||
key={index}
|
||||
src={media.localPath}
|
||||
alt={alt || media.alt || ''}
|
||||
width={width}
|
||||
height={height}
|
||||
className={element.className || ''}
|
||||
style={parseStyleString(element.style.cssText)}
|
||||
priority={false}
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle regular image URLs
|
||||
if (src) {
|
||||
const imageProps = getImageProps(src);
|
||||
const width = widthAttr ? parseInt(widthAttr) : imageProps.width;
|
||||
const height = heightAttr ? parseInt(heightAttr) : imageProps.height;
|
||||
|
||||
// Check if it's an external URL
|
||||
if (src.startsWith('http')) {
|
||||
// For external images, use regular img tag
|
||||
return (
|
||||
<img
|
||||
key={index}
|
||||
src={imageProps.src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
className={element.className || ''}
|
||||
style={parseStyleString(element.style.cssText)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
key={index}
|
||||
src={imageProps.src}
|
||||
alt={alt || imageProps.alt || ''}
|
||||
width={width || 800}
|
||||
height={height || 600}
|
||||
className={element.className || ''}
|
||||
style={parseStyleString(element.style.cssText)}
|
||||
priority={false}
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle video elements
|
||||
if (tagName === 'video') {
|
||||
const videoProps: any = { key: index };
|
||||
|
||||
// Get sources
|
||||
const sources: React.ReactNode[] = [];
|
||||
Array.from(element.childNodes).forEach((child, i) => {
|
||||
if (child.nodeType === Node.ELEMENT_NODE && (child as HTMLElement).tagName.toLowerCase() === 'source') {
|
||||
const sourceEl = child as HTMLSourceElement;
|
||||
sources.push(
|
||||
<source key={i} src={sourceEl.src} type={sourceEl.type} />
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Set video props
|
||||
if (element.className) videoProps.className = element.className;
|
||||
if (element.style.cssText) videoProps.style = parseStyleString(element.style.cssText);
|
||||
if (element.getAttribute('autoPlay')) videoProps.autoPlay = true;
|
||||
if (element.getAttribute('loop')) videoProps.loop = true;
|
||||
if (element.getAttribute('muted')) videoProps.muted = true;
|
||||
if (element.getAttribute('playsInline')) videoProps.playsInline = true;
|
||||
if (element.getAttribute('poster')) videoProps.poster = element.getAttribute('poster');
|
||||
|
||||
return (
|
||||
<video {...videoProps}>
|
||||
{sources}
|
||||
</video>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle divs with special data attributes for backgrounds
|
||||
if (tagName === 'div' && element.getAttribute('data-color-overlay')) {
|
||||
const colorOverlay = element.getAttribute('data-color-overlay');
|
||||
const overlayOpacity = parseFloat(element.getAttribute('data-overlay-opacity') || '0.5');
|
||||
|
||||
// Get the original classes and style
|
||||
const className = element.className;
|
||||
const style = parseStyleString(element.style.cssText);
|
||||
|
||||
// Convert children
|
||||
const children = Array.from(node.childNodes).map((child, i) => convertNode(child, i));
|
||||
|
||||
return (
|
||||
<div key={index} className={className} style={style}>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ backgroundColor: colorOverlay, opacity: overlayOpacity }}
|
||||
/>
|
||||
<div className="relative">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle divs with video background data attributes
|
||||
if (tagName === 'div' && element.getAttribute('data-video-bg') === 'true') {
|
||||
const className = element.className;
|
||||
const style = parseStyleString(element.style.cssText);
|
||||
const mp4 = element.getAttribute('data-video-mp4');
|
||||
const webm = element.getAttribute('data-video-webm');
|
||||
|
||||
const children = Array.from(node.childNodes).map((child, i) => convertNode(child, i));
|
||||
|
||||
return (
|
||||
<div key={index} className={className} style={style}>
|
||||
{mp4 || webm ? (
|
||||
<video
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
>
|
||||
{mp4 && <source src={mp4} type="video/mp4" />}
|
||||
{webm && <source src={webm} type="video/webm" />}
|
||||
</video>
|
||||
) : null}
|
||||
<div className="relative z-10">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Standard attribute mapping
|
||||
allowedProps.forEach(prop => {
|
||||
if (prop === 'style') {
|
||||
// Handle style separately
|
||||
if (element.style.cssText) {
|
||||
props.style = parseStyleString(element.style.cssText);
|
||||
}
|
||||
} else {
|
||||
const value = element.getAttribute(prop);
|
||||
if (value !== null) {
|
||||
props[prop] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle className specifically
|
||||
if (element.className && allowedProps.includes('className')) {
|
||||
props.className = element.className;
|
||||
}
|
||||
|
||||
// Convert children
|
||||
const children = Array.from(node.childNodes).map((child, i) => convertNode(child, i));
|
||||
|
||||
// Return React element
|
||||
return React.createElement(tagName, props, children);
|
||||
}
|
||||
|
||||
// Convert all children of body
|
||||
return Array.from(body.childNodes).map((node, index) => convertNode(node, index));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace WordPress asset URLs with local paths
|
||||
*/
|
||||
function replaceWordPressAssets(html: string): string {
|
||||
try {
|
||||
// Use the data layer to replace URLs
|
||||
const assetMap = getAssetMap();
|
||||
let processed = html;
|
||||
|
||||
// Replace URLs in src attributes
|
||||
Object.entries(assetMap).forEach(([wpUrl, localPath]) => {
|
||||
// Handle both full URLs and relative paths
|
||||
const urlPattern = new RegExp(wpUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
|
||||
processed = processed.replace(urlPattern, localPath as string);
|
||||
});
|
||||
|
||||
// Also handle any remaining WordPress URLs that might be in the format we expect
|
||||
processed = processed.replace(/https?:\/\/[^"'\s]+\/wp-content\/uploads\/\d{4}\/\d{2}\/([^"'\s]+)/g, (match, filename) => {
|
||||
// Try to find this file in our media
|
||||
const media = getMediaByUrl(match);
|
||||
if (media) {
|
||||
return media.localPath;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
return processed;
|
||||
} catch (error) {
|
||||
console.warn('Error replacing asset URLs:', error);
|
||||
return html;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert WordPress/Salient classes to Tailwind equivalents
|
||||
*/
|
||||
function convertWordPressClasses(html: string): string {
|
||||
const classMap: Record<string, string> = {
|
||||
// Salient/Vc_row classes
|
||||
'vc_row': 'flex flex-wrap -mx-4',
|
||||
'vc_row-fluid': 'w-full',
|
||||
'vc_col-sm-12': 'w-full px-4',
|
||||
'vc_col-md-6': 'w-full md:w-1/2 px-4',
|
||||
'vc_col-md-4': 'w-full md:w-1/3 px-4',
|
||||
'vc_col-md-3': 'w-full md:w-1/4 px-4',
|
||||
'vc_col-lg-6': 'w-full lg:w-1/2 px-4',
|
||||
'vc_col-lg-4': 'w-full lg:w-1/3 px-4',
|
||||
'vc_col-lg-3': 'w-full lg:w-1/4 px-4',
|
||||
|
||||
// Typography
|
||||
'wpb_wrapper': 'space-y-4',
|
||||
'wpb_text_column': 'prose max-w-none',
|
||||
'wpb_content_element': 'mb-8',
|
||||
'wpb_single_image': 'my-4',
|
||||
'wpb_heading': 'text-2xl font-bold mb-2',
|
||||
|
||||
// Alignment
|
||||
'text-left': 'text-left',
|
||||
'text-center': 'text-center',
|
||||
'text-right': 'text-right',
|
||||
'alignleft': 'float-left mr-4 mb-4',
|
||||
'alignright': 'float-right ml-4 mb-4',
|
||||
'aligncenter': 'mx-auto',
|
||||
|
||||
// Colors
|
||||
'accent-color': 'text-primary',
|
||||
'primary-color': 'text-primary',
|
||||
'secondary-color': 'text-secondary',
|
||||
'text-color': 'text-gray-800',
|
||||
'light-text': 'text-gray-300',
|
||||
'dark-text': 'text-gray-900',
|
||||
|
||||
// Backgrounds
|
||||
'bg-light': 'bg-gray-50',
|
||||
'bg-light-gray': 'bg-gray-100',
|
||||
'bg-dark': 'bg-gray-900',
|
||||
'bg-dark-gray': 'bg-gray-800',
|
||||
'bg-primary': 'bg-primary',
|
||||
'bg-secondary': 'bg-secondary',
|
||||
'bg-white': 'bg-white',
|
||||
'bg-transparent': 'bg-transparent',
|
||||
|
||||
// Buttons
|
||||
'btn': 'inline-flex items-center justify-center px-4 py-2 rounded-lg font-semibold transition-colors duration-200',
|
||||
'btn-primary': 'bg-primary text-white hover:bg-primary-dark',
|
||||
'btn-secondary': 'bg-secondary text-white hover:bg-secondary-light',
|
||||
'btn-outline': 'border-2 border-primary text-primary hover:bg-primary hover:text-white',
|
||||
'btn-large': 'px-6 py-3 text-lg',
|
||||
'btn-small': 'px-3 py-1 text-sm',
|
||||
|
||||
// Containers
|
||||
'container': 'container mx-auto px-4',
|
||||
'container-fluid': 'w-full px-4',
|
||||
|
||||
// Spacing
|
||||
'mt-0': 'mt-0', 'mb-0': 'mb-0',
|
||||
'mt-2': 'mt-2', 'mb-2': 'mb-2',
|
||||
'mt-4': 'mt-4', 'mb-4': 'mb-4',
|
||||
'mt-6': 'mt-6', 'mb-6': 'mb-6',
|
||||
'mt-8': 'mt-8', 'mb-8': 'mb-8',
|
||||
'mt-12': 'mt-12', 'mb-12': 'mb-12',
|
||||
|
||||
// WordPress specific
|
||||
'wp-caption': 'figure',
|
||||
'wp-caption-text': 'figcaption text-sm text-gray-600 mt-2',
|
||||
'alignnone': 'block',
|
||||
'size-full': 'w-full',
|
||||
'size-large': 'w-full max-w-3xl',
|
||||
'size-medium': 'w-full max-w-xl',
|
||||
'size-thumbnail': 'w-32 h-32',
|
||||
};
|
||||
|
||||
let processed = html;
|
||||
|
||||
// Replace classes in HTML
|
||||
Object.entries(classMap).forEach(([wpClass, twClass]) => {
|
||||
// Handle class="..." with the class at the beginning
|
||||
const classRegex1 = new RegExp(`class=["']${wpClass}\\s+([^"']*)["']`, 'g');
|
||||
processed = processed.replace(classRegex1, (match, rest) => {
|
||||
const newClasses = `${twClass} ${rest}`.trim().replace(/\s+/g, ' ');
|
||||
return `class="${newClasses}"`;
|
||||
});
|
||||
|
||||
// Handle class="..." with the class in the middle
|
||||
const classRegex2 = new RegExp(`class=["']([^"']*)\\s+${wpClass}\\s+([^"']*)["']`, 'g');
|
||||
processed = processed.replace(classRegex2, (match, before, after) => {
|
||||
const newClasses = `${before} ${twClass} ${after}`.trim().replace(/\s+/g, ' ');
|
||||
return `class="${newClasses}"`;
|
||||
});
|
||||
|
||||
// Handle class="..." with the class at the end
|
||||
const classRegex3 = new RegExp(`class=["']([^"']*)\\s+${wpClass}["']`, 'g');
|
||||
processed = processed.replace(classRegex3, (match, before) => {
|
||||
const newClasses = `${before} ${twClass}`.trim().replace(/\s+/g, ' ');
|
||||
return `class="${newClasses}"`;
|
||||
});
|
||||
|
||||
// Handle class="..." with only the class
|
||||
const classRegex4 = new RegExp(`class=["']${wpClass}["']`, 'g');
|
||||
processed = processed.replace(classRegex4, `class="${twClass}"`);
|
||||
});
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image props from source using the data layer
|
||||
*/
|
||||
function getImageProps(src: string): { src: string; width?: number; height?: number; alt?: string } {
|
||||
// Check if it's a data attribute for WordPress image ID
|
||||
if (src.startsWith('data-wp-image-id:')) {
|
||||
const imageId = src.replace('data-wp-image-id:', '');
|
||||
const media = getMediaById(parseInt(imageId));
|
||||
if (media) {
|
||||
return {
|
||||
src: media.localPath,
|
||||
width: media.width || 800,
|
||||
height: media.height || 600,
|
||||
alt: media.alt || ''
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find by URL
|
||||
const media = getMediaByUrl(src);
|
||||
if (media) {
|
||||
return {
|
||||
src: media.localPath,
|
||||
width: media.width || 800,
|
||||
height: media.height || 600,
|
||||
alt: media.alt || ''
|
||||
};
|
||||
}
|
||||
|
||||
// Check if it's already a local path
|
||||
if (src.startsWith('/media/')) {
|
||||
return { src, width: 800, height: 600 };
|
||||
}
|
||||
|
||||
// Return as-is for external URLs
|
||||
return { src, width: 800, height: 600 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process background attributes and convert to inline styles
|
||||
*/
|
||||
function processBackgroundAttributes(element: HTMLElement): { style?: string; className?: string } {
|
||||
const result: { style?: string; className?: string } = {};
|
||||
const styles: string[] = [];
|
||||
const classes: string[] = [];
|
||||
|
||||
// Check for data attributes from shortcodes
|
||||
const bgImage = element.getAttribute('data-bg-image');
|
||||
const bgVideo = element.getAttribute('data-video-bg');
|
||||
const videoMp4 = element.getAttribute('data-video-mp4');
|
||||
const videoWebm = element.getAttribute('data-video-webm');
|
||||
const parallax = element.getAttribute('data-parallax');
|
||||
|
||||
// Handle background image
|
||||
if (bgImage) {
|
||||
const media = getMediaById(parseInt(bgImage));
|
||||
if (media) {
|
||||
styles.push(`background-image: url(${media.localPath})`);
|
||||
styles.push('background-size: cover');
|
||||
styles.push('background-position: center');
|
||||
classes.push('bg-cover', 'bg-center');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle video background
|
||||
if (bgVideo === 'true' && (videoMp4 || videoWebm)) {
|
||||
// This will be handled by a separate video component
|
||||
// For now, we'll add a marker class
|
||||
classes.push('has-video-background');
|
||||
if (videoMp4) element.setAttribute('data-video-mp4', videoMp4);
|
||||
if (videoWebm) element.setAttribute('data-video-webm', videoWebm);
|
||||
}
|
||||
|
||||
// Handle parallax
|
||||
if (parallax === 'true') {
|
||||
classes.push('parallax-bg');
|
||||
}
|
||||
|
||||
// Handle inline styles from shortcode attributes
|
||||
const colorOverlay = element.getAttribute('color_overlay');
|
||||
const overlayStrength = element.getAttribute('overlay_strength');
|
||||
const topPadding = element.getAttribute('top_padding');
|
||||
const bottomPadding = element.getAttribute('bottom_padding');
|
||||
|
||||
if (colorOverlay) {
|
||||
const opacity = overlayStrength ? parseFloat(overlayStrength) : 0.5;
|
||||
styles.push(`position: relative`);
|
||||
classes.push('relative');
|
||||
|
||||
// Add overlay as a child element marker
|
||||
element.setAttribute('data-color-overlay', colorOverlay);
|
||||
element.setAttribute('data-overlay-opacity', opacity.toString());
|
||||
}
|
||||
|
||||
if (topPadding) {
|
||||
styles.push(`padding-top: ${topPadding}`);
|
||||
}
|
||||
|
||||
if (bottomPadding) {
|
||||
styles.push(`padding-bottom: ${bottomPadding}`);
|
||||
}
|
||||
|
||||
if (styles.length > 0) {
|
||||
result.style = styles.join('; ');
|
||||
}
|
||||
|
||||
if (classes.length > 0) {
|
||||
result.className = classes.join(' ');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Sub-components for specific content types
|
||||
export const ContentBlock: React.FC<{
|
||||
title?: string;
|
||||
content: string;
|
||||
className?: string;
|
||||
}> = ({ title, content, className = '' }) => (
|
||||
<div className={cn('mb-8', className)}>
|
||||
{title && <h3 className="text-2xl font-bold mb-4">{title}</h3>}
|
||||
<ContentRenderer content={content} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const RichText: React.FC<{
|
||||
html: string;
|
||||
className?: string;
|
||||
}> = ({ html, className = '' }) => (
|
||||
<ContentRenderer content={html} className={className} />
|
||||
);
|
||||
|
||||
export default ContentRenderer;
|
||||
252
components/content/FeaturedImage.tsx
Normal file
252
components/content/FeaturedImage.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { getViewport, generateImageSizes, getOptimalImageQuality } from '../../lib/responsive';
|
||||
|
||||
// Aspect ratio options
|
||||
type AspectRatio = '1:1' | '4:3' | '16:9' | '21:9' | 'auto';
|
||||
|
||||
// Size options
|
||||
type ImageSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
|
||||
interface FeaturedImageProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
aspectRatio?: AspectRatio;
|
||||
size?: ImageSize;
|
||||
caption?: string;
|
||||
priority?: boolean;
|
||||
className?: string;
|
||||
objectFit?: 'cover' | 'contain' | 'fill';
|
||||
lazy?: boolean;
|
||||
// Responsive props
|
||||
responsiveSrc?: {
|
||||
mobile?: string;
|
||||
tablet?: string;
|
||||
desktop?: string;
|
||||
};
|
||||
// Quality optimization
|
||||
quality?: number | 'auto';
|
||||
// Placeholder
|
||||
placeholder?: 'blur' | 'empty';
|
||||
blurDataURL?: string;
|
||||
}
|
||||
|
||||
// Helper function to get aspect ratio classes
|
||||
const getAspectRatio = (ratio: AspectRatio) => {
|
||||
switch (ratio) {
|
||||
case '1:1':
|
||||
return 'aspect-square';
|
||||
case '4:3':
|
||||
return 'aspect-[4/3]';
|
||||
case '16:9':
|
||||
return 'aspect-video';
|
||||
case '21:9':
|
||||
return 'aspect-[21/9]';
|
||||
case 'auto':
|
||||
return 'aspect-auto';
|
||||
default:
|
||||
return 'aspect-auto';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get size classes
|
||||
const getSizeStyles = (size: ImageSize) => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return 'max-w-xs';
|
||||
case 'md':
|
||||
return 'max-w-md';
|
||||
case 'lg':
|
||||
return 'max-w-lg';
|
||||
case 'xl':
|
||||
return 'max-w-xl';
|
||||
case 'full':
|
||||
return 'max-w-full';
|
||||
default:
|
||||
return 'max-w-lg';
|
||||
}
|
||||
};
|
||||
|
||||
export const FeaturedImage: React.FC<FeaturedImageProps> = ({
|
||||
src,
|
||||
alt,
|
||||
width,
|
||||
height,
|
||||
aspectRatio = 'auto',
|
||||
size = 'md',
|
||||
caption,
|
||||
priority = false,
|
||||
className = '',
|
||||
objectFit = 'cover',
|
||||
lazy = true,
|
||||
responsiveSrc,
|
||||
quality = 'auto',
|
||||
placeholder = 'empty',
|
||||
blurDataURL,
|
||||
}) => {
|
||||
const hasDimensions = width && height;
|
||||
const shouldLazyLoad = !priority && lazy;
|
||||
|
||||
// Get responsive image source
|
||||
const getResponsiveSrc = () => {
|
||||
if (responsiveSrc) {
|
||||
if (typeof window === 'undefined') return responsiveSrc.mobile || src;
|
||||
|
||||
const viewport = getViewport();
|
||||
if (viewport.isMobile && responsiveSrc.mobile) return responsiveSrc.mobile;
|
||||
if (viewport.isTablet && responsiveSrc.tablet) return responsiveSrc.tablet;
|
||||
if (viewport.isDesktop && responsiveSrc.desktop) return responsiveSrc.desktop;
|
||||
}
|
||||
return src;
|
||||
};
|
||||
|
||||
// Get optimal quality
|
||||
const getQuality = () => {
|
||||
if (quality === 'auto') {
|
||||
if (typeof window === 'undefined') return 75;
|
||||
const viewport = getViewport();
|
||||
return getOptimalImageQuality(viewport);
|
||||
}
|
||||
return quality;
|
||||
};
|
||||
|
||||
// Generate responsive sizes attribute
|
||||
const getSizes = () => {
|
||||
const baseSizes = generateImageSizes();
|
||||
|
||||
// Adjust based on component size prop
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return '(max-width: 640px) 50vw, (max-width: 768px) 33vw, 25vw';
|
||||
case 'md':
|
||||
return '(max-width: 640px) 75vw, (max-width: 768px) 50vw, 33vw';
|
||||
case 'lg':
|
||||
return baseSizes;
|
||||
case 'xl':
|
||||
return '(max-width: 640px) 100vw, (max-width: 768px) 75vw, 50vw';
|
||||
case 'full':
|
||||
return '100vw';
|
||||
default:
|
||||
return baseSizes;
|
||||
}
|
||||
};
|
||||
|
||||
const responsiveImageSrc = getResponsiveSrc();
|
||||
const optimalQuality = getQuality();
|
||||
|
||||
return (
|
||||
<figure className={cn('relative', getSizeStyles(size), className)}>
|
||||
<div className={cn(
|
||||
'relative overflow-hidden rounded-lg',
|
||||
getAspectRatio(aspectRatio),
|
||||
// Ensure container has dimensions if aspect ratio is specified
|
||||
aspectRatio !== 'auto' && 'w-full',
|
||||
// Mobile-optimized border radius
|
||||
'sm:rounded-lg'
|
||||
)}>
|
||||
<Image
|
||||
src={responsiveImageSrc}
|
||||
alt={alt}
|
||||
width={hasDimensions ? width : undefined}
|
||||
height={hasDimensions ? height : undefined}
|
||||
fill={!hasDimensions}
|
||||
priority={priority}
|
||||
loading={shouldLazyLoad ? 'lazy' : 'eager'}
|
||||
quality={optimalQuality}
|
||||
placeholder={placeholder}
|
||||
blurDataURL={blurDataURL}
|
||||
className={cn(
|
||||
'transition-transform duration-300 ease-in-out',
|
||||
objectFit === 'cover' && 'object-cover',
|
||||
objectFit === 'contain' && 'object-contain',
|
||||
objectFit === 'fill' && 'object-fill',
|
||||
// Smooth scaling on mobile, more pronounced on desktop
|
||||
'active:scale-95 md:hover:scale-105',
|
||||
// Ensure no layout shift
|
||||
'bg-gray-100'
|
||||
)}
|
||||
sizes={getSizes()}
|
||||
// Add loading optimization
|
||||
fetchPriority={priority ? 'high' : 'low'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{caption && (
|
||||
<figcaption className={cn(
|
||||
'mt-2 text-sm text-gray-600',
|
||||
'text-center italic',
|
||||
// Mobile-optimized text size
|
||||
'text-xs sm:text-sm'
|
||||
)}>
|
||||
{caption}
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
};
|
||||
|
||||
// Sub-components for common image patterns
|
||||
export const Avatar: React.FC<{
|
||||
src: string;
|
||||
alt: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}> = ({ src, alt, size = 'md', className = '' }) => {
|
||||
const sizeClasses = {
|
||||
sm: 'w-8 h-8',
|
||||
md: 'w-12 h-12',
|
||||
lg: 'w-16 h-16',
|
||||
}[size];
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'relative overflow-hidden rounded-full',
|
||||
sizeClasses,
|
||||
className
|
||||
)}>
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes={`${sizeClasses}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ImageGallery: React.FC<{
|
||||
images: Array<{
|
||||
src: string;
|
||||
alt: string;
|
||||
caption?: string;
|
||||
}>;
|
||||
cols?: 2 | 3 | 4;
|
||||
className?: string;
|
||||
}> = ({ images, cols = 3, className = '' }) => {
|
||||
const colClasses = {
|
||||
2: 'grid-cols-1 md:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
|
||||
}[cols];
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-4', colClasses, className)}>
|
||||
{images.map((image, index) => (
|
||||
<FeaturedImage
|
||||
key={index}
|
||||
src={image.src}
|
||||
alt={image.alt}
|
||||
caption={image.caption}
|
||||
size="full"
|
||||
aspectRatio="4:3"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturedImage;
|
||||
223
components/content/Hero.tsx
Normal file
223
components/content/Hero.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Container } from '../ui/Container';
|
||||
import { Button } from '../ui/Button';
|
||||
|
||||
// Hero height options
|
||||
type HeroHeight = 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
|
||||
// Hero variant options
|
||||
type HeroVariant = 'default' | 'dark' | 'primary' | 'gradient';
|
||||
|
||||
interface HeroProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
backgroundImage?: string;
|
||||
backgroundAlt?: string;
|
||||
height?: HeroHeight;
|
||||
variant?: HeroVariant;
|
||||
ctaText?: string;
|
||||
ctaLink?: string;
|
||||
ctaVariant?: 'primary' | 'secondary' | 'outline';
|
||||
overlay?: boolean;
|
||||
overlayOpacity?: number;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Helper function to get height styles
|
||||
const getHeightStyles = (height: HeroHeight) => {
|
||||
switch (height) {
|
||||
case 'sm':
|
||||
return 'min-h-[300px] md:min-h-[400px]';
|
||||
case 'md':
|
||||
return 'min-h-[400px] md:min-h-[500px]';
|
||||
case 'lg':
|
||||
return 'min-h-[500px] md:min-h-[600px]';
|
||||
case 'xl':
|
||||
return 'min-h-[600px] md:min-h-[700px]';
|
||||
case 'full':
|
||||
return 'min-h-screen';
|
||||
default:
|
||||
return 'min-h-[500px] md:min-h-[600px]';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get variant styles
|
||||
const getVariantStyles = (variant: HeroVariant) => {
|
||||
switch (variant) {
|
||||
case 'dark':
|
||||
return 'bg-gray-900 text-white';
|
||||
case 'primary':
|
||||
return 'bg-primary text-white';
|
||||
case 'gradient':
|
||||
return 'bg-gradient-to-br from-primary to-secondary text-white';
|
||||
default:
|
||||
return 'bg-gray-800 text-white';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get overlay opacity
|
||||
const getOverlayOpacity = (opacity?: number) => {
|
||||
if (opacity === undefined) return 'bg-black/50';
|
||||
if (opacity >= 1) return 'bg-black';
|
||||
if (opacity <= 0) return 'bg-transparent';
|
||||
return `bg-black/${Math.round(opacity * 100)}`;
|
||||
};
|
||||
|
||||
export const Hero: React.FC<HeroProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
backgroundImage,
|
||||
backgroundAlt = '',
|
||||
height = 'md',
|
||||
variant = 'default',
|
||||
ctaText,
|
||||
ctaLink,
|
||||
ctaVariant = 'primary',
|
||||
overlay = true,
|
||||
overlayOpacity,
|
||||
children,
|
||||
className = '',
|
||||
}) => {
|
||||
const hasBackground = !!backgroundImage;
|
||||
const hasCTA = !!ctaText && !!ctaLink;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'relative w-full overflow-hidden flex items-center justify-center',
|
||||
getHeightStyles(height),
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Background Image */}
|
||||
{hasBackground && (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src={backgroundImage}
|
||||
alt={backgroundAlt || title}
|
||||
fill
|
||||
priority
|
||||
className="object-cover"
|
||||
sizes="100vw"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background Variant (if no image) */}
|
||||
{!hasBackground && (
|
||||
<div className={cn(
|
||||
'absolute inset-0 z-0',
|
||||
getVariantStyles(variant)
|
||||
)} />
|
||||
)}
|
||||
|
||||
{/* Overlay */}
|
||||
{overlay && hasBackground && (
|
||||
<div className={cn(
|
||||
'absolute inset-0 z-10',
|
||||
getOverlayOpacity(overlayOpacity)
|
||||
)} />
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-20 w-full">
|
||||
<Container
|
||||
maxWidth="6xl"
|
||||
padding="lg"
|
||||
className={cn(
|
||||
'text-center',
|
||||
// Add padding for full-height heroes
|
||||
height === 'full' && 'py-12 md:py-20'
|
||||
)}
|
||||
>
|
||||
{/* Title */}
|
||||
<h1
|
||||
className={cn(
|
||||
'font-bold leading-tight mb-4',
|
||||
'text-3xl sm:text-4xl md:text-5xl lg:text-6xl',
|
||||
'tracking-tight',
|
||||
// Ensure text contrast
|
||||
hasBackground || variant !== 'default' ? 'text-white' : 'text-gray-900'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
{subtitle && (
|
||||
<p
|
||||
className={cn(
|
||||
'text-lg sm:text-xl md:text-2xl',
|
||||
'mb-8 max-w-3xl mx-auto',
|
||||
'leading-relaxed',
|
||||
hasBackground || variant !== 'default' ? 'text-gray-100' : 'text-gray-600'
|
||||
)}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* CTA Button */}
|
||||
{hasCTA && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant={ctaVariant}
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
if (ctaLink) {
|
||||
// Handle both internal and external links
|
||||
if (ctaLink.startsWith('http')) {
|
||||
window.open(ctaLink, '_blank');
|
||||
} else {
|
||||
// For Next.js routing, you'd use the router
|
||||
// This is a fallback for external links
|
||||
window.location.href = ctaLink;
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="animate-fade-in-up"
|
||||
>
|
||||
{ctaText}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Additional Content */}
|
||||
{children && (
|
||||
<div className="mt-8">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
// Sub-components for more complex hero layouts
|
||||
export const HeroContent: React.FC<{
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}> = ({ title, subtitle, children, className = '' }) => (
|
||||
<div className={cn('space-y-4 md:space-y-6', className)}>
|
||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold">{title}</h2>
|
||||
{subtitle && <p className="text-lg md:text-xl text-gray-200">{subtitle}</p>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const HeroActions: React.FC<{
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}> = ({ children, className = '' }) => (
|
||||
<div className={cn('flex flex-wrap gap-3 justify-center', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Hero;
|
||||
350
components/content/README.md
Normal file
350
components/content/README.md
Normal file
@@ -0,0 +1,350 @@
|
||||
# Content Components
|
||||
|
||||
Modern, component-based content rendering system for WordPress migration to Next.js. These components handle WordPress content display in a responsive, accessible, and SEO-friendly way.
|
||||
|
||||
## Components Overview
|
||||
|
||||
### 1. Hero Component (`Hero.tsx`)
|
||||
Full-width hero section with background image support, overlays, and CTAs.
|
||||
|
||||
**Features:**
|
||||
- Background images with Next.js Image optimization
|
||||
- Text overlay for readability
|
||||
- Multiple height options (sm, md, lg, xl, full)
|
||||
- Optional CTA button
|
||||
- Responsive sizing
|
||||
- Variant support (default, dark, primary, gradient)
|
||||
|
||||
**Usage:**
|
||||
```tsx
|
||||
import { Hero } from '@/components/content';
|
||||
|
||||
<Hero
|
||||
title="Welcome to KLZ Cables"
|
||||
subtitle="High-quality cable solutions for industrial applications"
|
||||
backgroundImage="/media/hero-image.jpg"
|
||||
height="lg"
|
||||
variant="dark"
|
||||
overlay
|
||||
overlayOpacity={0.6}
|
||||
ctaText="Learn More"
|
||||
ctaLink="/products"
|
||||
/>
|
||||
```
|
||||
|
||||
### 2. ContentRenderer Component (`ContentRenderer.tsx`)
|
||||
Sanitizes and renders HTML content from WordPress with class conversion.
|
||||
|
||||
**Features:**
|
||||
- HTML sanitization (removes scripts, styles, dangerous attributes)
|
||||
- WordPress class to Tailwind conversion
|
||||
- Next.js Image component integration
|
||||
- Shortcode processing
|
||||
- Safe HTML parsing
|
||||
- SEO-friendly markup
|
||||
|
||||
**Usage:**
|
||||
```tsx
|
||||
import { ContentRenderer } from '@/components/content';
|
||||
|
||||
<ContentRenderer
|
||||
content={page.contentHtml}
|
||||
sanitize={true}
|
||||
processAssets={true}
|
||||
convertClasses={true}
|
||||
/>
|
||||
```
|
||||
|
||||
**Supported HTML Elements:**
|
||||
- Typography: `<p>`, `<h1-h6>`, `<strong>`, `<em>`, `<small>`
|
||||
- Lists: `<ul>`, `<ol>`, `<li>`
|
||||
- Links: `<a>` (with Next.js Link optimization)
|
||||
- Images: `<img>` (with Next.js Image)
|
||||
- Tables: `<table>`, `<thead>`, `<tbody>`, `<tr>`, `<th>`, `<td>`
|
||||
- Layout: `<div>`, `<span>`, `<section>`, `<article>`, `<figure>`, `<figcaption>`
|
||||
- Code: `<code>`, `<pre>`
|
||||
- Quotes: `<blockquote>`
|
||||
|
||||
### 3. FeaturedImage Component (`FeaturedImage.tsx`)
|
||||
Optimized image display with responsive sizing and lazy loading.
|
||||
|
||||
**Features:**
|
||||
- Next.js Image optimization
|
||||
- Lazy loading
|
||||
- Aspect ratio control
|
||||
- Caption support
|
||||
- Alt text handling
|
||||
- Priority loading option
|
||||
|
||||
**Usage:**
|
||||
```tsx
|
||||
import { FeaturedImage } from '@/components/content';
|
||||
|
||||
<FeaturedImage
|
||||
src="/media/product.jpg"
|
||||
alt="Product image"
|
||||
aspectRatio="16:9"
|
||||
size="lg"
|
||||
caption="Product overview"
|
||||
priority={false}
|
||||
/>
|
||||
```
|
||||
|
||||
**Aspect Ratios:**
|
||||
- `1:1` - Square
|
||||
- `4:3` - Standard
|
||||
- `16:9` - Widescreen
|
||||
- `21:9` - Ultrawide
|
||||
- `auto` - Natural
|
||||
|
||||
**Sizes:**
|
||||
- `sm` - max-w-xs
|
||||
- `md` - max-w-md
|
||||
- `lg` - max-w-lg
|
||||
- `xl` - max-w-xl
|
||||
- `full` - max-w-full
|
||||
|
||||
### 4. Section Component (`Section.tsx`)
|
||||
Wrapper for content sections with background and padding options.
|
||||
|
||||
**Features:**
|
||||
- Background color variants
|
||||
- Padding options
|
||||
- Container integration
|
||||
- Full-width support
|
||||
- Semantic HTML
|
||||
|
||||
**Usage:**
|
||||
```tsx
|
||||
import { Section, SectionHeader, SectionContent } from '@/components/content';
|
||||
|
||||
<Section background="light" padding="xl">
|
||||
<SectionHeader
|
||||
title="Our Products"
|
||||
subtitle="Browse our extensive catalog"
|
||||
align="center"
|
||||
/>
|
||||
<SectionContent>
|
||||
{/* Your content here */}
|
||||
</SectionContent>
|
||||
</Section>
|
||||
```
|
||||
|
||||
**Background Options:**
|
||||
- `default` - White
|
||||
- `light` - Light gray
|
||||
- `dark` - Dark gray with white text
|
||||
- `primary` - Primary color with white text
|
||||
- `secondary` - Secondary color with white text
|
||||
- `gradient` - Gradient from primary to secondary
|
||||
|
||||
**Padding Options:**
|
||||
- `none` - No padding
|
||||
- `sm` - Small
|
||||
- `md` - Medium
|
||||
- `lg` - Large
|
||||
- `xl` - Extra large
|
||||
- `2xl` - Double extra large
|
||||
|
||||
### 5. Breadcrumbs Component (`Breadcrumbs.tsx`)
|
||||
SEO-friendly breadcrumb navigation with schema.org markup.
|
||||
|
||||
**Features:**
|
||||
- Schema.org structured data
|
||||
- Home icon
|
||||
- Responsive (collapses on mobile)
|
||||
- Customizable separators
|
||||
- Multiple variants
|
||||
|
||||
**Usage:**
|
||||
```tsx
|
||||
import { Breadcrumbs } from '@/components/content';
|
||||
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Products', href: '/products' },
|
||||
{ label: 'Cables', href: '/products/cables' },
|
||||
{ label: 'Current Page' }
|
||||
]}
|
||||
separator="/"
|
||||
showHome={true}
|
||||
collapseMobile={true}
|
||||
/>
|
||||
```
|
||||
|
||||
**Variants:**
|
||||
- `BreadcrumbsCompact` - Minimal design
|
||||
- `BreadcrumbsSimple` - Basic navigation
|
||||
|
||||
## Integration with WordPress Data
|
||||
|
||||
All components are designed to work seamlessly with the WordPress data layer:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Hero,
|
||||
Section,
|
||||
ContentRenderer,
|
||||
FeaturedImage,
|
||||
Breadcrumbs
|
||||
} from '@/components/content';
|
||||
import { getPageBySlug, getMediaById } from '@/lib/data';
|
||||
|
||||
export default async function Page({ params }) {
|
||||
const page = getPageBySlug(params.slug, params.locale);
|
||||
const featuredMedia = page.featuredImage ? getMediaById(page.featuredImage) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Home', href: `/${params.locale}` },
|
||||
{ label: page.title }
|
||||
]}
|
||||
/>
|
||||
|
||||
<Hero
|
||||
title={page.title}
|
||||
backgroundImage={featuredMedia?.localPath}
|
||||
height="md"
|
||||
/>
|
||||
|
||||
<Section padding="xl">
|
||||
<ContentRenderer content={page.contentHtml} />
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## WordPress Class Conversion
|
||||
|
||||
The ContentRenderer automatically converts common WordPress/Salient classes to Tailwind:
|
||||
|
||||
| WordPress Class | Tailwind Equivalent |
|
||||
|----------------|---------------------|
|
||||
| `vc_row` | `flex flex-wrap -mx-4` |
|
||||
| `vc_col-md-6` | `w-full md:w-1/2 px-4` |
|
||||
| `vc_col-md-4` | `w-full md:w-1/3 px-4` |
|
||||
| `text-center` | `text-center` |
|
||||
| `bg-light` | `bg-gray-50` |
|
||||
| `btn btn-primary` | `inline-flex items-center justify-center px-4 py-2 rounded-lg font-semibold bg-primary text-white hover:bg-primary-dark` |
|
||||
| `wpb_wrapper` | `space-y-4` |
|
||||
| `accent-color` | `text-primary` |
|
||||
|
||||
## Security Features
|
||||
|
||||
All components include security measures:
|
||||
|
||||
1. **HTML Sanitization**: Removes scripts, styles, and dangerous attributes
|
||||
2. **URL Validation**: Validates and sanitizes all URLs
|
||||
3. **XSS Prevention**: Removes inline event handlers and javascript: URLs
|
||||
4. **Safe Parsing**: Only allows safe HTML elements and attributes
|
||||
5. **Asset Validation**: Validates image sources and dimensions
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
- **Lazy Loading**: Images load only when needed
|
||||
- **Next.js Image**: Automatic optimization and WebP support
|
||||
- **Priority Loading**: Critical images can be preloaded
|
||||
- **Efficient Rendering**: Memoized processing for large content
|
||||
- **Responsive Images**: Proper `sizes` attribute for different viewports
|
||||
|
||||
## Internationalization
|
||||
|
||||
All components support internationalization:
|
||||
|
||||
```tsx
|
||||
<Hero
|
||||
title={t('hero.title')}
|
||||
subtitle={t('hero.subtitle')}
|
||||
ctaText={t('hero.cta')}
|
||||
/>
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Semantic HTML elements
|
||||
- Proper heading hierarchy
|
||||
- Alt text for images
|
||||
- ARIA labels where needed
|
||||
- Keyboard navigation support
|
||||
- Screen reader friendly
|
||||
|
||||
## Customization
|
||||
|
||||
All components accept `className` props for custom styling:
|
||||
|
||||
```tsx
|
||||
<Hero
|
||||
title="Custom Hero"
|
||||
className="custom-hero-class"
|
||||
// ... other props
|
||||
/>
|
||||
```
|
||||
|
||||
## TypeScript Support
|
||||
|
||||
All components include full TypeScript definitions:
|
||||
|
||||
```tsx
|
||||
import { HeroProps, SectionProps } from '@/components/content';
|
||||
|
||||
const heroConfig: HeroProps = {
|
||||
title: 'My Hero',
|
||||
height: 'lg',
|
||||
// TypeScript will guide you through all options
|
||||
};
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always provide alt text** for images
|
||||
2. **Use priority** for above-the-fold images
|
||||
3. **Sanitize content** from untrusted sources
|
||||
4. **Process assets** to ensure local file availability
|
||||
5. **Convert classes** for consistent styling
|
||||
6. **Add breadcrumbs** for SEO and navigation
|
||||
7. **Use semantic sections** for content structure
|
||||
|
||||
## Migration Notes
|
||||
|
||||
When migrating from WordPress:
|
||||
|
||||
1. Export content with `contentHtml` fields
|
||||
2. Download and host media files locally
|
||||
3. Map WordPress URLs to local paths
|
||||
4. Test class conversion for custom themes
|
||||
5. Verify shortcodes are processed or removed
|
||||
6. Check responsive behavior on all devices
|
||||
7. Validate SEO markup (schema.org)
|
||||
|
||||
## Component Composition
|
||||
|
||||
Components can be composed together:
|
||||
|
||||
```tsx
|
||||
<Section background="dark" padding="xl">
|
||||
<div className="text-center text-white">
|
||||
<h2 className="text-3xl font-bold mb-4">Featured Products</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-8">
|
||||
{products.map(product => (
|
||||
<Card key={product.id}>
|
||||
<FeaturedImage
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
aspectRatio="4:3"
|
||||
/>
|
||||
<CardBody>
|
||||
<h3>{product.name}</h3>
|
||||
<ContentRenderer content={product.description} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
```
|
||||
|
||||
This creates a cohesive, modern content system that maintains WordPress flexibility while leveraging Next.js capabilities.
|
||||
170
components/content/Section.tsx
Normal file
170
components/content/Section.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Container } from '../ui/Container';
|
||||
|
||||
// Section background options
|
||||
type SectionBackground = 'default' | 'light' | 'dark' | 'primary' | 'secondary' | 'gradient';
|
||||
|
||||
// Section padding options
|
||||
type SectionPadding = 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
|
||||
interface SectionProps {
|
||||
children: React.ReactNode;
|
||||
background?: SectionBackground;
|
||||
padding?: SectionPadding;
|
||||
fullWidth?: boolean;
|
||||
className?: string;
|
||||
id?: string;
|
||||
as?: React.ElementType;
|
||||
}
|
||||
|
||||
// Helper function to get background styles
|
||||
const getBackgroundStyles = (background: SectionBackground) => {
|
||||
switch (background) {
|
||||
case 'light':
|
||||
return 'bg-gray-50';
|
||||
case 'dark':
|
||||
return 'bg-gray-900 text-white';
|
||||
case 'primary':
|
||||
return 'bg-primary text-white';
|
||||
case 'secondary':
|
||||
return 'bg-secondary text-white';
|
||||
case 'gradient':
|
||||
return 'bg-gradient-to-br from-primary to-secondary text-white';
|
||||
default:
|
||||
return 'bg-white';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get padding styles
|
||||
const getPaddingStyles = (padding: SectionPadding) => {
|
||||
switch (padding) {
|
||||
case 'none':
|
||||
return 'py-0';
|
||||
case 'sm':
|
||||
return 'py-4 sm:py-6';
|
||||
case 'md':
|
||||
return 'py-8 sm:py-12';
|
||||
case 'lg':
|
||||
return 'py-12 sm:py-16';
|
||||
case 'xl':
|
||||
return 'py-16 sm:py-20 md:py-24';
|
||||
case '2xl':
|
||||
return 'py-20 sm:py-24 md:py-32';
|
||||
default:
|
||||
return 'py-12 sm:py-16';
|
||||
}
|
||||
};
|
||||
|
||||
export const Section: React.FC<SectionProps> = ({
|
||||
children,
|
||||
background = 'default',
|
||||
padding = 'md',
|
||||
fullWidth = false,
|
||||
className = '',
|
||||
id,
|
||||
as: Component = 'section',
|
||||
}) => {
|
||||
const sectionClasses = cn(
|
||||
'w-full',
|
||||
getBackgroundStyles(background),
|
||||
getPaddingStyles(padding),
|
||||
className
|
||||
);
|
||||
|
||||
const content = fullWidth ? (
|
||||
<div className={sectionClasses} id={id}>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<section className={sectionClasses} id={id}>
|
||||
<Container maxWidth="6xl" padding="md">
|
||||
{children}
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
|
||||
if (Component !== 'section' && !fullWidth) {
|
||||
return (
|
||||
<Component className={sectionClasses} id={id}>
|
||||
<Container maxWidth="6xl" padding="md">
|
||||
{children}
|
||||
</Container>
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
// Sub-components for common section patterns
|
||||
export const SectionHeader: React.FC<{
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
className?: string;
|
||||
}> = ({ title, subtitle, align = 'center', className = '' }) => {
|
||||
const alignment = {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
}[align];
|
||||
|
||||
return (
|
||||
<div className={cn('mb-8 md:mb-12', alignment, className)}>
|
||||
<h2 className={cn(
|
||||
'text-3xl md:text-4xl font-bold mb-3',
|
||||
'leading-tight tracking-tight'
|
||||
)}>
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className={cn(
|
||||
'text-lg md:text-xl',
|
||||
'max-w-3xl mx-auto',
|
||||
'opacity-90'
|
||||
)}>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SectionContent: React.FC<{
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}> = ({ children, className = '' }) => (
|
||||
<div className={cn('space-y-6 md:space-y-8', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const SectionGrid: React.FC<{
|
||||
children: React.ReactNode;
|
||||
cols?: 1 | 2 | 3 | 4;
|
||||
gap?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
className?: string;
|
||||
}> = ({ children, cols = 3, gap = 'md', className = '' }) => {
|
||||
const gapClasses = {
|
||||
sm: 'gap-4 md:gap-6',
|
||||
md: 'gap-6 md:gap-8',
|
||||
lg: 'gap-8 md:gap-12',
|
||||
xl: 'gap-10 md:gap-16',
|
||||
}[gap];
|
||||
|
||||
const colClasses = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 md:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
|
||||
}[cols];
|
||||
|
||||
return (
|
||||
<div className={cn('grid', colClasses, gapClasses, className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Section;
|
||||
6
components/content/index.ts
Normal file
6
components/content/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Content Components Export
|
||||
export { Hero, HeroContent, HeroActions } from './Hero';
|
||||
export { Section, SectionHeader, SectionContent, SectionGrid } from './Section';
|
||||
export { FeaturedImage, Avatar, ImageGallery } from './FeaturedImage';
|
||||
export { Breadcrumbs, BreadcrumbsCompact, BreadcrumbsSimple } from './Breadcrumbs';
|
||||
export { ContentRenderer, ContentBlock, RichText } from './ContentRenderer';
|
||||
Reference in New Issue
Block a user