Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions internal/app/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
85 changes: 71 additions & 14 deletions internal/plugins/campaigns/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions internal/plugins/campaigns/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
13 changes: 0 additions & 13 deletions internal/plugins/campaigns/settings.templ
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
<div class="space-y-6">
// Quick links.
<div class="flex flex-wrap gap-3">
<a href={ templ.SafeURL(fmt.Sprintf("/campaigns/%s/customize", cc.Campaign.ID)) } class="btn-secondary text-sm inline-flex items-center gap-1.5">
<i class="fa-solid fa-paintbrush text-xs"></i> Customization Hub
</a>
<a href={ templ.SafeURL(fmt.Sprintf("/campaigns/%s/plugins", cc.Campaign.ID)) } class="btn-secondary text-sm inline-flex items-center gap-1.5">
<i class="fa-solid fa-puzzle-piece text-xs"></i> Feature Hub
</a>
<a href={ templ.SafeURL(fmt.Sprintf("/campaigns/%s/api-keys", cc.Campaign.ID)) } class="btn-secondary text-sm inline-flex items-center gap-1.5">
<i class="fa-solid fa-key text-xs"></i> API Keys
</a>
</div>

// Foundry VTT.
if baseURL != "" {
<div class="card p-6">
Expand Down
15 changes: 15 additions & 0 deletions internal/plugins/packages/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) {
Expand Down Expand Up @@ -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
}

Expand Down
22 changes: 0 additions & 22 deletions internal/templates/layouts/app.templ
Original file line number Diff line number Diff line change
Expand Up @@ -423,29 +423,7 @@ templ Sidebar() {
</svg>
Members
</a>
<a
href={ templ.SafeURL(fmt.Sprintf("/campaigns/%s/plugins", GetCampaignID(ctx))) }
class={ sidebarNavLink,
templ.KV(sidebarNavActive, isPathPrefix(ctx, fmt.Sprintf("/campaigns/%s/plugins", GetCampaignID(ctx)))),
templ.KV(sidebarNavInactive, !isPathPrefix(ctx, fmt.Sprintf("/campaigns/%s/plugins", GetCampaignID(ctx)))) }
>
<span class="w-4 h-4 mr-3 shrink-0 flex items-center justify-center">
<i class="fa-solid fa-puzzle-piece text-xs"></i>
</span>
Features
</a>
if GetCampaignRole(ctx) >= 3 {
<a
href={ templ.SafeURL(fmt.Sprintf("/campaigns/%s/activity", GetCampaignID(ctx))) }
class={ sidebarNavLink,
templ.KV(sidebarNavActive, isPathPrefix(ctx, fmt.Sprintf("/campaigns/%s/activity", GetCampaignID(ctx)))),
templ.KV(sidebarNavInactive, !isPathPrefix(ctx, fmt.Sprintf("/campaigns/%s/activity", GetCampaignID(ctx)))) }
>
<svg class="w-4 h-4 mr-3 shrink-0" width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"></path>
</svg>
Activity Log
</a>
if IsAddonEnabled(ctx, "media-gallery") {
<a
href={ templ.SafeURL(fmt.Sprintf("/campaigns/%s/media", GetCampaignID(ctx))) }
Expand Down
77 changes: 76 additions & 1 deletion static/js/widgets/appearance_editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
brandName: config.brandName || '',
accentColor: config.accentColor || '',
fontFamily: config.fontFamily || '',
topbarStyle: { mode: '', color: '', gradient_from: '', gradient_to: '', gradient_dir: 'to-r' },
topbarStyle: { mode: '', color: '', gradient_from: '', gradient_to: '', gradient_dir: 'to-r', image_path: '' },
topbarContent: { mode: 'none', links: [], quote: '' }
};

Expand Down Expand Up @@ -91,6 +91,75 @@
var modeContainer = el.querySelector('#appearance-topbar-mode');
var solidPanel = el.querySelector('#appearance-topbar-solid');
var gradientPanel = el.querySelector('#appearance-topbar-gradient');
var imagePanel = el.querySelector('#appearance-topbar-image');

// Dynamically add "Image" button and upload panel if not in template.
if (modeContainer && !modeContainer.querySelector('[data-mode="image"]')) {
var imgBtn = document.createElement('button');
imgBtn.type = 'button';
imgBtn.className = 'btn-secondary text-xs px-3 py-1.5';
imgBtn.setAttribute('data-mode', 'image');
imgBtn.textContent = 'Image';
modeContainer.appendChild(imgBtn);
}
if (!imagePanel && modeContainer) {
imagePanel = document.createElement('div');
imagePanel.id = 'appearance-topbar-image';
imagePanel.className = 'hidden space-y-2';
imagePanel.innerHTML =
'<p class="text-xs text-fg-secondary">Upload a background image for the topbar.</p>' +
'<div class="flex items-center gap-3">' +
' <label class="btn-secondary text-xs cursor-pointer">' +
' <i class="fa-solid fa-upload mr-1.5"></i>Choose Image' +
' <input type="file" accept="image/*" class="hidden" id="topbar-image-input"/>' +
' </label>' +
' <button type="button" id="topbar-image-remove" class="text-xs text-fg-muted hover:text-rose-400 transition-colors">' +
' <i class="fa-solid fa-trash mr-1"></i>Remove' +
' </button>' +
'</div>';
// 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');
Expand Down Expand Up @@ -609,6 +678,9 @@
if (gradientPanel) {
gradientPanel.classList.toggle('hidden', mode !== 'gradient');
}
if (imagePanel) {
imagePanel.classList.toggle('hidden', mode !== 'image');
}
}

/**
Expand All @@ -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 = '';
Expand Down
9 changes: 8 additions & 1 deletion static/js/widgets/template_editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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().
Expand Down
Loading