Skip to content

Commit aaf67c6

Browse files
committed
feat: Sprint V-3 — Content Templates for entity creation
Add content templates that pre-fill the editor with structured content when creating entities. Templates can be campaign-scoped or global, and optionally bound to specific entity types. Backend: - Migration 000003: content_templates table with campaign/entity type FKs - ContentTemplate model, repository (CRUD with campaign+type filtering), service (validation, seeding), handler (REST API), routes - ContentTemplateSeeder interface on campaigns for auto-seeding on create - 4 default templates: Session Recap, NPC Profile, Location, Quest Log - Entity Create handler applies template content on creation Frontend: - Template picker dropdown on entity create form (loads via API on type select) - "Insert Template" slash command in editor (/ menu) with floating picker - SlashCommands.addCommand() for dynamic command registration - template_picker.js: form picker + editor insert menu logic Customization Hub: - New "Content Templates" tab with Alpine.js CRUD interface - Create/edit/delete campaign templates with icon picker - Built-in templates shown as read-only https://claude.ai/code/session_01QJLkgjQDu5qohzJKGV4hj9
1 parent 6712cbe commit aaf67c6

20 files changed

Lines changed: 1309 additions & 5 deletions

.ai/status.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,21 @@
88
<!-- ====================================================================== -->
99

1010
## Last Updated
11+
2026-03-10 -- **Sprint V-3: Content Templates (COMPLETE).**
12+
13+
12. **Content templates migration**: `content_templates` table with campaign_id FK, entity_type_id FK, TipTap JSON/HTML content, icon, sort order, and global flag.
14+
15+
13. **Content template CRUD**: `ContentTemplateRepository` (Create, FindByID, ListForCampaign, ListForCampaignAndType, Update, Delete), `ContentTemplateService` (validation, CRUD, SeedDefaults), `ContentTemplateHandler` (REST API: GET/POST/PUT/DELETE at `/content-templates`).
16+
17+
14. **Template picker on entity create form**: Dropdown appears when entity type is selected, populated via API. Selected template content is applied to the entity entry after creation.
18+
19+
15. **Editor slash command**: `/template` or `/insert template` in the editor shows a floating menu of available templates. Selecting one inserts the template's ProseMirror content at the cursor position.
20+
21+
16. **Default templates**: Four built-in templates (Session Recap, NPC Profile, Location, Quest Log) are seeded on campaign creation via `ContentTemplateSeeder` interface.
22+
23+
17. **Customization Hub tab**: "Content Templates" tab shows all campaign templates with create/edit/delete for campaign-scoped templates. Built-in templates show read-only badge.
24+
25+
### Previous Update
1126
2026-03-10 -- **Cleanup & consolidation pass after bug fixes.**
1227

1328
7. **JSON injection fix**: HX-Trigger header in `CreateEntityType` error path used string concatenation to build JSON. Replaced with `json.Marshal()` to prevent malformed JSON from error messages containing special characters.

