feat: add src/App.tsx
This commit is contained in:
parent
bf185e9858
commit
c21e37eb02
448
src/App.tsx
Normal file
448
src/App.tsx
Normal file
@ -0,0 +1,448 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { get, set } from 'idb-keyval'
|
||||||
|
import { api, apiBlob } from './api'
|
||||||
|
import type { Block, BlockType, TemplateDocument, TemplateRecord, TemplateSummary, TemplateType } from './types'
|
||||||
|
|
||||||
|
type Screen = 'templates' | 'editor' | 'preview' | 'data'
|
||||||
|
type SaveState = 'saved' | 'saving' | 'offline'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
user: {
|
||||||
|
name?: string
|
||||||
|
preferred_username?: string
|
||||||
|
email?: 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])
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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])
|
||||||
|
|
||||||
|
async function refreshTemplates() {
|
||||||
|
setTemplates(await api<TemplateSummary[]>('/api/templates'))
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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 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 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}}}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
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="intro">
|
||||||
|
<p>Build email templates and reports from reusable blocks, right from your phone.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
|
<section className="template-list">
|
||||||
|
{templates.map(template => (
|
||||||
|
<button key={template.id} className="template-card" onClick={() => void openTemplate(template.id)}>
|
||||||
|
<span className={`type-badge ${template.type}`}>{template.type === 'email' ? 'Email' : 'Report'}</span>
|
||||||
|
<strong>{template.name}</strong>
|
||||||
|
<span className="muted">
|
||||||
|
{template.published_version ? `Published v${template.published_version}` : 'Draft'} · {new Date(template.updated_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!templates.length && <div className="empty">No templates yet. Tap + to build your first one.</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" />
|
||||||
|
<h2>New template</h2>
|
||||||
|
<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" onClick={() => void createTemplate()}>Create template</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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={`save-state ${saveState}`}>
|
||||||
|
{saveState === 'saved' ? 'Saved' : saveState === 'saving' ? 'Saving…' : 'Saved locally'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button className="publish-button" onClick={() => void publish()}>
|
||||||
|
{active?.published_version ? `Publish v${active.published_version + 1}` : 'Publish'}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error && <div className="error-banner inline">{error}</div>}
|
||||||
|
|
||||||
|
{screen === 'editor' && (
|
||||||
|
<section className="canvas">
|
||||||
|
<div className="canvas-label">{active?.type === 'email' ? 'EMAIL' : 'REPORT'}</div>
|
||||||
|
{templateDoc.blocks.map((block, index) => (
|
||||||
|
<button key={block.id} className={`block-card ${selectedId === block.id ? 'selected' : ''}`} onClick={() => setSelectedId(block.id)}>
|
||||||
|
<span className="block-type">{blockLabels[block.type]}</span>
|
||||||
|
<strong>{blockSummary(block)}</strong>
|
||||||
|
<span className="block-index">{index + 1}</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>}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{screen === 'preview' && (
|
||||||
|
<section className="preview-panel">
|
||||||
|
<div className="preview-toolbar">
|
||||||
|
<div className="segmented small">
|
||||||
|
<button className={previewDevice === 'mobile' ? 'active' : ''} onClick={() => setPreviewDevice('mobile')}>Mobile</button>
|
||||||
|
<button className={previewDevice === 'desktop' ? 'active' : ''} onClick={() => setPreviewDevice('desktop')}>Desktop</button>
|
||||||
|
</div>
|
||||||
|
<button className="button" onClick={() => void refreshPreview()}>Refresh</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" onClick={() => void downloadPdf()}>Download PDF</button>}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{screen === 'data' && (
|
||||||
|
<section className="data-panel">
|
||||||
|
<h2>Sample data</h2>
|
||||||
|
<p className="muted">Use this while designing. Production callers can supply their own render data.</p>
|
||||||
|
<textarea className="json-editor" value={dataText} onChange={event => setDataText(event.target.value)} spellCheck={false} />
|
||||||
|
<button className="button primary full" onClick={applyData}>Apply sample data</button>
|
||||||
|
{!!paths.length && (
|
||||||
|
<>
|
||||||
|
<h3>Available fields</h3>
|
||||||
|
<div className="binding-list">
|
||||||
|
{paths.map(path => <code key={path}>{`{{${path}}}`}</code>)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<nav className="bottom-nav">
|
||||||
|
<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>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{addOpen && (
|
||||||
|
<div className="sheet-backdrop" onClick={() => setAddOpen(false)}>
|
||||||
|
<section className="sheet" onClick={event => event.stopPropagation()}>
|
||||||
|
<div className="grabber" />
|
||||||
|
<h2>Add block</h2>
|
||||||
|
<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">
|
||||||
|
<h2>{blockLabels[selected.type]}</h2>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user