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> = { 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]) } 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('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 [filter, setFilter] = useState('all') const [search, setSearch] = useState('') const [apiKeys, setApiKeys] = useState([]) const [newKeyName, setNewKeyName] = useState('') const [createdKey, setCreatedKey] = useState(null) const [copied, setCopied] = useState('') const [packImporting, setPackImporting] = useState(false) const packFileInput = useRef(null) const loadedId = useRef(null) const timer = useRef(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('/api/templates')) } async function refreshApiKeys() { setApiKeys(await api('/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(`/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) setCreatedKey(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 updateTheme(patch: Record) { 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(`/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('/api/api-keys', { method: 'POST', body: JSON.stringify({ name: newKeyName.trim() }) }) setCreatedKey(created) setNewKeyName('') await refreshApiKeys() } async function revokeApiKey(id: string) { await api(`/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 (
ARROWSTACK

Template Studio

Design once. Pull anywhere.

Email and report templates for your apps.

Build on mobile, publish a version, then consume it over an authenticated API.

⌕ setSearch(event.target.value)} placeholder="Search templates" />
{(['all', 'report', 'email'] as Filter[]).map(value => ( ))}
Pack format void importTemplatePack(event.target.files?.[0])} />
{error &&
{error}
}
{visibleTemplates.map(template => ( ))} {!visibleTemplates.length &&
No templates match this view.
}
{user.name || user.preferred_username || user.email} Sign out
{createOpen && (
setCreateOpen(false)}>
event.stopPropagation()}>
+

New template

Choose the output, then build it from blocks.

)}
) } 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 (
{active?.name} {published ? `v${active?.published_version}` : 'Draft'} {saveState === 'saved' ? 'Saved' : saveState === 'saving' ? 'Saving…' : 'Local only'}
{error &&
{error}
} {screen === 'editor' && (
{active?.type === 'email' ? '✉ Email' : '▤ Report'} {active?.template_key} {templateDoc.theme && Pack theme}
{templateDoc.blocks.length} blocks
{templateDoc.blocks.map((block, index) => ( ))} {!templateDoc.blocks.length &&

Start by adding a block.

}
)} {screen === 'preview' && (
LIVE DRAFT

Preview

{active?.type === 'report' && (
PAGE SETUP

A4 margins

mm
{[ ['Top', 'pageMarginTop', 12], ['Right', 'pageMarginRight', 12], ['Bottom', 'pageMarginBottom', 12], ['Left', 'pageMarginLeft', 12] ].map(([label, key, fallback]) => ( ))}
)}
{previewHtml ?