5c44dd545c
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
284 lines
12 KiB
TypeScript
284 lines
12 KiB
TypeScript
import React from 'react';
|
|
import { useEditor } from '@craftjs/core';
|
|
import {
|
|
BG_COLORS,
|
|
RADIUS_PRESETS,
|
|
} from '../../../constants/presets';
|
|
import {
|
|
StylePanelProps,
|
|
SectionLabel,
|
|
ColorSwatchGrid,
|
|
PresetButtonGrid,
|
|
CollapsibleSection,
|
|
ColorPickerField,
|
|
ArrayPropEditor,
|
|
SizeControl,
|
|
AspectRatioControl,
|
|
labelStyle,
|
|
inputStyle,
|
|
smallInputStyle,
|
|
sectionGap,
|
|
useNodeProp,
|
|
} from './shared';
|
|
import { AssetPicker } from '../../../ui/AssetPicker';
|
|
import { BoxModelSection, BorderEffectsSection, AnimVisSection } from './mediaBoxModel';
|
|
|
|
const GALLERY_COLUMN_PRESETS = [
|
|
{ label: '2', value: '2' },
|
|
{ label: '3', value: '3' },
|
|
{ label: '4', value: '4' },
|
|
{ label: '5', value: '5' },
|
|
];
|
|
|
|
/* ---------- MEDIA (Video / Gallery / Map / Slider) ---------- */
|
|
export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
|
const { actions } = useEditor();
|
|
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
|
|
|
const style = nodeProps.style || {};
|
|
|
|
// Same crop-fills-by-default + no stale-height-conflict treatment as
|
|
// ImageStylePanel (see there for the full rationale): applying a ratio
|
|
// defaults objectFit to 'cover' when unset, and clears height so Width +
|
|
// ratio + cover is the single source of truth for the box.
|
|
const applyAspectRatio = (v: string) => {
|
|
setPropStyle('aspectRatio', v);
|
|
if (v) {
|
|
if (!style.objectFit) setPropStyle('objectFit', 'cover');
|
|
setPropStyle('height', '');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* Video source -- upload/browse/paste-URL (paste-URL still handles
|
|
YouTube/Vimeo; upload/browse handle files via detectVideoType's
|
|
serve_asset resolution). */}
|
|
{nodeProps.videoUrl !== undefined && (
|
|
<div className="guided-section">
|
|
<SectionLabel>Video Source</SectionLabel>
|
|
<AssetPicker
|
|
mediaType="video"
|
|
value={nodeProps.videoUrl || ''}
|
|
onChange={(url) => setProp('videoUrl', url)}
|
|
variant="full"
|
|
placeholder="Or paste a YouTube/Vimeo/.mp4 URL..."
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Video size -- width + aspect ratio for the video frame. */}
|
|
{nodeProps.videoUrl !== undefined && (
|
|
<>
|
|
<SizeControl
|
|
label="Width"
|
|
value={(style.width as string) || ''}
|
|
onChange={(v) => setPropStyle('width', v)}
|
|
/>
|
|
{/* NOTE: unlike ImageStylePanel, there is no separate Height control
|
|
here to gate on aspect-ratio -- VideoBlock's <video>/iframe size
|
|
themselves from `width` + `aspectRatio` directly (see
|
|
VideoBlock.tsx), not from an outer-wrapper height, so adding one
|
|
would reintroduce the exact empty-space bug this fix targets. */}
|
|
<AspectRatioControl
|
|
value={(style.aspectRatio as string) || ''}
|
|
onChange={applyAspectRatio}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{/* Poster image (optional, file-type videos only in practice but
|
|
harmless to offer whenever the component has a poster prop). */}
|
|
{nodeProps.poster !== undefined && (
|
|
<div className="guided-section">
|
|
<SectionLabel>Poster Image (optional)</SectionLabel>
|
|
<AssetPicker
|
|
mediaType="image"
|
|
value={nodeProps.poster || ''}
|
|
onChange={(url) => setProp('poster', url)}
|
|
variant="full"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Map URL */}
|
|
{nodeProps.embedUrl !== undefined && nodeProps.videoUrl === undefined && (
|
|
<div style={sectionGap}>
|
|
<label style={labelStyle}>Embed URL</label>
|
|
<input type="text" value={nodeProps.embedUrl || ''} onChange={(e) => setProp('embedUrl', e.target.value)} placeholder="Google Maps embed URL" style={inputStyle} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Map address */}
|
|
{nodeProps.address !== undefined && (
|
|
<div style={sectionGap}>
|
|
<label style={labelStyle}>Address</label>
|
|
<input type="text" value={nodeProps.address || ''} onChange={(e) => setProp('address', e.target.value)} placeholder="123 Main St..." style={inputStyle} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Video options */}
|
|
{nodeProps.autoplay !== undefined && (
|
|
<div style={sectionGap}>
|
|
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
|
<input type="checkbox" checked={nodeProps.autoplay || false} onChange={(e) => setProp('autoplay', e.target.checked)} />
|
|
Autoplay
|
|
</label>
|
|
</div>
|
|
)}
|
|
{nodeProps.loop !== undefined && (
|
|
<div style={sectionGap}>
|
|
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
|
<input type="checkbox" checked={nodeProps.loop || false} onChange={(e) => setProp('loop', e.target.checked)} />
|
|
Loop
|
|
</label>
|
|
</div>
|
|
)}
|
|
{nodeProps.controls !== undefined && (
|
|
<div style={sectionGap}>
|
|
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
|
<input type="checkbox" checked={nodeProps.controls !== false} onChange={(e) => setProp('controls', e.target.checked)} />
|
|
Show Controls
|
|
</label>
|
|
</div>
|
|
)}
|
|
|
|
{/* Gallery layout -- columns + lightbox (existing-but-previously-unexposed props). */}
|
|
{nodeProps.images !== undefined && Array.isArray(nodeProps.images) && (
|
|
<>
|
|
<div className="guided-section">
|
|
<SectionLabel>Columns</SectionLabel>
|
|
<PresetButtonGrid
|
|
presets={GALLERY_COLUMN_PRESETS}
|
|
activeValue={String(nodeProps.columns ?? 3)}
|
|
onSelect={(v) => setProp('columns', Number(v))}
|
|
/>
|
|
</div>
|
|
<div style={sectionGap}>
|
|
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
|
<input type="checkbox" checked={!!nodeProps.lightbox} onChange={(e) => setProp('lightbox', e.target.checked)} />
|
|
Enable Lightbox
|
|
</label>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Gallery items */}
|
|
{nodeProps.images !== undefined && Array.isArray(nodeProps.images) && (
|
|
<CollapsibleSection title="Images">
|
|
<ArrayPropEditor
|
|
selectedId={selectedId}
|
|
propKey="images"
|
|
items={nodeProps.images}
|
|
renderItem={(item: any, index: number) => (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
{item.src !== undefined && (
|
|
<AssetPicker
|
|
variant="compact"
|
|
value={item.src || ''}
|
|
onChange={(url) => {
|
|
actions.setProp(selectedId, (props: any) => {
|
|
const updated = [...(props.images || [])];
|
|
updated[index] = { ...updated[index], src: url };
|
|
props.images = updated;
|
|
});
|
|
}}
|
|
/>
|
|
)}
|
|
{item.caption !== undefined && (
|
|
<input type="text" value={item.caption || ''} onChange={(e) => {
|
|
actions.setProp(selectedId, (props: any) => {
|
|
const updated = [...(props.images || [])];
|
|
updated[index] = { ...updated[index], caption: e.target.value };
|
|
props.images = updated;
|
|
});
|
|
}} placeholder="Caption" style={smallInputStyle} />
|
|
)}
|
|
</div>
|
|
)}
|
|
emptyItem={{ src: '', caption: '' }}
|
|
/>
|
|
</CollapsibleSection>
|
|
)}
|
|
|
|
{/* Slides */}
|
|
{nodeProps.slides !== undefined && Array.isArray(nodeProps.slides) && (
|
|
<CollapsibleSection title="Slides">
|
|
<ArrayPropEditor
|
|
selectedId={selectedId}
|
|
propKey="slides"
|
|
items={nodeProps.slides}
|
|
renderItem={(item: any, index: number) => (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
{item.heading !== undefined && (
|
|
<input type="text" value={item.heading || ''} onChange={(e) => {
|
|
actions.setProp(selectedId, (props: any) => {
|
|
const updated = [...(props.slides || [])];
|
|
updated[index] = { ...updated[index], heading: e.target.value };
|
|
props.slides = updated;
|
|
});
|
|
}} placeholder="Heading" style={smallInputStyle} />
|
|
)}
|
|
{item.text !== undefined && (
|
|
<textarea value={item.text || ''} onChange={(e) => {
|
|
actions.setProp(selectedId, (props: any) => {
|
|
const updated = [...(props.slides || [])];
|
|
updated[index] = { ...updated[index], text: e.target.value };
|
|
props.slides = updated;
|
|
});
|
|
}} placeholder="Text" rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} />
|
|
)}
|
|
{item.imageSrc !== undefined && (
|
|
<AssetPicker
|
|
variant="compact"
|
|
value={item.imageSrc || ''}
|
|
onChange={(url) => {
|
|
actions.setProp(selectedId, (props: any) => {
|
|
const updated = [...(props.slides || [])];
|
|
updated[index] = { ...updated[index], imageSrc: url };
|
|
props.slides = updated;
|
|
});
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
emptyItem={{ type: 'image', imageSrc: '', heading: 'New Slide', text: '', bgColor: '' }}
|
|
/>
|
|
</CollapsibleSection>
|
|
)}
|
|
|
|
{/* Overlay */}
|
|
{nodeProps.overlayColor !== undefined && (
|
|
<CollapsibleSection title="Overlay" defaultOpen={false}>
|
|
<ColorPickerField label="Color" value={nodeProps.overlayColor || '#000000'} onChange={(v) => setProp('overlayColor', v)} />
|
|
{nodeProps.overlayOpacity !== undefined && (
|
|
<div style={sectionGap}>
|
|
<label style={labelStyle}>Opacity: {nodeProps.overlayOpacity ?? 0}%</label>
|
|
<input type="range" min={0} max={100} value={nodeProps.overlayOpacity ?? 0} onChange={(e) => setProp('overlayOpacity', parseInt(e.target.value))} style={{ width: '100%' }} />
|
|
</div>
|
|
)}
|
|
</CollapsibleSection>
|
|
)}
|
|
|
|
{/* Background & radius */}
|
|
<CollapsibleSection title="Style" defaultOpen={false}>
|
|
<div className="guided-section">
|
|
<SectionLabel>Background</SectionLabel>
|
|
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
|
|
</div>
|
|
<div className="guided-section">
|
|
<SectionLabel>Border Radius</SectionLabel>
|
|
<PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setPropStyle('borderRadius', v)} />
|
|
</div>
|
|
</CollapsibleSection>
|
|
|
|
{/* Box model + animation/visibility rollout (Video/Gallery/Map/Slider
|
|
all carry these props now, so it's safe to render unconditionally). */}
|
|
<BoxModelSection style={style} setPropStyle={setPropStyle} />
|
|
<BorderEffectsSection style={style} setPropStyle={setPropStyle} />
|
|
<AnimVisSection nodeProps={nodeProps} setProp={setProp} />
|
|
</>
|
|
);
|
|
};
|