import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from '../layout/Container';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl, safeImageUrl } from '../../utils/escape';
/* ---------- Types ---------- */
type VideoType = 'youtube' | 'vimeo' | 'file' | 'none';
interface VideoBlockProps {
videoUrl?: string;
videoType?: VideoType;
embedUrl?: string;
poster?: string;
autoplay?: boolean;
muted?: boolean;
loop?: boolean;
controls?: boolean;
isBackground?: boolean;
overlayColor?: string;
overlayOpacity?: number;
innerMaxWidth?: string;
style?: CSSProperties;
children?: React.ReactNode;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
/* ---------- URL detection ---------- */
/**
* Extract a YouTube video ID from any of the URL shapes WHP users paste:
* youtu.be/ID, youtube.com/embed/ID, youtube.com/shorts/ID,
* youtube.com/live/ID (path-based), and youtube.com/watch?...v=ID where `v`
* may appear anywhere in the query string (not just as the first param).
*/
function extractYouTubeId(url: string): string | null {
const pathMatch = url.match(
/(?:youtube\.com\/(?:embed|shorts|live)\/|youtu\.be\/)([a-zA-Z0-9_-]+)/
);
if (pathMatch) return pathMatch[1];
const queryMatch = url.match(/youtube\.com\/watch\?([^\s#]+)/);
if (queryMatch) {
const v = new URLSearchParams(queryMatch[1]).get('v');
if (v) return v;
}
return null;
}
function detectVideoType(url: string): { type: VideoType; embedUrl: string } {
if (!url) return { type: 'none', embedUrl: '' };
// YouTube
const ytId = extractYouTubeId(url);
if (ytId) return { type: 'youtube', embedUrl: `https://www.youtube.com/embed/${ytId}?rel=0` };
// Vimeo: vimeo.com/ID, or vimeo.com/ID/HASH for unlisted/private videos
// (the hash becomes the player's `h` query param).
const vmMatch = url.match(/vimeo\.com\/(\d+)(?:\/([a-zA-Z0-9]+))?/);
if (vmMatch) {
const embedUrl = vmMatch[2]
? `https://player.vimeo.com/video/${vmMatch[1]}?h=${vmMatch[2]}`
: `https://player.vimeo.com/video/${vmMatch[1]}`;
return { type: 'vimeo', embedUrl };
}
// Direct file
if (url.match(/\.(mp4|webm|ogg|mov)(\?|$)/i)) return { type: 'file', embedUrl: url };
// Uploaded asset (proxy URL)
if (url.includes('assets-proxy') || url.includes('serve_asset')) return { type: 'file', embedUrl: url };
return { type: 'none', embedUrl: url };
}
/** Build embed params for YouTube/Vimeo iframes */
function buildEmbedParams(
baseUrl: string,
opts: { autoplay?: boolean; muted?: boolean; loop?: boolean; controls?: boolean }
): string {
const url = new URL(baseUrl);
if (opts.autoplay) url.searchParams.set('autoplay', '1');
if (opts.muted) url.searchParams.set('mute', '1');
if (opts.loop) url.searchParams.set('loop', '1');
if (opts.controls === false) url.searchParams.set('controls', '0');
return url.toString();
}
/* ---------- Placeholder ---------- */
const VIDEO_PLACEHOLDER = (
Add a video URL in settings
);
/* ========================================================================
Normal (non-background) Video Component
======================================================================== */
export const VideoBlock: UserComponent = ({
videoUrl = '',
videoType: _videoTypeProp,
embedUrl: _embedUrlProp,
poster = '',
autoplay = false,
muted = true,
loop = false,
controls = true,
isBackground = false,
overlayColor = '#000000',
overlayOpacity = 50,
innerMaxWidth = '1200px',
style = {},
}) => {
const {
connectors: { connect, drag },
} = useNode();
// Detect type from URL
const { type, embedUrl } = videoUrl ? detectVideoType(videoUrl) : { type: 'none' as VideoType, embedUrl: '' };
/* ---- Background mode ---- */
if (isBackground) {
return (
{
if (ref) connect(drag(ref));
}}
style={{
position: 'relative',
width: '100%',
minHeight: '300px',
overflow: 'hidden',
...style,
}}
>
{/* Background video layer */}
{type === 'file' && embedUrl && (
)}
{(type === 'youtube' || type === 'vimeo') && embedUrl && (
)}
{type === 'none' && (
)}
{/* Overlay */}
{/* Content drop zone */}
);
}
/* ---- Normal mode ---- */
return (
{
if (ref) connect(drag(ref));
}}
style={{
width: '100%',
...style,
}}
>
{type === 'none' && VIDEO_PLACEHOLDER}
{(type === 'youtube' || type === 'vimeo') && (
)}
{type === 'file' && (
)}
);
};
/* ========================================================================
Craft Config
======================================================================== */
VideoBlock.craft = {
displayName: 'Video',
props: {
videoUrl: '',
videoType: 'none',
embedUrl: '',
poster: '',
autoplay: false,
muted: true,
loop: false,
controls: true,
isBackground: false,
overlayColor: '#000000',
overlayOpacity: 50,
innerMaxWidth: '1200px',
style: {
width: '',
aspectRatio: '',
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
},
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
canMoveIn: () => true,
canMoveOut: () => true,
},
};
/* ========================================================================
HTML Export
======================================================================== */
(VideoBlock as any).toHtml = (props: VideoBlockProps, childrenHtml: string) => {
const {
videoUrl = '',
poster = '',
autoplay = false,
muted = true,
loop: doLoop = false,
controls = true,
isBackground = false,
overlayColor = '#000000',
overlayOpacity = 50,
innerMaxWidth = '1200px',
style = {},
} = props;
const { type, embedUrl } = videoUrl ? detectVideoType(videoUrl) : { type: 'none' as VideoType, embedUrl: '' };
/* ---- Background mode export ---- */
if (isBackground) {
const outerStyle = cssPropsToString({
position: 'relative',
width: '100%',
minHeight: '300px',
overflow: 'hidden',
...style,
});
const overlayStyle = cssPropsToString({
position: 'absolute',
inset: '0',
backgroundColor: overlayColor,
opacity: String(overlayOpacity / 100),
zIndex: '1',
pointerEvents: 'none',
});
const innerStyle = cssPropsToString({
position: 'relative',
zIndex: '2',
maxWidth: innerMaxWidth,
margin: '0 auto',
padding: '80px 20px',
});
let videoHtml = '';
if (type === 'file' && embedUrl) {
const vidStyle = cssPropsToString({
position: 'absolute',
top: '50%',
left: '50%',
minWidth: '100%',
minHeight: '100%',
width: 'auto',
height: 'auto',
transform: 'translate(-50%, -50%)',
objectFit: 'cover',
zIndex: '0',
pointerEvents: 'none',
});
videoHtml = ``;
} else if ((type === 'youtube' || type === 'vimeo') && embedUrl) {
const iframeSrc = buildEmbedParams(embedUrl, { autoplay: true, muted: true, loop: true, controls: false });
const ifrStyle = cssPropsToString({
position: 'absolute',
top: '50%',
left: '50%',
width: '177.78vh',
height: '100vh',
minWidth: '100%',
minHeight: '100%',
transform: 'translate(-50%, -50%)',
border: 'none',
zIndex: '0',
pointerEvents: 'none',
});
videoHtml = ``;
}
return {
html: `${videoHtml}${childrenHtml}
`,
};
}
/* ---- Normal mode export ---- */
const wrapperStyle = cssPropsToString({ width: '100%', ...style });
if (type === 'none' || !embedUrl) {
return { html: '' };
}
if (type === 'youtube' || type === 'vimeo') {
const iframeSrc = buildEmbedParams(embedUrl, { autoplay, muted, loop: doLoop, controls });
const containerStyle = cssPropsToString({
position: 'relative',
aspectRatio: (style as any)?.aspectRatio || '16 / 9',
overflow: 'hidden',
borderRadius: (style as any)?.borderRadius || undefined,
});
const iframeStyle = cssPropsToString({
position: 'absolute',
inset: '0',
width: '100%',
height: '100%',
border: 'none',
});
return {
html: ``,
};
}
// Direct file
const vidAttrs: string[] = [];
if (autoplay) vidAttrs.push('autoplay');
if (muted) vidAttrs.push('muted');
if (doLoop) vidAttrs.push('loop');
if (controls) vidAttrs.push('controls');
vidAttrs.push('playsinline');
const posterAttr = poster ? ` poster="${escapeAttr(safeImageUrl(poster))}"` : '';
const vidStyle = cssPropsToString({
display: 'block',
width: '100%',
aspectRatio: (style as any)?.aspectRatio || undefined,
objectFit: 'cover',
borderRadius: (style as any)?.borderRadius || undefined,
});
return {
html: ``,
};
};