diff --git a/api.go b/api.go index 253f81c..f204a64 100644 --- a/api.go +++ b/api.go @@ -820,6 +820,82 @@ func (app *appContext) Announce(gc *gin.Context) { respondBool(200, true, gc) } +// @Summary Save an announcement as a template for use or editing later. +// @Produce json +// @Param announcementTemplate body announcementTemplate true "Announcement request object" +// @Success 200 {object} boolResponse +// @Failure 500 {object} boolResponse +// @Router /users/announce/template [post] +// @Security Bearer +// @tags Users +func (app *appContext) SaveAnnounceTemplate(gc *gin.Context) { + var req announcementTemplate + gc.BindJSON(&req) + if !messagesEnabled { + respondBool(400, false, gc) + return + } + app.storage.announcements[req.Name] = req + if err := app.storage.storeAnnouncements(); err != nil { + respondBool(500, false, gc) + app.err.Printf("Failed to store announcement templates: %v", err) + return + } + respondBool(200, true, gc) +} + +// @Summary Save an announcement as a template for use or editing later. +// @Produce json +// @Success 200 {object} getAnnouncementsDTO +// @Router /users/announce/template [get] +// @Security Bearer +// @tags Users +func (app *appContext) GetAnnounceTemplates(gc *gin.Context) { + resp := &getAnnouncementsDTO{make([]string, len(app.storage.announcements))} + i := 0 + for name := range app.storage.announcements { + resp.Announcements[i] = name + i++ + } + gc.JSON(200, resp) +} + +// @Summary Get an announcement template. +// @Produce json +// @Success 200 {object} announcementTemplate +// @Failure 400 {object} boolResponse +// @Param name path string true "name of template" +// @Router /users/announce/template/{name} [get] +// @Security Bearer +// @tags Users +func (app *appContext) GetAnnounceTemplate(gc *gin.Context) { + name := gc.Param("name") + if announcement, ok := app.storage.announcements[name]; ok { + gc.JSON(200, announcement) + return + } + respondBool(400, false, gc) +} + +// @Summary Delete an announcement template. +// @Produce json +// @Success 200 {object} boolResponse +// @Failure 500 {object} boolResponse +// @Param name path string true "name of template" +// @Router /users/announce/template/{name} [delete] +// @Security Bearer +// @tags Users +func (app *appContext) DeleteAnnounceTemplate(gc *gin.Context) { + name := gc.Param("name") + delete(app.storage.announcements, name) + if err := app.storage.storeAnnouncements(); err != nil { + respondBool(500, false, gc) + app.err.Printf("Failed to store announcement templates: %v", err) + return + } + respondBool(200, false, gc) +} + // @Summary Create a new invite. // @Produce json // @Param generateInviteDTO body generateInviteDTO true "New invite request object" diff --git a/config.go b/config.go index feb8508..f1af63f 100644 --- a/config.go +++ b/config.go @@ -44,7 +44,7 @@ func (app *appContext) loadConfig() error { key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json")))) } } - for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_users", "matrix_users"} { + for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_users", "matrix_users", "announcements"} { app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".json")))) } app.URLBase = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/") diff --git a/config/config-base.json b/config/config-base.json index 184777e..58a2558 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -1331,6 +1331,14 @@ "type": "text", "value": "", "description": "Stores discord user IDs and language preferences." + }, + "announcements": { + "name": "Announcement templates", + "required": false, + "requires_restart": false, + "type": "text", + "value": "", + "description": "Stores custom announcement templates." } } } diff --git a/css/base.css b/css/base.css index 012c353..ccfefa4 100644 --- a/css/base.css +++ b/css/base.css @@ -134,6 +134,10 @@ div.card:contains(section.banner.footer) { width: 100%; } +.h-100 { + height: 100%; +} + .inline-block { display: inline-block; } diff --git a/html/admin.html b/html/admin.html index 1683da6..5b42891 100644 --- a/html/admin.html +++ b/html/admin.html @@ -163,15 +163,24 @@ ×
- - - - -

{{ .strings.markdownSupported }}

