feat: add theme swap and upload UI

This commit is contained in:
Admin 2026-09-23 06:47:54 +00:00
parent 9e5a06dd53
commit b958d85ada
4 changed files with 286 additions and 2 deletions

View File

@ -153,3 +153,55 @@ The constrained theme supports colors, border radius, background imagery and rep
- 10 MB per image
- PNG, JPEG, WebP and GIF only
- `template-pack.json` must be at ZIP root
# Reusable Theme Packs
Themes are independent from template content. Applying or removing a theme replaces only `document.theme`; blocks, bindings and sample data are unchanged.
A ZIP theme pack uses:
```text
my-theme.zip
├── theme-pack.json
└── assets/
├── background.webp
└── border.png
```
Minimal `theme-pack.json`:
```json
{
"format": "template-studio-theme",
"version": 1,
"name": "Clean Operations",
"appliesTo": "report",
"theme": {
"backgroundColor": "#eef3f7",
"surfaceColor": "#ffffff",
"textColor": "#172b3a",
"mutedColor": "#607282",
"accentColor": "#123b5d",
"borderColor": "#d8e1e8",
"borderRadius": 8,
"fontFamily": "Arial",
"reportDensity": "auto",
"pageMarginTop": 9,
"pageMarginRight": 9,
"pageMarginBottom": 9,
"pageMarginLeft": 9,
"pagePadding": 14,
"pageBackgroundMode": "none",
"pageBackgroundImage": "asset://background",
"decorativeBorder": false,
"borderImage": "asset://border"
},
"assets": [
{ "key": "background", "file": "assets/background.webp" },
{ "key": "border", "file": "assets/border.png" }
]
}
```
For themes with no image assets, the same manifest can be uploaded directly as a `.json` file. `appliesTo` may be `"report"`, `"email"`, or `"all"`.

View File

