settings/config: add root order and use on web, fix nesting and

animation

added an optional root "Order" field to the config. scripts/ini will
warn if you've used this and forgot to include any sections.

added more/most sections to a group now.

groups have their maxHeight set to 9999px once animation finishes, and
have it quickly set back to ~scrollHeight before they're animated
closed.
This commit is contained in:
Harvey Tindall
2025-11-24 18:31:35 +00:00
parent a3dc8b7e07
commit 8f3b860cc7
7 changed files with 170 additions and 21 deletions
+3
View File
@@ -64,6 +64,9 @@ type Group struct {
type Config struct { type Config struct {
Sections []Section `json:"sections" yaml:"sections"` Sections []Section `json:"sections" yaml:"sections"`
Groups []Group `json:"groups" yaml:"groups"` 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) { func (c *Config) removeSection(section string) {
+36 -2
View File
@@ -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: groups:
- group: external_services - group: external_services
name: "Integrations" name: "Integrations"
@@ -7,6 +21,7 @@ groups:
- group: chatbots - group: chatbots
- section: ombi - section: ombi
- section: jellyseerr - section: jellyseerr
- section: webhooks
- group: email - group: email
name: "Email" name: "Email"
description: "Options for sending emails through jfa-go." description: "Options for sending emails through jfa-go."
@@ -14,6 +29,7 @@ groups:
- section: email - section: email
- section: smtp - section: smtp
- section: mailgun - section: mailgun
- section: email_confirmation
- group: chatbots - group: chatbots
name: "Chat bots" name: "Chat bots"
description: "Options for messaging through chat services." description: "Options for messaging through chat services."
@@ -21,6 +37,24 @@ groups:
- section: discord - section: discord
- section: telegram - section: telegram
- section: matrix - 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: sections:
- section: updates - section: updates
meta: meta:
@@ -1259,7 +1293,7 @@ sections:
description: Path to custom email text template for announcements/custom messages. description: Path to custom email text template for announcements/custom messages.
- section: notifications - section: notifications
meta: meta:
name: Admin invite notifications name: Admin notifications
description: Allows toggling "user created" and "invite expired" notifications description: Allows toggling "user created" and "invite expired" notifications
to be sent to the admin per-invite. to be sent to the admin per-invite.
depends_true: messages|enabled depends_true: messages|enabled
@@ -1471,7 +1505,7 @@ sections:
description: Path to custom email in plain text description: Path to custom email in plain text
- section: user_expiry - section: user_expiry
meta: meta:
name: User Expiry name: Account Expiry
description: When set on an invite, users will be deleted or disabled a specified 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 amount of time after they create their account. Expiries can also be set and
extended for invididual users, optionally with a message why. extended for invididual users, optionally with a message why.
+3 -2
View File
@@ -916,7 +916,7 @@
</div> </div>
</div> </div>
<div class="flex flex-col md:flex-row gap-3"> <div class="flex flex-col md:flex-row gap-3">
<div class="md:card @low dark:~d_neutral flex md:flex flex-col gap-2 flex-1" id="settings-sidebar"> <div class="@low dark:~d_neutral flex md:flex flex-col gap-2" id="settings-sidebar">
<div class="flex flex-row justify-between"> <div class="flex flex-row justify-between">
<input type="search" class="field ~neutral @low input settings-section-button justify-between" id="settings-search" placeholder="{{ .strings.search }}"> <input type="search" class="field ~neutral @low input settings-section-button justify-between" id="settings-search" placeholder="{{ .strings.search }}">
<button class="button ~neutral @low center -ml-10 rounded-s-none settings-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></button> <button class="button ~neutral @low center -ml-10 rounded-s-none settings-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></button>
@@ -927,8 +927,9 @@
<a class="button ~urge dark:~d_info @low justify-center grow" target="_blank" href="https://wiki.jfa-go.com"><span class="flex">{{ .strings.wiki }} <i class="ri-book-shelf-line ml-2"></i></a> <a class="button ~urge dark:~d_info @low justify-center grow" target="_blank" href="https://wiki.jfa-go.com"><span class="flex">{{ .strings.wiki }} <i class="ri-book-shelf-line ml-2"></i></a>
<span class="button ~neutral @low justify-center grow" id="setting-profiles"><span class="flex">{{ .strings.userProfiles }} <i class="ri-user-line ml-2"></i></span></span> <span class="button ~neutral @low justify-center grow" id="setting-profiles"><span class="flex">{{ .strings.userProfiles }} <i class="ri-user-line ml-2"></i></span></span>
</div> </div>
<div class="flex md:flex flex-col gap-2 overflow-y-scroll" id="settings-sidebar-items"></div>
</div> </div>
<div class="card ~neutral @low overflow flex-1" id="settings-panel"> <div class="card ~neutral @low overflow flex-1 grow" id="settings-panel">
<div class="settings-section unfocused h-[100%]" id="settings-not-found"> <div class="settings-section unfocused h-[100%]" id="settings-not-found">
<div class="flex flex-col h-[100%] justify-center items-center"> <div class="flex flex-col h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic mb-2">{{ .strings.noResultsFound }}</span> <span class="text-2xl font-medium italic mb-2">{{ .strings.noResultsFound }}</span>
+5 -1
View File
@@ -2,11 +2,15 @@ module github.com/hrfee/jfa-go/scripts/ini
replace github.com/hrfee/jfa-go/common => ../../common replace github.com/hrfee/jfa-go/common => ../../common
go 1.18 go 1.22.4
require ( 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/common v0.0.0-20240824141650-fcdd4e451882 // indirect
github.com/hrfee/jfa-go/logmessages v0.0.0-20240806200606-6308db495a0a // 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/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+11
View File
@@ -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 h1:qbXZgCqb9eaPSJfLEXczQD2lxTv6jb6silMPIWW9j6o=
github.com/hrfee/jfa-go/logmessages v0.0.0-20240806200606-6308db495a0a/go.mod h1:c5HKkLayo0GrEUDlJwT12b67BL9cdPjP271Xlv/KDRQ= 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/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 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+44
View File
@@ -7,6 +7,7 @@ import (
"os" "os"
"strings" "strings"
"github.com/fatih/color"
"github.com/hrfee/jfa-go/common" "github.com/hrfee/jfa-go/common"
"gopkg.in/ini.v1" "gopkg.in/ini.v1"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -26,6 +27,49 @@ func generateIni(yamlPath string, iniPath string) {
if err != nil { if err != nil {
panic(err) 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() conf := ini.Empty()
for _, section := range configBase.Sections { for _, section := range configBase.Sections {
+66 -14
View File
@@ -476,7 +476,7 @@ interface Group {
group: string; group: string;
name: string; name: string;
description: string; description: string;
members: ({ group: string } | { section: string })[]; members: Member[];
} }
interface Section { interface Section {
@@ -573,9 +573,12 @@ class sectionPanel {
asElement = (): HTMLDivElement => { return this._section; } asElement = (): HTMLDivElement => { return this._section; }
} }
type Member = { group: string } | { section: string };
interface Settings { interface Settings {
groups: Group[]; groups: Group[];
sections: Section[]; sections: Section[];
order?: Member[];
} }
export class settingsList { export class settingsList {
@@ -586,7 +589,7 @@ export class settingsList {
private _loader = document.getElementById("settings-loader") as HTMLDivElement; private _loader = document.getElementById("settings-loader") as HTMLDivElement;
private _panel = document.getElementById("settings-panel") 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 _visibleSection: string;
private _sections: { [name: string]: sectionPanel }; private _sections: { [name: string]: sectionPanel };
private _buttons: { [name: string]: HTMLSpanElement }; private _buttons: { [name: string]: HTMLSpanElement };
@@ -611,7 +614,7 @@ export class settingsList {
// Takes all groups at once since members might contain each other. // Takes all groups at once since members might contain each other.
addGroups = (groups: Group[]) => { addGroups = (groups: Group[]) => {
groups.forEach((g) => { this._groups[g.group] = g }); 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; if (g.group in this._groupButtons) return null;
const container = document.createElement("div") as HTMLDivElement; const container = document.createElement("div") as HTMLDivElement;
@@ -628,37 +631,69 @@ export class settingsList {
</label> </label>
`; `;
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 icon = button.querySelector("i.icon");
const check = button.querySelector("input[type=checkbox]") as HTMLInputElement; const check = button.querySelector("input[type=checkbox]") as HTMLInputElement;
button.onclick = () => { button.onclick = () => {
check.checked = !check.checked; 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) { if (check.checked) {
icon.classList.add("rotated"); icon.classList.add("rotated");
// Hide the scrollbar while we animate
this._sidebar.style.overflowY = "hidden";
dropdown.classList.remove("unfocused"); 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%"; dropdown.style.opacity = "100%";
} else { } else {
icon.classList.remove("rotated"); icon.classList.remove("rotated");
const hide = () => { const mainTransitionEnd = () => {
dropdown.removeEventListener("transitionend", mainTransitionEnd);
dropdown.classList.add("unfocused"); 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); const mainTransitionStart = () => {
dropdown.removeEventListener("transitionend", mainTransitionStart)
dropdown.style.transitionDuration = "";
dropdown.addEventListener("transitionend", mainTransitionEnd);
dropdown.style.maxHeight = "0"; dropdown.style.maxHeight = "0";
dropdown.style.opacity = "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; check.onchange = onCheck;
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");
for (const member of g.members) { for (const member of g.members) {
if ("group" in member) { if ("group" in member) {
let subgroup = addGroup(this._groups[member.group]); let subgroup = addGroup(this._groups[member.group], indent+1);
if (!subgroup) { if (!subgroup) {
subgroup = this._groupButtons[member.group]; subgroup = this._groupButtons[member.group];
// Remove from page // Remove from page
@@ -727,6 +762,21 @@ export class settingsList {
this._sidebar.appendChild(this._buttons[name]); 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) => { private _showPanel = (name: string) => {
// console.log("showing", name); // console.log("showing", name);
for (let n in this._sections) { for (let n in this._sections) {
@@ -1014,6 +1064,8 @@ export class settingsList {
this.addGroups(this._settings.groups); this.addGroups(this._settings.groups);
if ("order" in this._settings && this._settings.order) this.setOrder(this._settings.order);
removeLoader(this._loader); removeLoader(this._loader);
for (let i = 0; i < this._loader.children.length; i++) { for (let i = 0; i < this._loader.children.length; i++) {
this._loader.children[i].classList.remove("invisible"); this._loader.children[i].classList.remove("invisible");