2026-04-05 18:31:16 -07:00
|
|
|
import React, { useState, useCallback, CSSProperties } from 'react';
|
|
|
|
|
import { useEditor } from '@craftjs/core';
|
|
|
|
|
import {
|
|
|
|
|
GRADIENTS,
|
2026-07-14 06:07:20 -07:00
|
|
|
SIZE_PRESETS,
|
|
|
|
|
ASPECT_RATIOS,
|
|
|
|
|
SPACING_PRESETS,
|
|
|
|
|
BORDER_STYLES,
|
2026-04-05 18:31:16 -07:00
|
|
|
} from '../../../constants/presets';
|
2026-07-12 12:43:59 -07:00
|
|
|
import { uploadAsset } from '../../../utils/assets';
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
/* ---------- 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';
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 12:43:59 -07:00
|
|
|
/* ---------- Helper: upload to WHP ----------
|
|
|
|
|
Thin re-export over the shared `uploadAsset` util (`@/utils/assets`) so
|
|
|
|
|
the existing callers importing `uploadToWhp` from here keep working
|
|
|
|
|
unchanged. */
|
|
|
|
|
export const uploadToWhp = uploadAsset;
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
/* ---------- 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;
|
2026-07-12 20:14:38 -07:00
|
|
|
/** Explicit column count. When omitted, a column count is derived from
|
|
|
|
|
* `presets.length` (see `defaultPresetGridColumns`) so odd-sized preset
|
|
|
|
|
* sets (5, 6, ...) don't leave a lone orphan button dangling on its own
|
|
|
|
|
* row under the fixed 4-column grid. */
|
|
|
|
|
columns?: number;
|
2026-04-05 18:31:16 -07:00
|
|
|
}
|
2026-07-12 20:14:38 -07:00
|
|
|
|
|
|
|
|
/** Picks a column count that avoids a single orphan on the last row.
|
|
|
|
|
* 4-or-fewer presets keep the classic single row of 4. 5 gets its own
|
|
|
|
|
* row (5 cols). 6 splits into two even rows of 3. Anything else falls
|
|
|
|
|
* back to a 4- or 3-column grid depending on which divides evenly. */
|
|
|
|
|
export function defaultPresetGridColumns(count: number): number {
|
|
|
|
|
if (count <= 4) return 4;
|
|
|
|
|
if (count === 5) return 5;
|
|
|
|
|
if (count === 6) return 3;
|
|
|
|
|
if (count % 4 === 0) return 4;
|
|
|
|
|
if (count % 3 === 0) return 3;
|
|
|
|
|
return 4;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const PresetButtonGrid: React.FC<PresetButtonGridProps> = ({ presets, activeValue, onSelect, columns }) => {
|
|
|
|
|
const cols = columns ?? defaultPresetGridColumns(presets.length);
|
|
|
|
|
return (
|
|
|
|
|
<div className="preset-grid" style={{ gridTemplateColumns: `repeat(${cols}, 1fr)` }}>
|
|
|
|
|
{presets.map((p) => (
|
|
|
|
|
<button
|
|
|
|
|
key={p.value}
|
|
|
|
|
className={`preset-btn ${String(activeValue) === p.value ? 'active' : ''}`}
|
|
|
|
|
onClick={() => onSelect(p.value)}
|
|
|
|
|
>
|
|
|
|
|
{p.label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-10 13:10:46 -07:00
|
|
|
/* ---------- Nav-family color fields ----------
|
|
|
|
|
The Colors section is shared across the whole nav family (Navbar / Logo /
|
|
|
|
|
Footer / Menu). Those components DON'T share a color-prop schema:
|
|
|
|
|
Navbar/Logo/Footer use backgroundColor/textColor/ctaColor, while Menu uses
|
|
|
|
|
linkColor/linkHoverColor/ctaBgColor/ctaTextColor. Deriving the visible fields
|
|
|
|
|
from whichever props actually exist keeps the section from rendering empty
|
|
|
|
|
(the "Colors dropdown with nothing to select" bug on Menu). */
|
|
|
|
|
export interface NavColorField {
|
|
|
|
|
key: string;
|
|
|
|
|
label: string;
|
|
|
|
|
fallback: string;
|
|
|
|
|
}
|
|
|
|
|
const NAV_COLOR_FIELDS: NavColorField[] = [
|
|
|
|
|
// Navbar / Logo / Footer schema
|
|
|
|
|
{ key: 'backgroundColor', label: 'Background', fallback: '#ffffff' },
|
|
|
|
|
{ key: 'textColor', label: 'Text Color', fallback: '#18181b' },
|
|
|
|
|
{ key: 'hoverColor', label: 'Hover Color', fallback: '#3b82f6' },
|
|
|
|
|
{ key: 'ctaColor', label: 'CTA Color', fallback: '#3b82f6' },
|
|
|
|
|
// Menu schema (distinct prop names)
|
|
|
|
|
{ key: 'linkColor', label: 'Link Color', fallback: '#3f3f46' },
|
|
|
|
|
{ key: 'linkHoverColor', label: 'Hover Color', fallback: '#3b82f6' },
|
|
|
|
|
{ key: 'ctaBgColor', label: 'CTA Background', fallback: '#3b82f6' },
|
|
|
|
|
// ctaTextColor is shared: Menu's CTA text AND Navbar's CTA text
|
|
|
|
|
{ key: 'ctaTextColor', label: 'CTA Text', fallback: '#ffffff' },
|
|
|
|
|
];
|
|
|
|
|
export function navColorFields(nodeProps: Record<string, any>): NavColorField[] {
|
|
|
|
|
return NAV_COLOR_FIELDS.filter((f) => nodeProps[f.key] !== undefined);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
/* ---------- 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>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 15:13:35 -07:00
|
|
|
/* ---------- useNodeProp ----------
|
|
|
|
|
Shared setProp/setPropStyle boilerplate repeated across many *StylePanel.tsx
|
|
|
|
|
files. Only adopted where a panel's inline definitions were byte-equivalent
|
|
|
|
|
to this (some panels use different param names or extra logic and keep
|
|
|
|
|
their own local versions). */
|
|
|
|
|
export function useNodeProp(selectedId: string) {
|
|
|
|
|
const { actions } = useEditor();
|
|
|
|
|
const setProp = useCallback((key: string, value: any) => actions.setProp(selectedId, (p: any) => { p[key] = value; }), [actions, selectedId]);
|
|
|
|
|
const setPropStyle = useCallback((prop: string, value: string) => actions.setProp(selectedId, (p: any) => { p.style = { ...p.style, [prop]: value }; }), [actions, selectedId]);
|
|
|
|
|
return { setProp, setPropStyle };
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
/* ---------- 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>
|
|
|
|
|
);
|
|
|
|
|
};
|
2026-07-14 06:07:20 -07:00
|
|
|
|
|
|
|
|
/* =========================================================================
|
|
|
|
|
ENH-Foundation: reusable StylePanel controls
|
|
|
|
|
---------------------------------------------------------------------
|
|
|
|
|
Presentational (value + onChange) controls shared by upcoming feature
|
|
|
|
|
panels (size/spacing/border/typography/animation/visibility rollout).
|
|
|
|
|
Nothing below is wired into any feature panel yet -- that's the job of
|
|
|
|
|
the downstream feature branches. Each control's onChange API is
|
|
|
|
|
documented on its props interface; see
|
|
|
|
|
.superpowers/sdd/task-enh-foundation-report.md for the consolidated list.
|
|
|
|
|
========================================================================= */
|
|
|
|
|
|
|
|
|
|
/* ---------- NumericUnitInput ----------
|
|
|
|
|
A number field + unit dropdown that parses/holds a CSS length string
|
|
|
|
|
("16px", "50%", "auto", "") and calls onChange(nextString).
|
|
|
|
|
- Clearing the number field emits '' (not '0px') -- lets the consumer
|
|
|
|
|
treat '' as "unset/inherit" instead of forcing a 0 value.
|
|
|
|
|
- value === 'auto' or '' both display as a blank number field (with the
|
|
|
|
|
`placeholder` -- default "auto" -- shown as a hint).
|
|
|
|
|
- Changing the unit re-emits with the current number and the new unit;
|
|
|
|
|
if the number is blank, changing the unit is a no-op (still emits '').
|
|
|
|
|
- `units` is configurable (default ['px','%']); the unit <select> is
|
|
|
|
|
omitted entirely when only one unit is passed. */
|
|
|
|
|
export function parseCssLength(value: string | undefined, units: string[] = ['px', '%']): { num: string; unit: string } {
|
|
|
|
|
if (!value || value === 'auto') return { num: '', unit: units[0] };
|
|
|
|
|
const m = String(value).trim().match(/^(-?\d*\.?\d+)\s*([a-zA-Z%]*)$/);
|
|
|
|
|
if (!m) return { num: '', unit: units[0] };
|
|
|
|
|
const unit = m[2] || units[0];
|
|
|
|
|
return { num: m[1], unit: units.includes(unit) ? unit : units[0] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface NumericUnitInputProps {
|
|
|
|
|
/** CSS length string, e.g. "16px" / "50%" / "auto" / "". */
|
|
|
|
|
value: string;
|
|
|
|
|
/** Called with the recombined string, e.g. "16px". Called with '' when the
|
|
|
|
|
* number field is cleared. */
|
|
|
|
|
onChange: (value: string) => void;
|
|
|
|
|
/** Allowed unit list; default ['px', '%']. Unit <select> is hidden when
|
|
|
|
|
* this has length 1. */
|
|
|
|
|
units?: string[];
|
|
|
|
|
min?: number;
|
|
|
|
|
max?: number;
|
|
|
|
|
step?: number;
|
|
|
|
|
placeholder?: string;
|
|
|
|
|
/** Base for the data-testid attrs (`${testId}`, `${testId}-number`,
|
|
|
|
|
* `${testId}-unit`); default 'numeric-unit'. */
|
|
|
|
|
testId?: string;
|
|
|
|
|
}
|
|
|
|
|
export const NumericUnitInput: React.FC<NumericUnitInputProps> = ({
|
|
|
|
|
value, onChange, units = ['px', '%'], min, max, step, placeholder = 'auto', testId = 'numeric-unit',
|
|
|
|
|
}) => {
|
|
|
|
|
const { num, unit } = parseCssLength(value, units);
|
|
|
|
|
const emit = (nextNum: string, nextUnit: string) => {
|
|
|
|
|
if (nextNum === '') { onChange(''); return; }
|
|
|
|
|
onChange(`${nextNum}${nextUnit}`);
|
|
|
|
|
};
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ display: 'flex', gap: 4 }} data-testid={testId}>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
data-testid={`${testId}-number`}
|
|
|
|
|
value={num}
|
|
|
|
|
min={min}
|
|
|
|
|
max={max}
|
|
|
|
|
step={step}
|
|
|
|
|
placeholder={placeholder}
|
|
|
|
|
onChange={(e) => emit(e.target.value, unit)}
|
|
|
|
|
style={{ ...inputStyle, flex: 1 }}
|
|
|
|
|
/>
|
|
|
|
|
{units.length > 1 && (
|
|
|
|
|
<select
|
|
|
|
|
data-testid={`${testId}-unit`}
|
|
|
|
|
value={unit}
|
|
|
|
|
onChange={(e) => emit(num, e.target.value)}
|
|
|
|
|
style={{ ...inputStyle, width: 60, flex: 'none' }}
|
|
|
|
|
>
|
|
|
|
|
{units.map((u) => <option key={u} value={u}>{u}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ---------- SizeControl ----------
|
|
|
|
|
Width (or height) editor: preset buttons (25/50/75/100%, Auto, Full) +
|
|
|
|
|
a custom NumericUnitInput. One dimension per instance -- render two
|
|
|
|
|
(label="Width" / label="Height") for components needing both, e.g.
|
|
|
|
|
ImageBlock / VideoBlock sizing. */
|
|
|
|
|
export interface SizeControlProps {
|
|
|
|
|
label: string;
|
|
|
|
|
/** CSS value for this one dimension, e.g. "100%" / "400px" / "auto" / "". */
|
|
|
|
|
value: string;
|
|
|
|
|
onChange: (value: string) => void;
|
|
|
|
|
presets?: { label: string; value: string }[];
|
|
|
|
|
units?: string[];
|
|
|
|
|
}
|
|
|
|
|
export const SizeControl: React.FC<SizeControlProps> = ({ label, value, onChange, presets = SIZE_PRESETS, units = ['px', '%'] }) => (
|
|
|
|
|
<div className="guided-section" data-testid="size-control">
|
|
|
|
|
<SectionLabel>{label}</SectionLabel>
|
|
|
|
|
<PresetButtonGrid presets={presets} activeValue={value} onSelect={onChange} />
|
|
|
|
|
<div style={{ marginTop: 6 }}>
|
|
|
|
|
<NumericUnitInput
|
|
|
|
|
value={presets.some((p) => p.value === value) ? '' : value}
|
|
|
|
|
onChange={onChange}
|
|
|
|
|
units={units}
|
|
|
|
|
testId="size-control-custom"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/* ---------- AspectRatioControl ----------
|
|
|
|
|
Preset buttons + a custom "W:H" text entry. Emits a CSS `aspect-ratio`
|
|
|
|
|
value, e.g. "16 / 9". 'Original' emits ''. Custom entry accepts
|
|
|
|
|
"16:9" / "16/9" / "16x9" and is parsed on blur or Enter. */
|
|
|
|
|
export function parseAspectRatioInput(raw: string): string {
|
|
|
|
|
const m = raw.trim().match(/^(\d+(?:\.\d+)?)\s*[:xX/]\s*(\d+(?:\.\d+)?)$/);
|
|
|
|
|
if (!m) return '';
|
|
|
|
|
return `${m[1]} / ${m[2]}`;
|
|
|
|
|
}
|
|
|
|
|
function formatAspectRatioForInput(value: string): string {
|
|
|
|
|
const m = value.match(/^(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)$/);
|
|
|
|
|
return m ? `${m[1]}:${m[2]}` : '';
|
|
|
|
|
}
|
|
|
|
|
export interface AspectRatioControlProps {
|
|
|
|
|
/** CSS `aspect-ratio` value, e.g. "16 / 9", or '' for Original/unset. */
|
|
|
|
|
value: string;
|
|
|
|
|
onChange: (value: string) => void;
|
|
|
|
|
presets?: { label: string; value: string }[];
|
|
|
|
|
}
|
|
|
|
|
export const AspectRatioControl: React.FC<AspectRatioControlProps> = ({ value, onChange, presets = ASPECT_RATIOS }) => {
|
|
|
|
|
const [custom, setCustom] = useState(() => formatAspectRatioForInput(value));
|
|
|
|
|
|
|
|
|
|
const applyCustom = () => onChange(parseAspectRatioInput(custom));
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="guided-section" data-testid="aspect-ratio-control">
|
|
|
|
|
<SectionLabel>Aspect Ratio</SectionLabel>
|
|
|
|
|
<PresetButtonGrid presets={presets} activeValue={value} onSelect={(v) => { onChange(v); setCustom(''); }} />
|
|
|
|
|
<div style={{ marginTop: 6 }}>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
data-testid="aspect-ratio-custom-input"
|
|
|
|
|
value={custom}
|
|
|
|
|
placeholder="W:H e.g. 16:9"
|
|
|
|
|
onChange={(e) => setCustom(e.target.value)}
|
|
|
|
|
onBlur={applyCustom}
|
|
|
|
|
onKeyDown={(e) => { if (e.key === 'Enter') applyCustom(); }}
|
|
|
|
|
style={{ ...inputStyle }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ---------- FocalPointGrid ----------
|
|
|
|
|
3x3 grid of buttons mapping to CSS `object-position` keyword pairs.
|
|
|
|
|
Used for image crop framing (object-position on an ImageBlock/VideoBlock
|
|
|
|
|
with object-fit: cover). Highlights the active cell; defaults the
|
|
|
|
|
highlight to 'center' when value is empty. */
|
|
|
|
|
export const FOCAL_POINTS: { label: string; value: string }[] = [
|
|
|
|
|
{ label: '↖', value: 'left top' }, { label: '↑', value: 'center top' }, { label: '↗', value: 'right top' },
|
|
|
|
|
{ label: '←', value: 'left center' }, { label: '•', value: 'center' }, { label: '→', value: 'right center' },
|
|
|
|
|
{ label: '↙', value: 'left bottom' }, { label: '↓', value: 'center bottom' }, { label: '↘', value: 'right bottom' },
|
|
|
|
|
];
|
|
|
|
|
export interface FocalPointGridProps {
|
|
|
|
|
/** CSS `object-position` value, e.g. "center" / "left top". */
|
|
|
|
|
value: string;
|
|
|
|
|
onChange: (value: string) => void;
|
|
|
|
|
}
|
|
|
|
|
export const FocalPointGrid: React.FC<FocalPointGridProps> = ({ value, onChange }) => {
|
|
|
|
|
const active = value || 'center';
|
|
|
|
|
return (
|
|
|
|
|
<div className="guided-section" data-testid="focal-point-grid">
|
|
|
|
|
<SectionLabel>Focal Point</SectionLabel>
|
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, width: 96 }}>
|
|
|
|
|
{FOCAL_POINTS.map((p) => (
|
|
|
|
|
<button
|
|
|
|
|
key={p.value}
|
|
|
|
|
data-testid={`focal-point-${p.value.replace(/\s+/g, '-')}`}
|
|
|
|
|
onClick={() => onChange(p.value)}
|
|
|
|
|
title={p.value}
|
|
|
|
|
style={{
|
|
|
|
|
aspectRatio: '1', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
|
|
|
|
|
background: active === p.value ? '#3b82f6' : '#27272a',
|
|
|
|
|
color: active === p.value ? '#fff' : '#71717a', fontSize: 12,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{p.label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ---------- SpacingControl ----------
|
|
|
|
|
Margin OR padding, per-side, with a link/unlink toggle.
|
|
|
|
|
API: onChange(side, value) -- called once per side. In linked mode
|
|
|
|
|
(default), editing the single shared field or clicking a preset calls
|
|
|
|
|
onChange for all four sides ('top','right','bottom','left') with the
|
|
|
|
|
same value so the consumer can setPropStyle each side independently
|
|
|
|
|
(e.g. marginTop/marginRight/marginBottom/marginLeft). In unlinked mode
|
|
|
|
|
each side's NumericUnitInput calls onChange for just that side. */
|
|
|
|
|
export type SpacingSide = 'top' | 'right' | 'bottom' | 'left';
|
|
|
|
|
export interface SpacingSideValues { top?: string; right?: string; bottom?: string; left?: string; }
|
|
|
|
|
const SPACING_SIDES: SpacingSide[] = ['top', 'right', 'bottom', 'left'];
|
|
|
|
|
export interface SpacingControlProps {
|
|
|
|
|
label: string;
|
|
|
|
|
value: SpacingSideValues;
|
|
|
|
|
/** Called per-side. Linked edits/presets call this once for each of the
|
|
|
|
|
* 4 sides (in top/right/bottom/left order) with the same value. */
|
|
|
|
|
onChange: (side: SpacingSide, value: string) => void;
|
|
|
|
|
presets?: { label: string; value: string }[];
|
|
|
|
|
units?: string[];
|
|
|
|
|
}
|
|
|
|
|
export const SpacingControl: React.FC<SpacingControlProps> = ({ label, value, onChange, presets = SPACING_PRESETS, units = ['px', '%', 'em', 'rem'] }) => {
|
|
|
|
|
const [linked, setLinked] = useState(true);
|
|
|
|
|
|
|
|
|
|
const applyToAllSides = (v: string) => SPACING_SIDES.forEach((s) => onChange(s, v));
|
|
|
|
|
const linkedValue = value.top ?? '';
|
|
|
|
|
const isAllEqual = SPACING_SIDES.every((s) => (value[s] ?? '') === linkedValue);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="guided-section" data-testid="spacing-control">
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
|
|
|
<SectionLabel>{label}</SectionLabel>
|
|
|
|
|
<button
|
|
|
|
|
data-testid="spacing-link-toggle"
|
|
|
|
|
onClick={() => setLinked(!linked)}
|
|
|
|
|
title={linked ? 'Unlink sides' : 'Link sides'}
|
|
|
|
|
style={{ background: 'none', border: 'none', color: linked ? '#3b82f6' : '#71717a', cursor: 'pointer', padding: 2 }}
|
|
|
|
|
>
|
|
|
|
|
<i className={`fa fa-link${linked ? '' : '-slash'}`} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<PresetButtonGrid presets={presets} activeValue={isAllEqual ? linkedValue : undefined} onSelect={applyToAllSides} />
|
|
|
|
|
{linked ? (
|
|
|
|
|
<div style={{ marginTop: 6 }}>
|
|
|
|
|
<NumericUnitInput value={linkedValue} onChange={applyToAllSides} units={units} testId="spacing-linked" />
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginTop: 6 }}>
|
|
|
|
|
{SPACING_SIDES.map((s) => (
|
|
|
|
|
<div key={s}>
|
|
|
|
|
<label style={{ ...labelStyle, fontSize: 9 }}>{s}</label>
|
|
|
|
|
<NumericUnitInput value={value[s] ?? ''} onChange={(v) => onChange(s, v)} units={units} testId={`spacing-${s}`} />
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ---------- BorderControl ----------
|
|
|
|
|
Width (px) + style (none/solid/dashed/dotted) + color. Single onChange
|
|
|
|
|
receives the merged { width, style, color } object so the consumer can
|
|
|
|
|
either setProp each part or build a shorthand via buildBorderShorthand()
|
|
|
|
|
(returns 'none' when width is falsy or style is 'none'). */
|
|
|
|
|
export interface BorderValue { width: string; style: string; color: string; }
|
|
|
|
|
export interface BorderControlProps {
|
|
|
|
|
value: BorderValue;
|
|
|
|
|
onChange: (value: BorderValue) => void;
|
|
|
|
|
label?: string;
|
|
|
|
|
}
|
|
|
|
|
export const BorderControl: React.FC<BorderControlProps> = ({ value, onChange, label = 'Border' }) => {
|
|
|
|
|
const set = (patch: Partial<BorderValue>) => onChange({ ...value, ...patch });
|
|
|
|
|
return (
|
|
|
|
|
<div className="guided-section" data-testid="border-control">
|
|
|
|
|
<SectionLabel>{label}</SectionLabel>
|
|
|
|
|
<div style={{ display: 'flex', gap: 6, marginBottom: 6 }}>
|
|
|
|
|
<div style={{ flex: 1 }}>
|
|
|
|
|
<NumericUnitInput value={value.width} onChange={(v) => set({ width: v })} units={['px']} placeholder="0" testId="border-width" />
|
|
|
|
|
</div>
|
|
|
|
|
<select
|
|
|
|
|
data-testid="border-style-select"
|
|
|
|
|
value={value.style}
|
|
|
|
|
onChange={(e) => set({ style: e.target.value })}
|
|
|
|
|
style={{ ...inputStyle, flex: 1 }}
|
|
|
|
|
>
|
|
|
|
|
{BORDER_STYLES.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<ColorPickerField label="Border Color" value={value.color} onChange={(v) => set({ color: v })} />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
export function buildBorderShorthand(value: BorderValue): string {
|
|
|
|
|
if (!value.width || !value.style || value.style === 'none') return 'none';
|
|
|
|
|
return `${value.width} ${value.style} ${value.color || '#000000'}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------- AnimationControl ----------
|
|
|
|
|
"Entrance animation" picker + optional delay. Emits the EXACT prop names
|
|
|
|
|
the export machinery already consumes (verified against buildDataAttrs()
|
|
|
|
|
in src/utils/html-export.ts): `animation` and `animationDelay`.
|
|
|
|
|
NOTE: the export supports fade-in / slide-up / slide-left / slide-right /
|
|
|
|
|
zoom-in / bounce (no "slide-down" -- html-export.ts has no such variant;
|
|
|
|
|
slide-left/slide-right exist instead), so ANIMATIONS reflects that exact
|
|
|
|
|
set rather than guessing. animationDelay is a plain seconds string (e.g.
|
|
|
|
|
"0.3") -- html-export.ts's inline script assigns it straight to
|
|
|
|
|
el.style.animationDelay, so consumers should suffix/accept a unit-bearing
|
|
|
|
|
string like "0.3s" if that's what they choose to store; this control
|
|
|
|
|
stores/edits the raw numeric string and leaves unit formatting to the
|
|
|
|
|
caller (kept as the simplest contract; see report for the exact
|
|
|
|
|
behavior verified against html-export.ts). */
|
|
|
|
|
export const ANIMATIONS: { label: string; value: string }[] = [
|
|
|
|
|
{ label: 'None', value: 'none' },
|
|
|
|
|
{ label: 'Fade In', value: 'fade-in' },
|
|
|
|
|
{ label: 'Slide Up', value: 'slide-up' },
|
|
|
|
|
{ label: 'Slide Left', value: 'slide-left' },
|
|
|
|
|
{ label: 'Slide Right', value: 'slide-right' },
|
|
|
|
|
{ label: 'Zoom In', value: 'zoom-in' },
|
|
|
|
|
{ label: 'Bounce', value: 'bounce' },
|
|
|
|
|
];
|
|
|
|
|
export interface AnimationValue { animation: string; animationDelay?: string; }
|
|
|
|
|
export interface AnimationControlProps {
|
|
|
|
|
value: AnimationValue;
|
|
|
|
|
onChange: (value: AnimationValue) => void;
|
|
|
|
|
}
|
|
|
|
|
export const AnimationControl: React.FC<AnimationControlProps> = ({ value, onChange }) => {
|
|
|
|
|
const animation = value.animation || 'none';
|
|
|
|
|
const animationDelay = value.animationDelay ?? '0';
|
|
|
|
|
return (
|
|
|
|
|
<div className="guided-section" data-testid="animation-control">
|
|
|
|
|
<SectionLabel>Entrance Animation</SectionLabel>
|
|
|
|
|
<PresetButtonGrid presets={ANIMATIONS} activeValue={animation} onSelect={(v) => onChange({ animation: v, animationDelay })} />
|
|
|
|
|
{animation !== 'none' && (
|
|
|
|
|
<div style={{ marginTop: 6 }}>
|
|
|
|
|
<label style={labelStyle}>Delay (seconds)</label>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
data-testid="animation-delay-input"
|
|
|
|
|
min={0}
|
|
|
|
|
step={0.1}
|
|
|
|
|
value={animationDelay}
|
|
|
|
|
onChange={(e) => onChange({ animation, animationDelay: e.target.value })}
|
|
|
|
|
style={inputStyle}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ---------- VisibilityControl ----------
|
|
|
|
|
Hide on Desktop / Tablet / Mobile toggles. Emits the EXACT prop names
|
|
|
|
|
the export machinery already consumes (verified against buildDataAttrs()
|
|
|
|
|
in src/utils/html-export.ts): `hideOnDesktop`, `hideOnTablet`,
|
|
|
|
|
`hideOnMobile` (each rendered as data-hide-desktop/tablet/mobile when
|
|
|
|
|
truthy). Single onChange receives the merged object. */
|
|
|
|
|
export interface VisibilityValue { hideOnDesktop?: boolean; hideOnTablet?: boolean; hideOnMobile?: boolean; }
|
|
|
|
|
export interface VisibilityControlProps {
|
|
|
|
|
value: VisibilityValue;
|
|
|
|
|
onChange: (value: VisibilityValue) => void;
|
|
|
|
|
}
|
|
|
|
|
const VISIBILITY_KEYS: (keyof VisibilityValue)[] = ['hideOnDesktop', 'hideOnTablet', 'hideOnMobile'];
|
|
|
|
|
export const VisibilityControl: React.FC<VisibilityControlProps> = ({ value, onChange }) => (
|
|
|
|
|
<div className="guided-section" data-testid="visibility-control">
|
|
|
|
|
<SectionLabel>Visibility</SectionLabel>
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
|
|
|
{VISIBILITY_KEYS.map((key) => (
|
|
|
|
|
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#e4e4e7', cursor: 'pointer' }}>
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
data-testid={`visibility-${key}`}
|
|
|
|
|
checked={!!value[key]}
|
|
|
|
|
onChange={() => onChange({ ...value, [key]: !value[key] })}
|
|
|
|
|
/>
|
|
|
|
|
Hide on {key.replace('hideOn', '')}
|
|
|
|
|
</label>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|