Skip to content

Commit 9587098

Browse files
committed
feat: Sprint V-4 — Enhanced Graph View & Cover Images
Add @mention edges to the relations graph by scanning entity entry_html for data-mention-id attributes. Graph API now supports filtering by entity type, search query, local/ego graph (BFS), and orphan detection. D3 widget updated with filter toolbar, dashed mention edges, node sizing by connection count, type clustering, and orphan visualization. New cover_image layout block type with full-width banner images, configurable height and overlay options. New local_graph block type showing entity relationship neighborhood on profile pages. https://claude.ai/code/session_01QJLkgjQDu5qohzJKGV4hj9
1 parent aaf67c6 commit 9587098

17 files changed

Lines changed: 1027 additions & 72 deletions

File tree

.ai/status.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,24 @@
88
<!-- ====================================================================== -->
99

1010
## Last Updated
11-
2026-03-10 -- **Sprint V-3: Content Templates (COMPLETE).**
11+
2026-03-10 -- **Sprint V-4: Enhanced Graph View & Cover Images (COMPLETE).**
12+
13+
18. **@Mention edges in graph**: `FindAllMentionLinks()` in entity repository scans `entry_html` for `data-mention-id` attributes across a campaign. `MentionLink` model. `GetMentionLinks()` service method. `MentionLinkProvider` interface bridges entities→relations without circular imports. Mention edges appear as dashed purple lines in the D3 graph.
14+
15+
19. **Graph API filtering**: `GetFilteredGraphData()` with `GraphFilter` struct supporting `types` (entity type slugs), `search` (name match), `focus`+`hops` (BFS local/ego graph), `include_mentions`, `include_orphans`. BFS subgraph extraction via `bfsSubgraph()`. `GraphEdge.Kind` field distinguishes "relation" vs "mention" edges.
1216

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.
17+
20. **Graph UI enhancements**: Updated `relation_graph.js` with: filter toolbar (type multi-select, search input, mention toggle, orphan toggle), dashed mention edges with purple color, node sizing by connection count, type-based clustering via `d3.forceX/forceY`, orphan nodes with dotted borders, enhanced legend showing edge types and orphan indicator.
1418

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`).
19+
21. **Graph page template updates**: `GraphPage` handler now fetches entity types for filter dropdown. Template passes entity types as JSON via `data-entity-types` attribute. `EntityTypeListerForGraph` interface with adapter.
1620

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.
21+
22. **Cover image layout block**: Migration `000004_cover_image` adds `cover_image_path` column to entities. `cover_image` block type registered in block registry. `blockCoverImage()` templ component with configurable height (sm/md/lg) and overlay (none/gradient/dark). `UpdateCoverImageAPI` endpoint at `PUT /campaigns/:id/entities/:eid/cover-image`. Reuses `image-upload` widget for upload.
1822

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.
23+
23. **Local graph block**: `local_graph` block type registered in block registry. `blockLocalGraph()` renders a mini `relation-graph` widget with `data-focus-entity` and `data-hops` attributes for ego-graph mode on entity profile pages.
2024

21-
16. **Default templates**: Four built-in templates (Session Recap, NPC Profile, Location, Quest Log) are seeded on campaign creation via `ContentTemplateSeeder` interface.
25+
### Previous Update
26+
2026-03-10 -- **Sprint V-3: Content Templates (COMPLETE).**
2227

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.
28+
12-17. Content templates: migration, CRUD, template picker, editor slash command, default templates, Customization Hub tab.
2429

2530
### Previous Update
2631
2026-03-10 -- **Cleanup & consolidation pass after bug fixes.**
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE entities DROP COLUMN cover_image_path;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- Add cover_image_path column to entities for the cover image layout block.
2+
-- Stored separately from image_path (profile thumbnail) to allow both.
3+
ALTER TABLE entities ADD COLUMN cover_image_path VARCHAR(500) DEFAULT NULL AFTER image_path;

internal/app/routes.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,64 @@ func (a *widgetBlockListerAdapter) GetWidgetBlockMetas(ctx context.Context, camp
606606
return metas
607607
}
608608

609+
// mentionLinkAdapter wraps entities.EntityService to implement the
610+
// relations.MentionLinkProvider interface, supplying @mention link data
611+
// for the graph visualization without creating a circular import.
612+
type mentionLinkAdapter struct {
613+
svc entities.EntityService
614+
}
615+
616+
// GetMentionLinksForGraph returns @mention references across a campaign for
617+
// the relations graph. Converts between entity and relations package types.
618+
func (a *mentionLinkAdapter) GetMentionLinksForGraph(ctx context.Context, campaignID string, includeDmOnly bool, userID string) ([]relations.MentionLinkData, error) {
619+
// Determine role for visibility filtering: DM sees everything, others
620+
// see only entities they have access to.
621+
role := permissions.RolePlayer
622+
if includeDmOnly {
623+
role = permissions.RoleOwner
624+
}
625+
626+
links, err := a.svc.GetMentionLinks(ctx, campaignID, role, userID)
627+
if err != nil {
628+
return nil, err
629+
}
630+
result := make([]relations.MentionLinkData, len(links))
631+
for i, l := range links {
632+
result[i] = relations.MentionLinkData{
633+
SourceEntityID: l.SourceEntityID,
634+
TargetEntityID: l.TargetEntityID,
635+
}
636+
}
637+
return result, nil
638+
}
639+
640+
// entityTypeListerForGraphAdapter wraps entities.EntityService to implement the
641+
// relations.EntityTypeListerForGraph interface for the graph filter dropdown.
642+
type entityTypeListerForGraphAdapter struct {
643+
svc entities.EntityService
644+
}
645+
646+
// ListEntityTypesForGraph returns entity types as lightweight summaries.
647+
func (a *entityTypeListerForGraphAdapter) ListEntityTypesForGraph(ctx context.Context, campaignID string) ([]relations.EntityTypeSummary, error) {
648+
etypes, err := a.svc.GetEntityTypes(ctx, campaignID)
649+
if err != nil {
650+
return nil, err
651+
}
652+
result := make([]relations.EntityTypeSummary, 0, len(etypes))
653+
for _, et := range etypes {
654+
if !et.Enabled {
655+
continue
656+
}
657+
result = append(result, relations.EntityTypeSummary{
658+
Slug: et.Slug,
659+
Name: et.Name,
660+
Color: et.Color,
661+
Icon: et.Icon,
662+
})
663+
}
664+
return result, nil
665+
}
666+
609667
// RegisterRoutes sets up all application routes. It registers public routes
610668
// directly and delegates to each plugin's route registration function.
611669
//
@@ -882,7 +940,9 @@ func (a *App) RegisterRoutes() {
882940
// so it can be injected into the API handler for shop inventory support.
883941
relRepo := relations.NewRelationRepository(a.DB)
884942
relService := relations.NewRelationService(relRepo)
943+
relService.SetMentionLinkProvider(&mentionLinkAdapter{svc: entityService})
885944
relHandler := relations.NewHandler(relService)
945+
relHandler.SetEntityTypeLister(&entityTypeListerForGraphAdapter{svc: entityService})
886946
relations.RegisterRoutes(e, relHandler, campaignService, authService)
887947

888948
// Posts widget: entity sub-notes with rich text, visibility, and reorder.

internal/plugins/entities/block_registry_core.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,22 @@ func RegisterCoreBlocks(r *BlockRegistry) {
9595
return blockExtWidget(ctx.CC, ctx.Entity, ctx.Block)
9696
})
9797

98+
// Cover image block — full-width banner/hero image for entity pages.
99+
r.Register(BlockMeta{
100+
Type: "cover_image", Label: "Cover Image", Icon: "fa-panorama",
101+
Description: "Full-width banner image",
102+
}, func(ctx BlockRenderContext) templ.Component {
103+
return blockCoverImage(ctx.CC, ctx.Entity, ctx.CSRFToken, ctx.Block.Config)
104+
})
105+
106+
// Local graph block — mini-graph showing entity's neighborhood.
107+
r.Register(BlockMeta{
108+
Type: "local_graph", Label: "Local Graph", Icon: "fa-diagram-project",
109+
Description: "Entity relationship neighborhood",
110+
}, func(ctx BlockRenderContext) templ.Component {
111+
return blockLocalGraph(ctx.CC, ctx.Entity, ctx.Block.Config)
112+
})
113+
98114
// Container layout types — rendered by the template editor JS, not by
99115
// server-side templ. Registered here so they pass validation.
100116
r.Register(BlockMeta{

internal/plugins/entities/handler.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1114,6 +1114,39 @@ func (h *Handler) UpdateImageAPI(c echo.Context) error {
11141114
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
11151115
}
11161116

1117+
// UpdateCoverImageAPI updates the entity's cover/banner image path.
1118+
// PUT /campaigns/:id/entities/:eid/cover-image
1119+
func (h *Handler) UpdateCoverImageAPI(c echo.Context) error {
1120+
cc := campaigns.GetCampaignContext(c)
1121+
if cc == nil {
1122+
return apperror.NewMissingContext()
1123+
}
1124+
1125+
entityID := c.Param("eid")
1126+
1127+
// IDOR protection.
1128+
entity, err := h.service.GetByID(c.Request().Context(), entityID)
1129+
if err != nil {
1130+
return err
1131+
}
1132+
if entity.CampaignID != cc.Campaign.ID {
1133+
return apperror.NewNotFound("entity not found")
1134+
}
1135+
1136+
var body struct {
1137+
ImagePath string `json:"image_path"`
1138+
}
1139+
if err := json.NewDecoder(c.Request().Body).Decode(&body); err != nil {
1140+
return apperror.NewBadRequest("invalid JSON body")
1141+
}
1142+
1143+
if err := h.service.UpdateCoverImage(c.Request().Context(), entityID, body.ImagePath); err != nil {
1144+
return err
1145+
}
1146+
1147+
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
1148+
}
1149+
11171150
// --- Preview API ---
11181151

11191152
// htmlTagPattern matches HTML tags for stripping in entry excerpts.

internal/plugins/entities/model.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ type Entity struct {
219219
Entry *string `json:"entry,omitempty"` // TipTap/ProseMirror JSON document.
220220
EntryHTML *string `json:"entry_html,omitempty"` // Pre-rendered HTML from entry.
221221
ImagePath *string `json:"image_path,omitempty"`
222+
CoverImagePath *string `json:"cover_image_path,omitempty"` // Full-width banner image.
222223
ParentID *string `json:"parent_id,omitempty"`
223224
SortOrder int `json:"sort_order"` // Manual ordering within parent/category (0 = default).
224225
TypeLabel *string `json:"type_label,omitempty"` // Freeform subtype (e.g., "City" for a Location).
@@ -593,3 +594,13 @@ type BacklinkEntry struct {
593594
Entity Entity `json:"entity"`
594595
Snippet string `json:"snippet"`
595596
}
597+
598+
// --- Mention Links (for graph visualization) ---
599+
600+
// MentionLink represents a directional @mention reference from one entity to
601+
// another, extracted from entry_html. Used by the relations graph to show
602+
// mention-based edges alongside explicit relations.
603+
type MentionLink struct {
604+
SourceEntityID string `json:"sourceEntityId"`
605+
TargetEntityID string `json:"targetEntityId"`
606+
}

internal/plugins/entities/repository.go

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"errors"
88
"fmt"
9+
"regexp"
910
"strings"
1011

1112
"github.com/keyxmakerx/chronicle/internal/apperror"
@@ -430,6 +431,7 @@ type EntityRepository interface {
430431
UpdateFields(ctx context.Context, id string, fieldsData map[string]any) error
431432
UpdateFieldOverrides(ctx context.Context, id string, overrides *FieldOverrides) error
432433
UpdateImage(ctx context.Context, id, imagePath string) error
434+
UpdateCoverImage(ctx context.Context, id, coverImagePath string) error
433435
Delete(ctx context.Context, id string) error
434436
SlugExists(ctx context.Context, campaignID, slug string) (bool, error)
435437

@@ -481,6 +483,11 @@ type EntityRepository interface {
481483
// SetAliases replaces all aliases for an entity with the given list.
482484
// Deletes existing aliases and inserts new ones in a single transaction.
483485
SetAliases(ctx context.Context, entityID string, aliases []string) error
486+
487+
// FindAllMentionLinks returns all @mention references across a campaign by
488+
// scanning entry_html for data-mention-id attributes. Each result is a
489+
// source→target pair. Used by the graph visualization to show mention edges.
490+
FindAllMentionLinks(ctx context.Context, campaignID string, role int, userID string) ([]MentionLink, error)
484491
}
485492

486493
// entityRepository implements EntityRepository with MariaDB queries.
@@ -519,7 +526,7 @@ func (r *entityRepository) Create(ctx context.Context, entity *Entity) error {
519526

520527
// entitySelectColumns is the standard column list for entity queries with joined type info.
521528
const entitySelectColumns = `e.id, e.campaign_id, e.entity_type_id, e.name, e.slug,
522-
e.entry, e.entry_html, e.image_path, e.parent_id, e.sort_order, e.type_label,
529+
e.entry, e.entry_html, e.image_path, e.cover_image_path, e.parent_id, e.sort_order, e.type_label,
523530
e.is_private, e.visibility, e.is_template, e.fields_data, e.field_overrides, e.popup_config,
524531
e.created_by, e.created_at, e.updated_at,
525532
et.name, et.icon, et.color, et.slug`
@@ -551,7 +558,7 @@ func (r *entityRepository) scanEntity(row *sql.Row) (*Entity, error) {
551558
var fieldsRaw, overridesRaw, popupRaw []byte
552559
err := row.Scan(
553560
&e.ID, &e.CampaignID, &e.EntityTypeID, &e.Name, &e.Slug,
554-
&e.Entry, &e.EntryHTML, &e.ImagePath, &e.ParentID, &e.SortOrder, &e.TypeLabel,
561+
&e.Entry, &e.EntryHTML, &e.ImagePath, &e.CoverImagePath, &e.ParentID, &e.SortOrder, &e.TypeLabel,
555562
&e.IsPrivate, &e.Visibility, &e.IsTemplate, &fieldsRaw, &overridesRaw, &popupRaw,
556563
&e.CreatedBy, &e.CreatedAt, &e.UpdatedAt,
557564
&e.TypeName, &e.TypeIcon, &e.TypeColor, &e.TypeSlug,
@@ -711,6 +718,30 @@ func (r *entityRepository) UpdateImage(ctx context.Context, id, imagePath string
711718
return nil
712719
}
713720

721+
// UpdateCoverImage updates only the cover_image_path for an entity.
722+
// Used by the cover image upload API.
723+
func (r *entityRepository) UpdateCoverImage(ctx context.Context, id, coverImagePath string) error {
724+
var val any
725+
if coverImagePath != "" {
726+
val = coverImagePath
727+
}
728+
729+
query := `UPDATE entities SET cover_image_path = ?, updated_at = NOW() WHERE id = ?`
730+
result, err := r.db.ExecContext(ctx, query, val, id)
731+
if err != nil {
732+
return fmt.Errorf("updating entity cover image: %w", err)
733+
}
734+
735+
rows, err := result.RowsAffected()
736+
if err != nil {
737+
return fmt.Errorf("checking rows affected: %w", err)
738+
}
739+
if rows == 0 {
740+
return apperror.NewNotFound("entity not found")
741+
}
742+
return nil
743+
}
744+
714745
// Delete removes an entity.
715746
func (r *entityRepository) Delete(ctx context.Context, id string) error {
716747
result, err := r.db.ExecContext(ctx, `DELETE FROM entities WHERE id = ?`, id)
@@ -1152,7 +1183,7 @@ func (r *entityRepository) scanEntityRow(rows *sql.Rows) (*Entity, error) {
11521183
var fieldsRaw, overridesRaw, popupRaw []byte
11531184
err := rows.Scan(
11541185
&e.ID, &e.CampaignID, &e.EntityTypeID, &e.Name, &e.Slug,
1155-
&e.Entry, &e.EntryHTML, &e.ImagePath, &e.ParentID, &e.SortOrder, &e.TypeLabel,
1186+
&e.Entry, &e.EntryHTML, &e.ImagePath, &e.CoverImagePath, &e.ParentID, &e.SortOrder, &e.TypeLabel,
11561187
&e.IsPrivate, &e.Visibility, &e.IsTemplate, &fieldsRaw, &overridesRaw, &popupRaw,
11571188
&e.CreatedBy, &e.CreatedAt, &e.UpdatedAt,
11581189
&e.TypeName, &e.TypeIcon, &e.TypeColor, &e.TypeSlug,
@@ -1445,3 +1476,55 @@ func (r *entityRepository) SetAliases(ctx context.Context, entityID string, alia
14451476

14461477
return tx.Commit()
14471478
}
1479+
1480+
// mentionIDPattern matches data-mention-id="<uuid>" attributes in entry_html.
1481+
var mentionIDPattern = regexp.MustCompile(`data-mention-id="([a-f0-9-]+)"`)
1482+
1483+
// FindAllMentionLinks scans all entities in a campaign for @mention references
1484+
// in their entry_html and returns source→target pairs. Respects visibility
1485+
// filtering so players only see links from/to entities they can access.
1486+
func (r *entityRepository) FindAllMentionLinks(ctx context.Context, campaignID string, role int, userID string) ([]MentionLink, error) {
1487+
where := `WHERE e.campaign_id = ? AND e.entry_html LIKE '%data-mention-id=%'`
1488+
args := []any{campaignID}
1489+
1490+
visFilter, visArgs := visibilityFilter(role, userID)
1491+
where += visFilter
1492+
args = append(args, visArgs...)
1493+
1494+
query := fmt.Sprintf(`SELECT e.id, e.entry_html FROM entities e %s`, where)
1495+
1496+
rows, err := r.db.QueryContext(ctx, query, args...)
1497+
if err != nil {
1498+
return nil, fmt.Errorf("finding mention links: %w", err)
1499+
}
1500+
defer rows.Close()
1501+
1502+
var links []MentionLink
1503+
for rows.Next() {
1504+
var sourceID string
1505+
var entryHTML *string
1506+
if err := rows.Scan(&sourceID, &entryHTML); err != nil {
1507+
return nil, fmt.Errorf("scanning mention row: %w", err)
1508+
}
1509+
if entryHTML == nil {
1510+
continue
1511+
}
1512+
1513+
// Extract all mention target IDs from the HTML.
1514+
matches := mentionIDPattern.FindAllStringSubmatch(*entryHTML, -1)
1515+
seen := make(map[string]bool)
1516+
for _, match := range matches {
1517+
targetID := match[1]
1518+
// Skip self-mentions and dedup within same entity.
1519+
if targetID == sourceID || seen[targetID] {
1520+
continue
1521+
}
1522+
seen[targetID] = true
1523+
links = append(links, MentionLink{
1524+
SourceEntityID: sourceID,
1525+
TargetEntityID: targetID,
1526+
})
1527+
}
1528+
}
1529+
return links, rows.Err()
1530+
}

internal/plugins/entities/routes.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ func RegisterRoutes(e *echo.Echo, h *Handler, campaignSvc campaigns.CampaignServ
3636

3737
// Image API.
3838
cg.PUT("/entities/:eid/image", h.UpdateImageAPI, campaigns.RequireRole(campaigns.RoleScribe))
39+
cg.PUT("/entities/:eid/cover-image", h.UpdateCoverImageAPI, campaigns.RequireRole(campaigns.RoleScribe))
3940

4041
// Popup preview config API (Scribe+).
4142
cg.PUT("/entities/:eid/popup-config", h.UpdatePopupConfigAPI, campaigns.RequireRole(campaigns.RoleScribe))

0 commit comments

Comments
 (0)