Skip to content

Commit a5370d9

Browse files
committed
feat(armory): add Armory plugin with item gallery, sidebar link, and dashboard block (Sprint B-2)
Add the Armory & Inventory plugin as a view-layer plugin following the NPC gallery pattern. Items are entities whose entity type has preset_category "item" — set when created from a system manifest preset. Key changes: - Migration 000009: add preset_category column to entity_types table - EntityType model: add PresetCategory field, plumb through repo/service/input - Extensions applier: pass category through EntityTypeCreateInput adapter - New armory plugin: model, repository, service, handler, routes, templ files - Register "armory" addon in builtinAddons - Add Armory sidebar link in app.templ Zone 2 (after NPCs) - Register armory_preview dashboard block in block registry - Wire armory routes and adapter in routes.go https://claude.ai/code/session_01WJEjfBqjZaGatHiXXXDupo
1 parent f82ada0 commit a5370d9

22 files changed

Lines changed: 1010 additions & 41 deletions
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
DROP INDEX idx_entity_types_preset_category ON entity_types;
2+
ALTER TABLE entity_types DROP COLUMN preset_category;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
-- Add preset_category column to entity_types.
2+
-- This tracks which system preset category an entity type was created from
3+
-- (e.g., "character", "item", "creature"). Used by the Armory plugin to
4+
-- identify item-type entity types across different game systems.
5+
ALTER TABLE entity_types
6+
ADD COLUMN preset_category VARCHAR(50) DEFAULT NULL AFTER color;
7+
8+
-- Add index for efficient filtering by preset category.
9+
CREATE INDEX idx_entity_types_preset_category ON entity_types (campaign_id, preset_category);