.ai/todo.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ _Quick capture, backlinks, enhanced graph, editor power-ups. See `.ai/obsidian-n
201201
- [x] **Sprint V-1.5: Inline Secrets / DM-Only Blocks in Editor** — SecretMark TipTap extension (Ctrl+Shift+S toggle), `sanitize.StripSecretsHTML/JSON` server-side stripping for non-Scribe, `.chronicle-secret` CSS with amber eye-slash icon, toolbar button. Already implemented alongside V-1.
202202
- [x] **Sprint V-2: Backlinks Panel & Entity Aliases** — HTMX lazy-loaded backlinks with Redis caching + context snippets. Entity aliases table (migration 061), alias CRUD API, aliases widget (tag chips), search/auto-linker/mention integration via `ListNames()` UNION. 11 new tests.
203203
- [x] **Full-Page Journal & Category Nav Redesign** — Full-page journal view at `/campaigns/:id/journal` with two-panel Obsidian-like layout (note tree sidebar + TipTap editor, search, folder tabs, autosave). Journal link in sidebar nav (gated on notes addon). Notes FAB hidden on journal page to avoid sync conflicts. Removed "Session Journal" topbar button. Redesigned sidebar drill panel: merged header + action bar into compact clickable row (category name opens full page, replacing "View All"), count as pill badge, inline "+" button. Entity tree: folder/page icons, guide lines, smooth transitions, distinct reorder vs reparent D&D feedback.
204-
- [ ] **Sprint V-3: Content Templates**Pre-fill editor with structured content (Session Recap, etc.). Template picker in create flow + editor insert.
204+
- [x] **Sprint V-3: Content Templates**`content_templates` table, ContentTemplateService with CRUD + seeding, REST API, template picker on entity create form (loads by entity type), "Insert Template" slash command in editor, 4 default templates (Session Recap, NPC Profile, Location, Quest Log) seeded on campaign creation, Customization Hub "Content Templates" tab for management.
205205
- [ ] **Sprint V-4: Enhanced Graph View & Cover Images**@mention links in graph, entity type/tag filtering, local graph (N hops), clustering, orphan detection. Cover/banner image layout block type for entity pages.
206206
- [ ] **Sprint V-5: Session Journal Audio Attachments** — Audio file upload support in Session Journal notes. Users can attach audio recordings (voice memos, session recordings, ambient tracks) to session notes. Privacy controls: share audio with session participants (public to group) or keep private (visible only to uploader). Media plugin integration for storage/serving. Allowed MIME types: audio/mpeg, audio/ogg, audio/wav, audio/webm. Inline audio player in note view. Migration for audio attachment metadata.
207207

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DROP TABLE IF EXISTS content_templates;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
-- Content templates provide pre-filled editor content for entity creation.
2+
-- Templates can be global (is_global=1, campaign_id IS NULL) or per-campaign.
3+
CREATE TABLE IF NOT EXISTS content_templates (
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+
description VARCHAR(500) NOT NULL DEFAULT '',
9+
content_json JSON NOT NULL,
10+
content_html TEXT NOT NULL DEFAULT '',
11+
icon VARCHAR(50) NOT NULL DEFAULT 'fa-file-lines',
12+
sort_order INT NOT NULL DEFAULT 0,
13+
is_global BOOLEAN NOT NULL DEFAULT FALSE,
14+
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
15+
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
16+
17+
INDEX idx_ct_campaign (campaign_id),
18+
INDEX idx_ct_entity_type (entity_type_id),
19+
INDEX idx_ct_global (is_global),
20+
21+
CONSTRAINT fk_ct_campaign FOREIGN KEY (campaign_id)
22+
REFERENCES campaigns(id) ON DELETE CASCADE,
23+
CONSTRAINT fk_ct_entity_type FOREIGN KEY (entity_type_id)
24+
REFERENCES entity_types(id) ON DELETE CASCADE
25+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

internal/app/routes.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,14 @@ func (a *App) RegisterRoutes() {
705705
entityHandler := entities.NewHandler(entityService)
706706
entities.RegisterRoutes(e, entityHandler, campaignService, authService)
707707

708+
// Content template routes (entity content blueprints).
709+
contentTemplateRepo := entities.NewContentTemplateRepository(a.DB)
710+
contentTemplateService := entities.NewContentTemplateService(contentTemplateRepo, entityTypeRepo)
711+
contentTemplateHandler := entities.NewContentTemplateHandler(contentTemplateService)
712+
entities.RegisterContentTemplateRoutes(e, contentTemplateHandler, campaignService, authService)
713+
campaignService.SetContentTemplateSeeder(contentTemplateService)
714+
entityHandler.SetContentTemplateService(contentTemplateService)
715+
708716
// Media plugin: file upload, storage, thumbnailing, serving.
709717
// Graceful degradation: if the media directory can't be created, log a warning
710718
// but don't crash -- the rest of the app keeps running.

internal/plugins/campaigns/customize.templ

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@ templ CustomizePage(cc *CampaignContext, entityTypes []SettingsEntityType, csrfT
7676
>
7777
<i class="fa-solid fa-bars mr-1.5 text-xs"></i> Navigation
7878
</button>
79+
<button
80+
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap"
81+
:class="tab === 'content-templates' ? 'text-accent border-accent' : 'text-fg-secondary border-transparent hover:text-fg hover:border-edge'"
82+
@click="tab = 'content-templates'"
83+
>
84+
<i class="fa-solid fa-file-lines mr-1.5 text-xs"></i> Content Templates
85+
</button>
7986
<button
8087
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap"
8188
:class="tab === 'extensions' ? 'text-accent border-accent' : 'text-fg-secondary border-transparent hover:text-fg hover:border-edge'"
@@ -111,6 +118,13 @@ templ CustomizePage(cc *CampaignContext, entityTypes []SettingsEntityType, csrfT
111118
</div>
112119
</div>
113120

121+
<!-- Content Templates tab: manage content templates for entity creation -->
122+
<div x-show="tab === 'content-templates'" x-cloak class="h-full overflow-y-auto">
123+
<div class="max-w-3xl mx-auto px-6 py-4">
124+
@contentTemplatesTab(cc, csrfToken)
125+
</div>
126+
</div>
127+
114128
<!-- Extensions tab: enable/disable campaign addons + attributes editor -->
115129
<div x-show="tab === 'extensions'" x-cloak class="h-full overflow-y-auto">
116130
<div class="max-w-3xl mx-auto px-6 py-4">
@@ -477,3 +491,228 @@ templ LayoutEditorFragment(cc *CampaignContext, et *LayoutEditorEntityType, csrf
477491
></div>
478492
</div>
479493
}
494+
495+
// contentTemplatesTab renders the content template management interface.
496+
// Templates are loaded via HTMX from the entities plugin's API.
497+
templ contentTemplatesTab(cc *CampaignContext, csrfToken string) {
498+
<div class="space-y-4">
499+
<div class="flex items-center justify-between">
500+
<div>
501+
<h2 class="text-base font-semibold text-fg mb-0.5">Content Templates</h2>
502+
<p class="text-xs text-fg-secondary">
503+
Pre-fill the editor when creating new pages. Templates appear in the create form and the editor's "/" menu.
504+
</p>
505+
</div>
506+
</div>
507+
508+
// Template list and management (Alpine.js powered).
509+
<div
510+
x-data={ fmt.Sprintf(`{
511+
templates: [],
512+
loading: true,
513+
editing: null,
514+
creating: false,
515+
form: { name: '', description: '', icon: 'fa-file-lines', entity_type_id: 0, content_json: '', content_html: '' },
516+
async load() {
517+
this.loading = true;
518+
try {
519+
var resp = await fetch('/campaigns/%s/content-templates', {
520+
headers: { 'Accept': 'application/json' }
521+
});
522+
if (resp.ok) this.templates = await resp.json();
523+
} finally { this.loading = false; }
524+
},
525+
async save() {
526+
var url = '/campaigns/%s/content-templates';
527+
var method = 'POST';
528+
if (this.editing) {
529+
url += '/' + this.editing;
530+
method = 'PUT';
531+
}
532+
if (!this.form.content_json) {
533+
this.form.content_json = '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Start writing..."}]}]}';
534+
this.form.content_html = '<p>Start writing...</p>';
535+
}
536+
var resp = await fetch(url, {
537+
method: method,
538+
headers: {
539+
'Content-Type': 'application/json',
540+
'X-CSRF-Token': '%s'
541+
},
542+
credentials: 'same-origin',
543+
body: JSON.stringify(this.form)
544+
});
545+
if (resp.ok) {
546+
this.editing = null;
547+
this.creating = false;
548+
this.form = { name: '', description: '', icon: 'fa-file-lines', entity_type_id: 0, content_json: '', content_html: '' };
549+
this.load();
550+
Chronicle.notify('Template saved.', 'success');
551+
} else {
552+
Chronicle.notify('Failed to save template.', 'error');
553+
}
554+
},
555+
async remove(id) {
556+
if (!confirm('Delete this template?')) return;
557+
var resp = await fetch('/campaigns/%s/content-templates/' + id, {
558+
method: 'DELETE',
559+
headers: { 'X-CSRF-Token': '%s' },
560+
credentials: 'same-origin'
561+
});
562+
if (resp.ok) {
563+
this.load();
564+
Chronicle.notify('Template deleted.', 'success');
565+
}
566+
},
567+
edit(t) {
568+
this.editing = t.id;
569+
this.creating = true;
570+
this.form = {
571+
name: t.name,
572+
description: t.description,
573+
icon: t.icon,
574+
entity_type_id: t.entity_type_id || 0,
575+
content_json: t.content_json,
576+
content_html: t.content_html
577+
};
578+
},
579+
cancel() {
580+
this.editing = null;
581+
this.creating = false;
582+
this.form = { name: '', description: '', icon: 'fa-file-lines', entity_type_id: 0, content_json: '', content_html: '' };
583+
}
584+
}`, cc.Campaign.ID, cc.Campaign.ID, csrfToken, cc.Campaign.ID, csrfToken) }
585+
x-init="load()"
586+
>
587+
// Loading state.
588+
<div x-show="loading" class="card p-6 text-center">
589+
<i class="fa-solid fa-spinner fa-spin text-fg-muted"></i>
590+
<span class="text-sm text-fg-muted ml-2">Loading templates...</span>
591+
</div>
592+
593+
// Template list.
594+
<div x-show="!loading && !creating" x-cloak>
595+
<div class="space-y-2 mb-4">
596+
<template x-for="t in templates" x-bind:key="t.id">
597+
<div class="card p-4 flex items-center gap-3">
598+
<span class="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0">
599+
<i class="fa-solid text-accent text-sm" x-bind:class="t.icon || 'fa-file-lines'"></i>
600+
</span>
601+
<div class="flex-1 min-w-0">
602+
<div class="font-medium text-sm text-fg truncate" x-text="t.name"></div>
603+
<div class="text-xs text-fg-muted truncate" x-text="t.description || 'No description'"></div>
604+
<div x-show="t.entity_type_name" class="text-[10px] text-fg-muted mt-0.5">
605+
<i class="fa-solid fa-folder-open mr-0.5"></i>
606+
<span x-text="t.entity_type_name"></span>
607+
</div>
608+
</div>
609+
<div class="flex items-center gap-1 shrink-0">
610+
<template x-if="!t.is_global">
611+
<button
612+
@click="edit(t)"
613+
class="w-7 h-7 flex items-center justify-center rounded text-fg-muted hover:text-accent hover:bg-accent/10 transition-colors"
614+
title="Edit"
615+
>
616+
<i class="fa-solid fa-pen text-xs"></i>
617+
</button>
618+
</template>
619+
<template x-if="!t.is_global">
620+
<button
621+
@click="remove(t.id)"
622+
class="w-7 h-7 flex items-center justify-center rounded text-fg-muted hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
623+
title="Delete"
624+
>
625+
<i class="fa-solid fa-trash text-xs"></i>
626+
</button>
627+
</template>
628+
<template x-if="t.is_global">
629+
<span class="text-[10px] text-fg-muted bg-surface-alt px-2 py-0.5 rounded-full">Built-in</span>
630+
</template>
631+
</div>
632+
</div>
633+
</template>
634+
</div>
635+
636+
<div x-show="templates.length === 0 && !loading" class="card p-6 text-center">
637+
<i class="fa-solid fa-file-lines text-2xl text-fg-muted mb-2"></i>
638+
<p class="text-sm text-fg-secondary">No content templates yet.</p>
639+
</div>
640+
641+
<button
642+
@click="creating = true"
643+
class="btn-primary text-sm"
644+
>
645+
<i class="fa-solid fa-plus mr-1.5"></i> New Template
646+
</button>
647+
</div>
648+
649+
// Create / Edit form.
650+
<div x-show="creating" x-cloak>
651+
<div class="card p-6 space-y-4">
652+
<h3 class="text-sm font-semibold text-fg" x-text="editing ? 'Edit Template' : 'New Template'"></h3>
653+
654+
<div>
655+
<label class="block text-sm font-medium text-fg-body mb-1">Name</label>
656+
<input
657+
type="text"
658+
x-model="form.name"
659+
class="input w-full"
660+
placeholder="e.g., Session Recap, NPC Profile"
661+
maxlength="200"
662+
/>
663+
</div>
664+
665+
<div>
666+
<label class="block text-sm font-medium text-fg-body mb-1">Description (optional)</label>
667+
<input
668+
type="text"
669+
x-model="form.description"
670+
class="input w-full"
671+
placeholder="Brief description of what this template is for"
672+
maxlength="500"
673+
/>
674+
</div>
675+
676+
<div>
677+
<label class="block text-sm font-medium text-fg-body mb-1">Icon</label>
678+
<select x-model="form.icon" class="input w-full">
679+
<option value="fa-file-lines">File</option>
680+
<option value="fa-scroll">Scroll</option>
681+
<option value="fa-user">Person</option>
682+
<option value="fa-location-dot">Location</option>
683+
<option value="fa-list-check">Checklist</option>
684+
<option value="fa-dragon">Dragon</option>
685+
<option value="fa-shield-halved">Shield</option>
686+
<option value="fa-book">Book</option>
687+
<option value="fa-map">Map</option>
688+
<option value="fa-crown">Crown</option>
689+
<option value="fa-skull">Skull</option>
690+
<option value="fa-wand-magic-sparkles">Magic</option>
691+
</select>
692+
</div>
693+
694+
<div>
695+
<label class="block text-sm font-medium text-fg-body mb-1">Content (TipTap JSON)</label>
696+
<textarea
697+
x-model="form.content_json"
698+
class="input w-full h-32 font-mono text-xs"
699+
placeholder='{"type":"doc","content":[...]}'
700+
></textarea>
701+
<p class="mt-1 text-xs text-fg-secondary">
702+
Paste TipTap/ProseMirror document JSON. To get this, create the template content in
703+
any entity's editor, then copy the entry JSON from the browser developer tools.
704+
</p>
705+
</div>
706+
707+
<div class="flex items-center gap-3">
708+
<button @click="save()" class="btn-primary text-sm">
709+
<i class="fa-solid fa-check mr-1.5"></i>
710+
<span x-text="editing ? 'Update' : 'Create'"></span>
711+
</button>
712+
<button @click="cancel()" class="btn-secondary text-sm">Cancel</button>
713+
</div>
714+
</div>
715+
</div>
716+
</div>
717+
</div>
718+
}

internal/plugins/campaigns/model.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,12 @@ type EntityTypeSeeder interface {
381381
SeedDefaults(ctx context.Context, campaignID string) error
382382
}
383383

384+
// ContentTemplateSeeder seeds default content templates when a campaign is
385+
// created. Implemented by the entities plugin's ContentTemplateService.
386+
type ContentTemplateSeeder interface {
387+
SeedDefaults(ctx context.Context, campaignID string) error
388+
}
389+
384390
// --- Request DTOs (bound from HTTP requests) ---
385391

386392
// CreateCampaignRequest holds the data submitted by the campaign creation form.

internal/plugins/campaigns/service.go

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ type CampaignService interface {
6767
AdminAddMember(ctx context.Context, campaignID, userID string, role Role) error
6868

6969
// Lifecycle hooks — set after construction to avoid circular initialization.
70+
SetContentTemplateSeeder(seeder ContentTemplateSeeder)
7071
SetMediaCleaner(cleaner MediaCleaner)
7172
SetHookDispatcher(dispatcher CampaignHookDispatcher)
7273
}
@@ -100,10 +101,11 @@ type campaignService struct {
100101
repo CampaignRepository
101102
users UserFinder
102103
mail MailService // May be nil if SMTP is not configured.
103-
seeder EntityTypeSeeder // Seeds default entity types on campaign creation. May be nil.
104-
mediaCleaner MediaCleaner // Cleans up media files on campaign delete. May be nil.
105-
hookDispatcher CampaignHookDispatcher // Dispatches WASM lifecycle events. May be nil.
106-
baseURL string
104+
seeder EntityTypeSeeder // Seeds default entity types on campaign creation. May be nil.
105+
templateSeeder ContentTemplateSeeder // Seeds default content templates on campaign creation. May be nil.
106+
mediaCleaner MediaCleaner // Cleans up media files on campaign delete. May be nil.
107+
hookDispatcher CampaignHookDispatcher // Dispatches WASM lifecycle events. May be nil.
108+
baseURL string
107109
}
108110

109111
// NewCampaignService creates a new campaign service with the given dependencies.
@@ -118,6 +120,12 @@ func NewCampaignService(repo CampaignRepository, users UserFinder, mail MailServ
118120
}
119121
}
120122

123+
// SetContentTemplateSeeder sets the seeder for default content templates.
124+
// Called after all plugins are wired to avoid initialization order issues.
125+
func (s *campaignService) SetContentTemplateSeeder(seeder ContentTemplateSeeder) {
126+
s.templateSeeder = seeder
127+
}
128+
121129
// SetMediaCleaner sets the media cleaner for campaign deletion cleanup.
122130
// Called after all plugins are wired to avoid initialization order issues.
123131
func (s *campaignService) SetMediaCleaner(cleaner MediaCleaner) {
@@ -197,6 +205,16 @@ func (s *campaignService) Create(ctx context.Context, userID string, input Creat
197205
}
198206
}
199207

208+
// Seed default content templates for the new campaign.
209+
if s.templateSeeder != nil {
210+
if err := s.templateSeeder.SeedDefaults(ctx, campaign.ID); err != nil {
211+
slog.Warn("failed to seed default content templates",
212+
slog.String("campaign_id", campaign.ID),
213+
slog.Any("error", err),
214+
)
215+
}
216+
}
217+
200218
slog.Info("campaign created",
201219
slog.String("campaign_id", campaign.ID),
202220
slog.String("slug", campaign.Slug),

0 commit comments

Comments
 (0)