Skip to content

Commit 6b226a2

Browse files
committed
Fix Customization Hub: deduplicate appearance controls, add draft+save model, consolidate features page
- Remove backdrop upload and accent color picker from settings page (now only in Customization Hub) - Fix accent color HX-Refresh bug that caused full page reload and navigation away from Appearance tab - Convert appearance editor to draft+save model: changes preview locally, persist on explicit Save - Remove Features & Packs tab from Customization Hub; move attributes editor to Categories tab - Fix Plugin Hub feature toggle: use HX-Trigger for in-place refresh instead of HX-Redirect - Add /plugins/fragment endpoint for HTMX partial re-fetch https://claude.ai/code/session_01JmHn8AGZVkKPAvHDgR67Hu
1 parent a46f378 commit 6b226a2

8 files changed

Lines changed: 400 additions & 292 deletions

File tree

.ai/status.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@
1010
## Last Updated
1111
2026-03-11 -- **Sprint W-0.5: Visual Customization + Admin DB Explorer (IN PROGRESS).**
1212

13+
28. **Customization Hub & Features page bug fixes.** Five issues resolved:
14+
- **Settings page deduplication**: Removed backdrop upload and accent color picker from campaign settings page — appearance customization now lives exclusively in the Customization Hub's Appearance tab.
15+
- **Accent color page refresh fix**: `UpdateAccentColorAPI` no longer sends `HX-Refresh: true`, preventing the full page reload that navigated users away from the Appearance tab.
16+
- **Draft+Save model for appearance**: Appearance tab now uses a local draft model — brand name, accent color, and topbar style changes preview instantly but are only persisted when the user clicks "Save Changes". Backdrop upload remains immediate (file upload requires server storage).
17+
- **Features tab removed from Customization Hub**: The "Features & Packs" tab was removed; features are managed exclusively on the dedicated Plugin Hub page (`/plugins`). The per-category Attributes editor was moved into the Categories tab where it fits naturally.
18+
- **Plugin Hub in-place toggle**: Feature enable/disable on the Plugin Hub now uses `HX-Trigger: plugin-hub-refresh` instead of `HX-Redirect`, enabling instant in-place list refresh via a new `/plugins/fragment` endpoint.
19+
20+
### Previous Update
21+
2026-03-11 -- **Sprint W-0.5: Visual Customization + Admin DB Explorer (IN PROGRESS).**
22+
1323
27. **Fix: Embed plugin migrations in binary (ADR-030).** Root cause of entity page errors and DB Explorer showing 0/0: plugin migrations used relative filesystem paths (`internal/plugins/*/migrations/`) that only resolve when the binary's CWD is the project root. In Docker, the binary runs from `/app` so migration directories were never found, tables were never created, and entity pages crashed. Fix: each plugin now embeds its `migrations/*.sql` via Go's `embed.FS`. `PluginSchema.MigrationsDir` (string) replaced with `MigrationsFS` (`fs.FS`). `RegisteredPlugins()` moved from `database` package to `cmd/server/main.go` to avoid import cycles (database can't import plugin packages). `PluginSchemas` stored on `App` struct and passed to `DatabaseExplorer` for on-demand re-migration. New `embed.go` files in calendar, maps, sessions, timeline, syncapi plugins.
1424

1525
### Previous Update

internal/plugins/addons/handler.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -173,15 +173,14 @@ func (h *Handler) ToggleCampaignAddon(c echo.Context) error {
173173
}
174174
}
175175

176-
// If the toggle came from the Plugin Hub page, redirect back there
177-
// so it re-renders with updated state.
176+
// If the toggle came from the Plugin Hub page, trigger an in-place
177+
// refresh of the addon list instead of a full-page redirect.
178178
if c.FormValue("redirect_to") == "plugins" {
179-
redirectURL := "/campaigns/" + cc.Campaign.ID + "/plugins"
180179
if middleware.IsHTMX(c) {
181-
c.Response().Header().Set("HX-Redirect", redirectURL)
180+
c.Response().Header().Set("HX-Trigger", "plugin-hub-refresh")
182181
return c.NoContent(http.StatusNoContent)
183182
}
184-
return c.Redirect(http.StatusSeeOther, redirectURL)
183+
return c.Redirect(http.StatusSeeOther, "/campaigns/"+cc.Campaign.ID+"/plugins")
185184
}
186185

187186
// Return updated addon list for HTMX swap (addons settings page).

internal/plugins/campaigns/branding.templ

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,21 @@ templ AccentColorPicker(campaignID string, currentColor string, csrfToken string
128128
}
129129

