Files
site-builder/craft/src/components/basic/SocialLinks.tsx
T

221 lines
6.3 KiB
TypeScript
Raw Normal View History

import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl, cssValue } from '../../utils/escape';
interface SocialLink {
platform: string;
url: string;
}
interface SocialLinksProps {
links?: SocialLink[];
iconSize?: string;
iconColor?: string;
iconBgColor?: string;
iconShape?: 'none' | 'circle' | 'square' | 'rounded';
gap?: string;
alignment?: 'left' | 'center' | 'right';
style?: CSSProperties;
}
const platformIcons: Record<string, string> = {
facebook: 'fa-facebook',
twitter: 'fa-twitter',
instagram: 'fa-instagram',
linkedin: 'fa-linkedin',
youtube: 'fa-youtube',
github: 'fa-github',
tiktok: 'fa-music',
pinterest: 'fa-pinterest',
snapchat: 'fa-snapchat',
whatsapp: 'fa-whatsapp',
spotify: 'fa-spotify',
twitch: 'fa-twitch',
};
const platformLabels: Record<string, string> = {
facebook: 'Facebook',
twitter: 'Twitter / X',
instagram: 'Instagram',
linkedin: 'LinkedIn',
youtube: 'YouTube',
github: 'GitHub',
tiktok: 'TikTok',
pinterest: 'Pinterest',
snapchat: 'Snapchat',
whatsapp: 'WhatsApp',
spotify: 'Spotify',
twitch: 'Twitch',
};
const defaultLinks: SocialLink[] = [
{ platform: 'facebook', url: '#' },
{ platform: 'twitter', url: '#' },
{ platform: 'instagram', url: '#' },
{ platform: 'linkedin', url: '#' },
];
const getShapeStyle = (shape: string, size: string): CSSProperties => {
if (shape === 'none') return {};
const numSize = parseInt(size) || 24;
const boxSize = `${numSize + 16}px`;
const base: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: boxSize,
height: boxSize,
};
if (shape === 'circle') return { ...base, borderRadius: '50%' };
if (shape === 'square') return { ...base, borderRadius: '0' };
if (shape === 'rounded') return { ...base, borderRadius: '6px' };
return base;
};
const alignMap: Record<string, string> = {
left: 'flex-start',
center: 'center',
right: 'flex-end',
};
export const SocialLinks: UserComponent<SocialLinksProps> = ({
links = defaultLinks,
iconSize = '20px',
iconColor = '#ffffff',
iconBgColor = '#374151',
iconShape = 'circle',
gap = '10px',
alignment = 'center',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<div
ref={(ref: HTMLDivElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
display: 'flex',
flexWrap: 'wrap',
gap,
justifyContent: alignMap[alignment] || 'center',
alignItems: 'center',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
{links.map((link, i) => {
const iconClass = platformIcons[link.platform] || 'fa-link';
const shapeStyle = getShapeStyle(iconShape, iconSize);
const hasBg = iconShape !== 'none';
return (
<a
key={i}
href={link.url || '#'}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.preventDefault()}
title={platformLabels[link.platform] || link.platform}
style={{
textDecoration: 'none',
color: iconColor,
backgroundColor: hasBg ? iconBgColor : 'transparent',
transition: 'opacity 0.2s',
...shapeStyle,
}}
>
<i className={`fa ${iconClass}`} style={{ fontSize: iconSize }} />
</a>
);
})}
</div>
);
};
/* ---------- Craft config ---------- */
SocialLinks.craft = {
displayName: 'Social Links',
props: {
links: defaultLinks,
iconSize: '20px',
iconColor: '#ffffff',
iconBgColor: '#374151',
iconShape: 'circle',
gap: '10px',
alignment: 'center',
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
};
/* ---------- HTML export ---------- */
(SocialLinks as any).toHtml = (props: SocialLinksProps, _childrenHtml: string) => {
const links = props.links || defaultLinks;
// Sanitized -- raw string-interpolation sinks in aStyle/getShapeStr below.
const iconSize = cssValue(props.iconSize) || '20px';
const iconColor = cssValue(props.iconColor) || '#ffffff';
const iconBgColor = cssValue(props.iconBgColor) || '#374151';
const iconShape = props.iconShape || 'circle';
const gap = props.gap || '10px';
const alignment = props.alignment || 'center';
const hasBg = iconShape !== 'none';
const wrapperStyle = cssPropsToString({
display: 'flex',
flexWrap: 'wrap',
gap,
justifyContent: alignMap[alignment] || 'center',
alignItems: 'center',
...props.style,
});
const numSize = parseInt(iconSize) || 20;
const boxSize = `${numSize + 16}px`;
const getShapeStr = (): string => {
const parts: string[] = [
`display:inline-flex`,
`align-items:center`,
`justify-content:center`,
`width:${boxSize}`,
`height:${boxSize}`,
];
if (iconShape === 'circle') parts.push('border-radius:50%');
else if (iconShape === 'square') parts.push('border-radius:0');
else if (iconShape === 'rounded') parts.push('border-radius:6px');
return parts.join(';');
};
const linksHtml = links.map((link) => {
const iconClass = platformIcons[link.platform] || 'fa-link';
const title = platformLabels[link.platform] || link.platform;
let aStyle = `text-decoration:none;color:${iconColor};background-color:${hasBg ? iconBgColor : 'transparent'}`;
if (hasBg) {
aStyle += `;${getShapeStr()}`;
}
// The link's only content is the icon glyph, so the glyph itself is
// aria-hidden and the accessible name lives on the link (aria-label,
// mirroring the existing `title` tooltip since title support in
// screen readers is inconsistent).
return `<a href="${escapeAttr(safeUrl(link.url || '#'))}" target="_blank" rel="noopener noreferrer" title="${escapeAttr(title)}" aria-label="${escapeAttr(title)}" style="${aStyle}"><i class="fa ${escapeAttr(iconClass)}" style="font-size:${iconSize}" aria-hidden="true"></i></a>`;
}).join('\n ');
return {
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>
${linksHtml}
</div>`,
};
};