1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-12-22 09:00:10 +00:00

accounts: add templates for announcements

you can now save announcements as templates, and then use them later by
hovering over the "Announce" button, as well as delete them.
This commit is contained in:
Harvey Tindall 2021-07-10 16:43:27 +01:00
parent 35f0fead53
commit 3e55cd1e31
Signed by: hrfee
GPG Key ID: BBC65952848FB1A2
11 changed files with 293 additions and 51 deletions

76
api.go
View File

@ -820,6 +820,82 @@ func (app *appContext) Announce(gc *gin.Context) {
respondBool(200, true, gc) 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. // @Summary Create a new invite.
// @Produce json // @Produce json
// @Param generateInviteDTO body generateInviteDTO true "New invite request object" // @Param generateInviteDTO body generateInviteDTO true "New invite request object"

View File

@ -44,7 +44,7 @@ func (app *appContext) loadConfig() error {
key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json")))) 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.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(""), "/") app.URLBase = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/")

View File

@ -1331,6 +1331,14 @@
"type": "text", "type": "text",
"value": "", "value": "",
"description": "Stores discord user IDs and language preferences." "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."
} }
} }
} }

View File

@ -134,6 +134,10 @@ div.card:contains(section.banner.footer) {
width: 100%; width: 100%;
} }
.h-100 {
height: 100%;
}
.inline-block { .inline-block {
display: inline-block; display: inline-block;
} }

View File

@ -163,15 +163,24 @@
<span class="heading"><span id="header-announce"></span> <span class="modal-close">&times;</span></span> <span class="heading"><span id="header-announce"></span> <span class="modal-close">&times;</span></span>
<div class="row"> <div class="row">
<div class="col flex-col content mt-half"> <div class="col flex-col content mt-half">
<label class="label supra" for="announce-subject"> {{ .strings.subject }}</label> <div id="announce-details">
<input type="text" id="announce-subject" class="input ~neutral !normal mb-1 mt-half"> <label class="label supra" for="announce-subject"> {{ .strings.subject }}</label>
<label class="label supra" for="textarea-announce">{{ .strings.message }}</label> <input type="text" id="announce-subject" class="input ~neutral !normal mb-1 mt-half">
<textarea id="textarea-announce" class="textarea full-width ~neutral !normal mt-half monospace"></textarea> <label class="label supra" for="textarea-announce">{{ .strings.message }}</label>
<p class="support mt-half mb-1">{{ .strings.markdownSupported }}</p> <textarea id="textarea-announce" class="textarea full-width ~neutral !normal mt-half monospace"></textarea>
<label> <p class="support mt-half mb-1">{{ .strings.markdownSupported }}</p>
<input type="submit" class="unfocused"> </div>
<span class="button ~urge !normal full-width center supra submit">{{ .strings.submit }}</span> <label class="label unfocused" id="announce-name"><p class="supra">{{ .strings.name }}</p>
<input type="text" class="input ~neutral !normal mb-1 mt-half">
<p class="support">{{ .strings.templateEnterName }}</p>
</label> </label>
<div class="row flex-expand">
<label>
<input type="submit" class="unfocused">
<span class="button ~urge !normal center supra submit">{{ .strings.send }}</span>
</label>
<span class="button ~info !normal center supra" id="save-announce">{{ .strings.saveAsTemplate }}</span>
</div>
</div> </div>
<div class="col card ~neutral !low"> <div class="col card ~neutral !low">
<span class="subheading supra">{{ .strings.preview }}</span> <span class="subheading supra">{{ .strings.preview }}</span>
@ -542,7 +551,15 @@
</div> </div>
<div class="row"> <div class="row">
<span class="col sm button ~neutral !normal center mb-half" id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span> <span class="col sm button ~neutral !normal center mb-half" id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
<span class="col sm button ~info !normal center mb-half" id="accounts-announce">{{ .strings.announce }}</span> <div id="accounts-announce-dropdown" class="col sm dropdown" tabindex="0">
<span class="h-100 sm button ~info !normal center mb-half" id="accounts-announce">{{ .strings.announce }}</span>
<div class="dropdown-display">
<div class="card ~neutral !low">
<span class="supra sm">{{ .strings.templates }}</span>
<div id="accounts-announce-templates"></div>
</div>
</div>
</div>
<span class="col sm button ~urge !normal center mb-half" id="accounts-modify-user">{{ .strings.modifySettings }}</span> <span class="col sm button ~urge !normal center mb-half" id="accounts-modify-user">{{ .strings.modifySettings }}</span>
<span class="col sm button ~warning !normal center mb-half" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span> <span class="col sm button ~warning !normal center mb-half" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span>
<span class="col sm button ~positive !normal center mb-half" id="accounts-disable-enable">{{ .strings.disable }}</span> <span class="col sm button ~positive !normal center mb-half" id="accounts-disable-enable">{{ .strings.disable }}</span>

