2026-04-05 18:31:16 -07:00
|
|
|
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
|
|
|
|
|
import { useNode, Element, UserComponent } from '@craftjs/core';
|
|
|
|
|
import { Container } from '../layout/Container';
|
|
|
|
|
import { cssPropsToString } from '../../utils/style-helpers';
|
2026-07-12 12:05:57 -07:00
|
|
|
import { escapeAttr, safeUrl } from '../../utils/escape';
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
/* ---------- Types ---------- */
|
|
|
|
|
|
|
|
|
|
type VideoType = 'youtube' | 'vimeo' | 'file' | 'none';
|
|
|
|
|
|
|
|
|
|
interface VideoBlockProps {
|
|
|
|
|
videoUrl?: string;
|
|
|
|
|
videoType?: VideoType;
|
|
|
|
|
embedUrl?: string;
|
|
|
|
|
autoplay?: boolean;
|
|
|
|
|
muted?: boolean;
|
|
|
|
|
loop?: boolean;
|
|
|
|
|
controls?: boolean;
|
|
|
|
|
isBackground?: boolean;
|
|
|
|
|
overlayColor?: string;
|
|
|
|
|
overlayOpacity?: number;
|
|
|
|
|
innerMaxWidth?: string;
|
|
|
|
|
style?: CSSProperties;
|
|
|
|
|
children?: React.ReactNode;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------- URL detection ---------- */
|
|
|
|
|
|
|
|
|
|
function detectVideoType(url: string): { type: VideoType; embedUrl: string } {
|
|
|
|
|
if (!url) return { type: 'none', embedUrl: '' };
|
|
|
|
|
|
|
|
|
|
// YouTube
|
|
|
|
|
const ytMatch = url.match(
|
|
|
|
|
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]+)/
|
|
|
|
|
);
|
|
|
|
|
if (ytMatch) return { type: 'youtube', embedUrl: `https://www.youtube.com/embed/${ytMatch[1]}?rel=0` };
|
|
|
|
|
|
|
|
|
|
// Vimeo
|
|
|
|
|
const vmMatch = url.match(/vimeo\.com\/(\d+)/);
|
|
|
|
|
if (vmMatch) return { type: 'vimeo', embedUrl: `https://player.vimeo.com/video/${vmMatch[1]}` };
|
|
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------- Upload helper ---------- */
|
|
|
|
|
|
|
|
|
|
async function uploadToWhp(file: File): Promise<string | null> {
|
|
|
|
|
const cfg = (window as any).WHP_CONFIG;
|
|
|
|
|
if (!cfg) return URL.createObjectURL(file);
|
|
|
|
|
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
formData.append('file', file);
|
|
|
|
|
try {
|
|
|
|
|
const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'X-CSRF-Token': cfg.csrfToken },
|
|
|
|
|
body: formData,
|
|
|
|
|
});
|
|
|
|
|
const data = await resp.json();
|
|
|
|
|
if (data.success && data.url) return data.url;
|
|
|
|
|
return null;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------- Placeholder ---------- */
|
|
|
|
|
|
|
|
|
|
const VIDEO_PLACEHOLDER = (
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
gap: 8,
|
|
|
|
|
width: '100%',
|
|
|
|
|
aspectRatio: '16 / 9',
|
|
|
|
|
background: '#27272a',
|
|
|
|
|
borderRadius: 8,
|
|
|
|
|
border: '2px dashed #3f3f46',
|
|
|
|
|
color: '#71717a',
|
|
|
|
|
fontFamily: 'sans-serif',
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
textAlign: 'center' as const,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<i className="fa fa-play-circle" style={{ fontSize: 36, opacity: 0.5 }} />
|
|
|
|
|
<span>Add a video URL in settings</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/* ========================================================================
|
|
|
|
|
Normal (non-background) Video Component
|
|
|
|
|
======================================================================== */
|
|
|
|
|
|
|
|
|
|
export const VideoBlock: UserComponent<VideoBlockProps> = ({
|
|
|
|
|
videoUrl = '',
|
|
|
|
|
videoType: _videoTypeProp,
|
|
|
|
|
embedUrl: _embedUrlProp,
|
|
|
|
|
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 (
|
|
|
|
|
<section
|
|
|
|
|
ref={(ref: HTMLElement | null): void => {
|
|
|
|
|
if (ref) connect(drag(ref));
|
|
|
|
|
}}
|
|
|
|
|
style={{
|
|
|
|
|
position: 'relative',
|
|
|
|
|
width: '100%',
|
|
|
|
|
minHeight: '300px',
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
...style,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{/* Background video layer */}
|
|
|
|
|
{type === 'file' && embedUrl && (
|
|
|
|
|
<video
|
|
|
|
|
src={embedUrl}
|
|
|
|
|
autoPlay
|
|
|
|
|
muted
|
|
|
|
|
loop
|
|
|
|
|
playsInline
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
top: '50%',
|
|
|
|
|
left: '50%',
|
|
|
|
|
minWidth: '100%',
|
|
|
|
|
minHeight: '100%',
|
|
|
|
|
width: 'auto',
|
|
|
|
|
height: 'auto',
|
|
|
|
|
transform: 'translate(-50%, -50%)',
|
|
|
|
|
objectFit: 'cover',
|
|
|
|
|
zIndex: 0,
|
|
|
|
|
pointerEvents: 'none',
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{(type === 'youtube' || type === 'vimeo') && embedUrl && (
|
|
|
|
|
<iframe
|
|
|
|
|
src={buildEmbedParams(embedUrl, { autoplay: true, muted: true, loop: true, controls: false })}
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
top: '50%',
|
|
|
|
|
left: '50%',
|
|
|
|
|
width: '177.78vh', // 16:9 ratio overflow
|
|
|
|
|
height: '100vh',
|
|
|
|
|
minWidth: '100%',
|
|
|
|
|
minHeight: '100%',
|
|
|
|
|
transform: 'translate(-50%, -50%)',
|
|
|
|
|
border: 'none',
|
|
|
|
|
zIndex: 0,
|
|
|
|
|
pointerEvents: 'none',
|
|
|
|
|
}}
|
|
|
|
|
allow="autoplay; encrypted-media"
|
|
|
|
|
allowFullScreen
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{type === 'none' && (
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
inset: 0,
|
|
|
|
|
background: '#1e293b',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
color: '#71717a',
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
fontFamily: 'sans-serif',
|
|
|
|
|
zIndex: 0,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<i className="fa fa-film" style={{ fontSize: 48, opacity: 0.3 }} />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{/* Overlay */}
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
inset: 0,
|
|
|
|
|
backgroundColor: overlayColor,
|
|
|
|
|
opacity: (overlayOpacity ?? 50) / 100,
|
|
|
|
|
zIndex: 1,
|
|
|
|
|
pointerEvents: 'none',
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
{/* Content drop zone */}
|
|
|
|
|
<Element
|
|
|
|
|
id="video-bg-inner"
|
|
|
|
|
is={Container}
|
|
|
|
|
canvas
|
|
|
|
|
style={{
|
|
|
|
|
position: 'relative',
|
|
|
|
|
zIndex: 2,
|
|
|
|
|
maxWidth: innerMaxWidth,
|
|
|
|
|
margin: '0 auto',
|
|
|
|
|
padding: '80px 20px',
|
|
|
|
|
}}
|
|
|
|
|
tag="div"
|
|
|
|
|
/>
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---- Normal mode ---- */
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
ref={(ref: HTMLDivElement | null): void => {
|
|
|
|
|
if (ref) connect(drag(ref));
|
|
|
|
|
}}
|
|
|
|
|
style={{
|
|
|
|
|
width: '100%',
|
|
|
|
|
...style,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{type === 'none' && VIDEO_PLACEHOLDER}
|
|
|
|
|
|
|
|
|
|
{(type === 'youtube' || type === 'vimeo') && (
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
position: 'relative',
|
|
|
|
|
paddingBottom: '56.25%',
|
|
|
|
|
height: 0,
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
borderRadius: (style as any)?.borderRadius || undefined,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<iframe
|
|
|
|
|
src={buildEmbedParams(embedUrl, { autoplay, muted, loop, controls })}
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
top: 0,
|
|
|
|
|
left: 0,
|
|
|
|
|
width: '100%',
|
|
|
|
|
height: '100%',
|
|
|
|
|
border: 'none',
|
|
|
|
|
}}
|
|
|
|
|
allow="autoplay; encrypted-media; picture-in-picture"
|
|
|
|
|
allowFullScreen
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{type === 'file' && (
|
|
|
|
|
<video
|
|
|
|
|
src={embedUrl}
|
|
|
|
|
autoPlay={autoplay}
|
|
|
|
|
muted={muted}
|
|
|
|
|
loop={loop}
|
|
|
|
|
controls={controls}
|
|
|
|
|
playsInline
|
|
|
|
|
style={{
|
|
|
|
|
display: 'block',
|
|
|
|
|
width: '100%',
|
|
|
|
|
borderRadius: (style as any)?.borderRadius || undefined,
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ========================================================================
|
|
|
|
|
Settings Panel
|
|
|
|
|
======================================================================== */
|
|
|
|
|
|
|
|
|
|
const VideoBlockSettings: React.FC = () => {
|
|
|
|
|
const {
|
|
|
|
|
actions: { setProp },
|
|
|
|
|
props,
|
|
|
|
|
} = useNode((node) => ({
|
|
|
|
|
props: node.data.props as VideoBlockProps,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const [urlInput, setUrlInput] = useState(props.videoUrl || '');
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
|
|
|
|
const detected = props.videoUrl ? detectVideoType(props.videoUrl) : { type: 'none' as VideoType, embedUrl: '' };
|
|
|
|
|
|
|
|
|
|
const applyUrl = useCallback(
|
|
|
|
|
(url: string) => {
|
|
|
|
|
const info = detectVideoType(url);
|
|
|
|
|
setProp((p: VideoBlockProps) => {
|
|
|
|
|
p.videoUrl = url;
|
|
|
|
|
p.videoType = info.type;
|
|
|
|
|
p.embedUrl = info.embedUrl;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[setProp]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handleUpload = useCallback(
|
|
|
|
|
async (file: File) => {
|
|
|
|
|
const url = await uploadToWhp(file);
|
|
|
|
|
if (url) {
|
|
|
|
|
setUrlInput(url);
|
|
|
|
|
applyUrl(url);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[applyUrl]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handleBrowse = useCallback(async () => {
|
|
|
|
|
const cfg = (window as any).WHP_CONFIG;
|
|
|
|
|
if (!cfg) return;
|
|
|
|
|
try {
|
|
|
|
|
const resp = await fetch(`${cfg.apiUrl}?action=list_assets&site_id=${cfg.siteId}`);
|
|
|
|
|
const data = await resp.json();
|
|
|
|
|
if (data.success && Array.isArray(data.assets)) {
|
|
|
|
|
const videos = data.assets.filter(
|
|
|
|
|
(a: any) => (a.type || '').startsWith('video') || (a.name || '').match(/\.(mp4|webm|ogg|mov)$/i)
|
|
|
|
|
);
|
|
|
|
|
if (videos.length === 0) {
|
|
|
|
|
alert('No video assets uploaded yet. Use the Upload button to add one.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const names = videos.map((a: any, i: number) => `${i + 1}. ${a.name}`).join('\n');
|
|
|
|
|
const choice = prompt(`Select a video (enter number):\n\n${names}`);
|
|
|
|
|
if (choice) {
|
|
|
|
|
const idx = parseInt(choice, 10) - 1;
|
|
|
|
|
if (videos[idx]) {
|
|
|
|
|
setUrlInput(videos[idx].url);
|
|
|
|
|
applyUrl(videos[idx].url);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Browse failed:', e);
|
|
|
|
|
}
|
|
|
|
|
}, [applyUrl]);
|
|
|
|
|
|
|
|
|
|
const typeBadge = (label: string, color: string) => (
|
|
|
|
|
<span
|
|
|
|
|
style={{
|
|
|
|
|
display: 'inline-block',
|
|
|
|
|
padding: '2px 8px',
|
|
|
|
|
borderRadius: 4,
|
|
|
|
|
background: color,
|
|
|
|
|
color: '#fff',
|
|
|
|
|
fontSize: 10,
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
textTransform: 'uppercase',
|
|
|
|
|
letterSpacing: '0.05em',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{label}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const overlayPresets = ['#000000', '#1e293b', '#0f172a', '#312e81', '#064e3b', '#7f1d1d'];
|
|
|
|
|
|
|
|
|
|
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
|
|
|
|
|
const inputStyle: CSSProperties = {
|
|
|
|
|
width: '100%',
|
|
|
|
|
padding: '4px 8px',
|
|
|
|
|
background: '#27272a',
|
|
|
|
|
color: '#e4e4e7',
|
|
|
|
|
border: '1px solid #3f3f46',
|
|
|
|
|
borderRadius: 4,
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
};
|
|
|
|
|
const checkboxRowStyle: CSSProperties = {
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
gap: 6,
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
color: '#e4e4e7',
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
|
|
|
{/* Video URL */}
|
|
|
|
|
<div>
|
|
|
|
|
<label style={labelStyle}>Video URL</label>
|
|
|
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={urlInput}
|
|
|
|
|
onChange={(e) => setUrlInput(e.target.value)}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === 'Enter') applyUrl(urlInput);
|
|
|
|
|
}}
|
|
|
|
|
placeholder="YouTube, Vimeo, or direct video URL..."
|
|
|
|
|
style={{ ...inputStyle, flex: 1 }}
|
|
|
|
|
/>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => applyUrl(urlInput)}
|
|
|
|
|
style={{
|
|
|
|
|
padding: '4px 12px',
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
borderRadius: 4,
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
border: '1px solid #3f3f46',
|
|
|
|
|
background: '#3b82f6',
|
|
|
|
|
color: '#fff',
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Apply
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Detected type badge */}
|
|
|
|
|
{detected.type !== 'none' && (
|
|
|
|
|
<div style={{ marginTop: 6 }}>
|
|
|
|
|
{detected.type === 'youtube' && typeBadge('YouTube', '#dc2626')}
|
|
|
|
|
{detected.type === 'vimeo' && typeBadge('Vimeo', '#1ab7ea')}
|
|
|
|
|
{detected.type === 'file' && typeBadge('Video File', '#16a34a')}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Upload / Browse */}
|
|
|
|
|
<div>
|
|
|
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => fileInputRef.current?.click()}
|
|
|
|
|
style={{
|
|
|
|
|
flex: 1,
|
|
|
|
|
padding: '8px 10px',
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
borderRadius: 4,
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
border: '1px solid #3f3f46',
|
|
|
|
|
background: '#3b82f6',
|
|
|
|
|
color: '#fff',
|
|
|
|
|
fontWeight: 500,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleBrowse}
|
|
|
|
|
style={{
|
|
|
|
|
flex: 1,
|
|
|
|
|
padding: '8px 10px',
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
borderRadius: 4,
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
border: '1px solid #3f3f46',
|
|
|
|
|
background: '#27272a',
|
|
|
|
|
color: '#e4e4e7',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<i className="fa fa-folder-open" style={{ marginRight: 4 }} /> Browse
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<input
|
|
|
|
|
ref={fileInputRef}
|
|
|
|
|
type="file"
|
|
|
|
|
accept="video/*"
|
|
|
|
|
style={{ display: 'none' }}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const file = e.target.files?.[0];
|
|
|
|
|
if (file) handleUpload(file);
|
|
|
|
|
e.target.value = '';
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Playback options */}
|
|
|
|
|
<div>
|
|
|
|
|
<label style={labelStyle}>Playback</label>
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
|
|
|
<label style={checkboxRowStyle}>
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={props.autoplay ?? false}
|
|
|
|
|
onChange={(e) => setProp((p: VideoBlockProps) => { p.autoplay = e.target.checked; })}
|
|
|
|
|
/>
|
|
|
|
|
Autoplay
|
|
|
|
|
</label>
|
|
|
|
|
<label style={checkboxRowStyle}>
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={props.muted ?? true}
|
|
|
|
|
onChange={(e) => setProp((p: VideoBlockProps) => { p.muted = e.target.checked; })}
|
|
|
|
|
/>
|
|
|
|
|
Muted
|
|
|
|
|
</label>
|
|
|
|
|
<label style={checkboxRowStyle}>
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={props.loop ?? false}
|
|
|
|
|
onChange={(e) => setProp((p: VideoBlockProps) => { p.loop = e.target.checked; })}
|
|
|
|
|
/>
|
|
|
|
|
Loop
|
|
|
|
|
</label>
|
|
|
|
|
<label style={checkboxRowStyle}>
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={props.controls ?? true}
|
|
|
|
|
onChange={(e) => setProp((p: VideoBlockProps) => { p.controls = e.target.checked; })}
|
|
|
|
|
/>
|
|
|
|
|
Show Controls
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Mode toggle */}
|
|
|
|
|
<div>
|
|
|
|
|
<label style={labelStyle}>Display Mode</label>
|
|
|
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setProp((p: VideoBlockProps) => { p.isBackground = false; })}
|
|
|
|
|
style={{
|
|
|
|
|
flex: 1,
|
|
|
|
|
padding: '6px 10px',
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
borderRadius: 4,
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
border: '1px solid #3f3f46',
|
|
|
|
|
background: !props.isBackground ? '#3b82f6' : '#27272a',
|
|
|
|
|
color: !props.isBackground ? '#fff' : '#a1a1aa',
|
|
|
|
|
fontWeight: 500,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Normal
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setProp((p: VideoBlockProps) => { p.isBackground = true; })}
|
|
|
|
|
style={{
|
|
|
|
|
flex: 1,
|
|
|
|
|
padding: '6px 10px',
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
borderRadius: 4,
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
border: '1px solid #3f3f46',
|
|
|
|
|
background: props.isBackground ? '#3b82f6' : '#27272a',
|
|
|
|
|
color: props.isBackground ? '#fff' : '#a1a1aa',
|
|
|
|
|
fontWeight: 500,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Background
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Background mode options */}
|
|
|
|
|
{props.isBackground && (
|
|
|
|
|
<>
|
|
|
|
|
<div>
|
|
|
|
|
<label style={labelStyle}>Overlay Color</label>
|
|
|
|
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
|
|
|
|
{overlayPresets.map((c) => (
|
|
|
|
|
<button
|
|
|
|
|
key={c}
|
|
|
|
|
onClick={() => setProp((p: VideoBlockProps) => { p.overlayColor = c; })}
|
|
|
|
|
style={{
|
|
|
|
|
width: 24,
|
|
|
|
|
height: 24,
|
|
|
|
|
borderRadius: 4,
|
|
|
|
|
border: '1px solid #3f3f46',
|
|
|
|
|
backgroundColor: c,
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
outline: props.overlayColor === c ? '2px solid #3b82f6' : 'none',
|
|
|
|
|
outlineOffset: 1,
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label style={labelStyle}>
|
|
|
|
|
Overlay Opacity: {props.overlayOpacity ?? 50}%
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="range"
|
|
|
|
|
min={0}
|
|
|
|
|
max={100}
|
|
|
|
|
value={props.overlayOpacity ?? 50}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
setProp((p: VideoBlockProps) => {
|
|
|
|
|
p.overlayOpacity = parseInt(e.target.value, 10);
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
style={{ width: '100%' }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label style={labelStyle}>Inner Max Width</label>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={props.innerMaxWidth || '1200px'}
|
|
|
|
|
onChange={(e) => setProp((p: VideoBlockProps) => { p.innerMaxWidth = e.target.value; })}
|
|
|
|
|
style={inputStyle}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ========================================================================
|
|
|
|
|
Craft Config
|
|
|
|
|
======================================================================== */
|
|
|
|
|
|
|
|
|
|
VideoBlock.craft = {
|
|
|
|
|
displayName: 'Video',
|
|
|
|
|
props: {
|
|
|
|
|
videoUrl: '',
|
|
|
|
|
videoType: 'none',
|
|
|
|
|
embedUrl: '',
|
|
|
|
|
autoplay: false,
|
|
|
|
|
muted: true,
|
|
|
|
|
loop: false,
|
|
|
|
|
controls: true,
|
|
|
|
|
isBackground: false,
|
|
|
|
|
overlayColor: '#000000',
|
|
|
|
|
overlayOpacity: 50,
|
|
|
|
|
innerMaxWidth: '1200px',
|
|
|
|
|
style: {},
|
|
|
|
|
},
|
|
|
|
|
rules: {
|
|
|
|
|
canDrag: () => true,
|
|
|
|
|
canMoveIn: () => true,
|
|
|
|
|
canMoveOut: () => true,
|
|
|
|
|
},
|
|
|
|
|
related: {
|
|
|
|
|
settings: VideoBlockSettings,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ========================================================================
|
|
|
|
|
HTML Export
|
|
|
|
|
======================================================================== */
|
|
|
|
|
|
|
|
|
|
(VideoBlock as any).toHtml = (props: VideoBlockProps, childrenHtml: string) => {
|
|
|
|
|
const {
|
|
|
|
|
videoUrl = '',
|
|
|
|
|
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',
|
|
|
|
|
});
|
2026-07-12 12:05:57 -07:00
|
|
|
videoHtml = `<video src="${escapeAttr(safeUrl(embedUrl))}" autoplay muted loop playsinline${vidStyle ? ` style="${vidStyle}"` : ''}></video>`;
|
2026-04-05 18:31:16 -07:00
|
|
|
} 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',
|
|
|
|
|
});
|
2026-07-12 12:05:57 -07:00
|
|
|
videoHtml = `<iframe src="${escapeAttr(safeUrl(iframeSrc))}" allow="autoplay; encrypted-media" allowfullscreen${ifrStyle ? ` style="${ifrStyle}"` : ''}></iframe>`;
|
2026-04-05 18:31:16 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
html: `<section${outerStyle ? ` style="${outerStyle}"` : ''}>${videoHtml}<div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></section>`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---- 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',
|
|
|
|
|
paddingBottom: '56.25%',
|
|
|
|
|
height: '0',
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
borderRadius: (style as any)?.borderRadius || undefined,
|
|
|
|
|
});
|
|
|
|
|
const iframeStyle = cssPropsToString({
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
top: '0',
|
|
|
|
|
left: '0',
|
|
|
|
|
width: '100%',
|
|
|
|
|
height: '100%',
|
|
|
|
|
border: 'none',
|
|
|
|
|
});
|
|
|
|
|
return {
|
2026-07-12 12:05:57 -07:00
|
|
|
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><div${containerStyle ? ` style="${containerStyle}"` : ''}><iframe src="${escapeAttr(safeUrl(iframeSrc))}" allow="autoplay; encrypted-media; picture-in-picture" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div></div>`,
|
2026-04-05 18:31:16 -07:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 vidStyle = cssPropsToString({
|
|
|
|
|
display: 'block',
|
|
|
|
|
width: '100%',
|
|
|
|
|
borderRadius: (style as any)?.borderRadius || undefined,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
2026-07-12 12:05:57 -07:00
|
|
|
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><video src="${escapeAttr(safeUrl(embedUrl))}" ${vidAttrs.join(' ')}${vidStyle ? ` style="${vidStyle}"` : ''}></video></div>`,
|
2026-04-05 18:31:16 -07:00
|
|
|
};
|
|
|
|
|
};
|