Site builder: security & data-loss hardening + unified asset picker + audit backlog #3
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useRef } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
import {
|
import {
|
||||||
BG_COLORS,
|
BG_COLORS,
|
||||||
@@ -12,18 +12,15 @@ import {
|
|||||||
PresetButtonGrid,
|
PresetButtonGrid,
|
||||||
CollapsibleSection,
|
CollapsibleSection,
|
||||||
ColorPickerField,
|
ColorPickerField,
|
||||||
uploadToWhp,
|
|
||||||
labelStyle,
|
labelStyle,
|
||||||
inputStyle,
|
inputStyle,
|
||||||
smallInputStyle,
|
|
||||||
btnActiveStyle,
|
|
||||||
sectionGap,
|
sectionGap,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
|
import { AssetPicker } from '../../../ui/AssetPicker';
|
||||||
|
|
||||||
/* ---------- BACKGROUND SECTION ---------- */
|
/* ---------- BACKGROUND SECTION ---------- */
|
||||||
export const BackgroundSectionStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
export const BackgroundSectionStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||||
const { actions } = useEditor();
|
const { actions } = useEditor();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const setProp = useCallback((key: string, value: any) => {
|
const setProp = useCallback((key: string, value: any) => {
|
||||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||||
@@ -35,34 +32,13 @@ export const BackgroundSectionStylePanel: React.FC<StylePanelProps> = ({ selecte
|
|||||||
});
|
});
|
||||||
}, [actions, selectedId]);
|
}, [actions, selectedId]);
|
||||||
|
|
||||||
const handleUpload = useCallback(async (file: File) => {
|
|
||||||
const url = await uploadToWhp(file);
|
|
||||||
if (url) setProp('bgImage', url);
|
|
||||||
}, [setProp]);
|
|
||||||
|
|
||||||
const style = nodeProps.style || {};
|
const style = nodeProps.style || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Background Image */}
|
{/* Background Image */}
|
||||||
<CollapsibleSection title="Background Image">
|
<CollapsibleSection title="Background Image">
|
||||||
{nodeProps.bgImage && (
|
<AssetPicker value={nodeProps.bgImage || ''} onChange={(url) => setProp('bgImage', url)} variant="full" />
|
||||||
<div style={{ marginBottom: 6, borderRadius: 4, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
|
||||||
<img src={nodeProps.bgImage} alt="" style={{ width: '100%', height: 80, objectFit: 'cover', display: 'block' }} />
|
|
||||||
<button onClick={() => setProp('bgImage', '')} style={{ position: 'absolute', top: 2, right: 2, width: 20, height: 20, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
||||||
<i className="fa fa-times" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
|
||||||
<button onClick={() => fileInputRef.current?.click()} style={{ ...btnActiveStyle(false), flex: 1 }}>
|
|
||||||
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
|
||||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleUpload(f); e.target.value = ''; }} />
|
|
||||||
<input type="text" value={nodeProps.bgImage || ''} placeholder="Or paste image URL..."
|
|
||||||
onChange={(e) => setProp('bgImage', e.target.value)} style={{ ...smallInputStyle, width: '100%', marginTop: 4 }} />
|
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
{/* Background Color */}
|
{/* Background Color */}
|
||||||
|
|||||||
@@ -1,22 +1,7 @@
|
|||||||
import React, { useRef } from 'react';
|
import React from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
import { labelStyle, inputStyle, sectionGap } from './shared';
|
import { labelStyle, inputStyle, sectionGap } from './shared';
|
||||||
|
import { AssetPicker } from '../../../ui/AssetPicker';
|
||||||
/* Upload helper (same contract as ImageBlock/Navbar): uploads to WHP if a
|
|
||||||
WHP_CONFIG is present, otherwise falls back to a local blob URL. */
|
|
||||||
async function uploadToWhp(file: File): Promise<string | null> {
|
|
||||||
const cfg = (window as any).WHP_CONFIG;
|
|
||||||
if (!cfg) return URL.createObjectURL(file);
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.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: fd,
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
return data.success && data.url ? data.url : null;
|
|
||||||
} catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Feature {
|
interface Feature {
|
||||||
title?: string; description?: string; icon?: string;
|
title?: string; description?: string; icon?: string;
|
||||||
@@ -33,7 +18,6 @@ interface Feature {
|
|||||||
export const FeaturesEditor: React.FC<{ selectedId: string; features: unknown }> = ({ selectedId, features }) => {
|
export const FeaturesEditor: React.FC<{ selectedId: string; features: unknown }> = ({ selectedId, features }) => {
|
||||||
const { actions } = useEditor();
|
const { actions } = useEditor();
|
||||||
const list: Feature[] = Array.isArray(features) ? (features as Feature[]) : [];
|
const list: Feature[] = Array.isArray(features) ? (features as Feature[]) : [];
|
||||||
const fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
|
||||||
|
|
||||||
const mutate = (fn: (arr: Feature[]) => Feature[]) =>
|
const mutate = (fn: (arr: Feature[]) => Feature[]) =>
|
||||||
actions.setProp(selectedId, (p: any) => { p.features = fn([...(p.features || [])]); });
|
actions.setProp(selectedId, (p: any) => { p.features = fn([...(p.features || [])]); });
|
||||||
@@ -42,9 +26,6 @@ export const FeaturesEditor: React.FC<{ selectedId: string; features: unknown }>
|
|||||||
const add = () =>
|
const add = () =>
|
||||||
mutate((arr) => [...arr, { title: 'New Feature', description: 'Describe this feature.', icon: '🔧', image: '', imageAlt: '', buttonText: '', buttonUrl: '' }]);
|
mutate((arr) => [...arr, { title: 'New Feature', description: 'Describe this feature.', icon: '🔧', image: '', imageAlt: '', buttonText: '', buttonUrl: '' }]);
|
||||||
const remove = (i: number) => mutate((arr) => { arr.splice(i, 1); return arr; });
|
const remove = (i: number) => mutate((arr) => { arr.splice(i, 1); return arr; });
|
||||||
const onUpload = async (i: number, file: File) => { const url = await uploadToWhp(file); if (url) update(i, 'image', url); };
|
|
||||||
|
|
||||||
const smallBtn: React.CSSProperties = { padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#27272a', color: '#e4e4e7' };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
@@ -57,21 +38,12 @@ export const FeaturesEditor: React.FC<{ selectedId: string; features: unknown }>
|
|||||||
<textarea value={feat.description || ''} onChange={(e) => update(i, 'description', e.target.value)} placeholder="Description" rows={2} style={{ ...inputStyle, resize: 'vertical' }} />
|
<textarea value={feat.description || ''} onChange={(e) => update(i, 'description', e.target.value)} placeholder="Description" rows={2} style={{ ...inputStyle, resize: 'vertical' }} />
|
||||||
|
|
||||||
{/* Media: image (upload/URL) takes precedence over the icon when set */}
|
{/* Media: image (upload/URL) takes precedence over the icon when set */}
|
||||||
{feat.image ? (
|
|
||||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
|
||||||
<img src={feat.image} alt="" style={{ width: 28, height: 28, objectFit: 'cover', borderRadius: 4, border: '1px solid #3f3f46' }} />
|
|
||||||
<input type="text" value={feat.image} onChange={(e) => update(i, 'image', e.target.value)} placeholder="Image URL" style={{ ...inputStyle, flex: 1 }} />
|
|
||||||
<button onClick={() => update(i, 'image', '')} title="Use icon instead" style={smallBtn}>Icon</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
<input type="text" value={feat.icon || ''} onChange={(e) => update(i, 'icon', e.target.value)} placeholder="Icon (emoji)" style={{ ...inputStyle, width: 60, flex: 'none', textAlign: 'center' }} />
|
<input type="text" value={feat.icon || ''} onChange={(e) => update(i, 'icon', e.target.value)} placeholder="Icon (emoji)" style={{ ...inputStyle, width: 60, flex: 'none', textAlign: 'center' }} />
|
||||||
<button onClick={() => fileRefs.current[i]?.click()} style={{ ...smallBtn, flex: 1 }}> Upload image</button>
|
<div style={{ flex: 1 }}>
|
||||||
<input type="text" value={feat.image || ''} onChange={(e) => update(i, 'image', e.target.value)} placeholder="or image URL" style={{ ...inputStyle, flex: 1 }} />
|
<AssetPicker variant="compact" value={feat.image || ''} onChange={(url) => update(i, 'image', url)} placeholder="or image URL" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<input ref={(el) => { fileRefs.current[i] = el; }} type="file" accept="image/*" style={{ display: 'none' }}
|
|
||||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) onUpload(i, f); e.target.value = ''; }} />
|
|
||||||
|
|
||||||
{/* Optional button */}
|
{/* Optional button */}
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
|||||||
@@ -1,101 +1,24 @@
|
|||||||
import React, { useCallback, useState, useRef } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
import {
|
import {
|
||||||
StylePanelProps,
|
StylePanelProps,
|
||||||
CollapsibleSection,
|
CollapsibleSection,
|
||||||
ColorPickerField,
|
ColorPickerField,
|
||||||
uploadToWhp,
|
|
||||||
labelStyle,
|
labelStyle,
|
||||||
inputStyle,
|
inputStyle,
|
||||||
smallInputStyle,
|
|
||||||
btnActiveStyle,
|
btnActiveStyle,
|
||||||
sectionGap,
|
sectionGap,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
|
import { AssetPicker } from '../../../ui/AssetPicker';
|
||||||
/* ---------- Asset Browser Inline ---------- */
|
|
||||||
const AssetBrowser: React.FC<{
|
|
||||||
filter: 'image' | 'video' | 'all';
|
|
||||||
onSelect: (url: string) => void;
|
|
||||||
}> = ({ filter, onSelect }) => {
|
|
||||||
const [assets, setAssets] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const loadAssets = useCallback(async () => {
|
|
||||||
const cfg = (window as any).WHP_CONFIG;
|
|
||||||
if (!cfg) return;
|
|
||||||
setLoading(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 filtered = filter === 'all' ? data.assets : data.assets.filter((a: any) => {
|
|
||||||
const t = (a.type || '').toLowerCase();
|
|
||||||
return filter === 'image' ? t.startsWith('image') : t.startsWith('video');
|
|
||||||
});
|
|
||||||
setAssets(filtered);
|
|
||||||
}
|
|
||||||
} catch (e) { console.error('Load assets failed:', e); }
|
|
||||||
setLoading(false);
|
|
||||||
}, [filter]);
|
|
||||||
|
|
||||||
const handleToggle = useCallback(() => {
|
|
||||||
if (!open) loadAssets();
|
|
||||||
setOpen(!open);
|
|
||||||
}, [open, loadAssets]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<button onClick={handleToggle} style={{
|
|
||||||
...btnActiveStyle(open), width: '100%', marginTop: 4,
|
|
||||||
}}>
|
|
||||||
<i className={`fa ${loading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 4 }} />
|
|
||||||
{open ? 'Close' : 'Browse Assets'}
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<div style={{ maxHeight: 160, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, marginTop: 6, background: '#18181b', borderRadius: 6, padding: 4 }}>
|
|
||||||
{assets.map((asset, i) => (
|
|
||||||
<div key={i} onClick={() => { onSelect(asset.url); setOpen(false); }}
|
|
||||||
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1', transition: 'border-color 0.15s', background: '#27272a', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
|
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}>
|
|
||||||
{(asset.type || '').startsWith('image') ? (
|
|
||||||
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
|
||||||
) : (
|
|
||||||
<div style={{ textAlign: 'center', padding: 4 }}>
|
|
||||||
<i className="fa fa-film" style={{ fontSize: 20, color: '#71717a' }} />
|
|
||||||
<div style={{ fontSize: 8, color: '#71717a', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 70 }}>
|
|
||||||
{asset.name?.replace(/^\d+_[a-f0-9]+_/, '')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{assets.length === 0 && (
|
|
||||||
<p style={{ gridColumn: '1/-1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: 12, margin: 0 }}>
|
|
||||||
No {filter} assets uploaded yet
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ---------- HERO STYLE PANEL ---------- */
|
/* ---------- HERO STYLE PANEL ---------- */
|
||||||
export const HeroStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
export const HeroStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||||
const { actions } = useEditor();
|
const { actions } = useEditor();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const setProp = useCallback((key: string, value: any) => {
|
const setProp = useCallback((key: string, value: any) => {
|
||||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
||||||
}, [actions, selectedId]);
|
}, [actions, selectedId]);
|
||||||
|
|
||||||
const handleUpload = useCallback(async (file: File, propKey: string) => {
|
|
||||||
const url = await uploadToWhp(file);
|
|
||||||
if (url) setProp(propKey, url);
|
|
||||||
}, [setProp]);
|
|
||||||
|
|
||||||
const bgType = nodeProps.bgType || 'color';
|
const bgType = nodeProps.bgType || 'color';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -161,55 +84,20 @@ export const HeroStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
|
|||||||
{bgType === 'image' && (
|
{bgType === 'image' && (
|
||||||
<div style={sectionGap}>
|
<div style={sectionGap}>
|
||||||
<label style={labelStyle}>Background Image</label>
|
<label style={labelStyle}>Background Image</label>
|
||||||
{nodeProps.bgImage && (
|
<AssetPicker value={nodeProps.bgImage || ''} onChange={(url) => setProp('bgImage', url)} variant="full" />
|
||||||
<div style={{ marginBottom: 6, borderRadius: 4, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
|
||||||
<img src={nodeProps.bgImage} alt="" style={{ width: '100%', height: 80, objectFit: 'cover', display: 'block' }} />
|
|
||||||
<button onClick={() => setProp('bgImage', '')} style={{ position: 'absolute', top: 2, right: 2, width: 20, height: 20, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
||||||
<i className="fa fa-times" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
|
||||||
<button onClick={() => fileInputRef.current?.click()} style={{ ...btnActiveStyle(false), flex: 1 }}>
|
|
||||||
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<AssetBrowser filter="image" onSelect={(url) => setProp('bgImage', url)} />
|
|
||||||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
|
||||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleUpload(f, 'bgImage'); e.target.value = ''; }} />
|
|
||||||
<input type="text" value={nodeProps.bgImage || ''} placeholder="Or paste URL..."
|
|
||||||
onChange={(e) => setProp('bgImage', e.target.value)} style={{ ...smallInputStyle, width: '100%', marginTop: 4 }} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{bgType === 'video' && (
|
{bgType === 'video' && (
|
||||||
<div style={sectionGap}>
|
<div style={sectionGap}>
|
||||||
<label style={labelStyle}>Background Video</label>
|
<label style={labelStyle}>Background Video</label>
|
||||||
{nodeProps.bgVideo && (
|
<AssetPicker
|
||||||
<div style={{ marginBottom: 6, padding: 8, background: '#18181b', borderRadius: 4, border: '1px solid #3f3f46', display: 'flex', alignItems: 'center', gap: 6 }}>
|
mediaType="video"
|
||||||
<i className="fa fa-film" style={{ color: '#3b82f6' }} />
|
variant="full"
|
||||||
<span style={{ fontSize: 11, color: '#e4e4e7', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
value={nodeProps.bgVideo || ''}
|
||||||
{nodeProps.bgVideo.replace(/.*filename=/, '').replace(/^\d+_[a-f0-9]+_/, '') || nodeProps.bgVideo.split('/').pop()}
|
onChange={(url) => setProp('bgVideo', url)}
|
||||||
</span>
|
placeholder="YouTube, Vimeo, or .mp4 URL"
|
||||||
<button onClick={() => setProp('bgVideo', '')} style={{ background: 'none', border: 'none', color: '#ef4444', cursor: 'pointer', fontSize: 12 }}>
|
/>
|
||||||
<i className="fa fa-times" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
|
||||||
<button onClick={() => {
|
|
||||||
const input = document.createElement('input');
|
|
||||||
input.type = 'file';
|
|
||||||
input.accept = 'video/*';
|
|
||||||
input.onchange = () => { const f = input.files?.[0]; if (f) handleUpload(f, 'bgVideo'); };
|
|
||||||
input.click();
|
|
||||||
}} style={{ ...btnActiveStyle(false), flex: 1 }}>
|
|
||||||
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload Video
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<AssetBrowser filter="video" onSelect={(url) => setProp('bgVideo', url)} />
|
|
||||||
<input type="text" value={nodeProps.bgVideo || ''} placeholder="YouTube, Vimeo, or .mp4 URL"
|
|
||||||
onChange={(e) => setProp('bgVideo', e.target.value)} style={{ ...smallInputStyle, width: '100%', marginTop: 4 }} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useCallback, useRef, CSSProperties } from 'react';
|
import React, { useCallback, CSSProperties } from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
import {
|
import {
|
||||||
IMAGE_RADIUS_PRESETS,
|
IMAGE_RADIUS_PRESETS,
|
||||||
@@ -8,21 +8,13 @@ import {
|
|||||||
SectionLabel,
|
SectionLabel,
|
||||||
PresetButtonGrid,
|
PresetButtonGrid,
|
||||||
TextInputField,
|
TextInputField,
|
||||||
uploadToWhp,
|
|
||||||
} from './shared';
|
} from './shared';
|
||||||
|
import { AssetPicker } from '../../../ui/AssetPicker';
|
||||||
|
|
||||||
/* ---------- IMAGE (with upload/browse/drop) ---------- */
|
/* ---------- IMAGE (with upload/browse/drop) ---------- */
|
||||||
export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||||
const { actions } = useEditor();
|
const { actions } = useEditor();
|
||||||
const style: CSSProperties = nodeProps.style || {};
|
const style: CSSProperties = nodeProps.style || {};
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [showBrowser, setShowBrowser] = useState(false);
|
|
||||||
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
|
|
||||||
const [browserLoading, setBrowserLoading] = useState(false);
|
|
||||||
const [imgUrl, setImgUrl] = useState(nodeProps.src || '');
|
|
||||||
|
|
||||||
const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg";
|
|
||||||
const isPlaceholder = !nodeProps.src || nodeProps.src.startsWith('data:image/svg');
|
|
||||||
|
|
||||||
const setPropStyle = useCallback(
|
const setPropStyle = useCallback(
|
||||||
(property: string, value: string) => {
|
(property: string, value: string) => {
|
||||||
@@ -33,34 +25,6 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
|||||||
[actions, selectedId],
|
[actions, selectedId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUpload = useCallback(async (file: File) => {
|
|
||||||
const url = await uploadToWhp(file);
|
|
||||||
if (url) {
|
|
||||||
actions.setProp(selectedId, (props: any) => { props.src = url; });
|
|
||||||
setImgUrl(url);
|
|
||||||
}
|
|
||||||
}, [actions, selectedId]);
|
|
||||||
|
|
||||||
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 maxWidthPresets = [
|
const maxWidthPresets = [
|
||||||
{ label: '25%', value: '25%' },
|
{ label: '25%', value: '25%' },
|
||||||
{ label: '50%', value: '50%' },
|
{ label: '50%', value: '50%' },
|
||||||
@@ -68,117 +32,16 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
|||||||
{ label: '100%', value: '100%' },
|
{ label: '100%', value: '100%' },
|
||||||
];
|
];
|
||||||
|
|
||||||
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';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Image source with upload/browse */}
|
{/* Image source */}
|
||||||
<div className="guided-section">
|
<div className="guided-section">
|
||||||
<SectionLabel>Image Source</SectionLabel>
|
<SectionLabel>Image Source</SectionLabel>
|
||||||
|
<AssetPicker
|
||||||
{!isPlaceholder && nodeProps.src ? (
|
value={nodeProps.src || ''}
|
||||||
<>
|
onChange={(url) => actions.setProp(selectedId, (props: any) => { props.src = url; })}
|
||||||
<div style={{ marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
variant="full"
|
||||||
<img src={nodeProps.src} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 150, objectFit: 'cover' }} />
|
|
||||||
<button
|
|
||||||
onClick={() => { actions.setProp(selectedId, (props: any) => { props.src = ''; }); setImgUrl(''); }}
|
|
||||||
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(nodeProps.src || '')}</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Upload + Browse buttons */}
|
|
||||||
<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={() => {
|
|
||||||
actions.setProp(selectedId, (props: any) => { props.src = asset.url; });
|
|
||||||
setImgUrl(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 for advanced users */}
|
|
||||||
<div style={{ marginTop: 6 }}>
|
|
||||||
<div className="guided-input-row">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="guided-input"
|
|
||||||
value={imgUrl}
|
|
||||||
placeholder="Or paste image URL..."
|
|
||||||
onChange={(e) => setImgUrl(e.target.value)}
|
|
||||||
style={{ fontSize: 11 }}
|
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
className="preset-btn apply-btn"
|
|
||||||
onClick={() => {
|
|
||||||
actions.setProp(selectedId, (props: any) => { props.src = imgUrl; });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Alt Text */}
|
{/* Alt Text */}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
smallInputStyle,
|
smallInputStyle,
|
||||||
sectionGap,
|
sectionGap,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
|
import { AssetPicker } from '../../../ui/AssetPicker';
|
||||||
|
|
||||||
/* ---------- MEDIA (Video / Gallery / Map / Slider) ---------- */
|
/* ---------- MEDIA (Video / Gallery / Map / Slider) ---------- */
|
||||||
export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||||
@@ -97,13 +98,17 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
|||||||
renderItem={(item: any, index: number) => (
|
renderItem={(item: any, index: number) => (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
{item.src !== undefined && (
|
{item.src !== undefined && (
|
||||||
<input type="text" value={item.src || ''} onChange={(e) => {
|
<AssetPicker
|
||||||
|
variant="compact"
|
||||||
|
value={item.src || ''}
|
||||||
|
onChange={(url) => {
|
||||||
actions.setProp(selectedId, (props: any) => {
|
actions.setProp(selectedId, (props: any) => {
|
||||||
const updated = [...(props.images || [])];
|
const updated = [...(props.images || [])];
|
||||||
updated[index] = { ...updated[index], src: e.target.value };
|
updated[index] = { ...updated[index], src: url };
|
||||||
props.images = updated;
|
props.images = updated;
|
||||||
});
|
});
|
||||||
}} placeholder="Image URL" style={smallInputStyle} />
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{item.caption !== undefined && (
|
{item.caption !== undefined && (
|
||||||
<input type="text" value={item.caption || ''} onChange={(e) => {
|
<input type="text" value={item.caption || ''} onChange={(e) => {
|
||||||
@@ -149,13 +154,17 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
|||||||
}} placeholder="Text" rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} />
|
}} placeholder="Text" rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} />
|
||||||
)}
|
)}
|
||||||
{item.image !== undefined && (
|
{item.image !== undefined && (
|
||||||
<input type="text" value={item.image || ''} onChange={(e) => {
|
<AssetPicker
|
||||||
|
variant="compact"
|
||||||
|
value={item.image || ''}
|
||||||
|
onChange={(url) => {
|
||||||
actions.setProp(selectedId, (props: any) => {
|
actions.setProp(selectedId, (props: any) => {
|
||||||
const updated = [...(props.slides || [])];
|
const updated = [...(props.slides || [])];
|
||||||
updated[index] = { ...updated[index], image: e.target.value };
|
updated[index] = { ...updated[index], image: url };
|
||||||
props.slides = updated;
|
props.slides = updated;
|
||||||
});
|
});
|
||||||
}} placeholder="Image URL" style={smallInputStyle} />
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
smallInputStyle,
|
smallInputStyle,
|
||||||
sectionGap,
|
sectionGap,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
|
import { AssetPicker } from '../../../ui/AssetPicker';
|
||||||
|
|
||||||
/* ---------- NAV / MENU / LOGO ---------- */
|
/* ---------- NAV / MENU / LOGO ---------- */
|
||||||
export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||||
@@ -122,8 +123,8 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
|
|||||||
{nodeProps.type === 'image' && (
|
{nodeProps.type === 'image' && (
|
||||||
<>
|
<>
|
||||||
<div style={sectionGap}>
|
<div style={sectionGap}>
|
||||||
<label style={labelStyle}>Image URL</label>
|
<label style={labelStyle}>Logo Image</label>
|
||||||
<input type="text" value={nodeProps.imageSrc || ''} onChange={(e) => setProp('imageSrc', e.target.value)} placeholder="https://..." style={inputStyle} />
|
<AssetPicker value={nodeProps.imageSrc || ''} onChange={(url) => setProp('imageSrc', url)} variant="full" />
|
||||||
</div>
|
</div>
|
||||||
<div style={sectionGap}>
|
<div style={sectionGap}>
|
||||||
<label style={labelStyle}>Image Width</label>
|
<label style={labelStyle}>Image Width</label>
|
||||||
@@ -147,8 +148,8 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
|
|||||||
</div>
|
</div>
|
||||||
{nodeProps.logoImage !== undefined && (
|
{nodeProps.logoImage !== undefined && (
|
||||||
<div style={sectionGap}>
|
<div style={sectionGap}>
|
||||||
<label style={labelStyle}>Logo Image URL</label>
|
<label style={labelStyle}>Logo Image</label>
|
||||||
<input type="text" value={nodeProps.logoImage || ''} onChange={(e) => setProp('logoImage', e.target.value)} placeholder="https://..." style={inputStyle} />
|
<AssetPicker value={nodeProps.logoImage || ''} onChange={(url) => setProp('logoImage', url)} variant="full" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{nodeProps.logoUrl !== undefined && (
|
{nodeProps.logoUrl !== undefined && (
|
||||||
|
|||||||
Reference in New Issue
Block a user