Add Craft.js site builder (v2) - complete rebuild from GrapesJS
Rebuilt the visual site builder from scratch using Craft.js, React 18, and TypeScript. The new editor renders directly in the DOM (no iframe), supports 40+ components, multi-page with shared header/footer, 16 templates, full-spectrum color/gradient controls, custom head code injection, save/publish workflow, and auto-save. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%23f1f5f9'/%3E%3Cstop offset='100%25' stop-color='%23e2e8f0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23bg)' width='400' height='300' rx='12'/%3E%3Crect x='2' y='2' width='396' height='296' rx='10' fill='none' stroke='%23cbd5e1' stroke-width='2' stroke-dasharray='8 4'/%3E%3Cg transform='translate(200,110)'%3E%3Crect x='-28' y='-28' width='56' height='56' rx='12' fill='%23cbd5e1' opacity='0.5'/%3E%3Cpath d='M-12 8 L-4 -2 L2 4 L8 -6 L16 8Z' fill='%2394a3b8'/%3E%3Ccircle cx='-6' cy='-10' r='5' fill='%2394a3b8'/%3E%3C/g%3E%3Ctext x='200' y='160' text-anchor='middle' fill='%2364748b' font-family='Inter,sans-serif' font-size='15' font-weight='500'%3EDrop image here%3C/text%3E%3Ctext x='200' y='182' text-anchor='middle' fill='%2394a3b8' font-family='Inter,sans-serif' font-size='12'%3Eor click to upload%3C/text%3E%3C/svg%3E";
|
||||
|
||||
interface ImageBlockProps {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
// Helper: upload a file to the WHP API and return the proxy URL
|
||||
async function uploadToWhp(file: File): Promise<string | null> {
|
||||
const cfg = (window as any).WHP_CONFIG;
|
||||
if (!cfg) return URL.createObjectURL(file); // Standalone fallback
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
export const ImageBlock: UserComponent<ImageBlockProps> = ({
|
||||
src = PLACEHOLDER_SRC,
|
||||
alt = '',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
actions: { setProp },
|
||||
} = useNode((node) => ({ selected: node.events.selected }));
|
||||
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
const isPlaceholder = !src || src === PLACEHOLDER_SRC || src.startsWith('data:image/svg');
|
||||
|
||||
// Handle drag-and-drop of files directly onto the image
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
const url = await uploadToWhp(file);
|
||||
if (url) setProp((p: ImageBlockProps) => { p.src = url; });
|
||||
}
|
||||
}, [setProp]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<img
|
||||
ref={(ref: HTMLImageElement | null) => {
|
||||
imgRef.current = ref;
|
||||
if (ref) connect(drag(ref));
|
||||
}}
|
||||
src={src}
|
||||
alt={alt || 'Image'}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
style={{
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
outline: 'none',
|
||||
cursor: selected ? 'move' : 'pointer',
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Helpers for parsing CSS unit values ---------- */
|
||||
|
||||
type SizeUnit = 'px' | '%' | 'auto';
|
||||
|
||||
function parseSizeValue(value: string | number | undefined): { num: string; unit: SizeUnit } {
|
||||
if (!value || value === 'auto') return { num: '', unit: 'auto' };
|
||||
const str = String(value);
|
||||
if (str === 'auto') return { num: '', unit: 'auto' };
|
||||
const match = str.match(/^(\d+(?:\.\d+)?)\s*(px|%)$/);
|
||||
if (match) return { num: match[1], unit: match[2] as SizeUnit };
|
||||
// Pure number = px
|
||||
if (/^\d+(?:\.\d+)?$/.test(str)) return { num: str, unit: 'px' };
|
||||
return { num: '', unit: 'px' };
|
||||
}
|
||||
|
||||
function buildSizeString(num: string, unit: SizeUnit): string | undefined {
|
||||
if (unit === 'auto') return 'auto';
|
||||
if (!num) return undefined;
|
||||
return `${num}${unit}`;
|
||||
}
|
||||
|
||||
type Alignment = 'left' | 'center' | 'right';
|
||||
|
||||
function detectAlignment(style: CSSProperties | undefined): Alignment {
|
||||
if (!style) return 'left';
|
||||
const ml = style.marginLeft;
|
||||
const mr = style.marginRight;
|
||||
if (ml === 'auto' && mr === 'auto') return 'center';
|
||||
if (ml === 'auto' && mr !== 'auto') return 'right';
|
||||
return 'left';
|
||||
}
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const ImageBlockSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as ImageBlockProps,
|
||||
}));
|
||||
|
||||
const isPlaceholder = !props.src || props.src === PLACEHOLDER_SRC || props.src?.startsWith('data:image/svg');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showBrowser, setShowBrowser] = useState(false);
|
||||
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
|
||||
const [browserLoading, setBrowserLoading] = useState(false);
|
||||
|
||||
// Sizing unit state
|
||||
const widthParsed = parseSizeValue(props.style?.width);
|
||||
const [widthUnit, setWidthUnit] = useState<SizeUnit>(widthParsed.unit === 'auto' ? 'px' : widthParsed.unit);
|
||||
const heightParsed = parseSizeValue(props.style?.height);
|
||||
const [heightUnit, setHeightUnit] = useState<SizeUnit>(heightParsed.unit === 'auto' ? 'px' : heightParsed.unit);
|
||||
const maxWidthParsed = parseSizeValue(props.style?.maxWidth);
|
||||
const [maxWidthUnit, setMaxWidthUnit] = useState<SizeUnit>(maxWidthParsed.unit === 'auto' ? '%' : maxWidthParsed.unit);
|
||||
|
||||
const alignment = detectAlignment(props.style);
|
||||
|
||||
const handleUpload = useCallback(async (file: File) => {
|
||||
const url = await uploadToWhp(file);
|
||||
if (url) setProp((p: ImageBlockProps) => { p.src = url; });
|
||||
}, [setProp]);
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
if (showBrowser) { setShowBrowser(false); return; }
|
||||
const cfg = (window as any).WHP_CONFIG;
|
||||
if (!cfg) return;
|
||||
setBrowserLoading(true);
|
||||
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 images = data.assets.filter((a: any) => (a.type || '').startsWith('image'));
|
||||
setBrowserAssets(images);
|
||||
setShowBrowser(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Browse failed:', e);
|
||||
} finally {
|
||||
setBrowserLoading(false);
|
||||
}
|
||||
}, [showBrowser]);
|
||||
|
||||
const radiusPresets = ['0', '4px', '8px', '16px', '50%'];
|
||||
|
||||
const setPropStyle = useCallback((key: string, value: string | undefined) => {
|
||||
setProp((p: ImageBlockProps) => {
|
||||
p.style = { ...p.style, [key]: value };
|
||||
});
|
||||
}, [setProp]);
|
||||
|
||||
const setAlignment = useCallback((align: Alignment) => {
|
||||
setProp((p: ImageBlockProps) => {
|
||||
const s = { ...p.style };
|
||||
if (align === 'center') {
|
||||
s.marginLeft = 'auto';
|
||||
s.marginRight = 'auto';
|
||||
s.display = 'block';
|
||||
} else if (align === 'right') {
|
||||
s.marginLeft = 'auto';
|
||||
s.marginRight = undefined;
|
||||
s.display = 'block';
|
||||
} else {
|
||||
s.marginLeft = undefined;
|
||||
s.marginRight = undefined;
|
||||
s.display = 'block';
|
||||
}
|
||||
p.style = s;
|
||||
});
|
||||
}, [setProp]);
|
||||
|
||||
// Extract friendly filename from URL
|
||||
const getFriendlyName = (src: string) => {
|
||||
const match = src.match(/filename=([^&]+)/);
|
||||
if (match) return decodeURIComponent(match[1]).replace(/^\d+_[a-f0-9]+_/, '');
|
||||
return src.split('/').pop() || 'image';
|
||||
};
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
|
||||
const inputStyle: CSSProperties = { flex: 1, minWidth: 0, padding: '4px 6px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 };
|
||||
const selectStyle: CSSProperties = { padding: '4px 2px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, cursor: 'pointer' };
|
||||
const btnStyle = (active: boolean): CSSProperties => ({
|
||||
flex: 1, padding: '4px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: active ? '#3b82f6' : '#27272a',
|
||||
color: active ? '#fff' : '#a1a1aa',
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{/* Image preview */}
|
||||
<div>
|
||||
<label style={labelStyle}>Image Source</label>
|
||||
|
||||
{!isPlaceholder ? (
|
||||
<>
|
||||
{/* Current image thumbnail + filename + remove */}
|
||||
<div style={{ marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
||||
<img src={props.src} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 150, objectFit: 'cover' }} />
|
||||
<button onClick={() => setProp((p: ImageBlockProps) => { p.src = PLACEHOLDER_SRC; })}
|
||||
style={{ position: 'absolute', top: 4, right: 4, width: 24, height: 24, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 12, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
title="Remove image">
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#a1a1aa', marginBottom: 8, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<i className="fa fa-check-circle" style={{ color: '#10b981' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{getFriendlyName(props.src || '')}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Drop zone when no image set */
|
||||
<div
|
||||
style={{ padding: '20px 12px', border: '2px dashed #3f3f46', borderRadius: 6, textAlign: 'center', color: '#71717a', fontSize: 12, cursor: 'pointer', marginBottom: 8, transition: 'border-color 0.15s' }}
|
||||
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = '#3b82f6'; }}
|
||||
onDragLeave={(e) => { e.currentTarget.style.borderColor = '#3f3f46'; }}
|
||||
onDrop={async (e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.borderColor = '#3f3f46';
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) await handleUpload(file);
|
||||
}}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<i className="fa fa-cloud-upload" style={{ fontSize: 24, display: 'block', marginBottom: 6, color: '#3b82f6' }} />
|
||||
Drop image here or click to upload
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons: Upload + Browse */}
|
||||
<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: showBrowser ? '#3b82f6' : '#27272a', color: showBrowser ? '#fff' : '#e4e4e7' }}
|
||||
>
|
||||
<i className={`fa ${browserLoading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 4 }} /> Browse
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inline asset browser grid */}
|
||||
{showBrowser && (
|
||||
<div style={{ maxHeight: 200, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, marginTop: 8, background: '#18181b', borderRadius: 6, padding: 4 }}>
|
||||
{browserAssets.map(asset => (
|
||||
<div
|
||||
key={asset.name}
|
||||
onClick={() => { setProp((p: ImageBlockProps) => { p.src = asset.url; }); setShowBrowser(false); }}
|
||||
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1', transition: 'border-color 0.15s' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
|
||||
>
|
||||
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
</div>
|
||||
))}
|
||||
{browserAssets.length === 0 && (
|
||||
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '12px 0', margin: 0 }}>No images uploaded yet. Use Upload above.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
||||
onChange={(e) => { const file = e.target.files?.[0]; if (file) handleUpload(file); e.target.value = ''; }} />
|
||||
|
||||
{/* URL input (collapsed, for advanced users) */}
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<input type="text"
|
||||
value={isPlaceholder ? '' : (props.src || '')}
|
||||
onChange={(e) => setProp((p: ImageBlockProps) => { p.src = e.target.value || PLACEHOLDER_SRC; })}
|
||||
placeholder="Or paste image URL..."
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#1e1e2a', color: '#71717a', border: '1px solid #27272a', borderRadius: 4, fontSize: 10 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alt Text */}
|
||||
<div>
|
||||
<label style={labelStyle}>Alt Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.alt || ''}
|
||||
onChange={(e) => setProp((p: ImageBlockProps) => { p.alt = e.target.value; })}
|
||||
placeholder="Describe the image..."
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Width */}
|
||||
<div>
|
||||
<label style={labelStyle}>Width</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={widthParsed.num}
|
||||
disabled={props.style?.width === 'auto'}
|
||||
onChange={(e) => {
|
||||
const val = buildSizeString(e.target.value, widthUnit);
|
||||
setPropStyle('width', val || 'auto');
|
||||
}}
|
||||
placeholder="auto"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<select
|
||||
value={props.style?.width === 'auto' ? 'auto' : widthUnit}
|
||||
onChange={(e) => {
|
||||
const unit = e.target.value as SizeUnit;
|
||||
if (unit === 'auto') {
|
||||
setPropStyle('width', 'auto');
|
||||
} else {
|
||||
setWidthUnit(unit);
|
||||
const num = widthParsed.num || '100';
|
||||
setPropStyle('width', `${num}${unit}`);
|
||||
}
|
||||
}}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="px">px</option>
|
||||
<option value="%">%</option>
|
||||
<option value="auto">auto</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Max Width */}
|
||||
<div>
|
||||
<label style={labelStyle}>Max Width</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={maxWidthParsed.num}
|
||||
onChange={(e) => {
|
||||
const val = buildSizeString(e.target.value, maxWidthUnit);
|
||||
setPropStyle('maxWidth', val || '100%');
|
||||
}}
|
||||
placeholder="100%"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<select
|
||||
value={maxWidthUnit}
|
||||
onChange={(e) => {
|
||||
const unit = e.target.value as SizeUnit;
|
||||
setMaxWidthUnit(unit);
|
||||
const num = maxWidthParsed.num || '100';
|
||||
setPropStyle('maxWidth', `${num}${unit}`);
|
||||
}}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="px">px</option>
|
||||
<option value="%">%</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Height */}
|
||||
<div>
|
||||
<label style={labelStyle}>Height</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={heightParsed.num}
|
||||
disabled={props.style?.height === 'auto'}
|
||||
onChange={(e) => {
|
||||
const val = buildSizeString(e.target.value, heightUnit);
|
||||
setPropStyle('height', val || 'auto');
|
||||
}}
|
||||
placeholder="auto"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<select
|
||||
value={props.style?.height === 'auto' ? 'auto' : heightUnit}
|
||||
onChange={(e) => {
|
||||
const unit = e.target.value as SizeUnit;
|
||||
if (unit === 'auto') {
|
||||
setPropStyle('height', 'auto');
|
||||
} else {
|
||||
setHeightUnit(unit);
|
||||
const num = heightParsed.num || '300';
|
||||
setPropStyle('height', `${num}${unit}`);
|
||||
}
|
||||
}}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="px">px</option>
|
||||
<option value="auto">auto</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Object Fit (visible when both width and height are explicit values) */}
|
||||
{props.style?.width && props.style.width !== 'auto' && props.style?.height && props.style.height !== 'auto' && (
|
||||
<div>
|
||||
<label style={labelStyle}>Object Fit</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['cover', 'contain', 'fill', 'none'] as const).map((fit) => (
|
||||
<button
|
||||
key={fit}
|
||||
onClick={() => setPropStyle('objectFit', fit)}
|
||||
style={btnStyle(props.style?.objectFit === fit)}
|
||||
>
|
||||
{fit}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alignment */}
|
||||
<div>
|
||||
<label style={labelStyle}>Alignment</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button onClick={() => setAlignment('left')} style={btnStyle(alignment === 'left')}>
|
||||
<i className="fa fa-align-left" style={{ marginRight: 3 }} />Left
|
||||
</button>
|
||||
<button onClick={() => setAlignment('center')} style={btnStyle(alignment === 'center')}>
|
||||
<i className="fa fa-align-center" style={{ marginRight: 3 }} />Center
|
||||
</button>
|
||||
<button onClick={() => setAlignment('right')} style={btnStyle(alignment === 'right')}>
|
||||
<i className="fa fa-align-right" style={{ marginRight: 3 }} />Right
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Border Radius */}
|
||||
<div>
|
||||
<label style={labelStyle}>Border Radius</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{radiusPresets.map((r) => (
|
||||
<button key={r} onClick={() => setPropStyle('borderRadius', r)}
|
||||
style={btnStyle(props.style?.borderRadius === r)}
|
||||
>{r}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ImageBlock.craft = {
|
||||
displayName: 'Image',
|
||||
props: { src: PLACEHOLDER_SRC, alt: '', style: { width: '100%', height: 'auto' } },
|
||||
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
|
||||
related: { settings: ImageBlockSettings },
|
||||
};
|
||||
|
||||
(ImageBlock as any).toHtml = (props: ImageBlockProps, _c: string) => {
|
||||
// Skip placeholder/empty images in export
|
||||
const src = props.src || '';
|
||||
if (!src || src.startsWith('data:image/svg') || src === PLACEHOLDER_SRC) {
|
||||
return { html: '' };
|
||||
}
|
||||
const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style });
|
||||
const alt = props.alt ? ` alt="${props.alt.replace(/"/g, '"')}"` : ' alt=""';
|
||||
return { html: `<img src="${src}"${alt}${s ? ` style="${s}"` : ''} />` };
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface MapEmbedProps {
|
||||
address?: string;
|
||||
zoom?: number;
|
||||
height?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
function buildMapUrl(address: string, zoom: number): string {
|
||||
const encoded = encodeURIComponent(address);
|
||||
return `https://maps.google.com/maps?q=${encoded}&z=${zoom}&output=embed`;
|
||||
}
|
||||
|
||||
export const MapEmbed: UserComponent<MapEmbedProps> = ({
|
||||
address = 'New York, NY',
|
||||
zoom = 14,
|
||||
height = '400px',
|
||||
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={{
|
||||
width: '100%',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src={buildMapUrl(address, zoom)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height,
|
||||
border: 'none',
|
||||
borderRadius: (style as any)?.borderRadius || '0px',
|
||||
display: 'block',
|
||||
}}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const MapEmbedSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as MapEmbedProps,
|
||||
}));
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
|
||||
};
|
||||
|
||||
const heightPresets = ['300px', '400px', '500px', '600px'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Address */}
|
||||
<div>
|
||||
<label style={labelStyle}>Address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.address || ''}
|
||||
onChange={(e) => setProp((p: MapEmbedProps) => { p.address = e.target.value; })}
|
||||
placeholder="Enter an address or location..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zoom */}
|
||||
<div>
|
||||
<label style={labelStyle}>Zoom: {props.zoom || 14}</label>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={20}
|
||||
value={props.zoom || 14}
|
||||
onChange={(e) => setProp((p: MapEmbedProps) => { p.zoom = parseInt(e.target.value, 10); })}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Height */}
|
||||
<div>
|
||||
<label style={labelStyle}>Height</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 6 }}>
|
||||
{heightPresets.map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
onClick={() => setProp((p: MapEmbedProps) => { p.height = h; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.height === h ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{h}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={props.height || ''}
|
||||
onChange={(e) => setProp((p: MapEmbedProps) => { p.height = e.target.value; })}
|
||||
placeholder="e.g. 400px"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
MapEmbed.craft = {
|
||||
displayName: 'Map',
|
||||
props: {
|
||||
address: 'New York, NY',
|
||||
zoom: 14,
|
||||
height: '400px',
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: MapEmbedSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(MapEmbed as any).toHtml = (props: MapEmbedProps, _childrenHtml: string) => {
|
||||
const {
|
||||
address = 'New York, NY',
|
||||
zoom = 14,
|
||||
height = '400px',
|
||||
style = {},
|
||||
} = props;
|
||||
|
||||
const wrapperStyle = cssPropsToString({ width: '100%', ...style });
|
||||
const iframeStyle = cssPropsToString({
|
||||
width: '100%',
|
||||
height,
|
||||
border: 'none',
|
||||
borderRadius: (style as any)?.borderRadius || undefined,
|
||||
display: 'block',
|
||||
});
|
||||
|
||||
const src = buildMapUrl(address, zoom);
|
||||
|
||||
return {
|
||||
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><iframe src="${src}" loading="lazy" referrerpolicy="no-referrer-when-downgrade" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,794 @@
|
||||
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';
|
||||
|
||||
/* ---------- 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',
|
||||
});
|
||||
videoHtml = `<video src="${embedUrl}" autoplay muted loop playsinline${vidStyle ? ` style="${vidStyle}"` : ''}></video>`;
|
||||
} 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 = `<iframe src="${iframeSrc}" allow="autoplay; encrypted-media" allowfullscreen${ifrStyle ? ` style="${ifrStyle}"` : ''}></iframe>`;
|
||||
}
|
||||
|
||||
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 {
|
||||
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><div${containerStyle ? ` style="${containerStyle}"` : ''}><iframe src="${iframeSrc}" allow="autoplay; encrypted-media; picture-in-picture" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div></div>`,
|
||||
};
|
||||
}
|
||||
|
||||
// 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 {
|
||||
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><video src="${embedUrl}" ${vidAttrs.join(' ')}${vidStyle ? ` style="${vidStyle}"` : ''}></video></div>`,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user