668 lines
26 KiB
TypeScript
668 lines
26 KiB
TypeScript
import React, { useCallback, useState } from 'react';
|
|
import Palette from './Palette/Palette';
|
|
import Canvas from './Canvas/Canvas';
|
|
import ProjectToolbar from '../ProjectToolbar/ProjectToolbar';
|
|
import { useProject } from '../../context/ProjectContext';
|
|
import styles from './VisualEditor.module.css';
|
|
import type { ComponentType, DropdownOption, TableColumn, TableRow } from '../../types/project';
|
|
|
|
// ── Dropdown options row editor ───────────────────────────────────────────────
|
|
|
|
type DropdownOptionsEditorProps = {
|
|
componentId: string;
|
|
options: DropdownOption[];
|
|
configuredValue: string;
|
|
onOptionsChange: (options: DropdownOption[]) => void;
|
|
onConfiguredValueChange: (value: string) => void;
|
|
};
|
|
|
|
function DropdownOptionsEditor({
|
|
options,
|
|
configuredValue,
|
|
onOptionsChange,
|
|
onConfiguredValueChange,
|
|
}: DropdownOptionsEditorProps): React.ReactElement {
|
|
const [optionsError, setOptionsError] = useState<string | null>(null);
|
|
|
|
const handleAddOption = () => {
|
|
onOptionsChange([...options, { label: '', value: '' }]);
|
|
};
|
|
|
|
const handleChangeOption = (index: number, field: 'label' | 'value', text: string) => {
|
|
const updated = options.map((o, i) =>
|
|
i === index ? { ...o, [field]: text } : o,
|
|
);
|
|
onOptionsChange(updated);
|
|
// Warn on duplicate values (non-blocking)
|
|
const values = updated.map((o) => o.value).filter(Boolean);
|
|
const hasDups = values.length !== new Set(values).size;
|
|
setOptionsError(hasDups ? 'Duplicate option values detected.' : null);
|
|
};
|
|
|
|
const handleRemoveOption = (index: number) => {
|
|
const updated = options.filter((_, i) => i !== index);
|
|
onOptionsChange(updated);
|
|
const values = updated.map((o) => o.value).filter(Boolean);
|
|
const hasDups = values.length !== new Set(values).size;
|
|
setOptionsError(hasDups ? 'Duplicate option values detected.' : null);
|
|
};
|
|
|
|
// Warn if configured value is not in option list (non-blocking)
|
|
const configuredValueValid =
|
|
configuredValue === '' ||
|
|
options.some((o) => o.value === configuredValue);
|
|
|
|
return (
|
|
<div style={{ marginTop: 8 }}>
|
|
<div className={styles.infoKey} style={{ marginBottom: 4, display: 'block' }}>Options</div>
|
|
{options.map((opt, i) => (
|
|
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 3, alignItems: 'center' }}>
|
|
<input
|
|
className={styles.infoInput}
|
|
style={{ width: 70 }}
|
|
placeholder="Label"
|
|
value={opt.label}
|
|
onChange={(e) => handleChangeOption(i, 'label', e.target.value)}
|
|
/>
|
|
<input
|
|
className={styles.infoInput}
|
|
style={{ width: 70 }}
|
|
placeholder="Value"
|
|
value={opt.value}
|
|
onChange={(e) => handleChangeOption(i, 'value', e.target.value)}
|
|
/>
|
|
<button
|
|
style={{ fontSize: 10, padding: '2px 5px', cursor: 'pointer', background: 'none',
|
|
border: '1px solid #d0d7de', borderRadius: 3, color: '#b91c1c', flexShrink: 0 }}
|
|
onClick={() => handleRemoveOption(i)}
|
|
title="Remove option"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
))}
|
|
{optionsError && (
|
|
<div style={{ fontSize: 10, color: '#b91c1c', marginBottom: 4 }}>{optionsError}</div>
|
|
)}
|
|
<button
|
|
style={{ fontSize: 11, padding: '2px 8px', cursor: 'pointer', background: '#f7f8fa',
|
|
border: '1px solid #d0d7de', borderRadius: 3, color: '#1f2328', marginBottom: 6 }}
|
|
onClick={handleAddOption}
|
|
>
|
|
+ Add option
|
|
</button>
|
|
<div className={styles.infoRow}>
|
|
<label className={styles.infoKey} htmlFor="prop-dropdown-value">Default value</label>
|
|
<input
|
|
id="prop-dropdown-value"
|
|
className={styles.infoInput}
|
|
type="text"
|
|
placeholder="e.g. dev"
|
|
value={configuredValue}
|
|
onChange={(e) => onConfiguredValueChange(e.target.value)}
|
|
style={!configuredValueValid ? { borderColor: '#f59e0b' } : undefined}
|
|
title={!configuredValueValid ? 'Value not found in options' : undefined}
|
|
/>
|
|
</div>
|
|
{!configuredValueValid && (
|
|
<div style={{ fontSize: 10, color: '#92400e', marginBottom: 4 }}>
|
|
Default value not found in options.
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Table columns editor ──────────────────────────────────────────────────────
|
|
|
|
type TableColumnsEditorProps = {
|
|
columns: TableColumn[];
|
|
onColumnsChange: (columns: TableColumn[]) => void;
|
|
};
|
|
|
|
function TableColumnsEditor({ columns, onColumnsChange }: TableColumnsEditorProps): React.ReactElement {
|
|
const [colError, setColError] = useState<string | null>(null);
|
|
|
|
const validate = (cols: TableColumn[]): string | null => {
|
|
const keys = cols.map((c) => c.key).filter(Boolean);
|
|
const dupKeys = keys.filter((k, i) => keys.indexOf(k) !== i);
|
|
if (dupKeys.length > 0) return `Duplicate column keys: ${dupKeys.map((k) => `"${k}"`).join(', ')}.`;
|
|
const emptyKey = cols.some((c) => !c.key);
|
|
if (emptyKey) return 'Column key must not be empty.';
|
|
return null;
|
|
};
|
|
|
|
const handleAdd = () => {
|
|
const updated = [...columns, { key: '', header: '' }];
|
|
onColumnsChange(updated);
|
|
setColError(validate(updated));
|
|
};
|
|
|
|
const handleChange = (index: number, field: 'key' | 'header', value: string) => {
|
|
const updated = columns.map((c, i) => i === index ? { ...c, [field]: value } : c);
|
|
onColumnsChange(updated);
|
|
setColError(validate(updated));
|
|
};
|
|
|
|
const handleRemove = (index: number) => {
|
|
const updated = columns.filter((_, i) => i !== index);
|
|
onColumnsChange(updated);
|
|
setColError(validate(updated));
|
|
};
|
|
|
|
return (
|
|
<div style={{ marginTop: 8 }}>
|
|
<div className={styles.infoKey} style={{ marginBottom: 4, display: 'block' }}>Columns</div>
|
|
{columns.map((col, i) => (
|
|
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 3, alignItems: 'center' }}>
|
|
<input
|
|
className={styles.infoInput}
|
|
style={{ width: 70 }}
|
|
placeholder="Label"
|
|
value={col.header}
|
|
onChange={(e) => handleChange(i, 'header', e.target.value)}
|
|
/>
|
|
<input
|
|
className={styles.infoInput}
|
|
style={{ width: 70, borderColor: !col.key ? '#f59e0b' : undefined }}
|
|
placeholder="Key"
|
|
value={col.key}
|
|
onChange={(e) => handleChange(i, 'key', e.target.value)}
|
|
title={!col.key ? 'Key must not be empty' : undefined}
|
|
/>
|
|
<button
|
|
style={{ fontSize: 10, padding: '2px 5px', cursor: 'pointer', background: 'none',
|
|
border: '1px solid #d0d7de', borderRadius: 3, color: '#b91c1c', flexShrink: 0 }}
|
|
onClick={() => handleRemove(i)}
|
|
title="Remove column"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
))}
|
|
{colError && (
|
|
<div style={{ fontSize: 10, color: '#b91c1c', marginBottom: 4 }}>{colError}</div>
|
|
)}
|
|
<button
|
|
style={{ fontSize: 11, padding: '2px 8px', cursor: 'pointer', background: '#f7f8fa',
|
|
border: '1px solid #d0d7de', borderRadius: 3, color: '#1f2328', marginBottom: 6 }}
|
|
onClick={handleAdd}
|
|
>
|
|
+ Add column
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Table rows editor ─────────────────────────────────────────────────────────
|
|
|
|
type TableRowsEditorProps = {
|
|
rows: TableRow[];
|
|
onRowsChange: (rows: TableRow[]) => void;
|
|
};
|
|
|
|
function TableRowsEditor({ rows, onRowsChange }: TableRowsEditorProps): React.ReactElement {
|
|
// Draft text for the JSON textarea; initialise from current rows
|
|
const [draft, setDraft] = useState<string>(() => JSON.stringify(rows, null, 2));
|
|
const [rowsError, setRowsError] = useState<string | null>(null);
|
|
|
|
// Sync draft when rows prop changes externally (e.g. on component selection)
|
|
const prevRowsRef = React.useRef<TableRow[]>(rows);
|
|
if (rows !== prevRowsRef.current) {
|
|
prevRowsRef.current = rows;
|
|
setDraft(JSON.stringify(rows, null, 2));
|
|
setRowsError(null);
|
|
}
|
|
|
|
const handleApply = () => {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(draft);
|
|
} catch (e: unknown) {
|
|
setRowsError(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
return;
|
|
}
|
|
if (!Array.isArray(parsed)) {
|
|
setRowsError('Rows must be a JSON array.');
|
|
return;
|
|
}
|
|
for (let i = 0; i < parsed.length; i++) {
|
|
const el = parsed[i];
|
|
if (el === null || typeof el !== 'object' || Array.isArray(el)) {
|
|
setRowsError(
|
|
`Row at index ${i} must be a non-null object (got ${
|
|
el === null ? 'null' : Array.isArray(el) ? 'array' : typeof el
|
|
}).`,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
setRowsError(null);
|
|
onRowsChange(parsed as TableRow[]);
|
|
};
|
|
|
|
return (
|
|
<div style={{ marginTop: 8 }}>
|
|
<div className={styles.infoKey} style={{ marginBottom: 4, display: 'block' }}>
|
|
Rows (JSON array)
|
|
</div>
|
|
<textarea
|
|
style={{
|
|
width: '100%', minHeight: 80, fontFamily: 'monospace', fontSize: 11,
|
|
border: '1px solid #d0d7de', borderRadius: 3, padding: '4px 6px',
|
|
resize: 'vertical', boxSizing: 'border-box',
|
|
borderColor: rowsError ? '#f59e0b' : undefined,
|
|
}}
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
spellCheck={false}
|
|
/>
|
|
{rowsError && (
|
|
<div style={{ fontSize: 10, color: '#b91c1c', marginBottom: 4 }}>{rowsError}</div>
|
|
)}
|
|
<button
|
|
style={{ fontSize: 11, padding: '2px 8px', cursor: 'pointer', background: '#f7f8fa',
|
|
border: '1px solid #d0d7de', borderRadius: 3, color: '#1f2328', marginBottom: 6 }}
|
|
onClick={handleApply}
|
|
>
|
|
Apply rows
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Component ─────────────────────────────────────────────────────────────────
|
|
|
|
function VisualEditor(): React.ReactElement {
|
|
const {
|
|
doc,
|
|
activePage,
|
|
canvas,
|
|
selectedId,
|
|
selectComponent,
|
|
isLoading,
|
|
} = useProject();
|
|
|
|
const {
|
|
addComponent,
|
|
moveComponent,
|
|
removeComponent,
|
|
updateComponentProperty,
|
|
updateComponentName,
|
|
updateComponentSize,
|
|
} = canvas;
|
|
|
|
const selectedComponent = activePage.components.find((x) => x.id === selectedId) ?? null;
|
|
|
|
// Local state for width/height text inputs (allows typing without snapping mid-edit)
|
|
const [widthDraft, setWidthDraft] = useState<string>('');
|
|
const [heightDraft, setHeightDraft] = useState<string>('');
|
|
|
|
// Sync drafts when selection changes
|
|
const prevSelectedId = React.useRef<string | null>(null);
|
|
if (selectedComponent && selectedComponent.id !== prevSelectedId.current) {
|
|
prevSelectedId.current = selectedComponent.id;
|
|
setWidthDraft(String(selectedComponent.size.width));
|
|
setHeightDraft(String(selectedComponent.size.height));
|
|
}
|
|
if (!selectedComponent && prevSelectedId.current !== null) {
|
|
prevSelectedId.current = null;
|
|
}
|
|
|
|
// Duplicate-name detection
|
|
const isDuplicateName = useCallback(
|
|
(name: string, excludeId: string) =>
|
|
activePage.components.some((c) => c.id !== excludeId && c.name === name),
|
|
[activePage.components],
|
|
);
|
|
|
|
const handleNameChange = useCallback(
|
|
(id: string, value: string) => {
|
|
updateComponentName(id, value);
|
|
},
|
|
[updateComponentName],
|
|
);
|
|
|
|
const handleWidthCommit = useCallback(
|
|
(id: string, currentHeight: number) => {
|
|
const n = parseInt(widthDraft, 10);
|
|
if (!isNaN(n) && n > 0) {
|
|
updateComponentSize(id, n, currentHeight);
|
|
} else {
|
|
// Reset draft to actual value if invalid
|
|
setWidthDraft(selectedComponent ? String(selectedComponent.size.width) : widthDraft);
|
|
}
|
|
},
|
|
[widthDraft, updateComponentSize, selectedComponent],
|
|
);
|
|
|
|
const handleHeightCommit = useCallback(
|
|
(id: string, currentWidth: number) => {
|
|
const n = parseInt(heightDraft, 10);
|
|
if (!isNaN(n) && n > 0) {
|
|
updateComponentSize(id, currentWidth, n);
|
|
} else {
|
|
setHeightDraft(selectedComponent ? String(selectedComponent.size.height) : heightDraft);
|
|
}
|
|
},
|
|
[heightDraft, updateComponentSize, selectedComponent],
|
|
);
|
|
|
|
// Add from palette click — place at a staggered default position
|
|
const handlePaletteAdd = useCallback(
|
|
(type: ComponentType) => {
|
|
const offset = activePage.components.length * 24;
|
|
addComponent(type, 32 + offset, 32 + offset);
|
|
},
|
|
[activePage.components.length, addComponent],
|
|
);
|
|
|
|
// Add from canvas drop
|
|
const handleCanvasDrop = useCallback(
|
|
(type: ComponentType, x: number, y: number) => {
|
|
addComponent(type, x, y);
|
|
},
|
|
[addComponent],
|
|
);
|
|
|
|
// Show a loading overlay while fetching a project from the backend
|
|
if (isLoading) {
|
|
return (
|
|
<div className={styles.editor}>
|
|
<ProjectToolbar />
|
|
<div className={styles.loadingOverlay}>Loading project…</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={styles.editor}>
|
|
{/* ── Project toolbar (New / Save / Load) ───────────────────── */}
|
|
<ProjectToolbar />
|
|
|
|
{/* ── Editor toolbar (page info) ────────────────────────────── */}
|
|
<div className={styles.editorBar}>
|
|
<span className={styles.projectName}>{doc.project.name}</span>
|
|
<span className={styles.pageName}>{activePage.name}</span>
|
|
<span className={styles.componentCount}>
|
|
{activePage.components.length} component
|
|
{activePage.components.length !== 1 ? 's' : ''}
|
|
</span>
|
|
</div>
|
|
|
|
{/* ── Main area: palette | canvas | info ────────────────────── */}
|
|
<div className={styles.body}>
|
|
<Palette onAdd={handlePaletteAdd} />
|
|
|
|
<Canvas
|
|
components={activePage.components}
|
|
selectedId={selectedId}
|
|
onSelect={selectComponent}
|
|
onMove={moveComponent}
|
|
onRemove={(id) => { removeComponent(id); selectComponent(null); }}
|
|
onDrop={handleCanvasDrop}
|
|
/>
|
|
|
|
{/* Selection info + live JSON */}
|
|
<aside className={styles.info}>
|
|
<div className={styles.infoHeader}>Selection</div>
|
|
{selectedComponent ? (
|
|
<div className={styles.infoBody}>
|
|
{/* Type + position — read-only */}
|
|
{([
|
|
['Type', selectedComponent.type],
|
|
['X', `${selectedComponent.position.x}px`],
|
|
['Y', `${selectedComponent.position.y}px`],
|
|
] as [string, string][]).map(([k, v]) => (
|
|
<div key={k} className={styles.infoRow}>
|
|
<span className={styles.infoKey}>{k}</span>
|
|
<span className={styles.infoVal}>{v}</span>
|
|
</div>
|
|
))}
|
|
|
|
{/* Editable name */}
|
|
<div className={styles.infoRow}>
|
|
<label className={styles.infoKey} htmlFor="prop-name">Name</label>
|
|
<input
|
|
id="prop-name"
|
|
className={styles.infoInput}
|
|
type="text"
|
|
value={selectedComponent.name}
|
|
onChange={(e) => handleNameChange(selectedComponent.id, e.target.value)}
|
|
style={
|
|
selectedComponent.name === '' ||
|
|
isDuplicateName(selectedComponent.name, selectedComponent.id)
|
|
? { borderColor: '#f59e0b' }
|
|
: undefined
|
|
}
|
|
title={
|
|
selectedComponent.name === ''
|
|
? 'Name must not be empty'
|
|
: isDuplicateName(selectedComponent.name, selectedComponent.id)
|
|
? 'Duplicate name — components must have unique names'
|
|
: undefined
|
|
}
|
|
/>
|
|
</div>
|
|
{selectedComponent.name === '' && (
|
|
<div style={{ fontSize: 10, color: '#b91c1c', marginBottom: 2 }}>
|
|
Name must not be empty.
|
|
</div>
|
|
)}
|
|
{selectedComponent.name !== '' &&
|
|
isDuplicateName(selectedComponent.name, selectedComponent.id) && (
|
|
<div style={{ fontSize: 10, color: '#92400e', marginBottom: 2 }}>
|
|
Duplicate name detected.
|
|
</div>
|
|
)}
|
|
|
|
{/* Editable width */}
|
|
<div className={styles.infoRow}>
|
|
<label className={styles.infoKey} htmlFor="prop-width">W</label>
|
|
<input
|
|
id="prop-width"
|
|
className={styles.infoInput}
|
|
type="number"
|
|
min={1}
|
|
value={widthDraft}
|
|
onChange={(e) => setWidthDraft(e.target.value)}
|
|
onBlur={() => handleWidthCommit(selectedComponent.id, selectedComponent.size.height)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') handleWidthCommit(selectedComponent.id, selectedComponent.size.height);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Editable height */}
|
|
<div className={styles.infoRow}>
|
|
<label className={styles.infoKey} htmlFor="prop-height">H</label>
|
|
<input
|
|
id="prop-height"
|
|
className={styles.infoInput}
|
|
type="number"
|
|
min={1}
|
|
value={heightDraft}
|
|
onChange={(e) => setHeightDraft(e.target.value)}
|
|
onBlur={() => handleHeightCommit(selectedComponent.id, selectedComponent.size.width)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') handleHeightCommit(selectedComponent.id, selectedComponent.size.width);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Visible checkbox */}
|
|
<div className={styles.infoRow}>
|
|
<label className={styles.infoKey} htmlFor="prop-visible">Visible</label>
|
|
<input
|
|
id="prop-visible"
|
|
type="checkbox"
|
|
checked={selectedComponent.properties.visible !== false}
|
|
onChange={(e) =>
|
|
updateComponentProperty(selectedComponent.id, 'visible', e.target.checked)
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{/* Disabled checkbox */}
|
|
<div className={styles.infoRow}>
|
|
<label className={styles.infoKey} htmlFor="prop-disabled">Disabled</label>
|
|
<input
|
|
id="prop-disabled"
|
|
type="checkbox"
|
|
checked={selectedComponent.properties.disabled === true}
|
|
onChange={(e) =>
|
|
updateComponentProperty(selectedComponent.id, 'disabled', e.target.checked)
|
|
}
|
|
/>
|
|
</div>
|
|
{['TextInput', 'TextArea', 'Checkbox', 'RadioGroup'].includes(selectedComponent.type) && (
|
|
<div className={styles.infoRow}>
|
|
<label className={styles.infoKey} htmlFor="prop-required">Required</label>
|
|
<input
|
|
id="prop-required"
|
|
type="checkbox"
|
|
checked={selectedComponent.properties.required === true}
|
|
onChange={(e) =>
|
|
updateComponentProperty(selectedComponent.id, 'required', e.target.checked)
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{selectedComponent.properties.label !== undefined && (
|
|
<div className={styles.infoRow}>
|
|
<label className={styles.infoKey} htmlFor="prop-label">Label</label>
|
|
<input
|
|
id="prop-label"
|
|
className={styles.infoInput}
|
|
type="text"
|
|
value={String(selectedComponent.properties.label ?? '')}
|
|
onChange={(e) =>
|
|
updateComponentProperty(selectedComponent.id, 'label', e.target.value)
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
{selectedComponent.properties.placeholder !== undefined && (
|
|
<div className={styles.infoRow}>
|
|
<label className={styles.infoKey} htmlFor="prop-placeholder">Placeholder</label>
|
|
<input
|
|
id="prop-placeholder"
|
|
className={styles.infoInput}
|
|
type="text"
|
|
value={String(selectedComponent.properties.placeholder ?? '')}
|
|
onChange={(e) =>
|
|
updateComponentProperty(selectedComponent.id, 'placeholder', e.target.value)
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
{(selectedComponent.type === 'Dropdown' || selectedComponent.type === 'RadioGroup') && (
|
|
<DropdownOptionsEditor
|
|
componentId={selectedComponent.id}
|
|
options={
|
|
Array.isArray(selectedComponent.properties.options)
|
|
? (selectedComponent.properties.options as DropdownOption[])
|
|
: []
|
|
}
|
|
configuredValue={
|
|
typeof selectedComponent.properties.value === 'string'
|
|
? selectedComponent.properties.value
|
|
: ''
|
|
}
|
|
onOptionsChange={(opts) =>
|
|
updateComponentProperty(selectedComponent.id, 'options', opts)
|
|
}
|
|
onConfiguredValueChange={(val) =>
|
|
updateComponentProperty(selectedComponent.id, 'value', val)
|
|
}
|
|
/>
|
|
)}
|
|
{selectedComponent.type === 'Checkbox' && (
|
|
<div className={styles.infoRow}>
|
|
<label className={styles.infoKey} htmlFor="prop-checked">Default checked</label>
|
|
<input
|
|
id="prop-checked"
|
|
type="checkbox"
|
|
checked={selectedComponent.properties.defaultValue === true}
|
|
onChange={(e) =>
|
|
updateComponentProperty(selectedComponent.id, 'defaultValue', e.target.checked)
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
{['TextInput', 'TextArea', 'StatusPanel', 'Container'].includes(selectedComponent.type) && (
|
|
<div className={styles.infoRow}>
|
|
<label className={styles.infoKey} htmlFor="prop-default-value">
|
|
{selectedComponent.type === 'Container' ? 'Body' : selectedComponent.type === 'StatusPanel' ? 'Message' : 'Default'}
|
|
</label>
|
|
<textarea
|
|
id="prop-default-value"
|
|
className={styles.infoInput}
|
|
value={typeof selectedComponent.properties.defaultValue === 'string' ? selectedComponent.properties.defaultValue : ''}
|
|
onChange={(e) =>
|
|
updateComponentProperty(selectedComponent.id, 'defaultValue', e.target.value)
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
{selectedComponent.type === 'StatusPanel' && (
|
|
<div className={styles.infoRow}>
|
|
<label className={styles.infoKey} htmlFor="prop-status">Tone</label>
|
|
<select
|
|
id="prop-status"
|
|
className={styles.infoInput}
|
|
value={typeof selectedComponent.properties.status === 'string' ? selectedComponent.properties.status : 'info'}
|
|
onChange={(e) =>
|
|
updateComponentProperty(selectedComponent.id, 'status', e.target.value)
|
|
}
|
|
>
|
|
<option value="info">Info</option>
|
|
<option value="success">Success</option>
|
|
<option value="warning">Warning</option>
|
|
<option value="error">Error</option>
|
|
</select>
|
|
</div>
|
|
)}
|
|
{selectedComponent.type === 'Table' && (
|
|
<>
|
|
<TableColumnsEditor
|
|
columns={
|
|
Array.isArray(selectedComponent.properties.columns)
|
|
? (selectedComponent.properties.columns as TableColumn[])
|
|
: []
|
|
}
|
|
onColumnsChange={(cols) =>
|
|
updateComponentProperty(selectedComponent.id, 'columns', cols)
|
|
}
|
|
/>
|
|
<TableRowsEditor
|
|
rows={
|
|
Array.isArray(selectedComponent.properties.rows)
|
|
? (selectedComponent.properties.rows as TableRow[])
|
|
: []
|
|
}
|
|
onRowsChange={(rowData) =>
|
|
updateComponentProperty(selectedComponent.id, 'rows', rowData)
|
|
}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p className={styles.infoEmpty}>No component selected</p>
|
|
)}
|
|
|
|
{/* Live project JSON */}
|
|
<div className={styles.jsonSection}>
|
|
<div className={styles.infoHeader}>Project JSON</div>
|
|
<pre className={styles.jsonPre}>{JSON.stringify(doc, null, 2)}</pre>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default VisualEditor;
|