323 lines
13 KiB
TypeScript
323 lines
13 KiB
TypeScript
import React, { useState, useCallback, CSSProperties } from 'react';
|
|||
|
|
import { useEditor } from '@craftjs/core';
|
||
|
|
import {
|
||
|
|
GRADIENTS,
|
||
|
|
} from '../../../constants/presets';
|
||
|
|
|
||
|
|
/* ---------- Helper: auto text color for bg ---------- */
|
||
|
|
export function autoTextColor(bg: string): string {
|
||
|
|
if (bg.startsWith('#')) {
|
||
|
|
const hex = bg.replace('#', '');
|
||
|
|
const r = parseInt(hex.substring(0, 2), 16);
|
||
|
|
const g = parseInt(hex.substring(2, 4), 16);
|
||
|
|
const b = parseInt(hex.substring(4, 6), 16);
|
||
|
|
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||
|
|
return luminance > 0.5 ? '#18181b' : '#ffffff';
|
||
|
|
}
|
||
|
|
return '#ffffff';
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---------- Helper: upload to WHP ---------- */
|
||
|
|
export 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; }
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---------- Shared inline styles ---------- */
|
||
|
|
export const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4, textTransform: 'capitalize' };
|
||
|
|
export const inputStyle: CSSProperties = { width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12, boxSizing: 'border-box' };
|
||
|
|
export const smallInputStyle: CSSProperties = { ...inputStyle, fontSize: 11, padding: '3px 6px' };
|
||
|
|
export const btnActiveStyle = (active: boolean): CSSProperties => ({
|
||
|
|
flex: 1, padding: '5px 4px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||
|
|
border: '1px solid #3f3f46',
|
||
|
|
background: active ? '#3b82f6' : '#27272a',
|
||
|
|
color: active ? '#fff' : '#a1a1aa',
|
||
|
|
fontWeight: active ? 600 : 400,
|
||
|
|
});
|
||
|
|
export const sectionGap: CSSProperties = { marginBottom: 14 };
|
||
|
|
|
||
|
|
/* ---------- Reusable sub-components ---------- */
|
||
|
|
|
||
|
|
interface SectionLabelProps { children: React.ReactNode; }
|
||
|
|
export const SectionLabel: React.FC<SectionLabelProps> = ({ children }) => (
|
||
|
|
<label className="guided-section-label">{children}</label>
|
||
|
|
);
|
||
|
|
|
||
|
|
interface ColorSwatchGridProps {
|
||
|
|
colors: { label: string; value: string }[];
|
||
|
|
activeValue: string | undefined;
|
||
|
|
onSelect: (value: string) => void;
|
||
|
|
}
|
||
|
|
export const ColorSwatchGrid: React.FC<ColorSwatchGridProps> = ({ colors, activeValue, onSelect }) => (
|
||
|
|
<div>
|
||
|
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 6 }}>
|
||
|
|
<input
|
||
|
|
type="color"
|
||
|
|
value={activeValue || '#000000'}
|
||
|
|
onChange={(e) => onSelect(e.target.value)}
|
||
|
|
style={{ width: 32, height: 28, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }}
|
||
|
|
/>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={activeValue || ''}
|
||
|
|
onChange={(e) => onSelect(e.target.value)}
|
||
|
|
placeholder="#000000"
|
||
|
|
style={{ flex: 1, padding: '3px 6px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, fontFamily: 'monospace', boxSizing: 'border-box' as const }}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="preset-grid">
|
||
|
|
{colors.map((c) => (
|
||
|
|
<button
|
||
|
|
key={c.value}
|
||
|
|
className={`preset-swatch ${activeValue === c.value ? 'active' : ''}`}
|
||
|
|
style={{ background: c.value }}
|
||
|
|
onClick={() => onSelect(c.value)}
|
||
|
|
title={c.label}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
|
||
|
|
interface PresetButtonGridProps {
|
||
|
|
presets: { label: string; value: string }[];
|
||
|
|
activeValue: string | undefined;
|
||
|
|
onSelect: (value: string) => void;
|
||
|
|
}
|
||
|
|
export const PresetButtonGrid: React.FC<PresetButtonGridProps> = ({ presets, activeValue, onSelect }) => (
|
||
|
|
<div className="preset-grid">
|
||
|
|
{presets.map((p) => (
|
||
|
|
<button
|
||
|
|
key={p.value}
|
||
|
|
className={`preset-btn ${String(activeValue) === p.value ? 'active' : ''}`}
|
||
|
|
onClick={() => onSelect(p.value)}
|
||
|
|
>
|
||
|
|
{p.label}
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
|
||
|
|
interface GradientSwatchGridProps {
|
||
|
|
activeValue: string | undefined;
|
||
|
|
onSelect: (value: string) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Parse "linear-gradient(135deg, #aaa 0%, #bbb 100%)" into parts */
|
||
|
|
function parseGradient(val: string | undefined): { angle: number; from: string; to: string } {
|
||
|
|
if (!val || val === 'none') return { angle: 135, from: '#667eea', to: '#764ba2' };
|
||
|
|
const m = val.match(/linear-gradient\(\s*(\d+)deg\s*,\s*(#[0-9a-fA-F]{3,8})\s*(?:\d+%?)?\s*,\s*(#[0-9a-fA-F]{3,8})/);
|
||
|
|
if (m) return { angle: parseInt(m[1]), from: m[2], to: m[3] };
|
||
|
|
return { angle: 135, from: '#667eea', to: '#764ba2' };
|
||
|
|
}
|
||
|
|
|
||
|
|
export const GradientSwatchGrid: React.FC<GradientSwatchGridProps> = ({ activeValue, onSelect }) => {
|
||
|
|
const [showCustom, setShowCustom] = useState(false);
|
||
|
|
const parsed = parseGradient(activeValue);
|
||
|
|
const [customFrom, setCustomFrom] = useState(parsed.from);
|
||
|
|
const [customTo, setCustomTo] = useState(parsed.to);
|
||
|
|
const [customAngle, setCustomAngle] = useState(parsed.angle);
|
||
|
|
|
||
|
|
const applyCustomGradient = (from: string, to: string, angle: number) => {
|
||
|
|
setCustomFrom(from);
|
||
|
|
setCustomTo(to);
|
||
|
|
setCustomAngle(angle);
|
||
|
|
onSelect(`linear-gradient(${angle}deg, ${from} 0%, ${to} 100%)`);
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div>
|
||
|
|
{/* Custom gradient builder toggle */}
|
||
|
|
<button
|
||
|
|
onClick={() => setShowCustom(!showCustom)}
|
||
|
|
style={{
|
||
|
|
width: '100%', padding: '5px 8px', fontSize: 11, marginBottom: 6,
|
||
|
|
background: showCustom ? '#3b82f6' : '#27272a', color: showCustom ? '#fff' : '#a1a1aa',
|
||
|
|
border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
|
||
|
|
display: 'flex', alignItems: 'center', gap: 6,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<i className={`fa fa-${showCustom ? 'chevron-down' : 'sliders'}`} style={{ fontSize: 10 }} />
|
||
|
|
Custom Gradient
|
||
|
|
</button>
|
||
|
|
{showCustom && (
|
||
|
|
<div style={{ padding: 8, background: '#1e1e22', borderRadius: 6, marginBottom: 8 }}>
|
||
|
|
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||
|
|
<div style={{ flex: 1 }}>
|
||
|
|
<label style={{ fontSize: 10, color: '#71717a', display: 'block', marginBottom: 3 }}>From</label>
|
||
|
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||
|
|
<input type="color" value={customFrom} onChange={(e) => applyCustomGradient(e.target.value, customTo, customAngle)}
|
||
|
|
style={{ width: 28, height: 24, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
|
||
|
|
<input type="text" value={customFrom} onChange={(e) => applyCustomGradient(e.target.value, customTo, customAngle)}
|
||
|
|
style={{ flex: 1, padding: '2px 4px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 3, fontSize: 10, fontFamily: 'monospace', boxSizing: 'border-box' as const }} />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div style={{ flex: 1 }}>
|
||
|
|
<label style={{ fontSize: 10, color: '#71717a', display: 'block', marginBottom: 3 }}>To</label>
|
||
|
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||
|
|
<input type="color" value={customTo} onChange={(e) => applyCustomGradient(customFrom, e.target.value, customAngle)}
|
||
|
|
style={{ width: 28, height: 24, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
|
||
|
|
<input type="text" value={customTo} onChange={(e) => applyCustomGradient(customFrom, e.target.value, customAngle)}
|
||
|
|
style={{ flex: 1, padding: '2px 4px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 3, fontSize: 10, fontFamily: 'monospace', boxSizing: 'border-box' as const }} />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label style={{ fontSize: 10, color: '#71717a', display: 'block', marginBottom: 3 }}>Angle: {customAngle}°</label>
|
||
|
|
<input type="range" min={0} max={360} value={customAngle} onChange={(e) => applyCustomGradient(customFrom, customTo, parseInt(e.target.value))}
|
||
|
|
style={{ width: '100%' }} />
|
||
|
|
</div>
|
||
|
|
{/* Live preview */}
|
||
|
|
<div style={{ height: 20, borderRadius: 4, marginTop: 6, background: `linear-gradient(${customAngle}deg, ${customFrom}, ${customTo})`, border: '1px solid #3f3f46' }} />
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{/* Preset swatches */}
|
||
|
|
<div className="preset-grid gradient-grid">
|
||
|
|
{GRADIENTS.map((g) => (
|
||
|
|
<button
|
||
|
|
key={g.label}
|
||
|
|
className={`preset-swatch gradient-swatch ${activeValue === g.value ? 'active' : ''}`}
|
||
|
|
style={{ background: g.value === 'none' ? '#27272a' : g.value }}
|
||
|
|
onClick={() => onSelect(g.value)}
|
||
|
|
title={g.label}
|
||
|
|
>
|
||
|
|
{g.value === 'none' ? '\u00D7' : ''}
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
interface TextInputFieldProps {
|
||
|
|
label: string;
|
||
|
|
value: string;
|
||
|
|
placeholder?: string;
|
||
|
|
onChange: (value: string) => void;
|
||
|
|
}
|
||
|
|
export const TextInputField: React.FC<TextInputFieldProps> = ({ label, value, placeholder, onChange }) => (
|
||
|
|
<div className="guided-section">
|
||
|
|
<SectionLabel>{label}</SectionLabel>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
className="guided-input"
|
||
|
|
value={value}
|
||
|
|
placeholder={placeholder}
|
||
|
|
onChange={(e) => onChange(e.target.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
|
||
|
|
/* ---------- Color picker with hex input ---------- */
|
||
|
|
interface ColorPickerFieldProps {
|
||
|
|
label: string;
|
||
|
|
value: string;
|
||
|
|
onChange: (value: string) => void;
|
||
|
|
}
|
||
|
|
export const ColorPickerField: React.FC<ColorPickerFieldProps> = ({ label, value, onChange }) => (
|
||
|
|
<div style={sectionGap}>
|
||
|
|
<label style={labelStyle}>{label}</label>
|
||
|
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||
|
|
<input
|
||
|
|
type="color"
|
||
|
|
value={value || '#000000'}
|
||
|
|
onChange={(e) => onChange(e.target.value)}
|
||
|
|
style={{ width: 36, height: 30, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }}
|
||
|
|
/>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={value || ''}
|
||
|
|
onChange={(e) => onChange(e.target.value)}
|
||
|
|
placeholder="#000000"
|
||
|
|
style={{ ...inputStyle, flex: 1 }}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
|
||
|
|
/* ---------- Collapsible section ---------- */
|
||
|
|
export const CollapsibleSection: React.FC<{ title: string; defaultOpen?: boolean; children: React.ReactNode }> = ({ title, defaultOpen = true, children }) => {
|
||
|
|
const [open, setOpen] = useState(defaultOpen);
|
||
|
|
return (
|
||
|
|
<div style={{ borderTop: '1px solid #2d2d3a', paddingTop: 8, marginTop: 4 }}>
|
||
|
|
<button
|
||
|
|
onClick={() => setOpen(!open)}
|
||
|
|
style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', background: 'none', border: 'none', color: '#a1a1aa', fontSize: 11, fontWeight: 600, cursor: 'pointer', padding: '4px 0', textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
||
|
|
>
|
||
|
|
<i className={`fa fa-chevron-${open ? 'down' : 'right'}`} style={{ fontSize: 8, width: 10 }} />
|
||
|
|
{title}
|
||
|
|
</button>
|
||
|
|
{open && <div style={{ paddingTop: 8 }}>{children}</div>}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
/* ---------- StylePanelProps interface ---------- */
|
||
|
|
export interface StylePanelProps {
|
||
|
|
selectedId: string;
|
||
|
|
nodeProps: Record<string, any>;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---------- Array Prop Editor (reusable for features, items, plans, etc.) ---------- */
|
||
|
|
interface ArrayPropEditorProps {
|
||
|
|
selectedId: string;
|
||
|
|
propKey: string;
|
||
|
|
items: any[];
|
||
|
|
renderItem: (item: any, index: number) => React.ReactNode;
|
||
|
|
emptyItem: any;
|
||
|
|
}
|
||
|
|
export const ArrayPropEditor: React.FC<ArrayPropEditorProps> = ({ selectedId, propKey, items, renderItem, emptyItem }) => {
|
||
|
|
const { actions } = useEditor();
|
||
|
|
|
||
|
|
const addItem = useCallback(() => {
|
||
|
|
actions.setProp(selectedId, (props: any) => {
|
||
|
|
props[propKey] = [...(props[propKey] || []), typeof emptyItem === 'object' ? { ...emptyItem } : emptyItem];
|
||
|
|
});
|
||
|
|
}, [actions, selectedId, propKey, emptyItem]);
|
||
|
|
|
||
|
|
const removeItem = useCallback((index: number) => {
|
||
|
|
actions.setProp(selectedId, (props: any) => {
|
||
|
|
const updated = [...(props[propKey] || [])];
|
||
|
|
updated.splice(index, 1);
|
||
|
|
props[propKey] = updated;
|
||
|
|
});
|
||
|
|
}, [actions, selectedId, propKey]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div>
|
||
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||
|
|
{items.map((item, i) => (
|
||
|
|
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 6, position: 'relative' }}>
|
||
|
|
<button
|
||
|
|
onClick={() => removeItem(i)}
|
||
|
|
style={{ position: 'absolute', top: 4, right: 4, padding: '1px 5px', fontSize: 9, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', zIndex: 1 }}
|
||
|
|
title="Remove"
|
||
|
|
>
|
||
|
|
<i className="fa fa-times" />
|
||
|
|
</button>
|
||
|
|
{renderItem(item, i)}
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
<button
|
||
|
|
onClick={addItem}
|
||
|
|
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||
|
|
>
|
||
|
|
+ Add Item
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|