feat: add theme swap and upload UI
This commit is contained in:
parent
9e5a06dd53
commit
b958d85ada
@ -153,3 +153,55 @@ The constrained theme supports colors, border radius, background imagery and rep
|
|||||||
- 10 MB per image
|
- 10 MB per image
|
||||||
- PNG, JPEG, WebP and GIF only
|
- PNG, JPEG, WebP and GIF only
|
||||||
- `template-pack.json` must be at ZIP root
|
- `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"`.
|
||||||
|
|||||||
123
src/App.tsx
123
src/App.tsx
@ -8,7 +8,8 @@ import type {
|
|||||||
TemplateDocument,
|
TemplateDocument,
|
||||||
TemplateRecord,
|
TemplateRecord,
|
||||||
TemplateSummary,
|
TemplateSummary,
|
||||||
TemplateType
|
TemplateType,
|
||||||
|
ThemeSummary
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
type Screen = 'templates' | 'editor' | 'preview' | 'data' | 'use'
|
type Screen = 'templates' | 'editor' | 'preview' | 'data' | 'use'
|
||||||
@ -100,6 +101,9 @@ export function App({ user }: Props) {
|
|||||||
const [copied, setCopied] = useState('')
|
const [copied, setCopied] = useState('')
|
||||||
const [packImporting, setPackImporting] = useState(false)
|
const [packImporting, setPackImporting] = useState(false)
|
||||||
const packFileInput = useRef<HTMLInputElement | null>(null)
|
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 loadedId = useRef<string | null>(null)
|
||||||
const timer = useRef<number | undefined>(undefined)
|
const timer = useRef<number | undefined>(undefined)
|
||||||
@ -129,6 +133,10 @@ export function App({ user }: Props) {
|
|||||||
setApiKeys(await api<ApiKeySummary[]>('/api/api-keys'))
|
setApiKeys(await api<ApiKeySummary[]>('/api/api-keys'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshThemes() {
|
||||||
|
setThemes(await api<ThemeSummary[]>('/api/themes'))
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshTemplates().catch(err => setError(String(err)))
|
void refreshTemplates().catch(err => setError(String(err)))
|
||||||
}, [])
|
}, [])
|
||||||
@ -234,6 +242,50 @@ export function App({ user }: Props) {
|
|||||||
return typeof value === 'boolean' ? value : fallback
|
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) {
|
function moveBlock(id: string, direction: -1 | 1) {
|
||||||
setTemplateDoc(current => {
|
setTemplateDoc(current => {
|
||||||
const blocks = [...current.blocks]
|
const blocks = [...current.blocks]
|
||||||
@ -273,7 +325,7 @@ export function App({ user }: Props) {
|
|||||||
async function goPreview() {
|
async function goPreview() {
|
||||||
setScreen('preview')
|
setScreen('preview')
|
||||||
try {
|
try {
|
||||||
await refreshPreview()
|
await Promise.all([refreshPreview(), refreshThemes()])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(String(err))
|
setError(String(err))
|
||||||
}
|
}
|
||||||
@ -544,6 +596,73 @@ export function App({ user }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<button className="button" onClick={() => void refreshPreview()}>↻ Refresh</button>
|
<button className="button" onClick={() => void refreshPreview()}>↻ Refresh</button>
|
||||||
</div>
|
</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' && (
|
{active?.type === 'report' && (
|
||||||
<section className="page-setup-card">
|
<section className="page-setup-card">
|
||||||
<div className="page-setup-heading">
|
<div className="page-setup-heading">
|
||||||
|
|||||||
103
src/refine.css
103
src/refine.css
@ -848,3 +848,106 @@ body{
|
|||||||
margin-top:4px;
|
margin-top:4px;
|
||||||
padding:10px 0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
10
src/types.ts
10
src/types.ts
@ -37,3 +37,13 @@ export type ApiKeySummary = {
|
|||||||
created_at: string
|
created_at: string
|
||||||
last_used_at?: string | null
|
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
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user