2026-04-05 18:31:16 -07:00
import React , { CSSProperties } from 'react' ;
import { useNode , UserComponent } from '@craftjs/core' ;
import { cssPropsToString } from '../../utils/style-helpers' ;
2026-07-12 11:49:10 -07:00
import { escapeHtml , escapeAttr } from '../../utils/escape' ;
2026-04-05 18:31:16 -07:00
interface GalleryImage {
src : string ;
alt : string ;
caption? : string ;
}
interface GalleryProps {
images? : GalleryImage [];
columns? : number ;
gap? : string ;
style? : CSSProperties ;
lightbox? : boolean ;
}
const placeholderSvg = ( index : number ) => {
const colors = [ '#3b82f6' , '#8b5cf6' , '#10b981' , '#f59e0b' , '#ef4444' , '#ec4899' ];
const color = colors [ index % colors . length ];
return `data:image/svg+xml, ${ encodeURIComponent ( `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300"><rect fill=" ${ color } " width="400" height="300" opacity="0.15"/><rect fill=" ${ color } " x="150" y="100" width="100" height="100" rx="12" opacity="0.3"/><text x="200" y="160" text-anchor="middle" font-family="sans-serif" font-size="24" fill=" ${ color } " opacity="0.6"> ${ index + 1 } </text></svg>` ) } ` ;
};
const defaultImages : GalleryImage [] = [
{ src : placeholderSvg ( 0 ), alt : 'Gallery image 1' , caption : 'First image' },
{ src : placeholderSvg ( 1 ), alt : 'Gallery image 2' , caption : 'Second image' },
{ src : placeholderSvg ( 2 ), alt : 'Gallery image 3' , caption : 'Third image' },
{ src : placeholderSvg ( 3 ), alt : 'Gallery image 4' , caption : 'Fourth image' },
{ src : placeholderSvg ( 4 ), alt : 'Gallery image 5' , caption : 'Fifth image' },
{ src : placeholderSvg ( 5 ), alt : 'Gallery image 6' , caption : 'Sixth image' },
];
export const Gallery : UserComponent < GalleryProps > = ({
images = defaultImages ,
columns = 3 ,
gap = '16px' ,
style = {},
lightbox = false ,
}) => {
const {
connectors : { connect , drag },
selected ,
} = useNode (( node ) => ({
selected : node.events.selected ,
}));
return (
< section
ref = {( ref : HTMLElement | null ) : void => { if ( ref ) connect ( drag ( ref )); }}
style = {{
padding : '60px 20px' ,
backgroundColor : '#ffffff' ,
outline : selected ? '2px solid #3b82f6' : 'none' ,
... style ,
}}
>
< div
style = {{
maxWidth : '1100px' ,
margin : '0 auto' ,
display : 'grid' ,
gridTemplateColumns : `repeat( ${ columns } , 1fr)` ,
gap : gap ,
}}
>
{ images . map (( img , i ) => (
< div key = { i } style = {{ position : 'relative' , overflow : 'hidden' , borderRadius : '8px' }}>
< img
src = { img . src }
alt = { img . alt }
style = {{
width : '100%' ,
height : '200px' ,
objectFit : 'cover' ,
display : 'block' ,
borderRadius : '8px' ,
backgroundColor : '#f1f5f9' ,
}}
/>
{ img . caption && (
< div
style = {{
position : 'absolute' ,
bottom : '0' ,
left : '0' ,
right : '0' ,
padding : '8px 12px' ,
background : 'linear-gradient(transparent, rgba(0,0,0,0.7))' ,
color : '#ffffff' ,
fontSize : '12px' ,
borderBottomLeftRadius : '8px' ,
borderBottomRightRadius : '8px' ,
}}
>
{ img . caption }
</ div >
)}
</ div >
))}
</ div >
</ section >
);
};
/* ---------- Settings panel ---------- */
const GallerySettings : React.FC = () => {
const { actions : { setProp }, props } = useNode (( node ) => ({
props : node.data.props as GalleryProps ,
}));
const images = props . images || defaultImages ;
const inputStyle : CSSProperties = {
width : '100%' , padding : '3px 6px' , background : '#27272a' , color : '#e4e4e7' ,
border : '1px solid #3f3f46' , borderRadius : 4 , fontSize : 11 ,
};
const labelStyle : CSSProperties = { fontSize : 11 , color : '#a1a1aa' , display : 'block' , marginBottom : 6 };
const updateImage = ( index : number , field : keyof GalleryImage , value : string ) => {
setProp (( p : GalleryProps ) => {
const updated = [...( p . images || defaultImages )];
updated [ index ] = { ... updated [ index ], [ field ] : value };
p . images = updated ;
});
};
const addImage = () => {
setProp (( p : GalleryProps ) => {
const current = p . images || defaultImages ;
p . images = [... current , { src : placeholderSvg ( current . length ), alt : `Gallery image ${ current . length + 1 } ` , caption : '' }];
});
};
const removeImage = ( index : number ) => {
setProp (( p : GalleryProps ) => {
const updated = [...( p . images || defaultImages )];
updated . splice ( index , 1 );
p . images = updated ;
});
};
const columnOptions = [ 2 , 3 , 4 , 5 , 6 ];
const gapOptions = [ '8px' , '12px' , '16px' , '24px' , '32px' ];
return (
< div style = {{ padding : '12px' , display : 'flex' , flexDirection : 'column' , gap : '14px' }}>
< div >
< label style = { labelStyle }> Background </ label >
< div style = {{ display : 'flex' , gap : 4 , flexWrap : 'wrap' }}>
{[ '#ffffff' , '#f8fafc' , '#f1f5f9' , '#18181b' , '#0f172a' ]. map (( c ) => (
< button
key = { c }
onClick = {() => setProp (( p : GalleryProps ) => { p . style = { ... p . style , backgroundColor : c }; })}
style = {{
width : 24 , height : 24 , borderRadius : 4 , border : '1px solid #3f3f46' ,
backgroundColor : c , cursor : 'pointer' ,
outline : props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none' ,
outlineOffset : 1 ,
}}
/>
))}
</ div >
</ div >
< div >
< label style = { labelStyle }> Columns </ label >
< div style = {{ display : 'flex' , gap : 4 }}>
{ columnOptions . map (( n ) => (
< button
key = { n }
onClick = {() => setProp (( p : GalleryProps ) => { p . columns = n ; })}
style = {{
padding : '4px 10px' , fontSize : 11 , borderRadius : 4 , cursor : 'pointer' ,
border : '1px solid #3f3f46' ,
background : props.columns === n ? '#3b82f6' : '#27272a' ,
color : props.columns === n ? '#fff' : '#e4e4e7' ,
}}
>
{ n }
</ button >
))}
</ div >
</ div >
< div >
< label style = { labelStyle }> Gap </ label >
< div style = {{ display : 'flex' , gap : 4 }}>
{ gapOptions . map (( g ) => (
< button
key = { g }
onClick = {() => setProp (( p : GalleryProps ) => { p . gap = g ; })}
style = {{
padding : '4px 8px' , fontSize : 11 , borderRadius : 4 , cursor : 'pointer' ,
border : '1px solid #3f3f46' ,
background : props.gap === g ? '#3b82f6' : '#27272a' ,
color : props.gap === g ? '#fff' : '#e4e4e7' ,
}}
>
{ g }
</ button >
))}
</ div >
</ div >
< div >
< label style = {{ ... labelStyle , display : 'flex' , alignItems : 'center' , gap : 6 }}>
< input
type = "checkbox"
checked = { props . lightbox || false }
onChange = {( e ) => setProp (( p : GalleryProps ) => { p . lightbox = e . target . checked ; })}
/>
Lightbox ( click to enlarge in exported HTML )
</ label >
</ div >
< div >
< label style = { labelStyle }> Images </ label >
< div style = {{ display : 'flex' , flexDirection : 'column' , gap : 8 }}>
{ images . map (( img , i ) => (
< div key = { i } style = {{ background : '#1e1e22' , borderRadius : 6 , padding : 8 , display : 'flex' , flexDirection : 'column' , gap : 4 }}>
< div style = {{ display : 'flex' , gap : 4 , alignItems : 'center' }}>
< img
src = { img . src }
alt = ""
style = {{ width : 32 , height : 32 , objectFit : 'cover' , borderRadius : 4 , flexShrink : 0 , backgroundColor : '#27272a' }}
/>
< input type = "text" value = { img . src } onChange = {( e ) => updateImage ( i , 'src' , e . target . value )} placeholder = "Image URL" style = {{ ... inputStyle , flex : 1 }} />
< button
onClick = {() => removeImage ( i )}
style = {{ padding : '2px 6px' , fontSize : 11 , background : '#ef4444' , color : '#fff' , border : 'none' , borderRadius : 4 , cursor : 'pointer' }}
>
X
</ button >
</ div >
< div style = {{ display : 'flex' , gap : 4 }}>
< input type = "text" value = { img . alt } onChange = {( e ) => updateImage ( i , 'alt' , e . target . value )} placeholder = "Alt text" style = {{ ... inputStyle , flex : 1 }} />
< input type = "text" value = { img . caption || '' } onChange = {( e ) => updateImage ( i , 'caption' , e . target . value )} placeholder = "Caption" style = {{ ... inputStyle , flex : 1 }} />
</ div >
</ div >
))}
</ div >
< button
onClick = { addImage }
style = {{ marginTop : 6 , width : '100%' , padding : '6px' , fontSize : 11 , background : '#27272a' , color : '#e4e4e7' , border : '1px solid #3f3f46' , borderRadius : 4 , cursor : 'pointer' }}
>
+ Add Image
</ button >
</ div >
</ div >
);
};
/* ---------- Craft config ---------- */
Gallery . craft = {
displayName : 'Gallery' ,
props : {
images : defaultImages ,
columns : 3 ,
gap : '16px' ,
style : { backgroundColor : '#ffffff' },
lightbox : false ,
},
rules : {
canDrag : () => true ,
canMoveIn : () => false ,
canMoveOut : () => true ,
},
related : {
settings : GallerySettings ,
},
};
/* ---------- HTML export ---------- */
( Gallery as any ). toHtml = ( props : GalleryProps , _childrenHtml : string ) => {
const sectionStyle = cssPropsToString ({
padding : '60px 20px' ,
... props . style ,
});
const images = props . images || defaultImages ;
const columns = props . columns || 3 ;
const gap = props . gap || '16px' ;
const lightbox = props . lightbox || false ;
const galleryId = 'gallery_' + Math . random (). toString ( 36 ). slice ( 2 , 8 );
const items = images . map (( img ) => {
const caption = img . caption
2026-07-12 11:49:10 -07:00
? `<div style="position:absolute;bottom:0;left:0;right:0;padding:8px 12px;background:linear-gradient(transparent,rgba(0,0,0,0.7));color:#ffffff;font-size:12px;border-bottom-left-radius:8px;border-bottom-right-radius:8px"> ${ escapeHtml ( img . caption ) } </div>`
2026-04-05 18:31:16 -07:00
: '' ;
2026-07-12 11:49:10 -07:00
const clickAttr = lightbox ? ` onclick=" ${ galleryId } _open(' ${ escapeAttr ( img . src ) } ')" style="cursor:pointer;position:relative;overflow:hidden;border-radius:8px"` : ' style="position:relative;overflow:hidden;border-radius:8px"' ;
2026-04-05 18:31:16 -07:00
return `<div ${ clickAttr } >
2026-07-12 11:49:10 -07:00
<img src=" ${ escapeAttr ( img . src ) } " alt=" ${ escapeAttr ( img . alt ) } " style="width:100%;height:200px;object-fit:cover;display:block;border-radius:8px;background-color:#f1f5f9" />
2026-04-05 18:31:16 -07:00
${ caption }
</div>` ;
}). join ( '\n ' );
let lightboxHtml = '' ;
if ( lightbox ) {
lightboxHtml = `
<div id=" ${ galleryId } _overlay" onclick=" ${ galleryId } _close()" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:9999;justify-content:center;align-items:center;cursor:pointer">
<img id=" ${ galleryId } _img" src="" alt="" style="max-width:90%;max-height:90%;object-fit:contain;border-radius:8px" />
</div>
<script>
function ${ galleryId } _open(src){var o=document.getElementById(' ${ galleryId } _overlay');document.getElementById(' ${ galleryId } _img').src=src;o.style.display='flex';}
function ${ galleryId } _close(){document.getElementById(' ${ galleryId } _overlay').style.display='none';}
</script>` ;
}
return {
html : `<section ${ sectionStyle ? ` style=" ${ sectionStyle } "` : '' } >
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat( ${ columns } ,1fr);gap: ${ gap } ">
${ items }
</div> ${ lightboxHtml }
</section>` ,
};
};