feat: add src/App.tsx
This commit is contained in:
parent
bf185e9858
commit
c21e37eb02
448
src/App.tsx
Normal file
448
src/App.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user