Skip to content

Commit 269f359

Browse files
committed
feat: add Foundry VTT module management UI to settings and admin panel
Add a "Foundry VTT Integration" section to campaign owner settings showing the module install URL with a copy-to-clipboard button and setup instructions. Add an admin panel page (/admin/foundry) for managing the Foundry module: - View current module version and install URL - Update module version to trigger Foundry update notifications - Dashboard card linking to the management page https://claude.ai/code/session_01XMwxFR8BCi5XvgaSVMSBZB
1 parent c948708 commit 269f359

7 files changed

Lines changed: 280 additions & 5 deletions

File tree

internal/app/routes.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,7 @@ func (a *App) RegisterRoutes() {
731731
campaignRepo := campaigns.NewCampaignRepository(a.DB)
732732
campaignService := campaigns.NewCampaignService(campaignRepo, userFinder, smtpService, entityService, a.Config.BaseURL)
733733
campaignHandler := campaigns.NewHandler(campaignService)
734+
campaignHandler.SetBaseURL(a.Config.BaseURL)
734735
campaignHandler.SetEntityLister(&entityTypeListerAdapter{svc: entityService})
735736
campaignHandler.SetLayoutFetcher(&entityTypeLayoutFetcherAdapter{svc: entityService})
736737
campaignHandler.SetRecentEntityLister(&recentEntityListerAdapter{svc: entityService})
@@ -810,6 +811,7 @@ func (a *App) RegisterRoutes() {
810811
// Admin plugin: site-wide management (users, campaigns, SMTP settings, storage).
811812
adminHandler := admin.NewHandler(authRepo, campaignService, smtpService)
812813
adminHandler.SetMediaDeps(mediaRepo, mediaService, a.Config.Upload.MaxSize)
814+
adminHandler.SetBaseURL(a.Config.BaseURL)
813815
adminGroup := admin.RegisterRoutes(e, adminHandler, authService, smtpHandler)
814816

815817
// Settings plugin: editable storage limits (global, per-user, per-campaign).

internal/plugins/admin/dashboard.templ

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,17 @@ templ AdminDashboardPage(userCount, campaignCount, mediaFileCount int, totalStor
145145
}
146146
<p class="text-xs text-fg-muted group-hover:text-accent transition-colors">Schema explorer &rarr;</p>
147147
</a>
148+
<!-- Foundry VTT -->
149+
<a href="/admin/foundry" class="card hover:shadow-md transition-shadow group">
150+
<div class="flex items-center justify-between">
151+
<p class="text-sm font-medium text-fg-secondary">Foundry VTT</p>
152+
<span class="w-10 h-10 rounded-lg bg-violet-50 dark:bg-violet-900/30 flex items-center justify-center">
153+
<i class="fa-solid fa-dice-d20 text-violet-500"></i>
154+
</span>
155+
</div>
156+
<p class="text-lg font-semibold text-fg mt-2">Module Manager</p>
157+
<p class="text-xs text-fg-muted mt-1 group-hover:text-accent transition-colors">Manage module &rarr;</p>
158+
</a>
148159
<!-- SMTP -->
149160
<a href="/admin/smtp" class="card hover:shadow-md transition-shadow group">
150161
<div class="flex items-center justify-between">
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// foundry.templ renders the admin Foundry VTT module management page.
2+
// Allows admins to view and update the module version and see the install URL.
3+
4+
package admin
5+
6+
import (
7+
"github.com/keyxmakerx/chronicle/internal/templates/layouts"
8+
)
9+
10+
// AdminFoundryModulePage renders the Foundry VTT module management page.
11+
templ AdminFoundryModulePage(data FoundryModuleData) {
12+
@layouts.App("Foundry VTT Module - Admin") {
13+
<div class="max-w-2xl mx-auto space-y-6">
14+
<div class="flex items-center justify-between">
15+
<div>
16+
<h1 class="text-2xl font-bold text-fg">Foundry VTT Module</h1>
17+
<p class="text-sm text-fg-secondary mt-1">
18+
Manage the Chronicle Sync module served to Foundry VTT clients.
19+
</p>
20+
</div>
21+
<a href="/admin" class="btn-secondary text-sm">Back</a>
22+
</div>
23+
24+
// Current status.
25+
<div class="card p-6">
26+
<h2 class="text-lg font-semibold text-fg mb-4">Module Status</h2>
27+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
28+
<div>
29+
<label class="block text-xs font-medium text-fg-muted mb-1">Current Version</label>
30+
<p class="text-lg font-bold text-fg">{ data.Version }</p>
31+
</div>
32+
<div>
33+
<label class="block text-xs font-medium text-fg-muted mb-1">Module ID</label>
34+
<p class="text-sm font-mono text-fg">chronicle-sync</p>
35+
</div>
36+
</div>
37+
</div>
38+
39+
// Install URL.
40+
<div class="card p-6">
41+
<h2 class="text-lg font-semibold text-fg mb-4">Install URL</h2>
42+
<p class="text-sm text-fg-secondary mb-3">
43+
Share this URL with users to install the module in Foundry VTT.
44+
</p>
45+
<div class="flex items-center gap-2">
46+
<input
47+
type="text"
48+
readonly
49+
value={ data.InstallURL }
50+
class="input w-full font-mono text-sm bg-surface-alt"
51+
id="foundry-admin-url"
52+
/>
53+
<button
54+
type="button"
55+
onclick="navigator.clipboard.writeText(document.getElementById('foundry-admin-url').value).then(() => { this.innerHTML = '<i class=\'fa-solid fa-check text-emerald-500\'></i>'; setTimeout(() => { this.innerHTML = '<i class=\'fa-solid fa-copy\'></i>'; }, 2000); })"
56+
class="btn-secondary text-sm px-3 py-2 shrink-0"
57+
title="Copy to clipboard"
58+
>
59+
<i class="fa-solid fa-copy"></i>
60+
</button>
61+
</div>
62+
<p class="text-xs text-fg-muted mt-2">
63+
In Foundry VTT: <strong>Add-on Modules → Install Module → Manifest URL</strong>
64+
</p>
65+
</div>
66+
67+
// Update version.
68+
<div class="card p-6">
69+
<h2 class="text-lg font-semibold text-fg mb-4">Update Module Version</h2>
70+
<p class="text-sm text-fg-secondary mb-4">
71+
Bump the module version to notify Foundry VTT clients that an update is available.
72+
Foundry checks the manifest URL periodically and shows an update badge when the
73+
version changes.
74+
</p>
75+
<form
76+
method="POST"
77+
action="/admin/foundry/version"
78+
hx-put="/admin/foundry/version"
79+
class="flex items-end gap-3"
80+
>
81+
<input type="hidden" name="csrf_token" value={ data.CSRFToken }/>
82+
<div class="flex-1">
83+
<label for="version" class="block text-sm font-medium text-fg-body mb-1">New Version</label>
84+
<input
85+
type="text"
86+
id="version"
87+
name="version"
88+
value={ data.Version }
89+
required
90+
class="input w-full"
91+
placeholder="0.2.0"
92+
pattern="[0-9]+\.[0-9]+\.[0-9]+"
93+
title="Semantic version (e.g. 0.2.0)"
94+
/>
95+
</div>
96+
<button type="submit" class="btn-primary text-sm">Update Version</button>
97+
</form>
98+
</div>
99+
100+
// Info panel.
101+
<div class="card p-6">
102+
<div class="flex items-start gap-4">
103+
<span class="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center shrink-0">
104+
<i class="fa-solid fa-circle-info text-accent"></i>
105+
</span>
106+
<div>
107+
<h3 class="text-sm font-semibold text-fg mb-1">How It Works</h3>
108+
<ul class="text-sm text-fg-secondary leading-relaxed space-y-1 list-disc ml-4">
109+
<li>The module manifest and zip are served directly from this Chronicle instance.</li>
110+
<li>Foundry VTT fetches the manifest URL to check for updates.</li>
111+
<li>When you bump the version, Foundry will show an update notification to all users.</li>
112+
<li>The module zip is generated on-the-fly from the <code class="text-xs bg-surface-alt px-1 rounded">foundry-module/</code> directory.</li>
113+
</ul>
114+
</div>
115+
</div>
116+
</div>
117+
</div>
118+
}
119+
}

internal/plugins/admin/handler.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ package admin
55

66
import (
77
"context"
8+
"encoding/json"
89
"fmt"
910
"log/slog"
1011
"net/http"
12+
"os"
1113
"strconv"
14+
"strings"
1215

1316
"github.com/labstack/echo/v4"
1417

@@ -43,6 +46,7 @@ type Handler struct {
4346
securityService SecurityService
4447
hygieneScanner DataHygieneScanner
4548
databaseExplorer DatabaseExplorer
49+
baseURL string
4650
}
4751

4852
// StoragePageData holds all data needed for the combined storage management page.
@@ -103,6 +107,11 @@ func (h *Handler) SetDatabaseExplorer(explorer DatabaseExplorer) {
103107
h.databaseExplorer = explorer
104108
}
105109

110+
// SetBaseURL sets the public-facing base URL for the Foundry module admin page.
111+
func (h *Handler) SetBaseURL(url string) {
112+
h.baseURL = url
113+
}
114+
106115
// --- Data Hygiene ---
107116

108117
// DataHygiene renders the data hygiene dashboard (GET /admin/data-hygiene).
@@ -748,3 +757,89 @@ type SecurityPageData struct {
748757
Sessions []auth.SessionInfo
749758
CSRFToken string
750759
}
760+
761+
// --- Foundry VTT Module Management ---
762+
763+
// FoundryModuleData holds data for the admin Foundry module page.
764+
type FoundryModuleData struct {
765+
Version string
766+
InstallURL string
767+
CSRFToken string
768+
}
769+
770+
// FoundryModule renders the Foundry VTT module management page (GET /admin/foundry).
771+
func (h *Handler) FoundryModule(c echo.Context) error {
772+
version := readFoundryModuleVersion()
773+
baseURL := strings.TrimRight(h.baseURL, "/")
774+
data := FoundryModuleData{
775+
Version: version,
776+
InstallURL: baseURL + "/foundry-module/module.json",
777+
CSRFToken: middleware.GetCSRFToken(c),
778+
}
779+
return middleware.Render(c, http.StatusOK, AdminFoundryModulePage(data))
780+
}
781+
782+
// UpdateFoundryModuleVersion updates the version in foundry-module/module.json
783+
// (PUT /admin/foundry/version).
784+
func (h *Handler) UpdateFoundryModuleVersion(c echo.Context) error {
785+
var req struct {
786+
Version string `json:"version" form:"version"`
787+
}
788+
if err := c.Bind(&req); err != nil || req.Version == "" {
789+
return apperror.NewBadRequest("version is required")
790+
}
791+
792+
// Read current module.json.
793+
data, err := os.ReadFile("foundry-module/module.json")
794+
if err != nil {
795+
return apperror.NewInternal(fmt.Errorf("read module.json: %w", err))
796+
}
797+
798+
var manifest map[string]any
799+
if err := json.Unmarshal(data, &manifest); err != nil {
800+
return apperror.NewInternal(fmt.Errorf("parse module.json: %w", err))
801+
}
802+
803+
manifest["version"] = req.Version
804+
805+
// Update the download URL to use the new version tag.
806+
baseURL := strings.TrimRight(h.baseURL, "/")
807+
manifest["download"] = baseURL + "/foundry-module/chronicle-sync.zip"
808+
manifest["manifest"] = baseURL + "/foundry-module/module.json"
809+
810+
out, err := json.MarshalIndent(manifest, "", " ")
811+
if err != nil {
812+
return apperror.NewInternal(fmt.Errorf("marshal module.json: %w", err))
813+
}
814+
out = append(out, '\n')
815+
816+
if err := os.WriteFile("foundry-module/module.json", out, 0644); err != nil {
817+
return apperror.NewInternal(fmt.Errorf("write module.json: %w", err))
818+
}
819+
820+
slog.Info("foundry module version updated",
821+
slog.String("version", req.Version),
822+
slog.String("by", auth.GetUserID(c)),
823+
)
824+
825+
if middleware.IsHTMX(c) {
826+
c.Response().Header().Set("HX-Redirect", "/admin/foundry")
827+
return c.NoContent(http.StatusNoContent)
828+
}
829+
return c.Redirect(http.StatusSeeOther, "/admin/foundry")
830+
}
831+
832+
// readFoundryModuleVersion reads the version from foundry-module/module.json.
833+
func readFoundryModuleVersion() string {
834+
data, err := os.ReadFile("foundry-module/module.json")
835+
if err != nil {
836+
return "unknown"
837+
}
838+
var manifest struct {
839+
Version string `json:"version"`
840+
}
841+
if err := json.Unmarshal(data, &manifest); err != nil {
842+
return "unknown"
843+
}
844+
return manifest.Version
845+
}

internal/plugins/admin/routes.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ func RegisterRoutes(e *echo.Echo, h *Handler, authService auth.AuthService, smtp
5252
admin.GET("/database/schema", h.DatabaseSchemaAPI)
5353
admin.POST("/database/migrations/apply", h.ApplyMigrationsAPI)
5454

55+
// Foundry VTT module management.
56+
admin.GET("/foundry", h.FoundryModule)
57+
admin.PUT("/foundry/version", h.UpdateFoundryModuleVersion)
58+
5559
// SMTP settings (delegates to SMTP plugin handler).
5660
if smtpHandler != nil {
5761
smtp.RegisterRoutes(admin, smtpHandler)

internal/plugins/campaigns/handler.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ type Handler struct {
113113
auditLogger AuditLogger
114114
addonLister AddonLister
115115
mediaUploader MediaUploader
116+
baseURL string
116117
}
117118

118119
// NewHandler creates a new campaign handler.
@@ -159,6 +160,12 @@ func (h *Handler) SetMediaUploader(uploader MediaUploader) {
159160
h.mediaUploader = uploader
160161
}
161162

163+
// SetBaseURL sets the public-facing base URL for generating integration URLs
164+
// (e.g. Foundry VTT module install URL).
165+
func (h *Handler) SetBaseURL(url string) {
166+
h.baseURL = url
167+
}
168+
162169
// logAudit fires a fire-and-forget audit entry. Errors are logged but
163170
// never block the primary operation.
164171
func (h *Handler) logAudit(c echo.Context, campaignID, action string, details map[string]any) {
@@ -323,7 +330,7 @@ func (h *Handler) Update(c echo.Context) error {
323330
if h.entityLister != nil {
324331
entityTypes, _ = h.entityLister.GetEntityTypesForSettings(c.Request().Context(), cc.Campaign.ID)
325332
}
326-
return middleware.Render(c, http.StatusOK, CampaignSettingsPage(cc, transfer, entityTypes, csrfToken, errMsg))
333+
return middleware.Render(c, http.StatusOK, CampaignSettingsPage(cc, transfer, entityTypes, csrfToken, errMsg, h.baseURL))
327334
}
328335

329336
h.logAudit(c, cc.Campaign.ID, "campaign.updated", nil)
@@ -598,7 +605,7 @@ func (h *Handler) Settings(c echo.Context) error {
598605
entityTypes, _ = h.entityLister.GetEntityTypesForSettings(c.Request().Context(), cc.Campaign.ID)
599606
}
600607

601-
return middleware.Render(c, http.StatusOK, CampaignSettingsPage(cc, transfer, entityTypes, csrfToken, ""))
608+
return middleware.Render(c, http.StatusOK, CampaignSettingsPage(cc, transfer, entityTypes, csrfToken, "", h.baseURL))
602609
}
603610

604611
// PluginHub renders the campaign plugin hub page, showing all enabled
@@ -1118,7 +1125,7 @@ func (h *Handler) TransferForm(c echo.Context) error {
11181125
entityTypes, _ = h.entityLister.GetEntityTypesForSettings(c.Request().Context(), cc.Campaign.ID)
11191126
}
11201127

1121-
return middleware.Render(c, http.StatusOK, CampaignSettingsPage(cc, transfer, entityTypes, csrfToken, ""))
1128+
return middleware.Render(c, http.StatusOK, CampaignSettingsPage(cc, transfer, entityTypes, csrfToken, "", h.baseURL))
11221129
}
11231130

11241131
// Transfer initiates an ownership transfer (POST /campaigns/:id/transfer).
@@ -1146,7 +1153,7 @@ func (h *Handler) Transfer(c echo.Context) error {
11461153
if h.entityLister != nil {
11471154
entityTypes, _ = h.entityLister.GetEntityTypesForSettings(c.Request().Context(), cc.Campaign.ID)
11481155
}
1149-
return middleware.Render(c, http.StatusOK, CampaignSettingsPage(cc, transfer, entityTypes, csrfToken, errMsg))
1156+
return middleware.Render(c, http.StatusOK, CampaignSettingsPage(cc, transfer, entityTypes, csrfToken, errMsg, h.baseURL))
11501157
}
11511158

11521159
if middleware.IsHTMX(c) {

internal/plugins/campaigns/settings.templ

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ func entityTypesJSON(types []SettingsEntityType) string {
2020
}
2121

2222
// CampaignSettingsPage renders the campaign settings page.
23-
templ CampaignSettingsPage(cc *CampaignContext, transfer *OwnershipTransfer, entityTypes []SettingsEntityType, csrfToken, errMsg string) {
23+
templ CampaignSettingsPage(cc *CampaignContext, transfer *OwnershipTransfer, entityTypes []SettingsEntityType, csrfToken, errMsg, baseURL string) {
2424
@layouts.App(cc.Campaign.Name + " - Settings") {
2525
<div class="max-w-2xl mx-auto">
2626
<div class="flex items-center justify-between mb-6">
@@ -146,6 +146,43 @@ templ CampaignSettingsPage(cc *CampaignContext, transfer *OwnershipTransfer, ent
146146
</p>
147147
</div>
148148

149+
// Foundry VTT integration — module install URL for Foundry clients.
150+
if baseURL != "" {
151+
<div class="card p-6 mb-6">
152+
<div class="flex items-center justify-between mb-2">
153+
<h2 class="text-lg font-semibold text-fg">Foundry VTT Integration</h2>
154+
</div>
155+
<p class="text-sm text-fg-secondary mb-4">
156+
Install the Chronicle Sync module in Foundry VTT to sync journals, maps, tokens, and calendars
157+
with this campaign in real-time.
158+
</p>
159+
<div>
160+
<label class="block text-sm font-medium text-fg-body mb-1">Module Install URL</label>
161+
<div class="flex items-center gap-2">
162+
<input
163+
type="text"
164+
readonly
165+
value={ fmt.Sprintf("%s/foundry-module/module.json", baseURL) }
166+
class="input w-full font-mono text-sm bg-surface-alt"
167+
id="foundry-install-url"
168+
/>
169+
<button
170+
type="button"
171+
onclick="navigator.clipboard.writeText(document.getElementById('foundry-install-url').value).then(() => { this.innerHTML = '<i class=\'fa-solid fa-check text-emerald-500\'></i>'; setTimeout(() => { this.innerHTML = '<i class=\'fa-solid fa-copy\'></i>'; }, 2000); })"
172+
class="btn-secondary text-sm px-3 py-2 shrink-0"
173+
title="Copy to clipboard"
174+
>
175+
<i class="fa-solid fa-copy"></i>
176+
</button>
177+
</div>
178+
<p class="text-xs text-fg-muted mt-2">
179+
In Foundry VTT, go to <strong>Add-on Modules → Install Module</strong>, paste this URL in the
180+
<strong>Manifest URL</strong> field, and click <strong>Install</strong>.
181+
</p>
182+
</div>
183+
</div>
184+
}
185+
149186
// Groups — manage user groups for entity permission grants.
150187
<div class="card p-6 mb-6">
151188
<div class="flex items-center justify-between mb-2">

0 commit comments

Comments
 (0)