Skip to content

Commit 463c902

Browse files
committed
feat: role-aware dashboards, command palette, worldbuilding prompts
U-1: Role selector in dashboard editor — backward-compatible JSON format storing per-role layouts (default/player/scribe) in existing column. Alpine.js toggle in customize page, dashboard_editor.js role-change event handling, handler merges role-specific layouts. 9 unit tests. W-1: Command palette (Ctrl+Shift+P) — context-aware navigation and action commands with fuzzy search, keyboard navigation, campaign/admin detection from URL/DOM. T-3: Worldbuilding prompts — full stack: migration 000008, repository, service with validation and SeedDefaults (16 prompts across 5 entity types), REST API (GET Player+, POST/PUT/DELETE Owner), HTMX lazy-loaded accordion panel on entity pages for Scribe+. 7 unit tests. https://claude.ai/code/session_01WJEjfBqjZaGatHiXXXDupo
1 parent aa5e043 commit 463c902

23 files changed

Lines changed: 1732 additions & 16 deletions

.ai/status.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,22 @@
88
<!-- ====================================================================== -->
99

1010
## Last Updated
11-
2026-03-17 -- **Post-Phase-1 Sprint: Visual customization fix, graph export, invite system.**
11+
2026-03-17 -- **Sprint: U-1 role dashboards, W-1 command palette, T-3 worldbuilding prompts.**
12+
13+
42. **Sprint: U-1 + W-1 (partial) + T-3.**
14+
- **U-1: Role-Aware Dashboard Editor (COMPLETE)** — Role selector in dashboard editor. `RoleDashboardLayouts` struct with backward-compatible JSON format: detects legacy bare `{"rows":[...]}` vs role-keyed `{"default":...,"player":...,"scribe":...}`. Alpine.js toggle (Default/Player/Scribe) in customize.templ. `dashboard_editor.js` appends `?role=` param, listens for `role-change` events. Handler merges role layouts via `SetRoleDashboardJSON`/`RemoveRoleDashboardJSON`. `ParseRoleDashboardLayout(role)` with fallback chain. `UpdateDashboardLayoutRaw` service method. 9 unit tests in `model_test.go`.
15+
- **W-1: Command Palette (PARTIAL)**`static/js/command_palette.js` (~280 lines). Ctrl+Shift+P trigger, modal with search input, scrollable command list. Context-aware: detects campaign ID from URL, admin from DOM. 13 campaign nav commands, 3 action commands, 3 universal commands. Fuzzy substring match, keyboard nav (arrows/enter/escape). Added to `base.templ` and `shortcuts_help.js`. Saved filters not yet implemented.
16+
- **T-3: Worldbuilding Prompts (COMPLETE)** — Full stack:
17+
- Migration 000008: `worldbuilding_prompts` table with campaign_id, entity_type_id, name, prompt_text, icon, sort_order, is_global, timestamps, foreign keys.
18+
- Model: `WorldbuildingPrompt`, `CreatePromptInput`, `UpdatePromptInput` structs.
19+
- Repository: `WorldbuildingPromptRepository` with Create, FindByID, ListForCampaign, ListForCampaignAndType, Update, Delete.
20+
- Service: `WorldbuildingPromptService` with validation (name max 200, text max 5000), default icon. `EntityTypeLister` interface (subset of EntityTypeRepository). `SeedDefaults` inserts 16 prompts across 5 types.
21+
- Handler: ListAPI (HTMX fragment support), CreateAPI, UpdateAPI, DeleteAPI with IDOR checks.
22+
- Routes: GET (Player+), POST/PUT/DELETE (Owner) at `/campaigns/:id/worldbuilding-prompts`.
23+
- Templates: `WorldbuildingPromptsPanel` (collapsible card, HTMX lazy-load) + `WorldbuildingPromptsFragment` (`<details>` accordion).
24+
- Seeding: `WorldbuildingPromptSeeder` interface in campaigns package, called during campaign creation.
25+
- Tests: 7 unit tests.
26+
- **Next up:** W-1 saved filters, or Phase 2 (X-1: System Upload UX), or other backlog items.
1227