130130
// appearanceTab renders the visual customization editor in the Customization Hub.
131-
// Contains: faux site outline, brand name/logo, accent color, and topbar styling.
131+
// Contains: faux site outline, brand name/logo, accent color, topbar styling,
132+
// and backdrop image. Changes preview locally; a Save button persists them.
132133
templ appearanceTab(cc *CampaignContext, csrfToken string) {
133134
<h2 class="text-base font-semibold text-fg mb-1">Appearance</h2>
134-
<p class="text-xs text-fg-secondary mb-6">Customize the visual style of your campaign.</p>
135+
<p class="text-xs text-fg-secondary mb-4">Customize the visual style of your campaign. Changes preview instantly — click Save when you're happy.</p>
136+
137+
<!-- Save bar (shown when there are unsaved changes) -->
138+
<div id="appearance-save-bar" class="hidden sticky top-0 z-10 mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 flex items-center justify-between">
139+
<span class="text-sm text-amber-800 dark:text-amber-200">
140+
<i class="fa-solid fa-circle-exclamation text-xs mr-1.5"></i> You have unsaved changes
141+
</span>
142+
<button type="button" id="appearance-save-btn" class="btn-primary text-sm px-4 py-1.5">
143+
<i class="fa-solid fa-check text-xs mr-1"></i> Save Changes
144+
</button>
145+
</div>
135146

136147
<div
137148
data-widget="appearance-editor"
@@ -179,6 +190,15 @@ templ appearanceTab(cc *CampaignContext, csrfToken string) {
179190
</div>
180191
</div>
181192

193+
<!-- Backdrop image (saves immediately since file upload requires server) -->
194+
<div class="card p-4 mb-4">
195+
<h3 class="text-sm font-semibold text-fg mb-2">
196+
<i class="fa-solid fa-image text-xs mr-1.5 text-fg-muted"></i> Backdrop Image
197+
</h3>
198+
<p class="text-xs text-fg-secondary mb-3">Header banner displayed on your campaign dashboard.</p>
199+
@BackdropUploadSection(cc.Campaign.ID, cc.Campaign.BackdropPath, csrfToken)
200+
</div>
201+
182202
<!-- Brand name -->
183203
<div class="card p-4 mb-4">
184204
<h3 class="text-sm font-semibold text-fg mb-2">
@@ -205,13 +225,49 @@ templ appearanceTab(cc *CampaignContext, csrfToken string) {
205225
</div>
206226
</div>
207227

208-
<!-- Accent color -->
228+
<!-- Accent color (JS-driven, no server calls until Save) -->
209229
<div class="card p-4 mb-4">
210230
<h3 class="text-sm font-semibold text-fg mb-2">
211231
<i class="fa-solid fa-droplet text-xs mr-1.5 text-fg-muted"></i> Accent Color
212232
</h3>
213233
<p class="text-xs text-fg-secondary mb-3">Theme color used for buttons, links, and highlights.</p>
214-
@AccentColorPicker(cc.Campaign.ID, cc.Campaign.ParseSettings().AccentColor, csrfToken)
234+
<div id="appearance-accent-colors" class="space-y-3">
235+
<div class="flex flex-wrap gap-2">
236+
for _, preset := range accentColorPresets {
237+
<button
238+
type="button"
239+
data-accent-color={ preset.Color }
240+
class="w-8 h-8 rounded-full border-2 transition-transform hover:scale-110 shrink-0"
241+
style={ fmt.Sprintf("background-color: %s", preset.Color) }
242+
if cc.Campaign.ParseSettings().AccentColor == preset.Color {
243+
class="w-8 h-8 rounded-full border-2 border-white ring-2 ring-offset-2 ring-offset-surface ring-fg transition-transform hover:scale-110 shrink-0"
244+
} else {
245+
class="w-8 h-8 rounded-full border-2 border-transparent hover:border-white/50 transition-transform hover:scale-110 shrink-0"
246+
}
247+
title={ preset.Name }
248+
></button>
249+
}
250+
// Reset to default button.
251+
<button
252+
type="button"
253+
data-accent-color=""
254+
class="w-8 h-8 rounded-full border-2 border-dashed border-edge flex items-center justify-center hover:border-fg-muted transition-colors shrink-0"
255+
if cc.Campaign.ParseSettings().AccentColor == "" {
256+
class="w-8 h-8 rounded-full border-2 border-dashed border-fg ring-2 ring-offset-2 ring-offset-surface ring-fg flex items-center justify-center transition-colors shrink-0"
257+
}
258+
title="Reset to default"
259+
>
260+
<i class="fa-solid fa-xmark text-[10px] text-fg-muted"></i>
261+
</button>
262+
</div>
263+
<p class="text-[10px] text-fg-muted" id="appearance-accent-label">
264+
if cc.Campaign.ParseSettings().AccentColor != "" {
265+
Current: { cc.Campaign.ParseSettings().AccentColor }
266+
} else {
267+
Using default theme color
268+
}
269+
</p>
270+
</div>
215271
</div>
216272

217273
<!-- Topbar style -->

internal/plugins/campaigns/customize.templ

Lines changed: 60 additions & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
// customize.templ renders the Campaign Customization Hub -- a centralized page
22
// for campaign owners to control dashboards, categories, page templates,
3-
// sidebar navigation, and campaign extensions.
3+
// sidebar navigation, and visual appearance.
44
//
55
// Tabs:
6-
// 1. Dashboard — Campaign dashboard layout editor (drag-and-drop blocks).
7-
// 2. Categories — Per-category settings: identity, attributes, and category
8-
// dashboard. Content lazy-loaded via HTMX from the entities
9-
// plugin (one fragment per selected category).
10-
// 3. Page Templates — Per-category page template editor (template-editor widget,
11-
// lazy-loaded via HTMX).
12-
// 4. Extensions — Enable/disable campaign addons (modules, widgets,
13-
// integrations). Lazy-loaded via HTMX from the addons plugin.
14-
// 5. Navigation — Sidebar ordering/visibility + custom sections & links.
6+
// 1. Dashboard — Campaign dashboard layout editor (drag-and-drop blocks).
7+
// 2. Categories — Per-category settings: identity, attributes, and category
8+
// dashboard. Content lazy-loaded via HTMX from the entities
9+
// plugin (one fragment per selected category).
10+
// 3. Page Templates — Per-category page template editor (template-editor widget,
11+
// lazy-loaded via HTMX).
12+
// 4. Navigation — Sidebar ordering/visibility + custom sections & links.
13+
// 5. Content Templates — Manage content templates for entity creation.
14+
// 6. Appearance — Branding, topbar, accent color, and backdrop customization.
15+
//
16+
// Features/addons are managed on the dedicated Plugin Hub page (/campaigns/:id/plugins).
1517
//
1618
// Route: GET /campaigns/:id/customize
1719
// Permission: RoleOwner only.
@@ -41,7 +43,7 @@ templ CustomizePage(cc *CampaignContext, entityTypes []SettingsEntityType, csrfT
4143
</a>
4244
<div>
4345
<h1 class="text-base font-semibold text-fg">Campaign Customization</h1>
44-
<p class="text-[11px] text-fg-secondary">Dashboards, categories, templates, navigation, and features</p>
46+
<p class="text-[11px] text-fg-secondary">Dashboards, categories, templates, navigation, and appearance</p>
4547
</div>
4648
</div>
4749
</div>
@@ -90,14 +92,7 @@ templ CustomizePage(cc *CampaignContext, entityTypes []SettingsEntityType, csrfT
9092
>
9193
<i class="fa-solid fa-palette mr-1.5 text-xs"></i> Appearance
9294
</button>
93-
<button
94-
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap"
95-
:class="tab === 'extensions' ? 'text-accent border-accent' : 'text-fg-secondary border-transparent hover:text-fg hover:border-edge'"
96-
@click="tab = 'extensions'"
97-
>
98-
<i class="fa-solid fa-plug mr-1.5 text-xs"></i> Features & Packs
99-
</button>
100-
</div>
95+
</div>
10196

10297
<!-- Tab content -->
10398
<div class="flex-1 overflow-hidden">
@@ -139,13 +134,7 @@ templ CustomizePage(cc *CampaignContext, entityTypes []SettingsEntityType, csrfT
139134
</div>
140135
</div>
141136

142-
<!-- Extensions tab: enable/disable campaign addons + attributes editor -->
143-
<div x-show="tab === 'extensions'" x-cloak class="h-full overflow-y-auto">
144-
<div class="max-w-3xl mx-auto px-6 py-4">
145-
@extensionsTab(cc, entityTypes, csrfToken)
146-
</div>
147137
</div>
148-
</div>
149138
</div>
150139
}
151140
}
@@ -234,6 +223,52 @@ templ categoriesTab(cc *CampaignContext, entityTypes []SettingsEntityType, csrfT
234223
<p class="text-sm text-fg-secondary">Select a category above to configure it.</p>
235224
</div>
236225
</div>
226+
227+
<!-- Attributes field editor (per-category) -->
228+
<div class="border-t border-edge pt-6 mt-6">
229+
<div class="flex items-center gap-2 mb-1">
230+
<i class="fa-solid fa-sliders text-sm text-accent"></i>
231+
<h3 class="text-sm font-semibold text-fg">Attributes</h3>
232+
</div>
233+
<p class="text-xs text-fg-secondary mb-3">
234+
Define the custom fields that appear on entity pages for each category.
235+
</p>
236+
237+
<!-- Category selector for attributes editor -->
238+
<div class="flex flex-wrap gap-2 mb-3" x-data="{ attrCat: 0 }">
239+
for _, et := range entityTypes {
240+
<button
241+
type="button"
242+
class="flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors text-sm"
243+
:class={ fmt.Sprintf("attrCat === %d ? 'border-accent bg-accent/10 text-accent font-medium' : 'border-edge bg-surface hover:border-accent/30 text-fg-secondary'", et.ID) }
244+
@click={ fmt.Sprintf("attrCat = %d", et.ID) }
245+
hx-get={ fmt.Sprintf("/campaigns/%s/entity-types/%d/attributes-fragment", cc.Campaign.ID, et.ID) }
246+
hx-target="#attributes-editor-container"
247+
hx-swap="innerHTML"
248+
>
249+
<span
250+
class="w-6 h-6 rounded flex items-center justify-center text-[10px] shrink-0"
251+
style={ fmt.Sprintf("background-color: %s20; color: %s", et.Color, et.Color) }
252+
>
253+
if et.Icon != "" {
254+
<i class={ "fa-solid " + et.Icon }></i>
255+
} else {
256+
<i class="fa-solid fa-file"></i>
257+
}
258+
</span>
259+
{ et.NamePlural }
260+
</button>
261+
}
262+
</div>
263+
264+
<!-- Lazy-loaded attributes editor container -->
265+
<div id="attributes-editor-container">
266+
<div class="card p-4 text-center text-sm text-fg-secondary">
267+
<i class="fa-solid fa-hand-pointer text-lg text-fg-muted mb-2"></i>
268+
<p>Select a category above to configure its attributes.</p>
269+
</div>
270+
</div>
271+
</div>
237272
}
238273
</div>
239274
}
@@ -344,132 +379,6 @@ templ navigationTab(cc *CampaignContext, entityTypes []SettingsEntityType, csrfT
344379
</div>
345380
}
346381

347-
// extensionsTab renders the addon/extension management interface. The addon
348-
// list is lazy-loaded via HTMX from the addons plugin fragment endpoint.
349-
// Also includes the Attributes field editor (per-category), which is managed
350-
// as a formal addon.
351-
templ extensionsTab(cc *CampaignContext, entityTypes []SettingsEntityType, csrfToken string) {
352-
<div class="space-y-6">
353-
<div>
354-
<h2 class="text-base font-semibold text-fg mb-0.5">Features & Content Packs</h2>
355-
<p class="text-xs text-fg-secondary">
356-
Enable or disable features and content packs for this campaign. Disabling does not delete your data.
357-
</p>
358-
</div>
359-
360-
<!-- Addon toggle list (lazy-loaded from addons plugin) -->
361-
<div
362-
id="addons-list"
363-
hx-get={ fmt.Sprintf("/campaigns/%s/addons/fragment", cc.Campaign.ID) }
364-
hx-trigger="intersect once"
365-
hx-swap="innerHTML"
366-
>
367-
<div class="text-sm text-fg-secondary text-center py-6">
368-
<i class="fa-solid fa-spinner fa-spin mr-1.5"></i> Loading extensions...
369-
</div>
370-
</div>
371-
372-
<!-- Content extensions (lazy-loaded from extensions handler) -->
373-
<div
374-
hx-get={ fmt.Sprintf("/campaigns/%s/extensions", cc.Campaign.ID) }
375-
hx-trigger="intersect once"
376-
hx-swap="innerHTML"
377-
>
378-
<div class="text-sm text-fg-secondary text-center py-4">
379-
<i class="fa-solid fa-spinner fa-spin mr-1.5"></i> Loading content packs...
380-
</div>
381-
</div>
382-
383-
<!-- Calendar display settings (shown when calendar addon exists) -->
384-
<div class="border-t border-edge pt-6">
385-
<div class="flex items-center gap-2 mb-1">
386-
<i class="fa-solid fa-calendar-days text-sm text-accent"></i>
387-
<h3 class="text-sm font-semibold text-fg">Calendar</h3>
388-
</div>
389-
<p class="text-xs text-fg-secondary mb-3">
390-
Add a calendar block to any of the three layout editors below. Each editor is available in its own tab on this page.
391-
</p>
392-
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
393-
<div class="card p-3">
394-
<div class="flex items-center gap-2 mb-1">
395-
<i class="fa-solid fa-gauge text-xs text-fg-muted"></i>
396-
<span class="text-sm font-medium text-fg">Dashboard</span>
397-
</div>
398-
<p class="text-xs text-fg-secondary">
399-
Add a "Calendar" block in the <strong>Dashboard</strong> tab to show upcoming events on your campaign home page.
400-
</p>
401-
</div>
402-
<div class="card p-3">
403-
<div class="flex items-center gap-2 mb-1">
404-
<i class="fa-solid fa-layer-group text-xs text-fg-muted"></i>
405-
<span class="text-sm font-medium text-fg">Category Pages</span>
406-
</div>
407-
<p class="text-xs text-fg-secondary">
408-
Add a "Calendar" block in the <strong>Categories</strong> tab to show upcoming events on category dashboards.
409-
</p>
410-
</div>
411-
<div class="card p-3">
412-
<div class="flex items-center gap-2 mb-1">
413-
<i class="fa-solid fa-file-lines text-xs text-fg-muted"></i>
414-
<span class="text-sm font-medium text-fg">Entity Pages</span>
415-
</div>
416-
<p class="text-xs text-fg-secondary">
417-
Add a "Calendar" block in the <strong>Page Templates</strong> tab, or events linked to an entity appear automatically at the bottom.
418-
</p>
419-
</div>
420-
</div>
421-
</div>
422-
423-
<!-- Attributes field editor (per-category) -->
424-
if len(entityTypes) > 0 {
425-
<div class="border-t border-edge pt-6">
426-
<div class="flex items-center gap-2 mb-1">
427-
<i class="fa-solid fa-sliders text-sm text-accent"></i>
428-
<h3 class="text-sm font-semibold text-fg">Attributes</h3>
429-
</div>
430-
<p class="text-xs text-fg-secondary mb-3">
431-
Define the custom fields that appear on entity pages for each category.
432-
Toggle the Attributes extension above to show or hide them.
433-
</p>
434-
435-
<!-- Category selector for attributes editor -->
436-
<div class="flex flex-wrap gap-2 mb-3" x-data="{ attrCat: 0 }">
437-
for _, et := range entityTypes {
438-
<button
439-
type="button"
440-
class="flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors text-sm"
441-
:class={ fmt.Sprintf("attrCat === %d ? 'border-accent bg-accent/10 text-accent font-medium' : 'border-edge bg-surface hover:border-accent/30 text-fg-secondary'", et.ID) }
442-
@click={ fmt.Sprintf("attrCat = %d", et.ID) }
443-
hx-get={ fmt.Sprintf("/campaigns/%s/entity-types/%d/attributes-fragment", cc.Campaign.ID, et.ID) }
444-
hx-target="#attributes-editor-container"
445-
hx-swap="innerHTML"
446-
>
447-
<span
448-
class="w-6 h-6 rounded flex items-center justify-center text-[10px] shrink-0"
449-
style={ fmt.Sprintf("background-color: %s20; color: %s", et.Color, et.Color) }
450-
>
451-
if et.Icon != "" {
452-
<i class={ "fa-solid " + et.Icon }></i>
453-
} else {
454-
<i class="fa-solid fa-file"></i>
455-
}
456-
</span>
457-
{ et.NamePlural }
458-
</button>
459-
}
460-
</div>
461-
462-
<!-- Lazy-loaded attributes editor container -->
463-
<div id="attributes-editor-container">
464-
<div class="card p-4 text-center text-sm text-fg-secondary">
465-
<i class="fa-solid fa-hand-pointer text-lg text-fg-muted mb-2"></i>
466-
<p>Select a category above to configure its attributes.</p>
467-
</div>
468-
</div>
469-
</div>
470-
}
471-
</div>
472-
}
473382

474383
// LayoutEditorFragment renders the template-editor widget mount for a single
475384
// entity type. Returned as an HTMX fragment in the Page Templates tab. Includes

0 commit comments

Comments
 (0)