Refs #32 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
79 lines
4.8 KiB
TypeScript
79 lines
4.8 KiB
TypeScript
import React, { useEffect, useRef } from 'react';
|
||
import { useEditor } from '@craftjs/core';
|
||
import { labelStyle, inputStyle, sectionGap, continuousSetProp, discreteSetProp } from './shared';
|
||
import { AssetPicker } from '../../../ui/AssetPicker';
|
||
import { useLayerFocus } from '../../left/LayerFocusContext';
|
||
|
||
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 mutate = (fn: (arr: Feature[]) => Feature[]) =>
|
||
discreteSetProp(actions, selectedId, (p: any) => { p.features = fn([...(p.features || [])]); });
|
||
// Field edits are typing bursts: one undo step per field per burst.
|
||
const update = (i: number, field: keyof Feature, value: string) =>
|
||
continuousSetProp(actions, selectedId, `${i}:${field}`, (p: any) => {
|
||
const arr = [...(p.features || [])]; arr[i] = { ...arr[i], [field]: value }; p.features = 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; });
|
||
|
||
// Layers panel -> array editor "scroll to this item" hookup. See
|
||
// ArrayItemFields.tsx for the generic-editor counterpart; this component
|
||
// keeps its own per-feature cards, so it tags/scrolls them directly.
|
||
const { focus } = useLayerFocus();
|
||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!focus || focus.prop !== 'features') return;
|
||
const card = rootRef.current?.querySelector(`[data-array-item="features:${focus.index}"]`);
|
||
card?.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' });
|
||
// `focus.nonce` is in the dep list so clicking the SAME row twice
|
||
// re-scrolls (the request object is otherwise identical).
|
||
}, [focus?.nonce, focus?.prop, focus?.index]);
|
||
|
||
return (
|
||
<div ref={rootRef} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
{list.map((feat, i) => (
|
||
<div key={i} data-array-item={`features:${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 */}
|
||
<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' }} />
|
||
<div style={{ flex: 1 }}>
|
||
<AssetPicker variant="compact" value={feat.image || ''} onChange={(url) => update(i, 'image', url)} placeholder="or image URL" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* 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>
|
||
);
|
||
};
|