@ -8,7 +8,8 @@ import type {
TemplateDocument,
TemplateRecord,
TemplateSummary,
TemplateType
TemplateType,
ThemeSummary
} from './types'
type Screen = 'templates' | 'editor' | 'preview' | 'data' | 'use'
@ -100,6 +101,9 @@ export function App({ user }: Props) {
const [copied, setCopied] = useState('')
const [packImporting, setPackImporting] = useState(false)
const packFileInput = useRef<HTMLInputElement | null>(null)
const [themes, setThemes] = useState<ThemeSummary[]>([])
const [themeImporting, setThemeImporting] = useState(false)
const themeFileInput = useRef<HTMLInputElement | null>(null)
const loadedId = useRef<string | null>(null)
const timer = useRef<number | undefined>(undefined)
@ -129,6 +133,10 @@ export function App({ user }: Props) {
setApiKeys(await api<ApiKeySummary[]>('/api/api-keys'))
}
async function refreshThemes() {
setThemes(await api<ThemeSummary[]>('/api/themes'))
}
useEffect(() => {
void refreshTemplates().catch(err => setError(String(err)))
}, [])
@ -234,6 +242,50 @@ export function App({ user }: Props) {
return typeof value === 'boolean' ? value : fallback
}
function applyLibraryTheme(theme: ThemeSummary) {
setTemplateDoc(current => ({
...current,
theme: {
...structuredClone(theme.theme),
_sourceThemeId: theme.id,
_sourceThemeName: theme.name
}
}))
window.setTimeout(() => void refreshPreview().catch(err => setError(String(err))), 900)
}
function removeLibraryTheme() {
setTemplateDoc(current => ({ ...current, theme: undefined }))
window.setTimeout(() => void refreshPreview().catch(err => setError(String(err))), 900)
}
async function importThemeFile(file?: File) {
if (!file) return
setThemeImporting(true)
setError('')
try {
let imported: { theme: ThemeSummary }
if (file.name.toLowerCase().endsWith('.json')) {
const manifest = JSON.parse(await file.text())
imported = await api<{ theme: ThemeSummary }>('/api/theme-packs/import-json', {
method: 'POST',
body: JSON.stringify(manifest)
})
} else if (file.name.toLowerCase().endsWith('.zip')) {
imported = await apiZip<{ theme: ThemeSummary }>('/api/theme-packs/import', file)
} else {
throw new Error('Theme uploads must be .zip or .json files.')
}
await refreshThemes()
applyLibraryTheme(imported.theme)
} catch (err) {
setError(String(err))
} finally {
setThemeImporting(false)
if (themeFileInput.current) themeFileInput.current.value = ''
}
}
function moveBlock(id: string, direction: -1 | 1) {
setTemplateDoc(current => {
const blocks = [...current.blocks]
@ -273,7 +325,7 @@ export function App({ user }: Props) {
async function goPreview() {
setScreen('preview')
try {
await refreshPreview()
await Promise.all([refreshPreview(), refreshThemes()])
} catch (err) {
setError(String(err))
}
@ -544,6 +596,73 @@ export function App({ user }: Props) {
</div>
<button className="button" onClick={() => void refreshPreview()}>↻ Refresh</button>
</div>
<section className="theme-library-card">
<div className="theme-library-heading">
<div>
<span className="eyebrow">THEMES</span>
<h3>{themeString('_sourceThemeName', 'Base theme')}</h3>
<p>Swap appearance without changing blocks, bindings, or data.</p>
</div>
<button
className="button"
disabled={themeImporting}
onClick={() => themeFileInput.current?.click()}
>
{themeImporting ? 'Uploading…' : 'Upload'}
</button>
</div>
<input
ref={themeFileInput}
className="visually-hidden"
type="file"
accept=".zip,.json,application/zip,application/json"
onChange={event => void importThemeFile(event.target.files?.[0])}
/>
<div className="theme-shelf">
<button
className={`theme-choice ${!templateDoc.theme?._sourceThemeId ? 'active' : ''}`}
onClick={removeLibraryTheme}
>
<span className="theme-swatch base-swatch">
<i /><i /><i />
</span>
<strong>Base</strong>
<small>No saved theme</small>
</button>
{themes
.filter(theme => theme.applies_to === 'all' || theme.applies_to === active?.type)
.map(theme => (
<button
key={theme.id}
className={`theme-choice ${themeString('_sourceThemeId', '') === theme.id ? 'active' : ''}`}
onClick={() => applyLibraryTheme(theme)}
>
<span
className="theme-swatch"
style={{
background: String(theme.theme.backgroundColor || '#eef2f7'),
borderColor: String(theme.theme.borderColor || '#d8e1e8')
}}
>
<i style={{ background: String(theme.theme.surfaceColor || '#ffffff') }} />
<i style={{ background: String(theme.theme.accentColor || '#111827') }} />
<i style={{ background: String(theme.theme.textColor || '#172033') }} />
</span>
<strong>{theme.name}</strong>
<small>{theme.applies_to === 'all' ? 'All templates' : theme.applies_to}</small>
</button>
))}
</div>
<div className="theme-library-footer">
<span>{themes.length ? `${themes.length} uploaded theme${themes.length === 1 ? '' : 's'}` : 'Upload your first reusable theme.'}</span>
<a href="/api/theme-packs/spec" target="_blank" rel="noreferrer">Theme format</a>
</div>
</section>
{active?.type === 'report' && (
<section className="page-setup-card">
<div className="page-setup-heading">

View File

@ -848,3 +848,106 @@ body{
margin-top:4px;
padding:10px 0;
}
.theme-library-card{
margin:0 0 14px;
padding:14px;
border:1px solid var(--line);
border-radius:16px;
background:white;
box-shadow:0 3px 12px rgba(15,23,42,.025);
}
.theme-library-heading{
display:flex;
align-items:flex-start;
justify-content:space-between;
gap:12px;
}
.theme-library-heading h3{
margin:3px 0 2px;
font-size:15px;
}
.theme-library-heading p{
margin:0;
color:#8994a4;
font-size:10px;
line-height:1.4;
}
.theme-shelf{
display:flex;
gap:9px;
overflow-x:auto;
padding:13px 1px 8px;
scrollbar-width:none;
}
.theme-shelf::-webkit-scrollbar{display:none}
.theme-choice{
flex:0 0 112px;
display:grid;
gap:5px;
text-align:left;
border:1px solid #e0e6ed;
border-radius:13px;
padding:8px;
background:#fbfcfd;
color:#263449;
}
.theme-choice.active{
border-color:#111827;
box-shadow:0 0 0 2px rgba(17,24,39,.08);
}
.theme-choice strong{
overflow:hidden;
white-space:nowrap;
text-overflow:ellipsis;
font-size:10px;
}
.theme-choice small{
color:#8a95a4;
font-size:8px;
text-transform:capitalize;
}
.theme-swatch{
height:58px;
display:flex;
align-items:flex-end;
gap:4px;
padding:7px;
overflow:hidden;
border:1px solid;
border-radius:9px;
}
.theme-swatch i{
display:block;
width:18px;
height:18px;
border-radius:5px;
box-shadow:0 1px 3px rgba(15,23,42,.12);
}
.theme-swatch i:first-child{
width:35px;
height:38px;
}
.base-swatch{
background:#eef2f7;
border-color:#d8e1e8;
}
.base-swatch i:first-child{background:#fff}
.base-swatch i:nth-child(2){background:#111827}
.base-swatch i:nth-child(3){background:#64748b}
.theme-library-footer{
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
padding-top:8px;
border-top:1px solid #edf1f5;
color:#8994a4;
font-size:9px;
}
.theme-library-footer a{
color:#42516a;
font-weight:800;
text-decoration:none;
}

View File

@ -37,3 +37,13 @@ export type ApiKeySummary = {
created_at: string
last_used_at?: string | null
}
export type ThemeSummary = {
id: string
name: string
applies_to: 'all' | TemplateType
theme: Record<string, unknown>
created_at: string
updated_at: string
}