-
{{ .strings.preview }} @@ -542,7 +551,15 @@
{{ .quantityStrings.addUser.Singular }} - {{ .strings.announce }} + {{ .strings.modifySettings }} {{ .strings.extendExpiry }} {{ .strings.disable }} diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index 1c53ded..16bc3cd 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -48,6 +48,7 @@ "unknown": "Unknown", "label": "Label", "announce": "Announce", + "templates": "Templates", "subject": "Subject", "message": "Message", "variables": "Variables", @@ -100,7 +101,10 @@ "searchDiscordUser": "Start typing the Discord username to find the user.", "findDiscordUser": "Find Discord user", "linkMatrixDescription": "Enter the username and password of the user to use as a bot. Once submitted, the app will restart.", - "matrixHomeServer": "Home server address" + "matrixHomeServer": "Home server address", + "saveAsTemplate": "Save as template", + "deleteTemplate": "Delete template", + "templateEnterName": "Enter a name to save this template." }, "notifications": { "changedEmailAddress": "Changed email address of {n}.", @@ -109,6 +113,7 @@ "saveSettings": "Settings were saved", "saveEmail": "Email saved.", "sentAnnouncement": "Announcement sent.", + "savedAnnouncement": "Announcement saved.", "setOmbiDefaults": "Stored ombi defaults.", "updateApplied": "Update applied, please restart.", "updateAppliedRefresh": "Update applied, please refresh.", diff --git a/main.go b/main.go index 5162d43..736a660 100644 --- a/main.go +++ b/main.go @@ -359,6 +359,10 @@ func start(asDaemon, firstCall bool) { if err := app.storage.loadMatrixUsers(); err != nil { app.err.Printf("Failed to load Matrix users: %v", err) } + app.storage.announcements_path = app.config.Section("files").Key("announcements").String() + if err := app.storage.loadAnnouncements(); err != nil { + app.err.Printf("Failed to load announcement templates: %v", err) + } app.storage.profiles_path = app.config.Section("files").Key("user_profiles").String() app.storage.loadProfiles() diff --git a/models.go b/models.go index 2cca9b6..0329da7 100644 --- a/models.go +++ b/models.go @@ -172,6 +172,16 @@ type announcementDTO struct { Message string `json:"message"` // Email content (markdown supported) } +type announcementTemplate struct { + Name string `json:"name"` // Name of template + Subject string `json:"subject"` // Email subject + Message string `json:"message"` // Email content (markdown supported) +} + +type getAnnouncementsDTO struct { + Announcements []string `json:"announcements"` // list of announcement names. +} + type errorListDTO map[string]map[string]string type configDTO map[string]interface{} diff --git a/router.go b/router.go index 720cc58..9276746 100644 --- a/router.go +++ b/router.go @@ -163,6 +163,12 @@ func (app *appContext) loadRoutes(router *gin.Engine) { // api.POST(p + "/setDefaults", app.SetDefaults) api.POST(p+"/users/settings", app.ApplySettings) api.POST(p+"/users/announce", app.Announce) + + api.GET(p+"/users/announce", app.GetAnnounceTemplates) + api.POST(p+"/users/announce/template", app.SaveAnnounceTemplate) + api.GET(p+"/users/announce/:name", app.GetAnnounceTemplate) + api.DELETE(p+"/users/announce/:name", app.DeleteAnnounceTemplate) + api.GET(p+"/config/update", app.CheckUpdate) api.POST(p+"/config/update", app.ApplyUpdate) api.GET(p+"/config/emails", app.GetCustomEmails) diff --git a/storage.go b/storage.go index f7d8e72..9c3b777 100644 --- a/storage.go +++ b/storage.go @@ -15,22 +15,23 @@ import ( ) type Storage struct { - timePattern string - invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path, customEmails_path, users_path, telegram_path, discord_path, matrix_path string - users map[string]time.Time - invites Invites - profiles map[string]Profile - defaultProfile string - displayprefs, ombi_template map[string]interface{} - emails map[string]EmailAddress - telegram map[string]TelegramUser // Map of Jellyfin User IDs to telegram users. - discord map[string]DiscordUser // Map of Jellyfin user IDs to discord users. - matrix map[string]MatrixUser // Map of Jellyfin user IDs to Matrix users. - customEmails customEmails - policy mediabrowser.Policy - configuration mediabrowser.Configuration - lang Lang - invitesLock, usersLock sync.Mutex + timePattern string + invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path, customEmails_path, users_path, telegram_path, discord_path, matrix_path, announcements_path string + users map[string]time.Time + invites Invites + profiles map[string]Profile + defaultProfile string + displayprefs, ombi_template map[string]interface{} + emails map[string]EmailAddress + telegram map[string]TelegramUser // Map of Jellyfin User IDs to telegram users. + discord map[string]DiscordUser // Map of Jellyfin user IDs to discord users. + matrix map[string]MatrixUser // Map of Jellyfin user IDs to Matrix users. + customEmails customEmails + policy mediabrowser.Policy + configuration mediabrowser.Configuration + lang Lang + announcements map[string]announcementTemplate + invitesLock, usersLock sync.Mutex } type TelegramUser struct { @@ -839,6 +840,14 @@ func (st *Storage) storeOmbiTemplate() error { return storeJSON(st.ombi_path, st.ombi_template) } +func (st *Storage) loadAnnouncements() error { + return loadJSON(st.announcements_path, &st.announcements) +} + +func (st *Storage) storeAnnouncements() error { + return storeJSON(st.announcements_path, st.announcements) +} + func (st *Storage) loadProfiles() error { err := loadJSON(st.profiles_path, &st.profiles) for name, profile := range st.profiles { diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts index 17d8e06..dec6c15 100644 --- a/ts/modules/accounts.ts +++ b/ts/modules/accounts.ts @@ -26,7 +26,13 @@ interface getPinResponse { token: string; username: string; } - + +interface announcementTemplate { + name: string; + subject: string; + message: string; +} + var addDiscord: (passData: string) => void; class user implements User { @@ -547,6 +553,8 @@ export class accountsList { private _addUserButton = document.getElementById("accounts-add-user") as HTMLSpanElement; private _announceButton = document.getElementById("accounts-announce") as HTMLSpanElement; + private _announceSaveButton = document.getElementById("save-announce") as HTMLSpanElement; + private _announceNameLabel = document.getElementById("announce-name") as HTMLLabelElement; private _announcePreview: HTMLElement; private _previewLoaded = false; private _announceTextarea = document.getElementById("textarea-announce") as HTMLTextAreaElement; @@ -799,16 +807,60 @@ export class accountsList { this._announcePreview.innerHTML = content; } } - announce = () => { + saveAnnouncement = (event: Event) => { + event.preventDefault(); + const form = document.getElementById("form-announce") as HTMLFormElement; + const button = form.querySelector("span.submit") as HTMLSpanElement; + if (this._announceNameLabel.classList.contains("unfocused")) { + this._announceNameLabel.classList.remove("unfocused"); + form.onsubmit = this.saveAnnouncement; + button.textContent = window.lang.get("strings", "saveAsTemplate"); + this._announceSaveButton.classList.add("unfocused"); + const details = document.getElementById("announce-details"); + details.classList.add("unfocused"); + return; + } + const name = (this._announceNameLabel.querySelector("input") as HTMLInputElement).value; + if (!name) { return; } + const subject = document.getElementById("announce-subject") as HTMLInputElement; + let send: announcementTemplate = { + name: name, + subject: subject.value, + message: this._announceTextarea.value + } + _post("/users/announce/template", send, (req: XMLHttpRequest) => { + if (req.readyState == 4) { + this.reload(); + toggleLoader(button); + window.modals.announce.close(); + if (req.status != 200 && req.status != 204) { + window.notifications.customError("announcementError", window.lang.notif("errorFailureCheckLogs")); + } else { + window.notifications.customSuccess("announcementSuccess", window.lang.notif("savedAnnouncement")); + } + } + }); + } + announce = (event?: Event, template?: announcementTemplate) => { const modalHeader = document.getElementById("header-announce"); modalHeader.textContent = window.lang.quantity("announceTo", this._collectUsers().length); const form = document.getElementById("form-announce") as HTMLFormElement; let list = this._collectUsers(); const button = form.querySelector("span.submit") as HTMLSpanElement; + removeLoader(button); + button.textContent = window.lang.get("strings", "send"); + const details = document.getElementById("announce-details"); + details.classList.remove("unfocused"); + this._announceSaveButton.classList.remove("unfocused"); const subject = document.getElementById("announce-subject") as HTMLInputElement; - - subject.value = ""; - this._announceTextarea.value = ""; + this._announceNameLabel.classList.add("unfocused"); + if (template) { + subject.value = template.subject; + this._announceTextarea.value = template.message; + } else { + subject.value = ""; + this._announceTextarea.value = ""; + } form.onsubmit = (event: Event) => { event.preventDefault(); toggleLoader(button); @@ -853,7 +905,53 @@ export class accountsList { } }); } - + loadTemplates = () => _get("/users/announce", null, (req: XMLHttpRequest) => { + if (req.readyState == 4) { + if (req.status != 200) { + this._announceButton.nextElementSibling.children[0].classList.add("unfocused"); + return; + } + this._announceButton.nextElementSibling.children[0].classList.remove("unfocused"); + const list = req.response["announcements"] as string[]; + if (list.length == 0) { + this._announceButton.nextElementSibling.children[0].classList.add("unfocused"); + return; + } + const dList = document.getElementById("accounts-announce-templates") as HTMLDivElement; + dList.textContent = ''; + for (let name of list) { + const el = document.createElement("div") as HTMLDivElement; + el.classList.add("flex-expand", "ellipsis", "mt-half"); + el.innerHTML = ` + ${name}× + `; + (el.querySelector("span.accounts-announce-template-button") as HTMLSpanElement).onclick = () => { + _get("/users/announce/" + name, null, (req: XMLHttpRequest) => { + if (req.readyState == 4) { + let template: announcementTemplate; + if (req.status != 200) { + window.notifications.customError("getTemplateError", window.lang.notif("errorFailureCheckLogs")); + } else { + template = req.response; + } + this.announce(null, template); + } + }); + }; + (el.querySelector("span.accounts-announce-template-delete") as HTMLSpanElement).onclick = () => { + _delete("/users/announce/" + name, null, (req: XMLHttpRequest) => { + if (req.readyState == 4) { + if (req.status != 200) { + window.notifications.customError("deleteTemplateError", window.lang.notif("errorFailureCheckLogs")); + } + this.reload(); + } + }); + }; + dList.appendChild(el); + } + } + }); enableDisableUsers = () => { // We can share the delete modal for this const modalHeader = document.getElementById("header-delete-user"); @@ -1154,26 +1252,31 @@ export class accountsList { } }); }); + + this._announceSaveButton.onclick = this.saveAnnouncement; } - reload = () => _get("/users", null, (req: XMLHttpRequest) => { - if (req.readyState == 4 && req.status == 200) { - // same method as inviteList.reload() - let accountsOnDOM: { [id: string]: boolean } = {}; - for (let id in this._users) { accountsOnDOM[id] = true; } - for (let u of (req.response["users"] as User[])) { - if (u.id in this._users) { - this._users[u.id].update(u); - delete accountsOnDOM[u.id]; - } else { - this.add(u); + reload = () => { + _get("/users", null, (req: XMLHttpRequest) => { + if (req.readyState == 4 && req.status == 200) { + // same method as inviteList.reload() + let accountsOnDOM: { [id: string]: boolean } = {}; + for (let id in this._users) { accountsOnDOM[id] = true; } + for (let u of (req.response["users"] as User[])) { + if (u.id in this._users) { + this._users[u.id].update(u); + delete accountsOnDOM[u.id]; + } else { + this.add(u); + } } + for (let id in accountsOnDOM) { + this._users[id].remove(); + delete this._users[id]; + } + this._checkCheckCount(); } - for (let id in accountsOnDOM) { - this._users[id].remove(); - delete this._users[id]; - } - this._checkCheckCount(); - } - }) + }); + this.loadTemplates(); + } }