internal/app/routes.go

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import (
3333
"github.com/keyxmakerx/chronicle/internal/templates/layouts"
3434
"github.com/keyxmakerx/chronicle/internal/templates/pages"
3535
ws "github.com/keyxmakerx/chronicle/internal/websocket"
36+
"github.com/keyxmakerx/chronicle/internal/plugins/armory"
3637
"github.com/keyxmakerx/chronicle/internal/plugins/npcs"
3738
"github.com/keyxmakerx/chronicle/internal/widgets/notes"
3839
"github.com/keyxmakerx/chronicle/internal/widgets/posts"
@@ -692,6 +693,44 @@ func (a *npcVisibilityTogglerAdapter) TogglePrivate(ctx context.Context, entityI
692693
return a.svc.TogglePrivate(ctx, entityID)
693694
}
694695

696+
// armoryItemTypeFinderAdapter wraps entities.EntityService to implement the
697+
// armory.ItemTypeFinder interface. Resolves item-category entity types using
698+
// the preset_category column.
699+
type armoryItemTypeFinderAdapter struct {
700+
svc entities.EntityService
701+
}
702+
703+
// FindItemTypeIDs returns the IDs of entity types with preset_category "item".
704+
func (a *armoryItemTypeFinderAdapter) FindItemTypeIDs(ctx context.Context, campaignID string) ([]int, error) {
705+
types, err := a.svc.GetEntityTypesByPresetCategory(ctx, campaignID, "item")
706+
if err != nil {
707+
return nil, err
708+
}
709+
ids := make([]int, len(types))
710+
for i, t := range types {
711+
ids[i] = t.ID
712+
}
713+
return ids, nil
714+
}
715+
716+
// FindItemTypes returns item-category entity types for the Armory filter dropdown.
717+
func (a *armoryItemTypeFinderAdapter) FindItemTypes(ctx context.Context, campaignID string) ([]armory.ItemTypeInfo, error) {
718+
types, err := a.svc.GetEntityTypesByPresetCategory(ctx, campaignID, "item")
719+
if err != nil {
720+
return nil, err
721+
}
722+
infos := make([]armory.ItemTypeInfo, len(types))
723+
for i, t := range types {
724+
infos[i] = armory.ItemTypeInfo{
725+
ID: t.ID,
726+
Name: t.Name,
727+
Icon: t.Icon,
728+
Color: t.Color,
729+
}
730+
}
731+
return infos, nil
732+
}
733+
695734
// RegisterRoutes sets up all application routes. It registers public routes
696735
// directly and delegates to each plugin's route registration function.
697736
//
@@ -1038,6 +1077,12 @@ func (a *App) RegisterRoutes() {
10381077
npcHandler.SetVisibilityToggler(&npcVisibilityTogglerAdapter{svc: entityService})
10391078
npcs.RegisterRoutes(e, npcHandler, campaignService, authService, addonService)
10401079

1080+
// Armory plugin: gallery/hub view for item-category entities.
1081+
armoryRepo := armory.NewArmoryRepository(a.DB)
1082+
armorySvc := armory.NewArmoryService(armoryRepo, &armoryItemTypeFinderAdapter{svc: entityService})
1083+
armoryHandler := armory.NewHandler(armorySvc)
1084+
armory.RegisterRoutes(e, armoryHandler, campaignService, authService, addonService)
1085+
10411086
// Notes widget: personal floating note-taking panel (Google Keep-style).
10421087
noteRepo := notes.NewNoteRepository(a.DB)
10431088
attRepo := notes.NewAttachmentRepository(a.DB)
@@ -1117,6 +1162,19 @@ func (a *App) RegisterRoutes() {
11171162
return npcs.BlockNPCGallery(bctx.CC, cards, limit)
11181163
})
11191164

1165+
// Armory preview block — embeds a compact item grid on entity pages/dashboards.
1166+
blockRegistry.Register(entities.BlockMeta{
1167+
Type: "armory_preview", Label: "Armory Preview", Icon: "fa-shield-halved",
1168+
Description: "Grid of campaign items", Addon: "armory",
1169+
}, func(bctx entities.BlockRenderContext) templ.Component {
1170+
limit := entities.BlockConfigLimit(bctx.Block.Config, "limit", 8)
1171+
cards, err := armoryHandler.GalleryBlock(context.Background(), bctx.CC.Campaign.ID, int(bctx.CC.MemberRole), "", limit)
1172+
if err != nil {
1173+
return templ.NopComponent
1174+
}
1175+
return armory.BlockArmoryPreview(bctx.CC, cards, limit)
1176+
})
1177+
11201178
// Set the registry on the entity service (validation) and as the global (rendering).
11211179
// The addon checker lets Render() skip blocks whose addon is disabled.
11221180
blockRegistry.SetAddonChecker(addonService)
@@ -1159,12 +1217,13 @@ func (a *App) RegisterRoutes() {
11591217
extApplier := extensions.NewContentApplier(
11601218
a.Config.ExtensionsPath,
11611219
extRepo,
1162-
extensions.NewEntityTypeAdapter(func(ctx context.Context, campaignID string, name, namePlural, icon, color string) (int, string, error) {
1220+
extensions.NewEntityTypeAdapter(func(ctx context.Context, campaignID string, name, namePlural, icon, color, presetCategory string) (int, string, error) {
11631221
et, err := entityService.CreateEntityType(ctx, campaignID, entities.CreateEntityTypeInput{
1164-
Name: name,
1165-
NamePlural: namePlural,
1166-
Icon: icon,
1167-
Color: color,
1222+
Name: name,
1223+
NamePlural: namePlural,
1224+
Icon: icon,
1225+
Color: color,
1226+
PresetCategory: presetCategory,
11681227
})
11691228
if err != nil {
11701229
return 0, "", err

internal/extensions/adapters.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,20 @@ import "context"
88
// entityTypeAdapter wraps any service with a CreateEntityType method
99
// matching the entities.EntityService interface.
1010
type entityTypeAdapter struct {
11-
create func(ctx context.Context, campaignID string, name, namePlural, icon, color string) (id int, slug string, err error)
11+
create func(ctx context.Context, campaignID string, name, namePlural, icon, color, presetCategory string) (id int, slug string, err error)
1212
}
1313

1414
// NewEntityTypeAdapter creates an adapter from a creation function.
1515
// The caller extracts the function from the concrete entity service.
1616
func NewEntityTypeAdapter(
17-
create func(ctx context.Context, campaignID string, name, namePlural, icon, color string) (int, string, error),
17+
create func(ctx context.Context, campaignID string, name, namePlural, icon, color, presetCategory string) (int, string, error),
1818
) EntityTypeCreator {
1919
return &entityTypeAdapter{create: create}
2020
}
2121

2222
// CreateEntityType implements EntityTypeCreator.
2323
func (a *entityTypeAdapter) CreateEntityType(ctx context.Context, campaignID string, input EntityTypeCreateInput) (EntityTypeResult, error) {
24-
id, slug, err := a.create(ctx, campaignID, input.Name, input.NamePlural, input.Icon, input.Color)
24+
id, slug, err := a.create(ctx, campaignID, input.Name, input.NamePlural, input.Icon, input.Color, input.PresetCategory)
2525
if err != nil {
2626
return EntityTypeResult{}, err
2727
}

internal/extensions/applier.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,11 @@ type EntityTypeCreator interface {
3131
// EntityTypeCreateInput mirrors entities.CreateEntityTypeInput to avoid
3232
// importing the entities package directly.
3333
type EntityTypeCreateInput struct {
34-
Name string
35-
NamePlural string
36-
Icon string
37-
Color string
34+
Name string
35+
NamePlural string
36+
Icon string
37+
Color string
38+
PresetCategory string // Optional preset category (e.g., "character", "item").
3839
}
3940

4041
// EntityTypeResult is the minimal result needed from entity type creation.
@@ -175,10 +176,11 @@ func (a *contentApplier) applyEntityTypeTemplates(
175176
) error {
176177
for _, t := range templates {
177178
input := EntityTypeCreateInput{
178-
Name: t.Name,
179-
NamePlural: t.NamePlural,
180-
Icon: t.Icon,
181-
Color: t.Color,
179+
Name: t.Name,
180+
NamePlural: t.NamePlural,
181+
Icon: t.Icon,
182+
Color: t.Color,
183+
PresetCategory: t.Category,
182184
}
183185

184186
result, err := a.entityTypes.CreateEntityType(ctx, campaignID, input)

internal/extensions/manifest.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ type EntityTypeTemplate struct {
7272
NamePlural string `json:"name_plural"`
7373
Icon string `json:"icon"`
7474
Color string `json:"color"`
75+
Category string `json:"category,omitempty"` // Preset category (e.g., "character", "item", "creature").
7576
Fields []TemplateField `json:"fields,omitempty"`
7677
}
7778

internal/plugins/addons/service.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ var builtinAddons = []addonDef{
149149
{Slug: "timeline", Name: "Timeline", Description: "Interactive visual timelines with zoom levels, entity grouping, and calendar integration.", Version: "0.1.0", Category: CategoryPlugin, Status: StatusActive, Icon: "fa-timeline", Author: "Chronicle"},
150150
{Slug: "sessions", Name: "Sessions", Description: "Track game sessions with scheduling, linked entities, and RSVP.", Version: "0.1.0", Category: CategoryPlugin, Status: StatusActive, Icon: "fa-calendar-check", Author: "Chronicle"},
151151
{Slug: "npcs", Name: "NPC Gallery", Description: "Browse and reveal character entities as NPCs for your players.", Version: "1.0.0", Category: CategoryPlugin, Status: StatusActive, Icon: "fa-users", Author: "Chronicle"},
152+
{Slug: "armory", Name: "Armory & Inventory", Description: "Item catalog, character inventories, and shop management. System-dependent item types with Foundry sync.", Version: "0.1.0", Category: CategoryPlugin, Status: StatusActive, Icon: "fa-shield-halved", Author: "Chronicle"},
152153

153154
// Integrations.
154155
{Slug: "sync-api", Name: "Sync API", Description: "Secure REST API for external tool integration (Foundry VTT, Roll20, etc.)", Version: "0.1.0", Category: CategoryIntegration, Status: StatusActive, Icon: "fa-arrows-rotate", Author: "Chronicle"},

internal/plugins/armory/.ai.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Armory Plugin
2+
3+
## Purpose
4+
View-layer plugin providing an item gallery/hub for campaign item entities.
5+
Follows the same pattern as the NPC gallery plugin. Items are entities whose
6+
entity type has `preset_category = 'item'` (set when created from a system
7+
manifest preset).
8+
9+
## Architecture
10+
- **No own tables** — queries `entities` and `entity_types` via the existing
11+
entities infrastructure. Uses the `preset_category` column on `entity_types`
12+
to identify item types.
13+
- **Adapter pattern**`ItemTypeFinder` interface injected from routes.go
14+
to resolve item type IDs without circular imports.
15+
- **Addon-gated** — requires the "armory" addon to be enabled.
16+
17+
## Key Types
18+
- `ItemCard` — view model for gallery grid cards
19+
- `ArmoryService` — business logic (list, count, type resolution)
20+
- `ArmoryRepository` — SQL queries against entities + entity_types
21+
22+
## Routes
23+
- `GET /campaigns/:id/armory` — gallery page (Player+)
24+
- `GET /campaigns/:id/armory/count` — JSON count for sidebar badge
25+
26+
## Block
27+
- `armory_preview` — compact item grid for entity page layouts
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// block.templ renders the armory_preview layout block — a compact item card
2+
// grid that can be placed on entity pages and category dashboards.
3+
package armory
4+
5+
import (
6+
"fmt"
7+
"github.com/keyxmakerx/chronicle/internal/plugins/campaigns"
8+
"github.com/keyxmakerx/chronicle/internal/templates/layouts"
9+
)
10+
11+
// BlockArmoryPreview renders a compact Armory preview block for entity page layouts.
12+
// Shows up to `limit` items with images/icons and names.
13+
templ BlockArmoryPreview(cc *campaigns.CampaignContext, cards []ItemCard, limit int) {
14+
<div class="armory-preview-block">
15+
<div class="flex items-center justify-between mb-3">
16+
<h3 class="text-sm font-semibold text-fg">
17+
<i class="fa-solid fa-shield-halved text-fg-muted mr-1.5 text-xs"></i>Armory
18+
</h3>
19+
<a
20+
href={ templ.SafeURL(fmt.Sprintf("/campaigns/%s/armory", cc.Campaign.ID)) }
21+
class="text-xs text-accent hover:underline"
22+
>
23+
View all
24+
</a>
25+
</div>
26+
27+
if len(cards) == 0 {
28+
<p class="text-xs text-fg-muted text-center py-4">No items in the armory yet.</p>
29+
} else {
30+
<div class="grid grid-cols-3 sm:grid-cols-4 gap-2">
31+
for _, card := range cards {
32+
<a
33+
href={ templ.SafeURL(fmt.Sprintf("/campaigns/%s/entities/%s", cc.Campaign.ID, card.ID)) }
34+
class="group"
35+
>
36+
<div class="aspect-square rounded-lg overflow-hidden bg-surface-alt mb-1">
37+
if card.ImagePath != nil && *card.ImagePath != "" {
38+
<img
39+
src={ layouts.MediaURL(ctx, *card.ImagePath) }
40+
alt={ card.Name }
41+
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
42+
/>
43+
} else {
44+
<div class="w-full h-full flex items-center justify-center">
45+
<i class={ "fa-solid " + card.TypeIcon + " text-lg text-fg-muted opacity-30" }></i>
46+
</div>
47+
}
48+
</div>
49+
<p class="text-[11px] text-fg truncate text-center group-hover:text-accent transition-colors">
50+
{ card.Name }
51+
</p>
52+
</a>
53+
}
54+
</div>
55+
}
56+
</div>
57+
}

0 commit comments

Comments
 (0)