View File

@ -48,6 +48,7 @@
"unknown": "Unknown", "unknown": "Unknown",
"label": "Label", "label": "Label",
"announce": "Announce", "announce": "Announce",
"templates": "Templates",
"subject": "Subject", "subject": "Subject",
"message": "Message", "message": "Message",
"variables": "Variables", "variables": "Variables",
@ -100,7 +101,10 @@
"searchDiscordUser": "Start typing the Discord username to find the user.", "searchDiscordUser": "Start typing the Discord username to find the user.",
"findDiscordUser": "Find Discord 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.", "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": { "notifications": {
"changedEmailAddress": "Changed email address of {n}.", "changedEmailAddress": "Changed email address of {n}.",
@ -109,6 +113,7 @@
"saveSettings": "Settings were saved", "saveSettings": "Settings were saved",
"saveEmail": "Email saved.", "saveEmail": "Email saved.",
"sentAnnouncement": "Announcement sent.", "sentAnnouncement": "Announcement sent.",
"savedAnnouncement": "Announcement saved.",
"setOmbiDefaults": "Stored ombi defaults.", "setOmbiDefaults": "Stored ombi defaults.",
"updateApplied": "Update applied, please restart.", "updateApplied": "Update applied, please restart.",
"updateAppliedRefresh": "Update applied, please refresh.", "updateAppliedRefresh": "Update applied, please refresh.",

View File

@ -359,6 +359,10 @@ func start(asDaemon, firstCall bool) {
if err := app.storage.loadMatrixUsers(); err != nil { if err := app.storage.loadMatrixUsers(); err != nil {
app.err.Printf("Failed to load Matrix users: %v", err) 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.profiles_path = app.config.Section("files").Key("user_profiles").String()
app.storage.loadProfiles() app.storage.loadProfiles()

View File

@ -172,6 +172,16 @@ type announcementDTO struct {
Message string `json:"message"` // Email content (markdown supported) 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 errorListDTO map[string]map[string]string
type configDTO map[string]interface{} type configDTO map[string]interface{}

View File

@ -163,6 +163,12 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
// api.POST(p + "/setDefaults", app.SetDefaults) // api.POST(p + "/setDefaults", app.SetDefaults)
api.POST(p+"/users/settings", app.ApplySettings) api.POST(p+"/users/settings", app.ApplySettings)
api.POST(p+"/users/announce", app.Announce) 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.GET(p+"/config/update", app.CheckUpdate)
api.POST(p+"/config/update", app.ApplyUpdate) api.POST(p+"/config/update", app.ApplyUpdate)
api.GET(p+"/config/emails", app.GetCustomEmails) api.GET(p+"/config/emails", app.GetCustomEmails)

View File

@ -15,22 +15,23 @@ import (
) )
type Storage struct { type Storage struct {
timePattern string 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 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 users map[string]time.Time
invites Invites invites Invites
profiles map[string]Profile profiles map[string]Profile
defaultProfile string defaultProfile string
displayprefs, ombi_template map[string]interface{} displayprefs, ombi_template map[string]interface{}
emails map[string]EmailAddress emails map[string]EmailAddress
telegram map[string]TelegramUser // Map of Jellyfin User IDs to telegram users. telegram map[string]TelegramUser // Map of Jellyfin User IDs to telegram users.
discord map[string]DiscordUser // Map of Jellyfin user IDs to discord 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. matrix map[string]MatrixUser // Map of Jellyfin user IDs to Matrix users.
customEmails customEmails customEmails customEmails
policy mediabrowser.Policy policy mediabrowser.Policy
configuration mediabrowser.Configuration configuration mediabrowser.Configuration
lang Lang lang Lang
invitesLock, usersLock sync.Mutex announcements map[string]announcementTemplate
invitesLock, usersLock sync.Mutex
} }
type TelegramUser struct { type TelegramUser struct {
@ -839,6 +840,14 @@ func (st *Storage) storeOmbiTemplate() error {
return storeJSON(st.ombi_path, st.ombi_template) 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 { func (st *Storage) loadProfiles() error {
err := loadJSON(st.profiles_path, &st.profiles) err := loadJSON(st.profiles_path, &st.profiles)
for name, profile := range st.profiles { for name, profile := range st.profiles {

View File

@ -26,7 +26,13 @@ interface getPinResponse {
token: string; token: string;
username: string; username: string;
} }
interface announcementTemplate {
name: string;
subject: string;
message: string;
}
var addDiscord: (passData: string) => void; var addDiscord: (passData: string) => void;
class user implements User { class user implements User {
@ -547,6 +553,8 @@ export class accountsList {
private _addUserButton = document.getElementById("accounts-add-user") as HTMLSpanElement; private _addUserButton = document.getElementById("accounts-add-user") as HTMLSpanElement;
private _announceButton = document.getElementById("accounts-announce") 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 _announcePreview: HTMLElement;
private _previewLoaded = false; private _previewLoaded = false;
private _announceTextarea = document.getElementById("textarea-announce") as HTMLTextAreaElement; private _announceTextarea = document.getElementById("textarea-announce") as HTMLTextAreaElement;
@ -799,16 +807,60 @@ export class accountsList {
this._announcePreview.innerHTML = content; 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"); const modalHeader = document.getElementById("header-announce");
modalHeader.textContent = window.lang.quantity("announceTo", this._collectUsers().length); modalHeader.textContent = window.lang.quantity("announceTo", this._collectUsers().length);
const form = document.getElementById("form-announce") as HTMLFormElement; const form = document.getElementById("form-announce") as HTMLFormElement;
let list = this._collectUsers(); let list = this._collectUsers();
const button = form.querySelector("span.submit") as HTMLSpanElement; 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; const subject = document.getElementById("announce-subject") as HTMLInputElement;
this._announceNameLabel.classList.add("unfocused");
subject.value = ""; if (template) {
this._announceTextarea.value = ""; subject.value = template.subject;
this._announceTextarea.value = template.message;
} else {
subject.value = "";
this._announceTextarea.value = "";
}
form.onsubmit = (event: Event) => { form.onsubmit = (event: Event) => {
event.preventDefault(); event.preventDefault();
toggleLoader(button); 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 = `
<span class="button ~neutral sm full-width accounts-announce-template-button">${name}</span><span class="button ~critical fr ml-1 accounts-announce-template-delete">&times;</span>
`;
(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 = () => { enableDisableUsers = () => {
// We can share the delete modal for this // We can share the delete modal for this
const modalHeader = document.getElementById("header-delete-user"); 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) => { reload = () => {
if (req.readyState == 4 && req.status == 200) { _get("/users", null, (req: XMLHttpRequest) => {
// same method as inviteList.reload() if (req.readyState == 4 && req.status == 200) {
let accountsOnDOM: { [id: string]: boolean } = {}; // same method as inviteList.reload()
for (let id in this._users) { accountsOnDOM[id] = true; } let accountsOnDOM: { [id: string]: boolean } = {};
for (let u of (req.response["users"] as User[])) { for (let id in this._users) { accountsOnDOM[id] = true; }
if (u.id in this._users) { for (let u of (req.response["users"] as User[])) {
this._users[u.id].update(u); if (u.id in this._users) {
delete accountsOnDOM[u.id]; this._users[u.id].update(u);
} else { delete accountsOnDOM[u.id];
this.add(u); } 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(); this.loadTemplates();
delete this._users[id]; }
}
this._checkCheckCount();
}
})
} }