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> = { 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 = { 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)) { 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('templates') const [templates, setTemplates] = useState([]) const [active, setActive] = useState(null) const [templateDoc, setTemplateDoc] = useState({ blocks: [] }) const [sampleData, setSampleData] = useState>({}) const [selectedId, setSelectedId] = useState(null) const [addOpen, setAddOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false) const [createName, setCreateName] = useState('') const [createType, setCreateType] = useState('report') const [saveState, setSaveState] = useState('saved') const [previewHtml, setPreviewHtml] = useState('') const [previewDevice, setPreviewDevice] = useState<'mobile' | 'desktop'>('mobile') const [dataText, setDataText] = useState('{}') const [error, setError] = useState('') const loadedId = useRef(null) const timer = useRef(undefined) const selected = templateDoc.blocks.find(block => block.id === selectedId) || null const paths = useMemo(() => flatten(sampleData), [sampleData]) async function refreshTemplates() { setTemplates(await api('/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(`/api/templates/${id}`) const local = await get<{ document: TemplateDocument; sampleData: Record }>( `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('/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) { 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(`/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 (
ARROWSTACK

Template Studio

Build email templates and reports from reusable blocks, right from your phone.

{error &&
{error}
}
{templates.map(template => ( ))} {!templates.length &&
No templates yet. Tap + to build your first one.
}
{user.name || user.preferred_username || user.email} Sign out
{createOpen && (
setCreateOpen(false)}>
event.stopPropagation()}>

New template

)}
) } return (
{active?.name} {saveState === 'saved' ? 'Saved' : saveState === 'saving' ? 'Saving…' : 'Saved locally'}
{error &&
{error}
} {screen === 'editor' && (
{active?.type === 'email' ? 'EMAIL' : 'REPORT'}
{templateDoc.blocks.map((block, index) => ( ))} {!templateDoc.blocks.length &&

Start by adding a block.

}
)} {screen === 'preview' && (
{previewHtml ?