1328
41. **Post-Phase-1 Sprint: W-0.5 + V-4b + U-2.**
1429
- **W-0.5 completion** — Accent color CSS variable now propagates to all 258 Tailwind utility usages. Updated `tailwind.config.js` to reference `var(--color-accent)` instead of hardcoded hex. Added `--color-accent-hover` and `--color-accent-light` CSS variables with auto-computed darker/lighter variants from the base hex color. New `AccentColorCSS()` helper in `layouts/data.go` generates the CSS block with all three variants. Topbar styling, brand name, brand logo were already working.

.ai/todo.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,12 +184,12 @@ _Fix orphaned data, cascade gaps, and admin DB visibility. See `.ai/phases.md`._
184184
- [x] **Sprint T-0 (M-1): D&D 5e Module — Data & Tooltip API** — SRD-legal JSON (spells 27, monsters 14, items 10, classes 12, races 9, conditions 15). Module init wiring, route registration, category-specific tooltip rendering, 9 tests.
185185
- [x] **Sprint T-1: D&D 5e Module — Reference Pages** — Browsable pages at `/modules/dnd5e/`. Category cards, searchable lists, formatted stat block detail pages. Quick-search (Ctrl+K) integration via ModuleSearchAdapter wiring into entity SearchAPI.
186186
- [x] **Sprint T-2: Pathfinder 2e Module** — ORC-licensed data (82 items across 6 categories: spells, creatures, equipment, ancestries, classes, conditions). GenericModule auto-instantiation, no custom Go code needed.
187-
- [ ] **Sprint T-3: Guided Worldbuilding Prompts**`worldbuilding_prompts` table. "Writing Prompts" collapsible panel on entity edit page. Default prompt packs per entity type. Owner-customizable.
187+
- [x] **Sprint T-3: Guided Worldbuilding Prompts**Migration 000008 (`worldbuilding_prompts` table). `WorldbuildingPromptRepository` with full CRUD + type filtering. `WorldbuildingPromptService` with validation, `EntityTypeLister` interface for seeding, `SeedDefaults()` inserts 16 prompts across 5 entity types (character: 4, location: 4, organization: 3, item: 2, quest: 3). REST API (GET Player+, POST/PUT/DELETE Owner). `WorldbuildingPromptsPanel` templ component with HTMX lazy-load (`hx-trigger="revealed"`) and `<details>` accordion. Shown on entity show page for Scribe+. Seeded automatically on campaign creation via `WorldbuildingPromptSeeder` interface. 7 unit tests.
188188
- [ ] **Sprint T-4: Entity Type Template Library** — Genre presets (fantasy, sci-fi, horror, modern, historical) as JSON fixtures. Campaign creation genre selection. "Import preset" in Customization Hub.
189189

190190
### Phase U: Collaboration & Platform Maturity
191191

