diff --git a/common/config.go b/common/config.go index 8b23b87..1a8eba5 100644 --- a/common/config.go +++ b/common/config.go @@ -64,6 +64,9 @@ type Group struct { type Config struct { Sections []Section `json:"sections" yaml:"sections"` Groups []Group `json:"groups" yaml:"groups"` + // Optional order, which can interleave sections and groups. + // If unset, falls back to sections in order, then groups in order. + Order []Member `json:"order,omitempty" yaml:"order,omitempty"` } func (c *Config) removeSection(section string) { diff --git a/config/config-base.yaml b/config/config-base.yaml index c0b6c6f..b58f960 100644 --- a/config/config-base.yaml +++ b/config/config-base.yaml @@ -1,3 +1,17 @@ +order: + - section: ui + - section: advanced + - section: jellyfin + - group: sign_up + - group: accounts + - section: messages + - group: external_services + - section: activity_log + - section: backups + - section: updates + - section: url_paths + - section: template_email + - section: files groups: - group: external_services name: "Integrations" @@ -7,6 +21,7 @@ groups: - group: chatbots - section: ombi - section: jellyseerr + - section: webhooks - group: email name: "Email" description: "Options for sending emails through jfa-go." @@ -14,6 +29,7 @@ groups: - section: email - section: smtp - section: mailgun + - section: email_confirmation - group: chatbots name: "Chat bots" description: "Options for messaging through chat services." @@ -21,6 +37,24 @@ groups: - section: discord - section: telegram - section: matrix + - group: sign_up + name: "Invites & Referrals" + description: "Settings relating to invites, the sign up page and referrals." + members: + - section: captcha + - section: password_validation + - section: invite_emails + - section: notifications + - section: welcome_email + - group: accounts + name: "Accounts" + description: "Settings relating to account management." + members: + - section: user_page + - section: password_resets + - section: user_expiry + - section: disable_enable + - section: deletion sections: - section: updates meta: @@ -1259,7 +1293,7 @@ sections: description: Path to custom email text template for announcements/custom messages. - section: notifications meta: - name: Admin invite notifications + name: Admin notifications description: Allows toggling "user created" and "invite expired" notifications to be sent to the admin per-invite. depends_true: messages|enabled @@ -1471,7 +1505,7 @@ sections: description: Path to custom email in plain text - section: user_expiry meta: - name: User Expiry + name: Account Expiry description: When set on an invite, users will be deleted or disabled a specified amount of time after they create their account. Expiries can also be set and extended for invididual users, optionally with a message why. diff --git a/html/admin.html b/html/admin.html index 2c2c3af..fddb5c8 100644 --- a/html/admin.html +++ b/html/admin.html @@ -916,7 +916,7 @@
-
+
@@ -927,8 +927,9 @@ {{ .strings.wiki }} {{ .strings.userProfiles }}
+
-
+
{{ .strings.noResultsFound }} diff --git a/scripts/ini/go.mod b/scripts/ini/go.mod index 531d89f..412489d 100644 --- a/scripts/ini/go.mod +++ b/scripts/ini/go.mod @@ -2,11 +2,15 @@ module github.com/hrfee/jfa-go/scripts/ini replace github.com/hrfee/jfa-go/common => ../../common -go 1.18 +go 1.22.4 require ( + github.com/fatih/color v1.18.0 // indirect github.com/hrfee/jfa-go/common v0.0.0-20240824141650-fcdd4e451882 // indirect github.com/hrfee/jfa-go/logmessages v0.0.0-20240806200606-6308db495a0a // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.25.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/scripts/ini/go.sum b/scripts/ini/go.sum index 1e5face..624d5d9 100644 --- a/scripts/ini/go.sum +++ b/scripts/ini/go.sum @@ -1,5 +1,16 @@ +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/hrfee/jfa-go/logmessages v0.0.0-20240806200606-6308db495a0a h1:qbXZgCqb9eaPSJfLEXczQD2lxTv6jb6silMPIWW9j6o= github.com/hrfee/jfa-go/logmessages v0.0.0-20240806200606-6308db495a0a/go.mod h1:c5HKkLayo0GrEUDlJwT12b67BL9cdPjP271Xlv/KDRQ= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= diff --git a/scripts/ini/main.go b/scripts/ini/main.go index 98810fc..3221462 100644 --- a/scripts/ini/main.go +++ b/scripts/ini/main.go @@ -7,6 +7,7 @@ import ( "os" "strings" + "github.com/fatih/color" "github.com/hrfee/jfa-go/common" "gopkg.in/ini.v1" "gopkg.in/yaml.v3" @@ -26,6 +27,49 @@ func generateIni(yamlPath string, iniPath string) { if err != nil { panic(err) } + // Validate that all groups/sections are listed in the root order, if it exists + if len(configBase.Order) > 0 { + // Expand order + var traverseGroup func(groupName string) []string + traverseGroup = func(groupName string) []string { + out := []string{} + for _, group := range configBase.Groups { + if group.Group == groupName { + for _, groupMember := range group.Members { + if groupMember.Group != "" { + out = append(out, traverseGroup(groupMember.Group)...) + } else if groupMember.Section != "" { + out = append(out, groupMember.Section) + } + } + break + } + } + return out + } + listedSects := map[string]bool{} + for _, member := range configBase.Order { + if member.Group != "" { + for _, sect := range traverseGroup(member.Group) { + listedSects[sect] = true + } + } else if member.Section != "" { + listedSects[member.Section] = true + } + } + + missingSections := false + for _, section := range configBase.Sections { + if _, ok := listedSects[section.Section]; !ok { + if !missingSections { + color.Red("WARNING: Root order specified but the following sections were not listed, directly or indirectly:") + missingSections = true + } + color.Red("\t%s", section.Section) + } + } + } + conf := ini.Empty() for _, section := range configBase.Sections { diff --git a/ts/modules/settings.ts b/ts/modules/settings.ts index 5e8a99b..ed0928b 100644 --- a/ts/modules/settings.ts +++ b/ts/modules/settings.ts @@ -476,7 +476,7 @@ interface Group { group: string; name: string; description: string; - members: ({ group: string } | { section: string })[]; + members: Member[]; } interface Section { @@ -573,9 +573,12 @@ class sectionPanel { asElement = (): HTMLDivElement => { return this._section; } } +type Member = { group: string } | { section: string }; + interface Settings { groups: Group[]; sections: Section[]; + order?: Member[]; } export class settingsList { @@ -586,7 +589,7 @@ export class settingsList { private _loader = document.getElementById("settings-loader") as HTMLDivElement; private _panel = document.getElementById("settings-panel") as HTMLDivElement; - private _sidebar = document.getElementById("settings-sidebar") as HTMLDivElement; + private _sidebar = document.getElementById("settings-sidebar-items") as HTMLDivElement; private _visibleSection: string; private _sections: { [name: string]: sectionPanel }; private _buttons: { [name: string]: HTMLSpanElement }; @@ -611,7 +614,7 @@ export class settingsList { // Takes all groups at once since members might contain each other. addGroups = (groups: Group[]) => { groups.forEach((g) => { this._groups[g.group] = g }); - const addGroup = (g: Group): HTMLElement => { + const addGroup = (g: Group, indent: number = 0): HTMLElement => { if (g.group in this._groupButtons) return null; const container = document.createElement("div") as HTMLDivElement; @@ -627,38 +630,70 @@ export class settingsList { `; + + const dropdown = document.createElement("div") as HTMLDivElement; + container.appendChild(dropdown); + dropdown.classList.add("ml-" + ((indent+1)*2)); + dropdown.style.maxHeight = "0"; + dropdown.style.opacity = "0"; + dropdown.classList.add("settings-dropdown", "unfocused", "flex", "flex-col", "gap-2", "transition-all"); const icon = button.querySelector("i.icon"); const check = button.querySelector("input[type=checkbox]") as HTMLInputElement; + button.onclick = () => { check.checked = !check.checked; + onCheck(); + }; + // When groups are nested, the outer group's scrollHeight will obviously change when an + // inner group is opened/closed. Instead of traversing the tree and adjusting the maxHeight property + // each open/close, just set the maxHeight to 9999px once the animation is completed. + // On close, quickly set maxHeight back to ~scrollHeight, then animate to 0. + const onCheck = () => { if (check.checked) { icon.classList.add("rotated"); + // Hide the scrollbar while we animate + this._sidebar.style.overflowY = "hidden"; dropdown.classList.remove("unfocused"); - dropdown.style.maxHeight = dropdown.scrollHeight+"px"; + const fullHeight = () => { + dropdown.removeEventListener("transitionend", fullHeight); + dropdown.style.maxHeight = "9999px"; + // Return the scrollbar (or whatever, just don't hide it) + this._sidebar.style.overflowY = ""; + }; + dropdown.addEventListener("transitionend", fullHeight); + dropdown.style.maxHeight = (1.2*dropdown.scrollHeight)+"px"; dropdown.style.opacity = "100%"; } else { icon.classList.remove("rotated"); - const hide = () => { + const mainTransitionEnd = () => { + dropdown.removeEventListener("transitionend", mainTransitionEnd); dropdown.classList.add("unfocused"); - dropdown.removeEventListener("transitionend", hide); + // Return the scrollbar (or whatever, just don't hide it) + this._sidebar.style.overflowY = ""; }; - dropdown.addEventListener("transitionend", hide); - dropdown.style.maxHeight = "0"; - dropdown.style.opacity = "0"; + const mainTransitionStart = () => { + dropdown.removeEventListener("transitionend", mainTransitionStart) + dropdown.style.transitionDuration = ""; + dropdown.addEventListener("transitionend", mainTransitionEnd); + dropdown.style.maxHeight = "0"; + dropdown.style.opacity = "0"; + } + // Hide the scrollbar while we animate + this._sidebar.style.overflowY = "hidden"; + // Disabling transitions then going from 9999 - scrollHeight doesn't work in firefox to me, + // so instead just make the transition duration really short. + dropdown.style.transitionDuration = "1ms"; + dropdown.addEventListener("transitionend", mainTransitionStart); + dropdown.style.maxHeight = (1.2*dropdown.scrollHeight)+"px"; } } - - const dropdown = document.createElement("div") as HTMLDivElement; - container.appendChild(dropdown); - dropdown.style.maxHeight = "0"; - dropdown.style.opacity = "0"; - dropdown.classList.add("unfocused", "flex", "flex-col", "gap-2", "flex-1", "max-h-0", "transition-all"); + check.onchange = onCheck; for (const member of g.members) { if ("group" in member) { - let subgroup = addGroup(this._groups[member.group]); + let subgroup = addGroup(this._groups[member.group], indent+1); if (!subgroup) { subgroup = this._groupButtons[member.group]; // Remove from page @@ -727,6 +762,21 @@ export class settingsList { this._sidebar.appendChild(this._buttons[name]); } + setOrder(order: Member[]) { + this._sidebar.textContent = ``; + for (const member of order) { + if ("group" in member) { + this._sidebar.appendChild(this._groupButtons[member.group]); + } else if ("section" in member) { + if (member.section in this._buttons) { + this._sidebar.appendChild(this._buttons[member.section]); + } else { + console.warn("Settings section specified in order but missing:", member.section); + } + } + } + } + private _showPanel = (name: string) => { // console.log("showing", name); for (let n in this._sections) { @@ -1014,6 +1064,8 @@ export class settingsList { this.addGroups(this._settings.groups); + if ("order" in this._settings && this._settings.order) this.setOrder(this._settings.order); + removeLoader(this._loader); for (let i = 0; i < this._loader.children.length; i++) { this._loader.children[i].classList.remove("invisible");