2026-09-23 02:45:47 +00:00

449 lines
18 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 } 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>
)
}