Skip to content

Commit aa5e043

Browse files
committed
feat: campaign invite system, accent color fix, graph PNG export
- W-0.5: Fix accent color propagation to all Tailwind utilities by referencing CSS variables instead of hardcoded hex values. Add auto-computed hover/light variants via AccentColorCSS() helper. - V-4b: Add PNG export button to relations graph (SVG→Canvas→PNG at 2x resolution for retina clarity). - U-2: Full campaign invite system — migration 000007, model, repo, service, handler, templates, routes. Email invites with token-based accept flow, login/register redirect support, HTMX management UI in campaign settings. 9 unit tests. https://claude.ai/code/session_01WJEjfBqjZaGatHiXXXDupo
1 parent 44f316a commit aa5e043

19 files changed

Lines changed: 1342 additions & 15 deletions

.ai/status.md

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

1010
## Last Updated
11-
2026-03-12 -- **Post-F-5 QoL: NPC sidebar link + plan reorg.**
11+
2026-03-17 -- **Post-Phase-1 Sprint: Visual customization fix, graph export, invite system.**
12+
13+
41. **Post-Phase-1 Sprint: W-0.5 + V-4b + U-2.**
14+
- **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.
15+
- **V-4a (cover images)** — Already fully implemented: migration 000004 (`cover_image_path`), API (`PUT /entities/:eid/cover-image`), `cover_image` layout block type in block registry, upload/change UI with hover overlay. No new work needed.
16+
- **V-4b (graph export)** — Added PNG export button to relations graph widget. Uses SVG→Canvas→PNG pipeline with 2x resolution for retina clarity. Download button in the graph controls bar alongside zoom buttons.
17+
- **U-2: Campaign Invite System (NEW)** — Full invite flow:
18+
- Migration 000007: `campaign_invites` table with token, role, expiry, accept tracking.
19+
- Model: `Invite` struct with `IsExpired()`, `IsPending()` helpers.
20+
- Repository: `InviteRepository` with Create, GetByToken, ListByCampaign, MarkAccepted, Delete, DeleteExpired, GetByEmailAndCampaign.
21+
- Service: `InviteService` with CreateInvite (token generation, duplicate check, email send), AcceptInvite (token validation, membership creation), ListInvites, RevokeInvite, GetInviteByToken.
22+
- Handler: `InviteHandler` with ListInvitesAPI, CreateInviteAPI, RevokeInviteAPI, AcceptInvitePage, InvitesPage.
23+
- Templates: `InviteAcceptPage` (standalone page for accept flow with login/register redirect), `InviteListFragment` (HTMX fragment for settings page with send form + invite table).
24+
- Routes: `RegisterInviteRoutes` — accept page at `/invites/accept?token=xxx`, CRUD at `/campaigns/:id/invites`.
25+
- Email: HTML+plaintext invite email with accept link, campaign name, role.
26+
- Auth redirect: Login and register handlers now support `?redirect=` parameter for post-auth redirect to invite accept page.
27+
- Tests: 9 tests covering create, validation, duplicate, accept, expired, already-accepted, revoke, list, default role.
28+
- **Next up:** Phase 2 (X-1: System Upload UX) or V-4 graph tag filtering, or Phase 4 collaboration features.
1229

1330
40. **Post-F-5 QoL: NPC sidebar navigation link.**
1431
- Added "NPCs" entry to campaign sidebar in `internal/templates/layouts/app.templ` below "All Pages" link.

.ai/todo.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ _Fix orphaned data, cascade gaps, and admin DB visibility. See `.ai/phases.md`._
190190
### Phase U: Collaboration & Platform Maturity
191191