192-
- [~] **Sprint U-1: Role-Aware Dashboards** — Two-dashboard architecture implemented: Campaign Page (public, `/campaigns/:id`) and Owner Dashboard (owner-only, `/campaigns/:id/dashboard`). Both independently customizable via Customization Hub. Migration 000006 adds `owner_dashboard_layout` column. Remaining: role selector in dashboard editor so Players/Scribes can see role-specific campaign page layouts.
192+
- [x] **Sprint U-1: Role-Aware Dashboards** — Two-dashboard architecture implemented: Campaign Page (public, `/campaigns/:id`) and Owner Dashboard (owner-only, `/campaigns/:id/dashboard`). Both independently customizable via Customization Hub. Migration 000006 adds `owner_dashboard_layout` column. Role selector in dashboard editor: `RoleDashboardLayouts` struct with backward-compatible JSON format (detects legacy bare `{"rows":[...]}` vs role-keyed `{"default":...,"player":...,"scribe":...}`). Alpine.js role toggle (Default/Player/Scribe) in customize.templ. `dashboard_editor.js` appends `?role=` to GET/PUT/DELETE endpoints, listens for `role-change` events. Handler merges role-specific layout via `SetRoleDashboardJSON`. `ParseRoleDashboardLayout(role)` returns role-specific layout with fallback to default. 9 unit tests for model methods.
193193
- [x] **Sprint U-2: Invite System** — Migration 000007 (`campaign_invites` table). InviteRepository, InviteService, InviteHandler. Email invitations with one-click accept link via `/invites/accept?token=xxx`. Invite management UI in campaign settings (HTMX lazy-loaded). Send form with email + role selector. Invite table with status badges (pending/accepted/expired) and revoke button. HTML+plaintext email template. Login/register redirect support (`?redirect=` param). 9 unit tests.
194194
- [ ] **Sprint U-3: 2FA/TOTP Support** — TOTP enrollment with QR code (`pquerna/otp`). Login redirect to TOTP input. Recovery codes (8 hashed). Admin force-disable.
195195
- [ ] **Sprint U-4: Accessibility Audit (WCAG 2.1 AA)** — ARIA labels, focus traps, skip-to-content, color contrast 4.5:1, keyboard nav, screen reader announcements, axe-core scanning.
@@ -211,7 +211,7 @@ _Quick capture, backlinks, enhanced graph, editor power-ups. See `.ai/obsidian-n
211211

212212
- [x] **Sprint W-0: Nav Menu Reorg Mode** — Small icon button near Dashboard in sidebar. Click to enter reorg mode for current level (categories or entities). Category level: drag to reorder category icons. Entity level: drag to reorder, create folders/submenus. Click again to exit reorg mode. Must work on desktop, tablet, and mobile. Button is context-aware: on base nav, reorders categories; drilled into a category, reorders entities.
213213
- [x] **Sprint W-0.5: Owner Visual Customization** — Change "Chronicle" brand name per-campaign with optional image/logo. Top bar color/gradient/animation/background image (responsive). Visual customization editor with faux site outline (editable boxes for colors/backgrounds). Appearance-only, not layout editing. Accent color CSS variable now propagates to all Tailwind utilities via `var(--color-accent)` references. Auto-computed hover/light variants.
214-
- [ ] **Sprint W-1: Command Palette & Saved Filters** — Ctrl+Shift+P action palette with fuzzy search. Saved entity list filter presets as sidebar links in `saved_filters` table.
214+
- [~] **Sprint W-1: Command Palette & Saved Filters**Command palette done: `static/js/command_palette.js` (~280 lines) with Ctrl+Shift+P trigger, context-aware commands (campaign navigation, actions, universal), fuzzy substring matching, keyboard navigation, `Chronicle.openCommandPalette()`/`closeCommandPalette()` API. Added to `base.templ` and `shortcuts_help.js`. Remaining: saved entity list filter presets (`saved_filters` table).
215215
- [ ] **Sprint W-2: Map Drawing Tools, Regions & Measurement** — Leaflet.Draw integration (freehand, polygons, circles, rectangles, text). Uses existing `map_drawings` table. Per-drawing visibility, color/opacity. Also: map regions (polygon fills/strokes/labels), measurement/distance tool, map embed layout block for entity pages.
216216
- [ ] **Sprint W-2.5: Nested / Linked Maps** — Click marker to open sub-map. `linked_map_id` on markers. Breadcrumb navigation between map levels. Competitive gap vs World Anvil/LegendKeeper.
217217
- [ ] **Sprint W-3: Discord Bot Integration** — Plugin at `internal/plugins/discord/`. Bot token config. Webhook session notifications. Reaction-based RSVP per ADR-012.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DROP TABLE IF EXISTS worldbuilding_prompts;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
-- Worldbuilding prompts: guided writing prompts to help users flesh out
2+
-- their campaign content. Scoped to campaigns and optionally to entity types.
3+
CREATE TABLE IF NOT EXISTS worldbuilding_prompts (
4+
id INT AUTO_INCREMENT PRIMARY KEY,
5+
campaign_id CHAR(36) NULL,
6+
entity_type_id INT NULL,
7+
name VARCHAR(200) NOT NULL,
8+
prompt_text TEXT NOT NULL,
9+
icon VARCHAR(50) NOT NULL DEFAULT 'fa-lightbulb',
10+
sort_order INT NOT NULL DEFAULT 0,
11+
is_global BOOLEAN NOT NULL DEFAULT FALSE,
12+
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
13+
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
14+
INDEX idx_wb_campaign (campaign_id),
15+
INDEX idx_wb_type (entity_type_id),
16+
CONSTRAINT fk_wb_campaign FOREIGN KEY (campaign_id) REFERENCES campaigns(id) ON DELETE CASCADE,
17+
CONSTRAINT fk_wb_entity_type FOREIGN KEY (entity_type_id) REFERENCES entity_types(id) ON DELETE CASCADE
18+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

internal/app/routes.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,13 @@ func (a *App) RegisterRoutes() {
806806
campaignService.SetContentTemplateSeeder(contentTemplateService)
807807
entityHandler.SetContentTemplateService(contentTemplateService)
808808

809+
// Worldbuilding prompt routes (guided writing prompts for content creators).
810+
wbPromptRepo := entities.NewWorldbuildingPromptRepository(a.DB)
811+
wbPromptService := entities.NewWorldbuildingPromptService(wbPromptRepo, entityTypeRepo)
812+
wbPromptHandler := entities.NewWorldbuildingPromptHandler(wbPromptService)
813+
entities.RegisterWorldbuildingPromptRoutes(e, wbPromptHandler, campaignService, authService)
814+
campaignService.SetWorldbuildingPromptSeeder(wbPromptService)
815+
809816
// Media plugin: file upload, storage, thumbnailing, serving.
810817
// Graceful degradation: if the media directory can't be created, log a warning
811818
// but don't crash -- the rest of the app keeps running.

internal/plugins/campaigns/customize.templ

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,22 +142,70 @@ templ CustomizePage(cc *CampaignContext, entityTypes []SettingsEntityType, csrfT
142142
// dashboardEditorTab renders the campaign dashboard layout editor with a
143143
// drag-and-drop block builder. Uses full-page flex layout so the editor
144144
// fills all available vertical space (matching Page Templates tab pattern).
145+
// Includes a role toggle (Default / Player / Scribe) so owners can create
146+
// per-role campaign page layouts.
145147
templ dashboardEditorTab(cc *CampaignContext, csrfToken string) {
146148
<div class="h-full flex flex-col">
147149
<div class="px-6 pt-4 pb-2 shrink-0">
148150
<div class="max-w-3xl mx-auto">
149151
<h2 class="text-base font-semibold text-fg mb-0.5">Campaign Page</h2>
150-
<p class="text-xs text-fg-secondary">
151-
Customize the layout of your campaign's main page visible to all members.
152+
<p class="text-xs text-fg-secondary mb-3">
153+
Customize the layout of your campaign's main page. Use the role toggle
154+
to create different layouts for Players and Scribes.
152155
</p>
156+
<!-- Role toggle buttons -->
157+
<div
158+
x-data="{ dashRole: 'default' }"
159+
x-init="$watch('dashRole', (val) => {
160+
const editor = document.getElementById('campaign-page-editor');
161+
if (editor) {
162+
editor.setAttribute('data-role', val);
163+
editor.dispatchEvent(new CustomEvent('role-change', { detail: { role: val } }));
164+
}
165+
})"
166+
>
167+
<div class="inline-flex rounded-lg bg-surface-alt p-0.5 mb-2">
168+
<button
169+
type="button"
170+
class="text-xs px-3 py-1.5 rounded-md font-medium transition-colors"
171+
:class="dashRole === 'default' ? 'bg-surface text-fg shadow-sm' : 'text-fg-secondary hover:text-fg'"
172+
@click="dashRole = 'default'"
173+
>
174+
<i class="fa-solid fa-users mr-1"></i>Default
175+
</button>
176+
<button
177+
type="button"
178+
class="text-xs px-3 py-1.5 rounded-md font-medium transition-colors"
179+
:class="dashRole === 'player' ? 'bg-surface text-fg shadow-sm' : 'text-fg-secondary hover:text-fg'"
180+
@click="dashRole = 'player'"
181+
>
182+
<i class="fa-solid fa-user mr-1"></i>Player
183+
</button>
184+
<button
185+
type="button"
186+
class="text-xs px-3 py-1.5 rounded-md font-medium transition-colors"
187+
:class="dashRole === 'scribe' ? 'bg-surface text-fg shadow-sm' : 'text-fg-secondary hover:text-fg'"
188+
@click="dashRole = 'scribe'"
189+
>
190+
<i class="fa-solid fa-feather mr-1"></i>Scribe
191+
</button>
192+
</div>
193+
<p class="text-xs text-fg-muted">
194+
<span x-show="dashRole === 'default'">All members see this layout unless a role-specific override is set.</span>
195+
<span x-show="dashRole === 'player'" x-cloak>Players see this layout. Falls back to Default if not customized.</span>
196+
<span x-show="dashRole === 'scribe'" x-cloak>Scribes see this layout. Falls back to Default if not customized.</span>
197+
</p>
198+
</div>
153199
</div>
154200
</div>
155201
<div class="flex-1 overflow-y-auto px-6 py-2">
156202
<div
203+
id="campaign-page-editor"
157204
data-widget="dashboard-editor"
158205
data-endpoint={ fmt.Sprintf("/campaigns/%s/dashboard-layout", cc.Campaign.ID) }
159206
data-campaign-id={ cc.Campaign.ID }
160207
data-csrf-token={ csrfToken }
208+
data-role="default"
161209
></div>
162210
</div>
163211
<div class="px-6 pt-6 pb-2 shrink-0 border-t border-edge mt-4">

internal/plugins/campaigns/handler.go

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package campaigns
33
import (
44
"context"
55
"encoding/json"
6+
"fmt"
67
"io"
78
"log/slog"
89
"net/http"
@@ -273,6 +274,8 @@ func (h *Handler) Create(c echo.Context) error {
273274
}
274275

275276
// Show renders the campaign dashboard (GET /campaigns/:id).
277+
// Uses role-aware dashboard layout: each role can have its own Campaign Page
278+
// layout, falling back to the default layout.
276279
func (h *Handler) Show(c echo.Context) error {
277280
cc := GetCampaignContext(c)
278281
if cc == nil {
@@ -769,53 +772,105 @@ func (h *Handler) SidebarDrill(c echo.Context) error {
769772
// --- Dashboard Layout ---
770773

771774
// GetDashboardLayout returns the current dashboard layout as JSON (GET /campaigns/:id/dashboard-layout).
775+
// Supports ?role= query param (default/player/scribe) for role-specific layouts.
772776
// Returns null if no custom layout is set (meaning the default is in use).
773777
func (h *Handler) GetDashboardLayout(c echo.Context) error {
774778
cc := GetCampaignContext(c)
775779
if cc == nil {
776780
return apperror.NewMissingContext()
777781
}
778782

779-
layout, err := h.service.GetDashboardLayout(c.Request().Context(), cc.Campaign.ID)
783+
roleName := c.QueryParam("role")
784+
if roleName == "" {
785+
roleName = "default"
786+
}
787+
788+
// Fetch campaign to access the raw dashboard_layout JSON.
789+
campaign, err := h.service.GetByID(c.Request().Context(), cc.Campaign.ID)
780790
if err != nil {
781791
return err
782792
}
783793

794+
layout := campaign.GetRoleDashboardJSON(roleName)
784795
return c.JSON(http.StatusOK, layout)
785796
}
786797

787798
// UpdateDashboardLayout saves a new dashboard layout (PUT /campaigns/:id/dashboard-layout).
799+
// Supports ?role= query param (default/player/scribe) to update a specific role's layout.
788800
func (h *Handler) UpdateDashboardLayout(c echo.Context) error {
789801
cc := GetCampaignContext(c)
790802
if cc == nil {
791803
return apperror.NewMissingContext()
792804
}
793805

806+
roleName := c.QueryParam("role")
807+
if roleName == "" {
808+
roleName = "default"
809+
}
810+
794811
var layout DashboardLayout
795812
if err := json.NewDecoder(c.Request().Body).Decode(&layout); err != nil {
796813
return apperror.NewBadRequest("invalid JSON body")
797814
}
798815

799-
if err := h.service.UpdateDashboardLayout(c.Request().Context(), cc.Campaign.ID, &layout); err != nil {
816+
if err := validateDashboardLayout(&layout); err != nil {
817+
return err
818+
}
819+
820+
// Fetch campaign, merge this role's layout into the wrapper, and save.
821+
campaign, err := h.service.GetByID(c.Request().Context(), cc.Campaign.ID)
822+
if err != nil {
823+
return err
824+
}
825+
826+
fullJSON, err := campaign.SetRoleDashboardJSON(roleName, &layout)
827+
if err != nil {
828+
return apperror.NewInternal(fmt.Errorf("marshaling role layout: %w", err))
829+
}
830+
831+
if err := h.service.UpdateDashboardLayoutRaw(c.Request().Context(), cc.Campaign.ID, fullJSON); err != nil {
800832
return err
801833
}
802834

803-
h.logAudit(c, cc.Campaign.ID, "dashboard_layout_updated", nil)
835+
h.logAudit(c, cc.Campaign.ID, "dashboard_layout_updated", map[string]any{"role": roleName})
804836
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
805837
}
806838

807839
// ResetDashboardLayout removes the custom dashboard layout (DELETE /campaigns/:id/dashboard-layout).
840+
// Supports ?role= query param. If "default", resets entire layout. If "player"/"scribe",
841+
// removes only that role's override.
808842
func (h *Handler) ResetDashboardLayout(c echo.Context) error {
809843
cc := GetCampaignContext(c)
810844
if cc == nil {
811845
return apperror.NewMissingContext()
812846
}
813847

814-
if err := h.service.ResetDashboardLayout(c.Request().Context(), cc.Campaign.ID); err != nil {
815-
return err
848+
roleName := c.QueryParam("role")
849+
if roleName == "" {
850+
roleName = "default"
851+
}
852+
853+
// For "default" reset with no role param, clear the entire column.
854+
if roleName == "default" {
855+
if err := h.service.ResetDashboardLayout(c.Request().Context(), cc.Campaign.ID); err != nil {
856+
return err
857+
}
858+
} else {
859+
// Remove just this role's layout from the wrapper.
860+
campaign, err := h.service.GetByID(c.Request().Context(), cc.Campaign.ID)
861+
if err != nil {
862+
return err
863+
}
864+
fullJSON, err := campaign.RemoveRoleDashboardJSON(roleName)
865+
if err != nil {
866+
return apperror.NewInternal(fmt.Errorf("removing role layout: %w", err))
867+
}
868+
if err := h.service.UpdateDashboardLayoutRaw(c.Request().Context(), cc.Campaign.ID, fullJSON); err != nil {
869+
return err
870+
}
816871
}
817872

818-
h.logAudit(c, cc.Campaign.ID, "dashboard_layout_reset", nil)
873+
h.logAudit(c, cc.Campaign.ID, "dashboard_layout_reset", map[string]any{"role": roleName})
819874
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
820875
}
821876

0 commit comments

Comments
 (0)