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

View File

@@ -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) {

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:
- 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.

View File

@@ -916,7 +916,7 @@
</div>
</div>
<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">
<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>
@@ -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>
<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 class="flex md:flex flex-col gap-2 overflow-y-scroll" id="settings-sidebar-items"></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="flex flex-col h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic mb-2">{{ .strings.noResultsFound }}</span>

View File

@@ -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
)

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/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=

View File

@@ -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 {

View File

@@ -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 {
<input class="unfocused" type="checkbox">
</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 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");