830 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useRef, useState } from 'react'
import { get, set } from 'idb-keyval'
import { api, apiBlob, apiZip } from './api'
import type {
ApiKeySummary,
Block,
BlockType,
TemplateDocument,
TemplateRecord,
TemplateSummary,
TemplateType
} from './types'
type Screen = 'templates' | 'editor' | 'preview' | 'data' | 'use'
type SaveState = 'saved' | 'saving' | 'offline'
type Filter = 'all' | TemplateType
type Props = {
user: {
name?: string
preferred_username?: string
email?: string
}
}
type CreatedApiKey = ApiKeySummary & {
api_key: string
}
const defaults: Record<BlockType, Record<string, unknown>> = {
heading: { text: 'New heading' },
text: { text: 'Write something useful.' },
kpi: { label: 'Metric', value: '{{summary.total}}' },
button: { text: 'Open link', url: 'https://example.com' },
image: { url: 'https://placehold.co/800x360', alt: 'Image' },
divider: {},
table: {
rowsPath: 'items',
columns: [
{ label: 'Name', key: 'name' },
{ label: 'Value', key: 'value' }
]
}
}
const blockLabels: Record<BlockType, string> = {
heading: 'Heading',
text: 'Text',
kpi: 'KPI',
button: 'Button',
image: 'Image',
divider: 'Divider',
table: 'Table'
}
function flatten(value: unknown, prefix = ''): string[] {
if (!value || typeof value !== 'object' || Array.isArray(value)) return prefix ? [prefix] : []
const out: string[] = []
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
const path = prefix ? `${prefix}.${key}` : key
if (child && typeof child === 'object' && !Array.isArray(child)) out.push(...flatten(child, path))
else out.push(path)
}
return out
}
function blockSummary(block: Block) {
if (block.type === 'divider') return 'Divider'
if (block.type === 'table') return `Rows: ${String(block.props.rowsPath || 'items')}`
if (block.type === 'kpi') return `${String(block.props.label || 'Metric')} · ${String(block.props.value || '')}`
return String(block.props.text || block.props.alt || blockLabels[block.type])
}
function formatDate(value?: string | null) {
if (!value) return 'Never'
return new Date(value).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })
}
export function App({ user }: Props) {
const [screen, setScreen] = useState<Screen>('templates')
const [templates, setTemplates] = useState<TemplateSummary[]>([])
const [active, setActive] = useState<TemplateRecord | null>(null)
const [templateDoc, setTemplateDoc] = useState<TemplateDocument>({ blocks: [] })
const [sampleData, setSampleData] = useState<Record<string, unknown>>({})
const [selectedId, setSelectedId] = useState<string | null>(null)
const [addOpen, setAddOpen] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [createName, setCreateName] = useState('')
const [createType, setCreateType] = useState<TemplateType>('report')
const [saveState, setSaveState] = useState<SaveState>('saved')
const [previewHtml, setPreviewHtml] = useState('')
const [previewDevice, setPreviewDevice] = useState<'mobile' | 'desktop'>('mobile')
const [dataText, setDataText] = useState('{}')
const [error, setError] = useState('')
const [filter, setFilter] = useState<Filter>('all')
const [search, setSearch] = useState('')
const [apiKeys, setApiKeys] = useState<ApiKeySummary[]>([])
const [newKeyName, setNewKeyName] = useState('')
const [createdKey, setCreatedKey] = useState<CreatedApiKey | null>(null)
const [copied, setCopied] = useState('')
const [packImporting, setPackImporting] = useState(false)
const packFileInput = useRef<HTMLInputElement | null>(null)
const loadedId = useRef<string | null>(null)
const timer = useRef<number | undefined>(undefined)
const selected = templateDoc.blocks.find(block => block.id === selectedId) || null
const paths = useMemo(() => flatten(sampleData), [sampleData])
const visibleTemplates = useMemo(() => {
const needle = search.trim().toLowerCase()
return templates.filter(template => {
if (filter !== 'all' && template.type !== filter) return false
if (!needle) return true
return `${template.name} ${template.template_key}`.toLowerCase().includes(needle)
})
}, [templates, filter, search])
const counts = useMemo(() => ({
total: templates.length,
report: templates.filter(template => template.type === 'report').length,
email: templates.filter(template => template.type === 'email').length
}), [templates])
async function refreshTemplates() {
setTemplates(await api<TemplateSummary[]>('/api/templates'))
}
async function refreshApiKeys() {
setApiKeys(await api<ApiKeySummary[]>('/api/api-keys'))
}
useEffect(() => {
void refreshTemplates().catch(err => setError(String(err)))
}, [])
async function saveNow() {
if (!active) return
await api(`/api/templates/${active.id}/draft`, {
method: 'PUT',
body: JSON.stringify({ document: templateDoc, sampleData })
})
}
useEffect(() => {
if (!active || loadedId.current !== active.id) return
window.clearTimeout(timer.current)
setSaveState('saving')
void set(`template-studio:${active.id}`, { document: templateDoc, sampleData, savedAt: Date.now() })
timer.current = window.setTimeout(async () => {
try {
await saveNow()
setSaveState('saved')
} catch {
setSaveState('offline')
}
}, 700)
return () => window.clearTimeout(timer.current)
}, [templateDoc, sampleData, active?.id])
async function openTemplate(id: string) {
setError('')
const remote = await api<TemplateRecord>(`/api/templates/${id}`)
const local = await get<{ document: TemplateDocument; sampleData: Record<string, unknown> }>(
`template-studio:${id}`
)
loadedId.current = id
setActive(remote)
setTemplateDoc(local?.document || remote.draft_document)
setSampleData(local?.sampleData || remote.sample_data || {})
setDataText(JSON.stringify(local?.sampleData || remote.sample_data || {}, null, 2))
setSelectedId(null)
setCreatedKey(null)
setScreen('editor')
}
async function createTemplate() {
if (!createName.trim()) return
const created = await api<TemplateRecord>('/api/templates', {
method: 'POST',
body: JSON.stringify({ name: createName.trim(), type: createType })
})
setCreateOpen(false)
setCreateName('')
await refreshTemplates()
await openTemplate(created.id)
}
function addBlock(type: BlockType) {
const block: Block = { id: crypto.randomUUID(), type, props: structuredClone(defaults[type]) }
setTemplateDoc(current => ({ ...current, blocks: [...current.blocks, block] }))
setAddOpen(false)
setSelectedId(block.id)
}
function updateBlock(id: string, patch: Record<string, unknown>) {
setTemplateDoc(current => ({
...current,
blocks: current.blocks.map(block =>
block.id === id ? { ...block, props: { ...block.props, ...patch } } : block
)
}))
}
function updateTheme(patch: Record<string, unknown>) {
setTemplateDoc(current => ({
...current,
theme: { ...(current.theme || {}), ...patch }
}))
}
function themeNumber(key: string, fallback: number) {
const value = Number(templateDoc.theme?.[key])
return Number.isFinite(value) ? value : fallback
}
function moveBlock(id: string, direction: -1 | 1) {
setTemplateDoc(current => {
const blocks = [...current.blocks]
const index = blocks.findIndex(block => block.id === id)
const target = index + direction
if (index < 0 || target < 0 || target >= blocks.length) return current
;[blocks[index], blocks[target]] = [blocks[target], blocks[index]]
return { ...current, blocks }
})
}
function removeBlock(id: string) {
setTemplateDoc(current => ({ ...current, blocks: current.blocks.filter(block => block.id !== id) }))
setSelectedId(null)
}
async function publish() {
if (!active) return
await saveNow()
const updated = await api<TemplateRecord>(`/api/templates/${active.id}/publish`, { method: 'POST' })
setActive(updated)
setSaveState('saved')
await refreshTemplates()
}
async function refreshPreview() {
if (!active) return
await saveNow()
const format = active.type === 'email' ? 'email' : 'html'
const result = await api<{ html: string }>(`/api/templates/${active.id}/render`, {
method: 'POST',
body: JSON.stringify({ format, data: sampleData })
})
setPreviewHtml(result.html)
}
async function goPreview() {
setScreen('preview')
try {
await refreshPreview()
} catch (err) {
setError(String(err))
}
}
async function goUse() {
setScreen('use')
try {
await refreshApiKeys()
} catch (err) {
setError(String(err))
}
}
async function downloadPdf() {
if (!active) return
await saveNow()
const blob = await apiBlob(`/api/templates/${active.id}/render`, {
method: 'POST',
body: JSON.stringify({ format: 'pdf', data: sampleData })
})
const href = URL.createObjectURL(blob)
const link = window.document.createElement('a')
link.href = href
link.download = `${active.name.replace(/[^a-z0-9]+/gi, '-').toLowerCase() || 'report'}.pdf`
link.click()
URL.revokeObjectURL(href)
}
function applyData() {
try {
const parsed = JSON.parse(dataText)
setSampleData(parsed)
setError('')
} catch {
setError('Sample data must be valid JSON.')
}
}
function bindingField(block: Block) {
if (block.type === 'kpi') return 'value'
if (block.type === 'image') return 'alt'
if (block.type === 'table' || block.type === 'divider') return null
return 'text'
}
function insertBinding(path: string) {
if (!selected) return
const field = bindingField(selected)
if (!field) return
updateBlock(selected.id, { [field]: `${String(selected.props[field] || '')}{{${path}}}` })
}
async function createApiKey() {
if (!newKeyName.trim()) return
const created = await api<CreatedApiKey>('/api/api-keys', {
method: 'POST',
body: JSON.stringify({ name: newKeyName.trim() })
})
setCreatedKey(created)
setNewKeyName('')
await refreshApiKeys()
}
async function revokeApiKey(id: string) {
await api<void>(`/api/api-keys/${id}`, { method: 'DELETE' })
setCreatedKey(current => current?.id === id ? null : current)
await refreshApiKeys()
}
async function copy(value: string, label: string) {
await navigator.clipboard.writeText(value)
setCopied(label)
window.setTimeout(() => setCopied(''), 1400)
}
async function importTemplatePack(file?: File) {
if (!file) return
if (!file.name.toLowerCase().endsWith('.zip')) {
setError('Template packs must be ZIP files.')
return
}
setPackImporting(true)
setError('')
try {
const imported = await apiZip<{ template: TemplateRecord; asset_count: number }>(
'/api/template-packs/import',
file
)
await refreshTemplates()
await openTemplate(imported.template.id)
} catch (err) {
setError(String(err))
} finally {
setPackImporting(false)
if (packFileInput.current) packFileInput.current.value = ''
}
}
if (screen === 'templates') {
return (
<main className="app">
<header className="topbar landing">
<div>
<div className="eyebrow">ARROWSTACK</div>
<h1>Template Studio</h1>
</div>
<button className="circle primary" onClick={() => setCreateOpen(true)} aria-label="New template">+</button>
</header>
<section className="hero">
<div>
<span className="hero-kicker">Design once. Pull anywhere.</span>
<h2>Email and report templates for your apps.</h2>
<p>Build on mobile, publish a version, then consume it over an authenticated API.</p>
</div>
<div className="hero-stats">
<button onClick={() => setFilter('all')}><strong>{counts.total}</strong><span>All</span></button>
<button onClick={() => setFilter('report')}><strong>{counts.report}</strong><span>Reports</span></button>
<button onClick={() => setFilter('email')}><strong>{counts.email}</strong><span>Emails</span></button>
</div>
</section>
<section className="library-controls">
<div className="search-field">
<span>⌕</span>
<input value={search} onChange={event => setSearch(event.target.value)} placeholder="Search templates" />
</div>
<div className="filter-row">
{(['all', 'report', 'email'] as Filter[]).map(value => (
<button key={value} className={filter === value ? 'active' : ''} onClick={() => setFilter(value)}>
{value === 'all' ? 'All' : value === 'report' ? 'Reports' : 'Emails'}
</button>
))}
</div>
<div className="pack-actions">
<button
className="pack-import"
disabled={packImporting}
onClick={() => packFileInput.current?.click()}
>
{packImporting ? 'Importing…' : '✦ Import AI pack'}
</button>
<a href="/api/template-packs/spec" target="_blank" rel="noreferrer">Pack format</a>
<input
ref={packFileInput}
className="visually-hidden"
type="file"
accept=".zip,application/zip,application/x-zip-compressed"
onChange={event => void importTemplatePack(event.target.files?.[0])}
/>
</div>
</section>
{error && <div className="error-banner">{error}</div>}
<section className="template-list">
{visibleTemplates.map(template => (
<button key={template.id} className="template-card refined" onClick={() => void openTemplate(template.id)}>
<span className={`template-icon ${template.type}`}>{template.type === 'email' ? '✉' : '▤'}</span>
<span className="template-card-body">
<span className="template-card-topline">
<strong>{template.name}</strong>
<span className={`status-dot ${template.published_version ? 'published' : 'draft'}`} />
</span>
<span className="template-key">{template.template_key}</span>
<span className="muted">
{template.published_version ? `Published v${template.published_version}` : 'Draft only'} · {new Date(template.updated_at).toLocaleDateString()}
</span>
</span>
<span className="chevron">›</span>
</button>
))}
{!visibleTemplates.length && <div className="empty">No templates match this view.</div>}
</section>
<footer className="account-row">
<span>{user.name || user.preferred_username || user.email}</span>
<a href="/auth/logout">Sign out</a>
</footer>
{createOpen && (
<div className="sheet-backdrop" onClick={() => setCreateOpen(false)}>
<section className="sheet compact" onClick={event => event.stopPropagation()}>
<div className="grabber" />
<div className="sheet-heading">
<span className="sheet-icon">+</span>
<div>
<h2>New template</h2>
<p>Choose the output, then build it from blocks.</p>
</div>
</div>
<label>
Name
<input autoFocus value={createName} onChange={event => setCreateName(event.target.value)} placeholder="Weekly packing report" />
</label>
<div className="segmented">
<button className={createType === 'report' ? 'active' : ''} onClick={() => setCreateType('report')}>▤ Report</button>
<button className={createType === 'email' ? 'active' : ''} onClick={() => setCreateType('email')}>✉ Email</button>
</div>
<button className="button primary full large" onClick={() => void createTemplate()}>Create template</button>
</section>
</div>
)}
</main>
)
}
const published = Boolean(active?.published_version)
const pullUrl = active ? `https://templates.arrowstack.dev/api/v1/templates/${active.template_key}` : ''
const renderUrl = active ? `${pullUrl}/render` : ''
return (
<main className="app editor-shell">
<header className="topbar editor-topbar">
<button className="circle" onClick={() => { setScreen('templates'); void refreshTemplates() }}>←</button>
<div className="title-stack">
<strong>{active?.name}</strong>
<span className="template-meta">
<span className={`mini-status ${published ? 'published' : 'draft'}`}>{published ? `v${active?.published_version}` : 'Draft'}</span>
<span className={`save-state ${saveState}`}>
{saveState === 'saved' ? 'Saved' : saveState === 'saving' ? 'Saving…' : 'Local only'}
</span>
</span>
</div>
<button className="publish-button" onClick={() => void publish()}>
{published ? 'Publish update' : 'Publish'}
</button>
</header>
{error && <div className="error-banner inline">{error}</div>}
{screen === 'editor' && (
<section className="canvas">
<div className="editor-context">
<div>
<span className={`type-pill ${active?.type}`}>{active?.type === 'email' ? '✉ Email' : '▤ Report'}</span>
<code>{active?.template_key}</code>
{templateDoc.theme && <span className="theme-pill">Pack theme</span>}
</div>
<span>{templateDoc.blocks.length} blocks</span>
</div>
<div className="document-surface">
{templateDoc.blocks.map((block, index) => (
<button key={block.id} className={`block-card ${selectedId === block.id ? 'selected' : ''}`} onClick={() => setSelectedId(block.id)}>
<span className="drag-hint">⋮⋮</span>
<span className="block-copy">
<span className="block-type">{blockLabels[block.type]}</span>
<strong>{blockSummary(block)}</strong>
</span>
<span className="block-index">{String(index + 1).padStart(2, '0')}</span>
</button>
))}
<button className="add-block" onClick={() => setAddOpen(true)}>+ Add block</button>
{!templateDoc.blocks.length && <p className="muted center">Start by adding a block.</p>}
</div>
</section>
)}
{screen === 'preview' && (
<section className="preview-panel">
<div className="section-heading">
<div>
<span className="eyebrow">LIVE DRAFT</span>
<h2>Preview</h2>
</div>
<button className="button" onClick={() => void refreshPreview()}>↻ Refresh</button>
</div>
{active?.type === 'report' && (
<section className="page-setup-card">
<div className="page-setup-heading">
<div>
<span className="eyebrow">PAGE SETUP</span>
<h3>A4 margins</h3>
</div>
<span>mm</span>
</div>
<div className="page-margin-grid">
{[
['Top', 'pageMarginTop', 12],
['Right', 'pageMarginRight', 12],
['Bottom', 'pageMarginBottom', 12],
['Left', 'pageMarginLeft', 12]
].map(([label, key, fallback]) => (
<label key={String(key)}>
{label}
<input
type="number"
inputMode="decimal"
min="0"
max="40"
step="1"
value={themeNumber(String(key), Number(fallback))}
onChange={event => updateTheme({ [String(key)]: Number(event.target.value) })}
/>
</label>
))}
</div>
<label className="content-padding-control">
Content padding <span>px</span>
<input
type="range"
min="0"
max="80"
step="1"
value={themeNumber('pagePadding', 18)}
onChange={event => updateTheme({ pagePadding: Number(event.target.value) })}
/>
<strong>{themeNumber('pagePadding', 18)} px</strong>
</label>
<button className="text-button page-reset" onClick={() => updateTheme({
pageMarginTop: 12,
pageMarginRight: 12,
pageMarginBottom: 12,
pageMarginLeft: 12,
pagePadding: 18
})}>Reset page setup</button>
</section>
)}
<div className="segmented preview-segment">
<button className={previewDevice === 'mobile' ? 'active' : ''} onClick={() => setPreviewDevice('mobile')}>Phone</button>
<button className={previewDevice === 'desktop' ? 'active' : ''} onClick={() => setPreviewDevice('desktop')}>Desktop</button>
</div>
<div className={`preview-frame-wrap ${previewDevice}`}>
{previewHtml ? <iframe title="Template preview" srcDoc={previewHtml} sandbox="" /> : <div className="empty">Generating preview…</div>}
</div>
{active?.type === 'report' && <button className="button primary full large" onClick={() => void downloadPdf()}>Download draft PDF</button>}
</section>
)}
{screen === 'data' && (
<section className="data-panel">
<div className="section-heading">
<div>
<span className="eyebrow">DESIGN DATA</span>
<h2>Sample data</h2>
</div>
</div>
<p className="muted copy">This is only design-time data. Calling apps send their real data when they render.</p>
<textarea className="json-editor" value={dataText} onChange={event => setDataText(event.target.value)} spellCheck={false} />
<button className="button primary full large" onClick={applyData}>Apply sample data</button>
{!!paths.length && (
<section className="field-panel">
<h3>Available bindings</h3>
<div className="binding-list">
{paths.map(path => <code key={path}>{`{{${path}}}`}</code>)}
</div>
</section>
)}
</section>
)}
{screen === 'use' && (
<section className="use-panel">
<div className="section-heading">
<div>
<span className="eyebrow">PUBLISHED API</span>
<h2>Use this template</h2>
</div>
<span className={`publish-state ${published ? 'ready' : ''}`}>{published ? 'Ready' : 'Publish first'}</span>
</div>
<div className="integration-card">
<span className="card-label">Template key</span>
<div className="copy-row">
<code>{active?.template_key}</code>
<button onClick={() => void copy(active?.template_key || '', 'key')}>{copied === 'key' ? 'Copied' : 'Copy'}</button>
</div>
</div>
<div className="integration-card">
<span className="card-label">Pull published template</span>
<div className="copy-row multiline">
<code>GET {pullUrl}</code>
<button onClick={() => void copy(pullUrl, 'pull')}>{copied === 'pull' ? 'Copied' : 'Copy'}</button>
</div>
</div>
<div className="integration-card">
<span className="card-label">Render with data</span>
<div className="copy-row multiline">
<code>POST {renderUrl}</code>
<button onClick={() => void copy(renderUrl, 'render')}>{copied === 'render' ? 'Copied' : 'Copy'}</button>
</div>
<pre>{`{
"format": "${active?.type === 'email' ? 'email' : 'pdf'}",
"data": {
"customer": { "name": "Example" }
}
}`}</pre>
</div>
<div className="auth-card">
<div className="auth-heading">
<div>
<h3>Authentication</h3>
<p>Anonymous pulls are blocked.</p>
</div>
<span className="lock">⌁</span>
</div>
<div className="auth-option">
<strong>API key</strong>
<code>X-API-Key: ts_live_...</code>
</div>
<div className="auth-option">
<strong>Arrowstack OAuth</strong>
<code>Authorization: Bearer &lt;token&gt;</code>
<span>Machine client: template-studio-api</span>
</div>
</div>
<div className="key-manager">
<div className="key-manager-heading">
<div>
<h3>API keys</h3>
<p>Create one per consuming app so each can be revoked independently.</p>
</div>
</div>
<div className="create-key-row">
<input value={newKeyName} onChange={event => setNewKeyName(event.target.value)} placeholder="FieldLens production" />
<button className="button primary" onClick={() => void createApiKey()}>Create</button>
</div>
{createdKey && (
<div className="secret-reveal">
<strong>Copy this key now</strong>
<p>It is stored hashed and will not be shown again.</p>
<div className="copy-row multiline secret">
<code>{createdKey.api_key}</code>
<button onClick={() => void copy(createdKey.api_key, 'secret')}>{copied === 'secret' ? 'Copied' : 'Copy'}</button>
</div>
</div>
)}
<div className="key-list">
{apiKeys.map(key => (
<div className="key-row" key={key.id}>
<span>
<strong>{key.name}</strong>
<code>{key.key_prefix}…</code>
</span>
<span className="key-meta">
<small>Last used {formatDate(key.last_used_at)}</small>
<button onClick={() => void revokeApiKey(key.id)}>Revoke</button>
</span>
</div>
))}
{!apiKeys.length && <p className="muted">No active API keys.</p>}
</div>
</div>
</section>
)}
<nav className="bottom-nav four">
<button className={screen === 'editor' ? 'active' : ''} onClick={() => setScreen('editor')}><span>✎</span>Edit</button>
<button className={screen === 'preview' ? 'active' : ''} onClick={() => void goPreview()}><span>◉</span>Preview</button>
<button className={screen === 'data' ? 'active' : ''} onClick={() => setScreen('data')}><span>{'{ }'}</span>Data</button>
<button className={screen === 'use' ? 'active' : ''} onClick={() => void goUse()}><span>↗</span>Use</button>
</nav>
{addOpen && (
<div className="sheet-backdrop" onClick={() => setAddOpen(false)}>
<section className="sheet" onClick={event => event.stopPropagation()}>
<div className="grabber" />
<div className="sheet-heading">
<span className="sheet-icon">+</span>
<div>
<h2>Add block</h2>
<p>Keep the structure simple; the renderer handles output details.</p>
</div>
</div>
<div className="block-picker">
{(Object.keys(blockLabels) as BlockType[]).map(type => (
<button key={type} onClick={() => addBlock(type)}>
<span className="picker-icon">{type === 'heading' ? 'H' : type === 'text' ? 'T' : type === 'kpi' ? '#' : type === 'button' ? '↗' : type === 'image' ? '▧' : type === 'divider' ? '—' : '▦'}</span>
{blockLabels[type]}
</button>
))}
</div>
</section>
</div>
)}
{selected && screen === 'editor' && (
<div className="sheet-backdrop light" onClick={() => setSelectedId(null)}>
<section className="sheet block-editor" onClick={event => event.stopPropagation()}>
<div className="grabber" />
<div className="sheet-title-row">
<div>
<span className="eyebrow">EDIT BLOCK</span>
<h2>{blockLabels[selected.type]}</h2>
</div>
<button className="text-button" onClick={() => setSelectedId(null)}>Done</button>
</div>
{(selected.type === 'heading' || selected.type === 'text') && (
<label>
Text
<textarea value={String(selected.props.text || '')} onChange={event => updateBlock(selected.id, { text: event.target.value })} />
</label>
)}
{selected.type === 'kpi' && (
<>
<label>Label<input value={String(selected.props.label || '')} onChange={event => updateBlock(selected.id, { label: event.target.value })} /></label>
<label>Value<input value={String(selected.props.value || '')} onChange={event => updateBlock(selected.id, { value: event.target.value })} /></label>
</>
)}
{selected.type === 'button' && (
<>
<label>Text<input value={String(selected.props.text || '')} onChange={event => updateBlock(selected.id, { text: event.target.value })} /></label>
<label>URL<input value={String(selected.props.url || '')} onChange={event => updateBlock(selected.id, { url: event.target.value })} /></label>
</>
)}
{selected.type === 'image' && (
<>
<label>Image URL<input value={String(selected.props.url || '')} onChange={event => updateBlock(selected.id, { url: event.target.value })} /></label>
<label>Alt text<input value={String(selected.props.alt || '')} onChange={event => updateBlock(selected.id, { alt: event.target.value })} /></label>
<label>
Image role
<select value={String(selected.props.variant || '')} onChange={event => updateBlock(selected.id, { variant: event.target.value })}>
<option value="">Auto</option>
<option value="logo">Logo</option>
<option value="hero">Hero / banner</option>
<option value="image">Normal image</option>
</select>
</label>
<div className="two-field-grid">
<label>Max width (px)<input type="number" inputMode="numeric" value={String(selected.props.maxWidth || '')} onChange={event => updateBlock(selected.id, { maxWidth: event.target.value ? Number(event.target.value) : undefined })} placeholder="Auto" /></label>
<label>Max height (px)<input type="number" inputMode="numeric" value={String(selected.props.maxHeight || '')} onChange={event => updateBlock(selected.id, { maxHeight: event.target.value ? Number(event.target.value) : undefined })} placeholder="Auto" /></label>
</div>
<label>
Fit
<select value={String(selected.props.fit || '')} onChange={event => updateBlock(selected.id, { fit: event.target.value })}>
<option value="">Auto</option>
<option value="contain">Contain</option>
<option value="cover">Cover</option>
<option value="fill">Fill</option>
</select>
</label>
</>
)}
{selected.type === 'table' && (
<label>Rows data path<input value={String(selected.props.rowsPath || '')} onChange={event => updateBlock(selected.id, { rowsPath: event.target.value })} placeholder="items" /></label>
)}
{bindingField(selected) && !!paths.length && (
<>
<h3>Insert data</h3>
<div className="binding-chips">
{paths.slice(0, 12).map(path => <button key={path} onClick={() => insertBinding(path)}>{path}</button>)}
</div>
</>
)}
<div className="block-actions">
<button onClick={() => moveBlock(selected.id, -1)}>↑ Move up</button>
<button onClick={() => moveBlock(selected.id, 1)}>↓ Move down</button>
</div>
<button className="danger-link" onClick={() => removeBlock(selected.id)}>Delete block</button>
</section>
</div>
)}
</main>
)
}