From c21e37eb028aa9290fc05be0b225afc72e34872f Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 23 Sep 2026 02:45:47 +0000 Subject: [PATCH] feat: add src/App.tsx --- src/App.tsx | 448 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 448 insertions(+) create mode 100644 src/App.tsx diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..a2caba6 --- /dev/null +++ b/src/App.tsx @@ -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> = { + 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 ?