diff --git a/internal/app/routes.go b/internal/app/routes.go index 06b9b523..de77de60 100644 --- a/internal/app/routes.go +++ b/internal/app/routes.go @@ -1192,6 +1192,11 @@ func (a *App) RegisterRoutes() { pkgRepo := packages.NewPackageRepository(a.DB) pkgGitHub := packages.NewGitHubClient() pkgService := packages.NewPackageService(pkgRepo, pkgGitHub, a.Config.Upload.MediaPath) + // Rescan system registry when a new system package is installed so it + // appears in the campaign Settings > Game System dropdown immediately. + packages.SetOnSystemInstall(pkgService, func() { + systems.ScanPackageDir(filepath.Join(a.Config.Upload.MediaPath, "packages", "systems")) + }) packages.ConfigureSettings(pkgService, settingsRepo) pkgHandler := packages.NewHandler(pkgService) pkgOwnerHandler := packages.NewOwnerHandler(pkgService) diff --git a/internal/plugins/campaigns/handler.go b/internal/plugins/campaigns/handler.go index 1014da17..a9e2d3b0 100644 --- a/internal/plugins/campaigns/handler.go +++ b/internal/plugins/campaigns/handler.go @@ -465,6 +465,75 @@ func (h *Handler) RemoveBackdrop(c echo.Context) error { return c.Redirect(http.StatusSeeOther, "/campaigns/"+cc.Campaign.ID+"/settings") } +// UploadTopbarImage handles POST /campaigns/:id/topbar-image. Accepts an image +// file, stores it via the media service, and sets the topbar style to image mode. +func (h *Handler) UploadTopbarImage(c echo.Context) error { + cc := GetCampaignContext(c) + if cc == nil { + return apperror.NewMissingContext() + } + if cc.MemberRole < RoleOwner { + return apperror.NewForbidden("only campaign owners can change the topbar") + } + if h.mediaUploader == nil { + return apperror.NewInternal(nil) + } + + file, err := c.FormFile("file") + if err != nil { + return apperror.NewBadRequest("no file provided") + } + src, err := file.Open() + if err != nil { + return apperror.NewInternal(err) + } + defer func() { _ = src.Close() }() + + fileBytes, err := io.ReadAll(src) + if err != nil { + return apperror.NewBadRequest("failed to read file") + } + mimeType := http.DetectContentType(fileBytes) + + // Reuse backdrop upload logic for media storage. + filename, err := h.mediaUploader.UploadBackdrop( + c.Request().Context(), cc.Campaign.ID, + auth.GetUserID(c), fileBytes, file.Filename, mimeType, + ) + if err != nil { + return err + } + + // Update topbar style to image mode with the uploaded file. + style := TopbarStyle{Mode: "image", ImagePath: filename} + if err := h.service.UpdateTopbarStyle(c.Request().Context(), cc.Campaign.ID, &style); err != nil { + return err + } + + h.logAudit(c, cc.Campaign.ID, "campaign.topbar_image.uploaded", nil) + return c.JSON(http.StatusOK, map[string]string{"status": "ok", "image_path": filename}) +} + +// RemoveTopbarImage handles DELETE /campaigns/:id/topbar-image. Resets topbar +// style to default (no image). +func (h *Handler) RemoveTopbarImage(c echo.Context) error { + cc := GetCampaignContext(c) + if cc == nil { + return apperror.NewMissingContext() + } + if cc.MemberRole < RoleOwner { + return apperror.NewForbidden("only campaign owners can change the topbar") + } + + style := TopbarStyle{Mode: ""} + if err := h.service.UpdateTopbarStyle(c.Request().Context(), cc.Campaign.ID, &style); err != nil { + return err + } + + h.logAudit(c, cc.Campaign.ID, "campaign.topbar_image.removed", nil) + return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) +} + // UpdateAccentColorAPI handles PUT /campaigns/:id/accent-color. Sets the // campaign's accent color for branding customization. func (h *Handler) UpdateAccentColorAPI(c echo.Context) error { @@ -775,25 +844,13 @@ func (h *Handler) Settings(c echo.Context) error { // PluginHub renders the campaign plugin hub page, showing all enabled // addons with quick links to their main pages. -// GET /campaigns/:id/plugins +// GET /campaigns/:id/plugins — redirects to Settings > Features tab. func (h *Handler) PluginHub(c echo.Context) error { cc := GetCampaignContext(c) if cc == nil { return apperror.NewMissingContext() } - - var addons []PluginHubAddon - if h.addonLister != nil { - var err error - addons, err = h.addonLister.ListForPluginHub(c.Request().Context(), cc.Campaign.ID) - if err != nil { - slog.Warn("plugin hub: list addons failed", slog.Any("error", err)) - } - } - - isOwner := cc.MemberRole >= RoleOwner - csrfToken := middleware.GetCSRFToken(c) - return middleware.Render(c, http.StatusOK, PluginHubPage(cc, addons, isOwner, csrfToken)) + return c.Redirect(http.StatusSeeOther, "/campaigns/"+cc.Campaign.ID+"/settings?tab=features") } // PluginHubFragment returns the plugin hub list content as an HTMX fragment. diff --git a/internal/plugins/campaigns/routes.go b/internal/plugins/campaigns/routes.go index fb4d6f96..3edf7cd5 100644 --- a/internal/plugins/campaigns/routes.go +++ b/internal/plugins/campaigns/routes.go @@ -73,6 +73,8 @@ func RegisterRoutes(e *echo.Echo, h *Handler, svc CampaignService, authSvc auth. cg.PUT("/accent-color", h.UpdateAccentColorAPI, RequireRole(RoleOwner)) cg.PUT("/branding", h.UpdateBrandingAPI, RequireRole(RoleOwner)) cg.PUT("/topbar-style", h.UpdateTopbarStyleAPI, RequireRole(RoleOwner)) + cg.POST("/topbar-image", h.UploadTopbarImage, RequireRole(RoleOwner)) + cg.DELETE("/topbar-image", h.RemoveTopbarImage, RequireRole(RoleOwner)) cg.PUT("/topbar-content", h.UpdateTopbarContentAPI, RequireRole(RoleOwner)) cg.PUT("/font-family", h.UpdateFontFamilyAPI, RequireRole(RoleOwner)) cg.PUT("/welcome-message", h.UpdateWelcomeMessageAPI, RequireRole(RoleOwner)) diff --git a/internal/plugins/campaigns/settings.templ b/internal/plugins/campaigns/settings.templ index a483fee9..e618b4b4 100644 --- a/internal/plugins/campaigns/settings.templ +++ b/internal/plugins/campaigns/settings.templ @@ -667,19 +667,6 @@ templ settingsPeopleTab(cc *CampaignContext, members []CampaignMember, transfer // settingsIntegrationsTab renders Foundry VTT, API keys, export/import, and quick links. templ settingsIntegrationsTab(cc *CampaignContext, csrfToken, baseURL string) {
- // Quick links. -
- - Customization Hub - - - Feature Hub - - - API Keys - -
- // Foundry VTT. if baseURL != "" {
diff --git a/internal/plugins/packages/service.go b/internal/plugins/packages/service.go index c50cd72c..7ed229ed 100644 --- a/internal/plugins/packages/service.go +++ b/internal/plugins/packages/service.go @@ -112,6 +112,7 @@ type packageService struct { settings SettingsReader settingsWriter SettingsWriter mediaDir string // Root media directory (e.g., ./media). + onSystemInstall func() // Called after a system package is installed. } // NewPackageService creates a new package service with the given dependencies. @@ -123,6 +124,14 @@ func NewPackageService(repo PackageRepository, github *GitHubClient, mediaDir st } } +// SetOnSystemInstall wires a callback invoked after a system package is installed. +// Used to rescan the system registry so newly installed systems appear immediately. +func SetOnSystemInstall(svc PackageService, fn func()) { + if s, ok := svc.(*packageService); ok { + s.onSystemInstall = fn + } +} + // ConfigureSettings wires settings reader/writer into the package service. // Called from routes.go after both services are initialized. func ConfigureSettings(svc PackageService, settings SettingsReader) { @@ -396,6 +405,12 @@ func (s *packageService) InstallVersion(ctx context.Context, packageID, version slog.String("version", version), slog.String("path", destDir), ) + + // Notify system registry to rescan after a system package install. + if pkg.Type != PackageTypeFoundryModule && s.onSystemInstall != nil { + s.onSystemInstall() + } + return nil } diff --git a/internal/templates/layouts/app.templ b/internal/templates/layouts/app.templ index 2bf840b2..d7dfaa50 100644 --- a/internal/templates/layouts/app.templ +++ b/internal/templates/layouts/app.templ @@ -423,29 +423,7 @@ templ Sidebar() { Members - - - - - Features - if GetCampaignRole(ctx) >= 3 { - - - - - Activity Log - if IsAddonEnabled(ctx, "media-gallery") { Upload a background image for the topbar.

' + + '
' + + ' ' + + ' ' + + '
'; + // Insert after gradient panel. + if (gradientPanel) { + gradientPanel.parentNode.insertBefore(imagePanel, gradientPanel.nextSibling); + } + + // Wire upload handler. + var imgInput = imagePanel.querySelector('#topbar-image-input'); + if (imgInput) { + imgInput.addEventListener('change', function () { + var file = imgInput.files[0]; + if (!file) return; + var form = new FormData(); + form.append('file', file); + fetch('/campaigns/' + config.campaignId + '/topbar-image', { + method: 'POST', body: form, credentials: 'same-origin', + headers: { 'X-CSRF-Token': Chronicle.getCsrf() } + }).then(function (res) { + if (res.ok) { + Chronicle.notify('Topbar image uploaded — reloading...', 'success'); + setTimeout(function () { window.location.reload(); }, 600); + } else { + Chronicle.notify('Failed to upload image', 'error'); + } + }); + }); + } + var imgRemove = imagePanel.querySelector('#topbar-image-remove'); + if (imgRemove) { + imgRemove.addEventListener('click', function () { + Chronicle.apiFetch('/campaigns/' + config.campaignId + '/topbar-image', { method: 'DELETE' }) + .then(function (res) { + if (res.ok) { + draft.topbarStyle.mode = ''; + draft.topbarStyle.image_path = ''; + setActiveMode(''); + updateTopbarPreview(); + Chronicle.notify('Topbar image removed', 'success'); + setTimeout(function () { window.location.reload(); }, 600); + } + }); + }); + } + } var solidColorInput = el.querySelector('#appearance-topbar-color'); var gradFromInput = el.querySelector('#appearance-topbar-gradient-from'); var gradToInput = el.querySelector('#appearance-topbar-gradient-to'); @@ -609,6 +678,9 @@ if (gradientPanel) { gradientPanel.classList.toggle('hidden', mode !== 'gradient'); } + if (imagePanel) { + imagePanel.classList.toggle('hidden', mode !== 'image'); + } } /** @@ -626,6 +698,9 @@ var dir = GRADIENT_DIR_CSS[draft.topbarStyle.gradient_dir] || 'to right'; previewTopbar.style.background = 'linear-gradient(' + dir + ', ' + draft.topbarStyle.gradient_from + ', ' + draft.topbarStyle.gradient_to + ')'; previewTopbar.style.color = isLightColor(draft.topbarStyle.gradient_from) ? '' : '#f9fafb'; + } else if (mode === 'image' && draft.topbarStyle.image_path) { + previewTopbar.style.background = 'url(/media/' + draft.topbarStyle.image_path + ') center/cover no-repeat'; + previewTopbar.style.color = '#f9fafb'; } else { previewTopbar.style.background = ''; previewTopbar.style.color = ''; diff --git a/static/js/widgets/template_editor.js b/static/js/widgets/template_editor.js index 31b76585..c05710e9 100644 --- a/static/js/widgets/template_editor.js +++ b/static/js/widgets/template_editor.js @@ -15,6 +15,7 @@ */ Chronicle.register('template-editor', { init(el) { + console.log('[template-editor] init called, endpoint:', el.dataset.endpoint); this.el = el; this.endpoint = el.dataset.endpoint; this.campaignId = el.dataset.campaignId; @@ -1516,8 +1517,14 @@ Chronicle.register('template-editor', { bindSave() { const btn = this.findSaveBtn(); + console.log('[template-editor] bindSave: btn found =', !!btn, btn); if (btn) { - btn.addEventListener('click', () => this.save()); + btn.addEventListener('click', () => { + console.log('[template-editor] Save button clicked'); + this.save(); + }); + } else { + console.warn('[template-editor] Save button #te-save-btn NOT FOUND — save will not work'); } // Ctrl+S / Cmd+S shortcut. Store reference for cleanup in destroy().