192192
- [~] **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.
193-
- [ ] **Sprint U-2: Invite System** — Migration: `campaign_invites` table. Email invitations with one-click accept link. Non-public campaigns require invitation. Invite management UI.
193+
- [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.
196196
- [ ] **Sprint U-5: Infrastructure & Deployment** — Docker-compose full stack verification with health checks. Makefile full-stack target. `CONTRIBUTING.md`. CI against docker-compose.
@@ -204,13 +204,13 @@ _Quick capture, backlinks, enhanced graph, editor power-ups. See `.ai/obsidian-n
204204
- [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.
205205
- [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.
206206
- [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.
207-
- [ ] **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.
207+
- [~] **Sprint V-4: Enhanced Graph View & Cover Images**@mention links in graph, entity type filtering ✅, tag filtering (deferred — needs service plumbing), local graph (N hops), clustering, orphan detection ✅, PNG export ✅. Cover/banner image layout block type ✅ (migration 000004, API, block registry, upload UI). Remaining: tag-based filtering on graph (requires TagEntityLister adapter).
208208
- [x] **Sprint V-5: Session Journal Audio Attachments**`note_attachments` table (migration 000005), `AttachmentRepository` + `AttachmentService` + REST handlers (list/upload/delete/transcript). Media service extended with audio MIME types (mp3/ogg/wav/webm) and magic bytes validation; `sanitizeImage()` guarded to skip audio. Journal UI: microphone upload button in toolbar, inline `<audio>` players per attachment, collapsible editable transcript textarea, delete support. Also fixed: journal save bug (`_tiptapBundle``window.TipTap`), added @mentions (MentionLink + MentionExtension) to journal editor, added session edit modal UI.
209209

210210
### Phase W: Polish, Ecosystem & Delight
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.
213-
- [~] **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. Future: per-addon "feature in use" indicators showing which entities/pages use each feature, which widgets are available, click associations to navigate. Disabled feature banner with "offline" sticker instead of complete removal.
213+
- [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.
214214
- [ ] **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.
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.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DROP TABLE IF EXISTS campaign_invites;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
-- Campaign invite system: email-based invitations with one-click accept.
2+
CREATE TABLE IF NOT EXISTS campaign_invites (
3+
id CHAR(36) NOT NULL,
4+
campaign_id CHAR(36) NOT NULL,
5+
email VARCHAR(255) NOT NULL,
6+
role VARCHAR(20) NOT NULL DEFAULT 'player',
7+
token VARCHAR(64) NOT NULL,
8+
created_by CHAR(36) NOT NULL,
9+
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
10+
expires_at DATETIME NOT NULL,
11+
accepted_at DATETIME DEFAULT NULL,
12+
13+
PRIMARY KEY (id),
14+
UNIQUE KEY uq_invite_token (token),
15+
INDEX idx_invite_campaign (campaign_id),
16+
INDEX idx_invite_email (email),
17+
CONSTRAINT fk_invite_campaign FOREIGN KEY (campaign_id) REFERENCES campaigns(id) ON DELETE CASCADE,
18+
CONSTRAINT fk_invite_created_by FOREIGN KEY (created_by) REFERENCES users(id),
19+
CONSTRAINT chk_invite_role CHECK (role IN ('player', 'scribe'))
20+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

internal/app/routes.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,12 @@ func (a *App) RegisterRoutes() {
768768
campaignHandler.SetGroupService(groupService)
769769
campaigns.RegisterRoutes(e, campaignHandler, campaignService, authService)
770770

771+
// Campaign invites.
772+
inviteRepo := campaigns.NewInviteRepository(a.DB)
773+
inviteService := campaigns.NewInviteService(inviteRepo, campaignRepo, smtpService, a.Config.BaseURL)
774+
inviteHandler := campaigns.NewInviteHandler(inviteService, campaignService, a.Config.BaseURL)
775+
campaigns.RegisterInviteRoutes(e, inviteHandler, campaignService, authService)
776+
771777
// Discover page (/) -- browse public campaigns. Uses OptionalAuth so
772778
// authenticated users get the App layout with sidebar, while guests
773779
// see a standalone page with signup CTA.

internal/plugins/auth/handler.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,18 @@ func (h *Handler) Login(c echo.Context) error {
108108
// Set the session cookie.
109109
setSessionCookie(c, token, h.sessionTTL)
110110

111+
// Redirect to the requested page (e.g., invite accept), or dashboard.
112+
redirectTo := "/dashboard"
113+
if redir := c.QueryParam("redirect"); redir != "" && strings.HasPrefix(redir, "/") {
114+
redirectTo = redir
115+
}
116+
111117
// HTMX requests get a redirect header; browser forms get a 303 redirect.
112118
if middleware.IsHTMX(c) {
113-
c.Response().Header().Set("HX-Redirect", "/dashboard")
119+
c.Response().Header().Set("HX-Redirect", redirectTo)
114120
return c.NoContent(http.StatusNoContent)
115121
}
116-
return c.Redirect(http.StatusSeeOther, "/dashboard")
122+
return c.Redirect(http.StatusSeeOther, redirectTo)
117123
}
118124

119125
// RegisterForm renders the registration page (GET /register).
@@ -181,11 +187,17 @@ func (h *Handler) Register(c echo.Context) error {
181187

182188
setSessionCookie(c, token, h.sessionTTL)
183189

190+
// Redirect to the requested page (e.g., invite accept), or dashboard.
191+
redirectTo := "/dashboard"
192+
if redir := c.QueryParam("redirect"); redir != "" && strings.HasPrefix(redir, "/") {
193+
redirectTo = redir
194+
}
195+
184196
if middleware.IsHTMX(c) {
185-
c.Response().Header().Set("HX-Redirect", "/dashboard")
197+
c.Response().Header().Set("HX-Redirect", redirectTo)
186198
return c.NoContent(http.StatusNoContent)
187199
}
188-
return c.Redirect(http.StatusSeeOther, "/dashboard")
200+
return c.Redirect(http.StatusSeeOther, redirectTo)
189201
}
190202

191203
// Logout destroys the session and clears the cookie (POST /logout).
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package campaigns
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/labstack/echo/v4"
7+
"github.com/keyxmakerx/chronicle/internal/apperror"
8+
"github.com/keyxmakerx/chronicle/internal/middleware"
9+
"github.com/keyxmakerx/chronicle/internal/plugins/auth"
10+
)
11+
12+
// InviteHandler handles HTTP requests for campaign invites.
13+
type InviteHandler struct {
14+
service InviteService
15+
campaigns CampaignService
16+
baseURL string
17+
}
18+
19+
// NewInviteHandler creates a new invite handler.
20+
func NewInviteHandler(service InviteService, campaigns CampaignService, baseURL string) *InviteHandler {
21+
return &InviteHandler{
22+
service: service,
23+
campaigns: campaigns,
24+
baseURL: baseURL,
25+
}
26+
}
27+
28+
// ListInvitesAPI returns all invites for a campaign as JSON.
29+
// GET /campaigns/:id/invites
30+
func (h *InviteHandler) ListInvitesAPI(c echo.Context) error {
31+
cc := GetCampaignContext(c)
32+
if cc == nil {
33+
return apperror.NewMissingContext()
34+
}
35+
36+
invites, err := h.service.ListInvites(c.Request().Context(), cc.Campaign.ID)
37+
if err != nil {
38+
return apperror.NewInternal(err)
39+
}
40+
if invites == nil {
41+
invites = []Invite{}
42+
}
43+
return c.JSON(http.StatusOK, invites)
44+
}
45+
46+
// CreateInviteAPI creates a new campaign invite.
47+
// POST /campaigns/:id/invites
48+
func (h *InviteHandler) CreateInviteAPI(c echo.Context) error {
49+
cc := GetCampaignContext(c)
50+
if cc == nil {
51+
return apperror.NewMissingContext()
52+
}
53+
54+
var input CreateInviteInput
55+
if err := c.Bind(&input); err != nil {
56+
return apperror.NewBadRequest("invalid request body")
57+
}
58+
59+
userID := auth.GetUserID(c)
60+
invite, err := h.service.CreateInvite(c.Request().Context(), cc.Campaign.ID, userID, input)
61+
if err != nil {
62+
return err
63+
}
64+
65+
return c.JSON(http.StatusCreated, invite)
66+
}
67+
68+
// RevokeInviteAPI deletes a pending invite.
69+
// DELETE /campaigns/:id/invites/:inviteId
70+
func (h *InviteHandler) RevokeInviteAPI(c echo.Context) error {
71+
cc := GetCampaignContext(c)
72+
if cc == nil {
73+
return apperror.NewMissingContext()
74+
}
75+
_ = cc // Used for auth check via route middleware.
76+
77+
inviteID := c.Param("inviteId")
78+
if inviteID == "" {
79+
return apperror.NewBadRequest("invite ID required")
80+
}
81+
82+
if err := h.service.RevokeInvite(c.Request().Context(), inviteID); err != nil {
83+
return apperror.NewInternal(err)
84+
}
85+
86+
return c.NoContent(http.StatusNoContent)
87+
}
88+
89+
// AcceptInvitePage handles invite acceptance.
90+
// GET /invites/accept?token=xxx
91+
func (h *InviteHandler) AcceptInvitePage(c echo.Context) error {
92+
token := c.QueryParam("token")
93+
if token == "" {
94+
return apperror.NewBadRequest("missing invite token")
95+
}
96+
97+
// Fetch the invite to show details.
98+
invite, err := h.service.GetInviteByToken(c.Request().Context(), token)
99+
if err != nil {
100+
return err
101+
}
102+
103+
// Fetch campaign name for display.
104+
campaign, _ := h.campaigns.GetByID(c.Request().Context(), invite.CampaignID)
105+
106+
// Check if user is logged in.
107+
userID := auth.GetUserID(c)
108+
if userID == "" {
109+
// Not logged in — show the invite details and prompt to log in or register.
110+
campaignName := ""
111+
if campaign != nil {
112+
campaignName = campaign.Name
113+
}
114+
return middleware.Render(c, http.StatusOK, InviteAcceptPage(invite, campaignName, token, false, ""))
115+
}
116+
117+
// User is logged in — accept the invite.
118+
accepted, err := h.service.AcceptInvite(c.Request().Context(), token, userID)
119+
if err != nil {
120+
// If it's a validation error (already member, expired), show it.
121+
campaignName := ""
122+
if campaign != nil {
123+
campaignName = campaign.Name
124+
}
125+
return middleware.Render(c, http.StatusOK, InviteAcceptPage(invite, campaignName, token, false, err.Error()))
126+
}
127+
128+
// Success — redirect to the campaign.
129+
campaignName := ""
130+
if campaign != nil {
131+
campaignName = campaign.Name
132+
}
133+
_ = accepted
134+
return middleware.Render(c, http.StatusOK, InviteAcceptPage(invite, campaignName, token, true, ""))
135+
}
136+
137+
// InvitesPage renders the invite management tab in campaign settings.
138+
// GET /campaigns/:id/invites/page
139+
func (h *InviteHandler) InvitesPage(c echo.Context) error {
140+
cc := GetCampaignContext(c)
141+
if cc == nil {
142+
return apperror.NewMissingContext()
143+
}
144+
145+
invites, err := h.service.ListInvites(c.Request().Context(), cc.Campaign.ID)
146+
if err != nil {
147+
return apperror.NewInternal(err)
148+
}
149+
150+
if middleware.IsHTMX(c) {
151+
return middleware.Render(c, http.StatusOK, InviteListFragment(cc, invites))
152+
}
153+
return middleware.Render(c, http.StatusOK, InviteListFragment(cc, invites))
154+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package campaigns
2+
3+
import "time"
4+
5+
// Invite represents a pending invitation for a user to join a campaign.
6+
type Invite struct {
7+
ID string `json:"id"`
8+
CampaignID string `json:"campaign_id"`
9+
Email string `json:"email"`
10+
Role string `json:"role"`
11+
Token string `json:"-"` // Never exposed in JSON.
12+
CreatedBy string `json:"created_by"`
13+
CreatedAt time.Time `json:"created_at"`
14+
ExpiresAt time.Time `json:"expires_at"`
15+
AcceptedAt *time.Time `json:"accepted_at,omitempty"`
16+
17+
// Joined from users table for display.
18+
CreatedByName string `json:"created_by_name,omitempty"`
19+
}
20+
21+
// IsExpired returns true if the invite has passed its expiry time.
22+
func (i *Invite) IsExpired() bool {
23+
return time.Now().UTC().After(i.ExpiresAt)
24+
}
25+
26+
// IsPending returns true if the invite has not been accepted or expired.
27+
func (i *Invite) IsPending() bool {
28+
return i.AcceptedAt == nil && !i.IsExpired()
29+
}
30+
31+
// CreateInviteInput holds the data needed to create a new campaign invite.
32+
type CreateInviteInput struct {
33+
Email string `json:"email"`
34+
Role string `json:"role"`
35+
}
36+
37+
// inviteTokenBytes is the number of random bytes in an invite token.
38+
const inviteTokenBytes = 32
39+
40+
// inviteExpiryDays is how long an invite link stays valid.
41+
const inviteExpiryDays = 7

0 commit comments

Comments
 (0)