diff --git a/docs/template-pack.md b/docs/template-pack.md index e39b154..aa3547b 100644 --- a/docs/template-pack.md +++ b/docs/template-pack.md @@ -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"`. diff --git a/src/App.tsx b/src/App.tsx index 11b541a..9cbb63a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(null) + const [themes, setThemes] = useState([]) + const [themeImporting, setThemeImporting] = useState(false) + const themeFileInput = useRef(null) const loadedId = useRef(null) const timer = useRef(undefined) @@ -129,6 +133,10 @@ export function App({ user }: Props) { setApiKeys(await api('/api/api-keys')) } + async function refreshThemes() { + setThemes(await api('/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) { +
+
+
+ THEMES +

{themeString('_sourceThemeName', 'Base theme')}

+

Swap appearance without changing blocks, bindings, or data.

+
+ +
+ + void importThemeFile(event.target.files?.[0])} + /> + +
+ + + {themes + .filter(theme => theme.applies_to === 'all' || theme.applies_to === active?.type) + .map(theme => ( + + ))} +
+ +
+ {themes.length ? `${themes.length} uploaded theme${themes.length === 1 ? '' : 's'}` : 'Upload your first reusable theme.'} + Theme format +
+
+ {active?.type === 'report' && (
diff --git a/src/refine.css b/src/refine.css index e63b189..25c39ed 100644 --- a/src/refine.css +++ b/src/refine.css @@ -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; +} diff --git a/src/types.ts b/src/types.ts index 0ab38b0..fe2e86a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 + created_at: string + updated_at: string +}