feat: add src/App.tsx

This commit is contained in:
Admin 2026-09-23 02:45:47 +00:00
parent bf185e9858
commit c21e37eb02

448
src/App.tsx Normal file
View 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>
)
}