247 lines
13 KiB
TypeScript
247 lines
13 KiB
TypeScript
import { useState } from 'react'
|
|
import { X, Type, Paintbrush, Palette, RotateCcw } from 'lucide-react'
|
|
import { BLOCKS } from './blocks'
|
|
import { ThemeBuilder } from './ThemeBuilder'
|
|
import type { ThemeConfig, CanvasBlock, CustomBlockDef, DataSource } from '../App'
|
|
|
|
type Tab = 'content' | 'style' | 'theme'
|
|
|
|
function ColorControl({ label, value, onChange, themeVar, themeLabel }: {
|
|
label: string
|
|
value: string
|
|
onChange: (v: string) => void
|
|
themeVar?: string
|
|
themeLabel?: string
|
|
}) {
|
|
const isTheme = value === themeVar
|
|
const hexVal = /^#[0-9a-fA-F]{6}$/.test(value) ? value : '#888888'
|
|
|
|
return (
|
|
<div className="mb-3">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="text-xs text-zinc-600">{label}</span>
|
|
{themeVar && (
|
|
<button
|
|
onClick={() => onChange(isTheme ? '' : themeVar!)}
|
|
className={`text-xs px-1.5 py-0.5 rounded transition-colors ${isTheme ? 'bg-indigo-100 text-indigo-600' : 'text-zinc-400 hover:text-indigo-500'}`}
|
|
>
|
|
{isTheme ? '✓ Using theme' : `Use ${themeLabel}`}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{!isTheme && (
|
|
<div className="flex items-center gap-1.5">
|
|
<input type="color" aria-label={`${label} color picker`} value={hexVal} onChange={e => onChange(e.target.value)}
|
|
className="w-7 h-7 rounded cursor-pointer border border-zinc-200 p-0.5 bg-white" />
|
|
<input type="text" aria-label={`${label} value`} value={value} onChange={e => onChange(e.target.value)}
|
|
className="flex-1 text-xs font-mono px-2 py-1 border border-zinc-200 rounded outline-none focus:border-indigo-400"
|
|
placeholder="#000000 or CSS value" maxLength={30} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TextControl({ label, value, onChange, placeholder }: {
|
|
label: string; value: string; onChange: (v: string) => void; placeholder?: string
|
|
}) {
|
|
return (
|
|
<div className="mb-2">
|
|
<span className="text-xs text-zinc-600 block mb-1">{label}</span>
|
|
<input type="text" aria-label={label} value={value} onChange={e => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
className="w-full text-xs px-2 py-1.5 border border-zinc-200 rounded outline-none focus:border-indigo-400 bg-white"
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function BlockEditor({
|
|
block, customBlocks, theme, dataSources,
|
|
onContentChange, onStyleChange, onStyleReset, onThemeChange, onDeselect,
|
|
}: {
|
|
block: CanvasBlock
|
|
customBlocks: CustomBlockDef[]
|
|
theme: ThemeConfig
|
|
dataSources: DataSource[]
|
|
onContentChange: (instanceId: string, key: string, value: string) => void
|
|
onStyleChange: (instanceId: string, key: string, value: string) => void
|
|
onStyleReset: (instanceId: string) => void
|
|
onThemeChange: (t: ThemeConfig) => void
|
|
onDeselect: () => void
|
|
}) {
|
|
const [tab, setTab] = useState<Tab>('content')
|
|
|
|
const isCustom = block.blockId.startsWith('custom:')
|
|
const def = isCustom ? null : BLOCKS.find(b => b.id === block.blockId)
|
|
const customDef = isCustom ? customBlocks.find(b => `custom:${b.id}` === block.blockId) : null
|
|
const blockLabel = isCustom ? (customDef?.name ?? 'Custom') : (def?.label ?? block.blockId)
|
|
const contentFields = def?.contentFields ?? []
|
|
const hasOverrides = Object.keys(block.styles).length > 0
|
|
|
|
const styleVal = (key: string) => block.styles[key] ?? ''
|
|
const setStyle = (key: string, val: string) => onStyleChange(block.instanceId, key, val)
|
|
const setContent = (key: string, val: string) => onContentChange(block.instanceId, key, val)
|
|
const contentVal = (key: string, fallback: string) => block.content[key] ?? fallback
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* Header */}
|
|
<div className="px-4 pt-4 pb-3 border-b border-zinc-100 flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-xs font-semibold text-zinc-400 uppercase tracking-widest mb-0.5">Editing</h2>
|
|
<p className="text-sm font-semibold text-zinc-800">{blockLabel}</p>
|
|
</div>
|
|
<button onClick={onDeselect} aria-label="Close block editor"
|
|
className="w-7 h-7 flex items-center justify-center rounded-md hover:bg-zinc-100 text-zinc-400 transition-colors">
|
|
<X size={13} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex border-b border-zinc-100 px-3 pt-2 gap-0.5">
|
|
{([
|
|
{ id: 'content' as Tab, label: 'Content', icon: Type },
|
|
{ id: 'style' as Tab, label: 'Style', icon: Paintbrush },
|
|
{ id: 'theme' as Tab, label: 'Theme', icon: Palette },
|
|
]).map(({ id, label, icon: Icon }) => (
|
|
<button key={id} onClick={() => setTab(id)}
|
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-t-md text-xs font-medium transition-all border-b-2 -mb-px ${
|
|
tab === id ? 'border-indigo-500 text-indigo-600 bg-indigo-50/50' : 'border-transparent text-zinc-500 hover:text-zinc-700'
|
|
}`}>
|
|
<Icon size={11} />{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
{/* Content tab */}
|
|
{tab === 'content' && (
|
|
<div className="p-4">
|
|
{isCustom ? (
|
|
<p className="text-xs text-zinc-400 text-center py-4">Custom React components are edited in the code editor. Click the ✎ icon in the block library.</p>
|
|
) : contentFields.length === 0 ? (
|
|
<p className="text-xs text-zinc-400 text-center py-4">This block has no editable content fields.</p>
|
|
) : (
|
|
<div>
|
|
{contentFields.map(field => {
|
|
const val = contentVal(field.key, def?.defaultContent?.[field.key] ?? '')
|
|
return (
|
|
<div key={field.key} className="mb-3">
|
|
<label className="text-xs font-medium text-zinc-600 block mb-1">{field.label}</label>
|
|
{field.type === 'textarea' ? (
|
|
<textarea value={val} onChange={e => setContent(field.key, e.target.value)}
|
|
placeholder={field.placeholder}
|
|
className="w-full text-xs px-2 py-1.5 border border-zinc-200 rounded outline-none focus:border-indigo-400 bg-white resize-none"
|
|
style={{ height: '72px' }} />
|
|
) : field.type === 'select' ? (
|
|
<select value={val} onChange={e => setContent(field.key, e.target.value)}
|
|
className="w-full text-xs px-2 py-1.5 border border-zinc-200 rounded outline-none focus:border-indigo-400 bg-white">
|
|
{field.options?.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
|
</select>
|
|
) : field.type === 'image' ? (
|
|
<div>
|
|
<input type="url" value={val}
|
|
onChange={e => setContent(field.key, e.target.value)}
|
|
placeholder={field.placeholder ?? 'https://example.com/image.jpg'}
|
|
className="w-full text-xs px-2 py-1.5 border border-zinc-200 rounded outline-none focus:border-indigo-400 bg-white mb-1.5" />
|
|
{val && (
|
|
<div className="rounded border border-zinc-200 overflow-hidden bg-zinc-50" style={{ height: '80px' }}>
|
|
<img src={val} alt="preview" className="w-full h-full object-cover" onError={e => { (e.target as HTMLImageElement).style.display = 'none' }} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : field.type === 'datasource' ? (
|
|
<div>
|
|
<select value={val} onChange={e => setContent(field.key, e.target.value)}
|
|
className="w-full text-xs px-2 py-1.5 border border-zinc-200 rounded outline-none focus:border-indigo-400 bg-white">
|
|
<option value="">— Select data source —</option>
|
|
{dataSources.map(ds => <option key={ds.id} value={ds.id}>{ds.name}</option>)}
|
|
</select>
|
|
{dataSources.length === 0 && (
|
|
<p className="text-xs text-zinc-400 mt-1">No data sources yet. Click "Data" in the header to add one.</p>
|
|
)}
|
|
</div>
|
|
) : field.type === 'datafield' ? (
|
|
<input type="text" value={val}
|
|
onChange={e => setContent(field.key, e.target.value)}
|
|
placeholder={field.placeholder ?? field.hint ?? 'field name'}
|
|
className="w-full text-xs px-2 py-1.5 border border-zinc-200 rounded outline-none focus:border-indigo-400 bg-white font-mono" />
|
|
) : (
|
|
<input type={field.type === 'url' ? 'url' : 'text'} value={val}
|
|
onChange={e => setContent(field.key, e.target.value)}
|
|
placeholder={field.placeholder}
|
|
className="w-full text-xs px-2 py-1.5 border border-zinc-200 rounded outline-none focus:border-indigo-400 bg-white" />
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Style tab */}
|
|
{tab === 'style' && (
|
|
<div className="p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Component Overrides</span>
|
|
{hasOverrides && (
|
|
<button onClick={() => onStyleReset(block.instanceId)}
|
|
className="flex items-center gap-1 text-xs text-zinc-400 hover:text-red-500 transition-colors">
|
|
<RotateCcw size={9} /> Reset
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<ColorControl label="Background" value={styleVal('backgroundColor')}
|
|
onChange={v => setStyle('backgroundColor', v)}
|
|
themeVar="var(--cms-bg)" themeLabel="bg" />
|
|
<ColorControl label="Text color" value={styleVal('color')}
|
|
onChange={v => setStyle('color', v)}
|
|
themeVar="var(--cms-fg)" themeLabel="fg" />
|
|
|
|
<div className="border-t border-zinc-100 my-3" />
|
|
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-3">Spacing</p>
|
|
<TextControl label="Padding" value={styleVal('padding')} onChange={v => setStyle('padding', v)} placeholder="1rem 2rem" />
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<TextControl label="Margin top" value={styleVal('marginTop')} onChange={v => setStyle('marginTop', v)} placeholder="1rem" />
|
|
<TextControl label="Margin bottom" value={styleVal('marginBottom')} onChange={v => setStyle('marginBottom', v)} placeholder="1rem" />
|
|
</div>
|
|
|
|
<div className="border-t border-zinc-100 my-3" />
|
|
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-3">Shape & Layout</p>
|
|
<TextControl label="Border radius" value={styleVal('borderRadius')} onChange={v => setStyle('borderRadius', v)} placeholder="0.5rem" />
|
|
<TextControl label="Max width" value={styleVal('maxWidth')} onChange={v => setStyle('maxWidth', v)} placeholder="640px" />
|
|
|
|
<div className="border-t border-zinc-100 my-3" />
|
|
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Opacity</p>
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<input type="range" min={0} max={1} step={0.05}
|
|
value={parseFloat(styleVal('opacity') || '1')}
|
|
onChange={e => setStyle('opacity', e.target.value)}
|
|
className="flex-1 accent-indigo-500" />
|
|
<span className="text-xs font-mono text-zinc-500 w-8 text-right">{styleVal('opacity') || '1'}</span>
|
|
</div>
|
|
|
|
<div className="border-t border-zinc-100 my-3" />
|
|
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Border</p>
|
|
<TextControl label="Border" value={styleVal('border')} onChange={v => setStyle('border', v)} placeholder="1px solid #e4e4e7" />
|
|
<TextControl label="Box shadow" value={styleVal('boxShadow')} onChange={v => setStyle('boxShadow', v)} placeholder="0 4px 24px rgba(0,0,0,0.1)" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Theme tab */}
|
|
{tab === 'theme' && (
|
|
<div>
|
|
<div className="px-4 py-2 bg-indigo-50 border-b border-indigo-100">
|
|
<p className="text-xs text-indigo-600">Changes here apply to all blocks via CSS variables.</p>
|
|
</div>
|
|
<ThemeBuilder theme={theme} onChange={onThemeChange} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|