refine: update src/App.tsx

This commit is contained in:
Admin 2026-09-23 03:00:01 +00:00
parent 5c43fbb871
commit 5b54173931

View File

@ -1,10 +1,19 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { get, set } from 'idb-keyval' import { get, set } from 'idb-keyval'
import { api, apiBlob } from './api' import { api, apiBlob } from './api'
import type { Block, BlockType, TemplateDocument, TemplateRecord, TemplateSummary, TemplateType } from './types' import type {
ApiKeySummary,
Block,
BlockType,
TemplateDocument,
TemplateRecord,
TemplateSummary,
TemplateType
} from './types'
type Screen = 'templates' | 'editor' | 'preview' | 'data' type Screen = 'templates' | 'editor' | 'preview' | 'data' | 'use'
type SaveState = 'saved' | 'saving' | 'offline' type SaveState = 'saved' | 'saving' | 'offline'
type Filter = 'all' | TemplateType
type Props = { type Props = {
user: { user: {
@ -14,6 +23,10 @@ type Props = {
} }
} }
type CreatedApiKey = ApiKeySummary & {
api_key: string
}
const defaults: Record<BlockType, Record<string, unknown>> = { const defaults: Record<BlockType, Record<string, unknown>> = {
heading: { text: 'New heading' }, heading: { text: 'New heading' },
text: { text: 'Write something useful.' }, text: { text: 'Write something useful.' },
@ -58,6 +71,11 @@ function blockSummary(block: Block) {
return String(block.props.text || block.props.alt || blockLabels[block.type]) 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) { export function App({ user }: Props) {
const [screen, setScreen] = useState<Screen>('templates') const [screen, setScreen] = useState<Screen>('templates')
const [templates, setTemplates] = useState<TemplateSummary[]>([]) const [templates, setTemplates] = useState<TemplateSummary[]>([])
@ -74,16 +92,41 @@ export function App({ user }: Props) {
const [previewDevice, setPreviewDevice] = useState<'mobile' | 'desktop'>('mobile') const [previewDevice, setPreviewDevice] = useState<'mobile' | 'desktop'>('mobile')
const [dataText, setDataText] = useState('{}') const [dataText, setDataText] = useState('{}')
const [error, setError] = useState('') const [error, setError] = useState('')
const [filter, setFilter] = useState<Filter>('all')
const [search, setSearch] = useState('')
const [apiKeys, setApiKeys] = useState<ApiKeySummary[]>([])
const [newKeyName, setNewKeyName] = useState('')
const [createdKey, setCreatedKey] = useState<CreatedApiKey | null>(null)
const [copied, setCopied] = useState('')
const loadedId = useRef<string | null>(null) const loadedId = useRef<string | null>(null)
const timer = useRef<number | undefined>(undefined) const timer = useRef<number | undefined>(undefined)
const selected = templateDoc.blocks.find(block => block.id === selectedId) || null const selected = templateDoc.blocks.find(block => block.id === selectedId) || null
const paths = useMemo(() => flatten(sampleData), [sampleData]) 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() { async function refreshTemplates() {
setTemplates(await api<TemplateSummary[]>('/api/templates')) setTemplates(await api<TemplateSummary[]>('/api/templates'))
} }
async function refreshApiKeys() {
setApiKeys(await api<ApiKeySummary[]>('/api/api-keys'))
}
useEffect(() => { useEffect(() => {
void refreshTemplates().catch(err => setError(String(err))) void refreshTemplates().catch(err => setError(String(err)))
}, []) }, [])
@ -126,6 +169,7 @@ export function App({ user }: Props) {
setSampleData(local?.sampleData || remote.sample_data || {}) setSampleData(local?.sampleData || remote.sample_data || {})
setDataText(JSON.stringify(local?.sampleData || remote.sample_data || {}, null, 2)) setDataText(JSON.stringify(local?.sampleData || remote.sample_data || {}, null, 2))
setSelectedId(null) setSelectedId(null)
setCreatedKey(null)
setScreen('editor') setScreen('editor')
} }
@ -202,6 +246,15 @@ export function App({ user }: Props) {
} }
} }
async function goUse() {
setScreen('use')
try {
await refreshApiKeys()
} catch (err) {
setError(String(err))
}
}
async function downloadPdf() { async function downloadPdf() {
if (!active) return if (!active) return
await saveNow() await saveNow()
@ -241,6 +294,29 @@ export function App({ user }: Props) {
updateBlock(selected.id, { [field]: `${String(selected.props[field] || '')}{{${path}}}` }) updateBlock(selected.id, { [field]: `${String(selected.props[field] || '')}{{${path}}}` })
} }
async function createApiKey() {
if (!newKeyName.trim()) return
const created = await api<CreatedApiKey>('/api/api-keys', {
method: 'POST',
body: JSON.stringify({ name: newKeyName.trim() })
})
setCreatedKey(created)
setNewKeyName('')
await refreshApiKeys()
}
async function revokeApiKey(id: string) {
await api<void>(`/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)
}
if (screen === 'templates') { if (screen === 'templates') {
return ( return (
<main className="app"> <main className="app">
@ -252,23 +328,53 @@ export function App({ user }: Props) {
<button className="circle primary" onClick={() => setCreateOpen(true)} aria-label="New template">+</button> <button className="circle primary" onClick={() => setCreateOpen(true)} aria-label="New template">+</button>
</header> </header>
<section className="intro"> <section className="hero">
<p>Build email templates and reports from reusable blocks, right from your phone.</p> <div>
<span className="hero-kicker">Design once. Pull anywhere.</span>
<h2>Email and report templates for your apps.</h2>
<p>Build on mobile, publish a version, then consume it over an authenticated API.</p>
</div>
<div className="hero-stats">
<button onClick={() => setFilter('all')}><strong>{counts.total}</strong><span>All</span></button>
<button onClick={() => setFilter('report')}><strong>{counts.report}</strong><span>Reports</span></button>
<button onClick={() => setFilter('email')}><strong>{counts.email}</strong><span>Emails</span></button>
</div>
</section>
<section className="library-controls">
<div className="search-field">
<span>⌕</span>
<input value={search} onChange={event => setSearch(event.target.value)} placeholder="Search templates" />
</div>
<div className="filter-row">
{(['all', 'report', 'email'] as Filter[]).map(value => (
<button key={value} className={filter === value ? 'active' : ''} onClick={() => setFilter(value)}>
{value === 'all' ? 'All' : value === 'report' ? 'Reports' : 'Emails'}
</button>
))}
</div>
</section> </section>
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner">{error}</div>}
<section className="template-list"> <section className="template-list">
{templates.map(template => ( {visibleTemplates.map(template => (
<button key={template.id} className="template-card" onClick={() => void openTemplate(template.id)}> <button key={template.id} className="template-card refined" onClick={() => void openTemplate(template.id)}>
<span className={`type-badge ${template.type}`}>{template.type === 'email' ? 'Email' : 'Report'}</span> <span className={`template-icon ${template.type}`}>{template.type === 'email' ? '✉' : '▤'}</span>
<span className="template-card-body">
<span className="template-card-topline">
<strong>{template.name}</strong> <strong>{template.name}</strong>
<span className="muted"> <span className={`status-dot ${template.published_version ? 'published' : 'draft'}`} />
{template.published_version ? `Published v${template.published_version}` : 'Draft'} · {new Date(template.updated_at).toLocaleDateString()}
</span> </span>
<span className="template-key">{template.template_key}</span>
<span className="muted">
{template.published_version ? `Published v${template.published_version}` : 'Draft only'} · {new Date(template.updated_at).toLocaleDateString()}
</span>
</span>
<span className="chevron">›</span>
</button> </button>
))} ))}
{!templates.length && <div className="empty">No templates yet. Tap + to build your first one.</div>} {!visibleTemplates.length && <div className="empty">No templates match this view.</div>}
</section> </section>
<footer className="account-row"> <footer className="account-row">
@ -280,16 +386,22 @@ export function App({ user }: Props) {
<div className="sheet-backdrop" onClick={() => setCreateOpen(false)}> <div className="sheet-backdrop" onClick={() => setCreateOpen(false)}>
<section className="sheet compact" onClick={event => event.stopPropagation()}> <section className="sheet compact" onClick={event => event.stopPropagation()}>
<div className="grabber" /> <div className="grabber" />
<div className="sheet-heading">
<span className="sheet-icon">+</span>
<div>
<h2>New template</h2> <h2>New template</h2>
<p>Choose the output, then build it from blocks.</p>
</div>
</div>
<label> <label>
Name Name
<input autoFocus value={createName} onChange={event => setCreateName(event.target.value)} placeholder="Weekly packing report" /> <input autoFocus value={createName} onChange={event => setCreateName(event.target.value)} placeholder="Weekly packing report" />
</label> </label>
<div className="segmented"> <div className="segmented">
<button className={createType === 'report' ? 'active' : ''} onClick={() => setCreateType('report')}>Report</button> <button className={createType === 'report' ? 'active' : ''} onClick={() => setCreateType('report')}>▤ Report</button>
<button className={createType === 'email' ? 'active' : ''} onClick={() => setCreateType('email')}>Email</button> <button className={createType === 'email' ? 'active' : ''} onClick={() => setCreateType('email')}>✉ Email</button>
</div> </div>
<button className="button primary full" onClick={() => void createTemplate()}>Create template</button> <button className="button primary full large" onClick={() => void createTemplate()}>Create template</button>
</section> </section>
</div> </div>
)} )}
@ -297,18 +409,25 @@ export function App({ user }: Props) {
) )
} }
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 ( return (
<main className="app editor-shell"> <main className="app editor-shell">
<header className="topbar editor-topbar"> <header className="topbar editor-topbar">
<button className="circle" onClick={() => { setScreen('templates'); void refreshTemplates() }}>←</button> <button className="circle" onClick={() => { setScreen('templates'); void refreshTemplates() }}>←</button>
<div className="title-stack"> <div className="title-stack">
<strong>{active?.name}</strong> <strong>{active?.name}</strong>
<span className="template-meta">
<span className={`mini-status ${published ? 'published' : 'draft'}`}>{published ? `v${active?.published_version}` : 'Draft'}</span>
<span className={`save-state ${saveState}`}> <span className={`save-state ${saveState}`}>
{saveState === 'saved' ? 'Saved' : saveState === 'saving' ? 'Saving…' : 'Saved locally'} {saveState === 'saved' ? 'Saved' : saveState === 'saving' ? 'Saving…' : 'Local only'}
</span>
</span> </span>
</div> </div>
<button className="publish-button" onClick={() => void publish()}> <button className="publish-button" onClick={() => void publish()}>
{active?.published_version ? `Publish v${active.published_version + 1}` : 'Publish'} {published ? 'Publish update' : 'Publish'}
</button> </button>
</header> </header>
@ -316,63 +435,193 @@ export function App({ user }: Props) {
{screen === 'editor' && ( {screen === 'editor' && (
<section className="canvas"> <section className="canvas">
<div className="canvas-label">{active?.type === 'email' ? 'EMAIL' : 'REPORT'}</div> <div className="editor-context">
<div>
<span className={`type-pill ${active?.type}`}>{active?.type === 'email' ? '✉ Email' : '▤ Report'}</span>
<code>{active?.template_key}</code>
</div>
<span>{templateDoc.blocks.length} blocks</span>
</div>
<div className="document-surface">
{templateDoc.blocks.map((block, index) => ( {templateDoc.blocks.map((block, index) => (
<button key={block.id} className={`block-card ${selectedId === block.id ? 'selected' : ''}`} onClick={() => setSelectedId(block.id)}> <button key={block.id} className={`block-card ${selectedId === block.id ? 'selected' : ''}`} onClick={() => setSelectedId(block.id)}>
<span className="drag-hint">⋮⋮</span>
<span className="block-copy">
<span className="block-type">{blockLabels[block.type]}</span> <span className="block-type">{blockLabels[block.type]}</span>
<strong>{blockSummary(block)}</strong> <strong>{blockSummary(block)}</strong>
<span className="block-index">{index + 1}</span> </span>
<span className="block-index">{String(index + 1).padStart(2, '0')}</span>
</button> </button>
))} ))}
<button className="add-block" onClick={() => setAddOpen(true)}>+ Add block</button> <button className="add-block" onClick={() => setAddOpen(true)}>+ Add block</button>
{!templateDoc.blocks.length && <p className="muted center">Start by adding a block.</p>} {!templateDoc.blocks.length && <p className="muted center">Start by adding a block.</p>}
</div>
</section> </section>
)} )}
{screen === 'preview' && ( {screen === 'preview' && (
<section className="preview-panel"> <section className="preview-panel">
<div className="preview-toolbar"> <div className="section-heading">
<div className="segmented small"> <div>
<button className={previewDevice === 'mobile' ? 'active' : ''} onClick={() => setPreviewDevice('mobile')}>Mobile</button> <span className="eyebrow">LIVE DRAFT</span>
<button className={previewDevice === 'desktop' ? 'active' : ''} onClick={() => setPreviewDevice('desktop')}>Desktop</button> <h2>Preview</h2>
</div> </div>
<button className="button" onClick={() => void refreshPreview()}>Refresh</button> <button className="button" onClick={() => void refreshPreview()}>↻ Refresh</button>
</div>
<div className="segmented preview-segment">
<button className={previewDevice === 'mobile' ? 'active' : ''} onClick={() => setPreviewDevice('mobile')}>Phone</button>
<button className={previewDevice === 'desktop' ? 'active' : ''} onClick={() => setPreviewDevice('desktop')}>Desktop</button>
</div> </div>
<div className={`preview-frame-wrap ${previewDevice}`}> <div className={`preview-frame-wrap ${previewDevice}`}>
{previewHtml ? <iframe title="Template preview" srcDoc={previewHtml} sandbox="" /> : <div className="empty">Generating preview…</div>} {previewHtml ? <iframe title="Template preview" srcDoc={previewHtml} sandbox="" /> : <div className="empty">Generating preview…</div>}
</div> </div>
{active?.type === 'report' && <button className="button primary full" onClick={() => void downloadPdf()}>Download PDF</button>} {active?.type === 'report' && <button className="button primary full large" onClick={() => void downloadPdf()}>Download draft PDF</button>}
</section> </section>
)} )}
{screen === 'data' && ( {screen === 'data' && (
<section className="data-panel"> <section className="data-panel">
<div className="section-heading">
<div>
<span className="eyebrow">DESIGN DATA</span>
<h2>Sample data</h2> <h2>Sample data</h2>
<p className="muted">Use this while designing. Production callers can supply their own render data.</p> </div>
</div>
<p className="muted copy">This is only design-time data. Calling apps send their real data when they render.</p>
<textarea className="json-editor" value={dataText} onChange={event => setDataText(event.target.value)} spellCheck={false} /> <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> <button className="button primary full large" onClick={applyData}>Apply sample data</button>
{!!paths.length && ( {!!paths.length && (
<> <section className="field-panel">
<h3>Available fields</h3> <h3>Available bindings</h3>
<div className="binding-list"> <div className="binding-list">
{paths.map(path => <code key={path}>{`{{${path}}}`}</code>)} {paths.map(path => <code key={path}>{`{{${path}}}`}</code>)}
</div> </div>
</> </section>
)} )}
</section> </section>
)} )}
<nav className="bottom-nav"> {screen === 'use' && (
<section className="use-panel">
<div className="section-heading">
<div>
<span className="eyebrow">PUBLISHED API</span>
<h2>Use this template</h2>
</div>
<span className={`publish-state ${published ? 'ready' : ''}`}>{published ? 'Ready' : 'Publish first'}</span>
</div>
<div className="integration-card">
<span className="card-label">Template key</span>
<div className="copy-row">
<code>{active?.template_key}</code>
<button onClick={() => void copy(active?.template_key || '', 'key')}>{copied === 'key' ? 'Copied' : 'Copy'}</button>
</div>
</div>
<div className="integration-card">
<span className="card-label">Pull published template</span>
<div className="copy-row multiline">
<code>GET {pullUrl}</code>
<button onClick={() => void copy(pullUrl, 'pull')}>{copied === 'pull' ? 'Copied' : 'Copy'}</button>
</div>
</div>
<div className="integration-card">
<span className="card-label">Render with data</span>
<div className="copy-row multiline">
<code>POST {renderUrl}</code>
<button onClick={() => void copy(renderUrl, 'render')}>{copied === 'render' ? 'Copied' : 'Copy'}</button>
</div>
<pre>{`{
"format": "${active?.type === 'email' ? 'email' : 'pdf'}",
"data": {
"customer": { "name": "Example" }
}
}`}</pre>
</div>
<div className="auth-card">
<div className="auth-heading">
<div>
<h3>Authentication</h3>
<p>Anonymous pulls are blocked.</p>
</div>
<span className="lock">⌁</span>
</div>
<div className="auth-option">
<strong>API key</strong>
<code>X-API-Key: ts_live_...</code>
</div>
<div className="auth-option">
<strong>Arrowstack OAuth</strong>
<code>Authorization: Bearer &lt;token&gt;</code>
<span>Machine client: template-studio-api</span>
</div>
</div>
<div className="key-manager">
<div className="key-manager-heading">
<div>
<h3>API keys</h3>
<p>Create one per consuming app so each can be revoked independently.</p>
</div>
</div>
<div className="create-key-row">
<input value={newKeyName} onChange={event => setNewKeyName(event.target.value)} placeholder="FieldLens production" />
<button className="button primary" onClick={() => void createApiKey()}>Create</button>
</div>
{createdKey && (
<div className="secret-reveal">
<strong>Copy this key now</strong>
<p>It is stored hashed and will not be shown again.</p>
<div className="copy-row multiline secret">
<code>{createdKey.api_key}</code>
<button onClick={() => void copy(createdKey.api_key, 'secret')}>{copied === 'secret' ? 'Copied' : 'Copy'}</button>
</div>
</div>
)}
<div className="key-list">
{apiKeys.map(key => (
<div className="key-row" key={key.id}>
<span>
<strong>{key.name}</strong>
<code>{key.key_prefix}…</code>
</span>
<span className="key-meta">
<small>Last used {formatDate(key.last_used_at)}</small>
<button onClick={() => void revokeApiKey(key.id)}>Revoke</button>
</span>
</div>
))}
{!apiKeys.length && <p className="muted">No active API keys.</p>}
</div>
</div>
</section>
)}
<nav className="bottom-nav four">
<button className={screen === 'editor' ? 'active' : ''} onClick={() => setScreen('editor')}><span>✎</span>Edit</button> <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 === 'preview' ? 'active' : ''} onClick={() => void goPreview()}><span>◉</span>Preview</button>
<button className={screen === 'data' ? 'active' : ''} onClick={() => setScreen('data')}><span>{'{ }'}</span>Data</button> <button className={screen === 'data' ? 'active' : ''} onClick={() => setScreen('data')}><span>{'{ }'}</span>Data</button>
<button className={screen === 'use' ? 'active' : ''} onClick={() => void goUse()}><span>↗</span>Use</button>
</nav> </nav>
{addOpen && ( {addOpen && (
<div className="sheet-backdrop" onClick={() => setAddOpen(false)}> <div className="sheet-backdrop" onClick={() => setAddOpen(false)}>
<section className="sheet" onClick={event => event.stopPropagation()}> <section className="sheet" onClick={event => event.stopPropagation()}>
<div className="grabber" /> <div className="grabber" />
<div className="sheet-heading">
<span className="sheet-icon">+</span>
<div>
<h2>Add block</h2> <h2>Add block</h2>
<p>Keep the structure simple; the renderer handles output details.</p>
</div>
</div>
<div className="block-picker"> <div className="block-picker">
{(Object.keys(blockLabels) as BlockType[]).map(type => ( {(Object.keys(blockLabels) as BlockType[]).map(type => (
<button key={type} onClick={() => addBlock(type)}> <button key={type} onClick={() => addBlock(type)}>
@ -390,7 +639,10 @@ export function App({ user }: Props) {
<section className="sheet block-editor" onClick={event => event.stopPropagation()}> <section className="sheet block-editor" onClick={event => event.stopPropagation()}>
<div className="grabber" /> <div className="grabber" />
<div className="sheet-title-row"> <div className="sheet-title-row">
<div>
<span className="eyebrow">EDIT BLOCK</span>
<h2>{blockLabels[selected.type]}</h2> <h2>{blockLabels[selected.type]}</h2>
</div>
<button className="text-button" onClick={() => setSelectedId(null)}>Done</button> <button className="text-button" onClick={() => setSelectedId(null)}>Done</button>
</div> </div>