site-builder: dedicated Features editor in guided panel (per-card icon/image+upload/button, robust to template items missing keys)
This commit is contained in:
@@ -0,0 +1,88 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { useEditor } from '@craftjs/core';
|
||||||
|
import { labelStyle, inputStyle, sectionGap } from './shared';
|
||||||
|
|
||||||
|
/* 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 {
|
||||||
|
title?: string; description?: string; icon?: string;
|
||||||
|
image?: string; imageAlt?: string; buttonText?: string; buttonUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dedicated per-feature editor for the FeaturesGrid guided panel. Unlike the
|
||||||
|
* generic array editor (which only shows fields already present on the item),
|
||||||
|
* this always exposes icon / image (upload or URL) / button controls, so it
|
||||||
|
* works on template-created features that lack those keys. An image renders in
|
||||||
|
* place of the icon whenever `image` is set.
|
||||||
|
*/
|
||||||
|
export const FeaturesEditor: React.FC<{ selectedId: string; features: unknown }> = ({ selectedId, features }) => {
|
||||||
|
const { actions } = useEditor();
|
||||||
|
const list: Feature[] = Array.isArray(features) ? (features as Feature[]) : [];
|
||||||
|
const fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
||||||
|
|
||||||
|
const mutate = (fn: (arr: Feature[]) => Feature[]) =>
|
||||||
|
actions.setProp(selectedId, (p: any) => { p.features = fn([...(p.features || [])]); });
|
||||||
|
const update = (i: number, field: keyof Feature, value: string) =>
|
||||||
|
mutate((arr) => { arr[i] = { ...arr[i], [field]: value }; return arr; });
|
||||||
|
const add = () =>
|
||||||
|
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 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 (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{list.map((feat, i) => (
|
||||||
|
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
|
<input type="text" value={feat.title || ''} onChange={(e) => update(i, 'title', e.target.value)} placeholder="Title" style={{ ...inputStyle, flex: 1 }} />
|
||||||
|
<button onClick={() => remove(i)} title="Remove" style={{ padding: '2px 8px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}>×</button>
|
||||||
|
</div>
|
||||||
|
<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 */}
|
||||||
|
{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' }}>
|
||||||
|
<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>
|
||||||
|
<input type="text" value={feat.image || ''} onChange={(e) => update(i, 'image', e.target.value)} placeholder="or image URL" style={{ ...inputStyle, flex: 1 }} />
|
||||||
|
</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 */}
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<input type="text" value={feat.buttonText || ''} onChange={(e) => update(i, 'buttonText', e.target.value)} placeholder="Button text (optional)" style={{ ...inputStyle, flex: 1 }} />
|
||||||
|
<input type="text" value={feat.buttonUrl || ''} onChange={(e) => update(i, 'buttonUrl', e.target.value)} placeholder="Button URL" style={{ ...inputStyle, flex: 1 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button onClick={add} style={{ ...labelStyle, padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', textAlign: 'center' }}>
|
||||||
|
+ Add Feature
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
smallInputStyle,
|
smallInputStyle,
|
||||||
sectionGap,
|
sectionGap,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
|
import { FeaturesEditor } from './FeaturesEditor';
|
||||||
|
|
||||||
/* ---------- SECTION-TYPE (Accordion, Tabs, Pricing, Testimonials, etc.) ---------- */
|
/* ---------- SECTION-TYPE (Accordion, Tabs, Pricing, Testimonials, etc.) ---------- */
|
||||||
export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }> = ({ selectedId, nodeProps, typeName }) => {
|
export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }> = ({ selectedId, nodeProps, typeName }) => {
|
||||||
@@ -96,6 +97,15 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
|
|||||||
|
|
||||||
{/* Array props (features, items, plans, testimonials, etc.) */}
|
{/* Array props (features, items, plans, testimonials, etc.) */}
|
||||||
{arrayProps.map(([key, items]) => {
|
{arrayProps.map(([key, items]) => {
|
||||||
|
// FeaturesGrid gets a dedicated editor (icon/image+upload/button per card),
|
||||||
|
// robust to template features that lack the image/button keys.
|
||||||
|
if (key === 'features') {
|
||||||
|
return (
|
||||||
|
<CollapsibleSection key={key} title="Features" defaultOpen>
|
||||||
|
<FeaturesEditor selectedId={selectedId} features={items} />
|
||||||
|
</CollapsibleSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
const arrayItems = items as any[];
|
const arrayItems = items as any[];
|
||||||
if (arrayItems.length === 0 && typeof arrayItems[0] !== 'object') return null;
|
if (arrayItems.length === 0 && typeof arrayItems[0] !== 'object') return null;
|
||||||
const sampleItem = arrayItems[0] || {};
|
const sampleItem = arrayItems[0] || {};
|
||||||
|
|||||||
Reference in New Issue
Block a user