import React, { CSSProperties, useState, useEffect, useRef, useCallback } from 'react'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; import { escapeHtml, escapeAttr, safeUrl, scopeId, cssValue } from '../../utils/escape'; interface Slide { type: 'image' | 'content'; imageSrc?: string; heading?: string; text?: string; buttonText?: string; buttonHref?: string; bgColor?: string; } interface ContentSliderProps { slides?: Slide[]; autoplay?: boolean; interval?: number; showDots?: boolean; showArrows?: boolean; height?: string; style?: CSSProperties; } const defaultSlides: Slide[] = [ { type: 'image', imageSrc: '', heading: 'First Slide', text: 'Welcome to our showcase', buttonText: 'Learn More', buttonHref: '#', bgColor: 'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)', }, { type: 'image', imageSrc: '', heading: 'Second Slide', text: 'Discover something amazing', buttonText: 'Get Started', buttonHref: '#', bgColor: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', }, { type: 'image', imageSrc: '', heading: 'Third Slide', text: 'Build your future today', buttonText: 'Contact Us', buttonHref: '#', bgColor: 'linear-gradient(135deg, #f59e0b 0%, #ef4444 100%)', }, ]; export const ContentSlider: UserComponent = ({ slides = defaultSlides, autoplay = true, interval = 5000, showDots = true, showArrows = true, height = '400px', style = {}, }) => { const { connectors: { connect, drag }, selected, } = useNode((node) => ({ selected: node.events.selected, })); const [activeIndex, setActiveIndex] = useState(0); const timerRef = useRef | null>(null); const items = slides.length > 0 ? slides : defaultSlides; const goTo = useCallback((index: number) => { setActiveIndex(((index % items.length) + items.length) % items.length); }, [items.length]); const goNext = useCallback(() => goTo(activeIndex + 1), [activeIndex, goTo]); const goPrev = useCallback(() => goTo(activeIndex - 1), [activeIndex, goTo]); useEffect(() => { if (autoplay && items.length > 1) { timerRef.current = setInterval(goNext, interval); return () => { if (timerRef.current) clearInterval(timerRef.current); }; } }, [autoplay, interval, goNext, items.length]); const arrowStyle: CSSProperties = { position: 'absolute', top: '50%', transform: 'translateY(-50%)', width: '40px', height: '40px', borderRadius: '50%', border: 'none', background: 'rgba(255,255,255,0.9)', color: '#18181b', fontSize: '16px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 2, boxShadow: '0 2px 8px rgba(0,0,0,0.15)', }; const renderSlide = (slide: Slide, i: number) => { const bg = slide.imageSrc ? { backgroundImage: `url(${slide.imageSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' } : slide.bgColor?.startsWith('linear-gradient') ? { backgroundImage: slide.bgColor } : { backgroundColor: slide.bgColor || '#3b82f6' }; return (
{(slide.heading || slide.text || slide.buttonText) && (
{slide.heading && (

{slide.heading}

)} {slide.text && (

{slide.text}

)} {slide.buttonText && ( e.preventDefault()} style={{ display: 'inline-block', padding: '12px 28px', background: '#ffffff', color: '#18181b', textDecoration: 'none', borderRadius: '8px', fontWeight: '600', fontSize: '15px', fontFamily: 'Inter, sans-serif', }} > {slide.buttonText} )}
)}
); }; return (
{ if (ref) connect(drag(ref)); }} style={{ position: 'relative', width: '100%', height, overflow: 'hidden', outline: selected ? '2px solid #3b82f6' : 'none', ...style, }} > {items.map((slide, i) => renderSlide(slide, i))} {showArrows && items.length > 1 && ( <> )} {showDots && items.length > 1 && (
{items.map((_, i) => (
)}
); }; /* ---------- Craft config ---------- */ ContentSlider.craft = { displayName: 'Content Slider', props: { slides: defaultSlides, autoplay: true, interval: 5000, showDots: true, showArrows: true, height: '400px', style: {}, }, rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true, }, }; /* ---------- HTML export ---------- */ (ContentSlider as any).toHtml = (props: ContentSliderProps, _childrenHtml: string, nodeId?: string) => { const { slides = defaultSlides, autoplay = true, interval = 5000, showDots = true, showArrows = true, height = '400px', style = {}, } = props; const items = slides.length > 0 ? slides : defaultSlides; // Deterministic AND unique id, scoped on the Craft node id, for this // slider's slide/dot element ids and inline-script globals -- so two // ContentSlider instances (e.g. both left at default slides) don't // collide and end up driving each other's rotation. const uid = scopeId(nodeId, JSON.stringify(items) + interval, 'cs'); const sectionStyle = cssPropsToString({ position: 'relative', width: '100%', height, overflow: 'hidden', ...style, }); const slidesHtml = items.map((slide, i) => { const hasBgImage = slide.imageSrc; // Sanitized -- slide.bgColor is a per-slide raw string-interpolation // sink (a malicious value could break out of the style="..." attribute). const safeBgColor = cssValue(slide.bgColor) || '#3b82f6'; const bgStyle = hasBgImage ? `background-image:url('${escapeAttr(safeUrl(slide.imageSrc!))}');background-size:cover;background-position:center` : slide.bgColor?.startsWith('linear-gradient') ? `background-image:${safeBgColor}` : `background-color:${safeBgColor}`; const contentParts: string[] = []; if (slide.heading) { contentParts.push(`

${escapeHtml(slide.heading)}

`); } if (slide.text) { contentParts.push(`

${escapeHtml(slide.text)}

`); } if (slide.buttonText) { contentParts.push(`${escapeHtml(slide.buttonText)}`); } const innerHtml = contentParts.length > 0 ? `
${contentParts.join('\n ')}
` : ''; return `
${innerHtml}
`; }).join('\n '); const arrowsHtml = showArrows && items.length > 1 ? ` ` : ''; const dotsHtml = showDots && items.length > 1 ? `
${items.map((_, i) => ``).join('\n ')}
` : ''; // Autoplay ticks call show() directly (internal), while manual nav goes // through the exposed window[...] functions -- that split lets us mark // the live region "polite" only on manual navigation, and keep it "off" // while autoplay is silently auto-rotating, so screen readers aren't // spammed with an announcement every `interval` ms (F-export review // Minor). const autoplayActive = autoplay && items.length > 1; const liveAttr = autoplayActive ? 'off' : 'polite'; return { html: `
${slidesHtml}
${arrowsHtml} ${dotsHtml}
`, }; };