feat: add mobile AI pack ZIP upload

This commit is contained in:
Admin 2026-09-23 04:03:19 +00:00
parent 0bbac8da30
commit 7da99c16a2
3 changed files with 116 additions and 1 deletions

View File

@ -17,3 +17,22 @@ test('email renderer produces HTML', () => {
assert.match(html, /Hello/)
assert.match(html, /doctype/i)
})
test('report renderer applies constrained pack theme styling', () => {
const html = renderReportHtml({
theme: {
backgroundColor: '#f6f1e8',
accentColor: '#8b5e34',
pageBackgroundImage: 'https://templates.arrowstack.dev/media/token/bg.webp'
},
blocks: [{
type: 'heading',
props: { text: 'Styled' },
style: { backgroundColor: '#fffaf2', padding: 18, borderRadius: 12 }
}]
}, {})
assert.match(html, /#f6f1e8/)
assert.match(html, /#8b5e34/)
assert.match(html, /bg\.webp/)
assert.match(html, /padding:18px/)
})

View File

@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { get, set } from 'idb-keyval'
import { api, apiBlob } from './api'
import { api, apiBlob, apiZip } from './api'
import type {
ApiKeySummary,
Block,
@ -98,6 +98,8 @@ export function App({ user }: Props) {
const [newKeyName, setNewKeyName] = useState('')
const [createdKey, setCreatedKey] = useState<CreatedApiKey | null>(null)
const [copied, setCopied] = useState('')
const [packImporting, setPackImporting] = useState(false)
const packFileInput = useRef<HTMLInputElement | null>(null)
const loadedId = useRef<string | null>(null)
const timer = useRef<number | undefined>(undefined)
@ -317,6 +319,30 @@ export function App({ user }: Props) {
window.setTimeout(() => setCopied(''), 1400)
}
async function importTemplatePack(file?: File) {
if (!file) return
if (!file.name.toLowerCase().endsWith('.zip')) {
setError('Template packs must be ZIP files.')
return
}
setPackImporting(true)
setError('')
try {
const imported = await apiZip<{ template: TemplateRecord; asset_count: number }>(
'/api/template-packs/import',
file
)
await refreshTemplates()
await openTemplate(imported.template.id)
} catch (err) {
setError(String(err))
} finally {
setPackImporting(false)
if (packFileInput.current) packFileInput.current.value = ''
}
}
if (screen === 'templates') {
return (
<main className="app">
@ -353,6 +379,23 @@ export function App({ user }: Props) {
</button>
))}
</div>
<div className="pack-actions">
<button
className="pack-import"
disabled={packImporting}
onClick={() => packFileInput.current?.click()}
>
{packImporting ? 'Importing…' : '✦ Import AI pack'}
</button>
<a href="/api/template-packs/spec" target="_blank" rel="noreferrer">Pack format</a>
<input
ref={packFileInput}
className="visually-hidden"
type="file"
accept=".zip,application/zip,application/x-zip-compressed"
onChange={event => void importTemplatePack(event.target.files?.[0])}
/>
</div>
</section>
{error && <div className="error-banner">{error}</div>}
@ -439,6 +482,7 @@ export function App({ user }: Props) {
<div>
<span className={`type-pill ${active?.type}`}>{active?.type === 'email' ? '✉ Email' : '▤ Report'}</span>
<code>{active?.template_key}</code>
{templateDoc.theme && <span className="theme-pill">Pack theme</span>}
</div>
<span>{templateDoc.blocks.length} blocks</span>
</div>

View File

@ -657,3 +657,55 @@ body{
grid-template-columns:repeat(3,minmax(0,1fr));
}
}
.pack-actions{
display:flex;
align-items:center;
gap:9px;
padding-top:9px;
}
.pack-import{
border:1px solid #111827;
border-radius:999px;
background:#111827;
color:white;
padding:8px 12px;
font-size:11px;
font-weight:800;
}
.pack-import:disabled{
opacity:.55;
cursor:progress;
}
.pack-actions a{
color:#64748b;
font-size:10px;
font-weight:800;
text-decoration:none;
}
.visually-hidden{
position:absolute!important;
width:1px!important;
height:1px!important;
padding:0!important;
margin:-1px!important;
overflow:hidden!important;
clip:rect(0,0,0,0)!important;
white-space:nowrap!important;
border:0!important;
}
.theme-pill{
flex:0 0 auto;
border-radius:999px;
background:#f4ecff;
color:#7048a8;
padding:4px 7px;
font-size:9px;
font-weight:800;
}
@media(min-width:760px){
.pack-actions{
padding-top:0;
margin-left:auto;
}
}