Modified - Added the persistent sticky side-menu preference and the finalized sidebar behavior for /user/settings and /-/admin.
release-nightly / nightly-binary (push) Has been cancelled
release-nightly / nightly-container (push) Has been cancelled

This commit is contained in:
2026-05-07 23:57:58 +00:00
parent 61a9e4bd06
commit 351d6b0811
14 changed files with 166 additions and 3 deletions
+6
View File
@@ -572,3 +572,9 @@ Project Change ID[date-time] - application-version - Type - Summary:
114 - [2026-05-07 09:30:56] - v1.27.0-dev-105-gb3a7692ce9 - Type: Modified - Moved the persistent footer outside the page scroll area.
- 1 - I modified `web_src/css/base.css` so pages with `show-persistent-footer` stop scrolling the whole `body` and instead scroll the `.full.height` content container, keeping the footer in its own fixed viewport slot.
- 2 - I modified `web_src/css/home.css` so the persistent footer no longer overlays the page with `position: fixed`, but stays permanently visible as a non-scrolling sibling below the main content area.
115 - [2026-05-07 09:54:43] - v1.27.0-dev-106-g61a9e4bd06 - Type: Modified - Added the persistent sticky side-menu preference and the finalized sidebar behavior for `/user/settings` and `/-/admin`.
- 1 - I modified `models/user/setting_options.go`, `routers/common/pagetmpl.go`, `templates/base/head.tmpl`, `routers/web/user/setting/profile.go`, `routers/web/web.go`, `templates/user/settings/appearance.tmpl`, `options/locale/locale_en-US.json`, `options/locale/locale_ro-RO.json`, and `models/user/setting_test.go` to add the persisted `ui.sticky_side_menus` preference, expose it to templates as `ShowStickySideMenus`, save it from `/user/settings/appearance`, document it in the UI, and cover it with a focused test.
- 2 - I modified `templates/user/settings/actions_general.tmpl` so `/user/settings/actions/general` passes the expected `pageClass` and participates in the same sticky-sidebar behavior as the other settings subpages.
- 3 - I modified `web_src/css/modules/flexcontainer.css` so, when the preference is enabled, the shared left menus on `/user/settings` and `/-/admin` use the current sticky layout: sticky positioning on the inner menu, `top: var(--page-spacing)`, footer-aware `max-height` handling for both persistent and non-persistent footer modes, hidden desktop scrollbars, green overflow hints, local menu scrolling, and the current `/user/settings` item padding.
- 4 - I modified `web_src/js/features/common-page.ts` so the sticky side menus update their overflow-hint classes dynamically as their scroll position, size, or expanded sections change.
+1 -1
View File
@@ -1 +1 @@
7a2d7f9780aab8a6ed828973079ce7c58da5f69d
80b62fc2b4904d0194cd619dc7ce5e24b11a47c5
+2
View File
@@ -15,6 +15,8 @@ const (
SettingsKeyHiddenCommentTypes = "issue.hidden_comment_types"
// SettingsKeyPersistentFooter is the setting key for whether the footer remains visible in the viewport
SettingsKeyPersistentFooter = "ui.persistent_footer"
// SettingsKeyStickySideMenus is the setting key for whether settings/admin side menus remain visible while scrolling
SettingsKeyStickySideMenus = "ui.sticky_side_menus"
// SettingsKeyDiffWhitespaceBehavior is the setting key for whitespace behavior of diff
SettingsKeyDiffWhitespaceBehavior = "diff.whitespace_behaviour"
// SettingsKeyShowOutdatedComments is the setting key whether or not to show outdated comments in PRs
+13
View File
@@ -71,3 +71,16 @@ func TestPersistentFooterSetting(t *testing.T) {
assert.NoError(t, err)
assert.True(t, enabled)
}
func TestStickySideMenusSetting(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
assert.NoError(t, user_model.SetUserSetting(t.Context(), 99, user_model.SettingsKeyStickySideMenus, "true"))
val, err := user_model.GetUserSetting(t.Context(), 99, user_model.SettingsKeyStickySideMenus, "false")
assert.NoError(t, err)
enabled, err := strconv.ParseBool(val)
assert.NoError(t, err)
assert.True(t, enabled)
}
+3
View File
@@ -807,6 +807,9 @@
"settings.footer": "Footer",
"settings.footer_desc": "Choose whether the site footer should remain visible at the bottom of the viewport.",
"settings.show_persistent_footer": "Keep the footer visible on screen",
"settings.side_menus": "Side menus",
"settings.side_menus_desc": "Choose whether the left menus in user settings and the admin area should remain visible while the page content scrolls.",
"settings.show_sticky_side_menus": "Keep the left menus visible while scrolling",
"settings.privacy": "Privacy",
"settings.keep_activity_private": "Hide Activity from profile page",
"settings.keep_activity_private_popup": "Makes the activity visible only for you and the admins",
+3
View File
@@ -807,6 +807,9 @@
"settings.footer": "Footer",
"settings.footer_desc": "Alege dacă footer-ul site-ului trebuie să rămână vizibil în partea de jos a ferestrei.",
"settings.show_persistent_footer": "Păstrează footer-ul vizibil pe ecran",
"settings.side_menus": "Meniuri laterale",
"settings.side_menus_desc": "Alege dacă meniurile din stânga din setările utilizatorului și din zona de administrare trebuie să rămână vizibile în timp ce conținutul paginii se derulează.",
"settings.show_sticky_side_menus": "Păstrează meniurile din stânga vizibile la derulare",
"settings.privacy": "Confidențialitate",
"settings.keep_activity_private": "Ascunde activitatea din pagina de profil",
"settings.keep_activity_private_popup": "Activitatea va fi vizibilă doar pentru tine și administratori",
+9
View File
@@ -85,6 +85,7 @@ func PageGlobalData(ctx *context.Context) {
ctx.Data["PageGlobalData"] = data
showPersistentFooter := false
showStickySideMenus := false
if ctx.Doer != nil {
val, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyPersistentFooter, "false")
if err != nil {
@@ -93,8 +94,16 @@ func PageGlobalData(ctx *context.Context) {
showPersistentFooter, _ = strconv.ParseBool(val)
ctx.SetSiteCookie(middleware.CookiePersistentFooter, strconv.FormatBool(showPersistentFooter), 0)
}
val, err = user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyStickySideMenus, "false")
if err != nil {
log.Error("GetUserSetting[%s] for user %d: %v", user_model.SettingsKeyStickySideMenus, ctx.Doer.ID, err)
} else {
showStickySideMenus, _ = strconv.ParseBool(val)
}
} else {
showPersistentFooter, _ = strconv.ParseBool(middleware.GetSiteCookie(ctx.Req, middleware.CookiePersistentFooter))
}
ctx.Data["ShowPersistentFooter"] = showPersistentFooter
ctx.Data["ShowStickySideMenus"] = showStickySideMenus
}
+14
View File
@@ -443,3 +443,17 @@ func UpdatePersistentFooter(ctx *context.Context) {
ctx.Flash.Success(ctx.Tr("settings.saved_successfully"))
ctx.Redirect(setting.AppSubURL + "/user/settings/appearance")
}
// UpdateStickySideMenus updates whether settings/admin side menus remain visible while scrolling.
func UpdateStickySideMenus(ctx *context.Context) {
showStickySideMenus := ctx.FormBool("show_sticky_side_menus")
if err := user_model.SetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyStickySideMenus, strconv.FormatBool(showStickySideMenus)); err != nil {
ctx.ServerError("SetUserSetting", err)
return
}
log.Trace("User settings updated: %s", ctx.Doer.Name)
ctx.Flash.Success(ctx.Tr("settings.saved_successfully"))
ctx.Redirect(setting.AppSubURL + "/user/settings/appearance")
}
+1
View File
@@ -636,6 +636,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Group("/appearance", func() {
m.Get("", user_setting.Appearance)
m.Post("/footer", user_setting.UpdatePersistentFooter)
m.Post("/sticky_side_menus", user_setting.UpdateStickySideMenus)
m.Post("/language", web.Bind(forms.UpdateLanguageForm{}), user_setting.UpdateUserLang)
m.Post("/hidden_comments", user_setting.UpdateUserHiddenComments)
m.Post("/theme", web.Bind(forms.UpdateThemeForm{}), user_setting.UpdateUIThemePost)
+1 -1
View File
@@ -23,7 +23,7 @@
{{template "base/head_script" .}}
{{template "custom/header" .}}
</head>
<body{{if .ShowPersistentFooter}} class="show-persistent-footer"{{end}} hx-swap="outerHTML" hx-push-url="false">
<body{{if or .ShowPersistentFooter .ShowStickySideMenus}} class="{{if .ShowPersistentFooter}}show-persistent-footer{{end}}{{if and .ShowPersistentFooter .ShowStickySideMenus}} {{end}}{{if .ShowStickySideMenus}}show-sticky-side-menus{{end}}"{{end}} hx-swap="outerHTML" hx-push-url="false">
{{template "custom/body_outer_pre" .}}
<div class="full height">
+1 -1
View File
@@ -1,3 +1,3 @@
{{template "user/settings/layout_head" (dict "ctxData" .)}}
{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings actions")}}
{{template "shared/actions/owner_general_settings" .}}
{{template "user/settings/layout_footer" .}}
+21
View File
@@ -56,6 +56,27 @@
</form>
</div>
<!-- Side menus -->
<h4 class="ui top attached header">
{{ctx.Locale.Tr "settings.side_menus"}}
</h4>
<div class="ui attached segment">
<form class="ui form" action="{{.Link}}/sticky_side_menus" method="post">
<p class="help">
{{ctx.Locale.Tr "settings.side_menus_desc"}}
</p>
<div class="inline field">
<div class="ui checkbox">
<input name="show_sticky_side_menus" type="checkbox" {{if .ShowStickySideMenus}}checked{{end}}>
<label>{{ctx.Locale.Tr "settings.show_sticky_side_menus"}}</label>
</div>
</div>
<div class="field">
<button class="ui primary button">{{ctx.Locale.Tr "save"}}</button>
</div>
</form>
</div>
<!-- Language -->
<h4 class="ui top attached header">
{{ctx.Locale.Tr "settings.language"}}
+63
View File
@@ -21,6 +21,62 @@
min-width: 0; /* make the "text truncate" work, otherwise the flex axis is not limited and the text just overflows */
}
body.show-sticky-side-menus .page-content.user.settings .flex-container-nav,
body.show-sticky-side-menus .page-content.admin .flex-container-nav {
min-height: 0;
}
body.show-sticky-side-menus .page-content.user.settings .flex-container-nav > .ui.vertical.menu,
body.show-sticky-side-menus .page-content.admin .flex-container-nav > .ui.vertical.menu {
position: sticky;
top: var(--page-spacing);
max-height: calc(100dvh - var(--page-spacing) - var(--page-space-bottom) - 24px);
overflow-y: auto;
overscroll-behavior: contain;
}
body.show-persistent-footer.show-sticky-side-menus .page-content.admin .flex-container-nav > .ui.vertical.menu {
max-height: calc(100dvh - var(--page-spacing) - 96px - env(safe-area-inset-bottom));
}
body.show-persistent-footer.show-sticky-side-menus .page-content.user.settings .flex-container-nav > .ui.vertical.menu {
max-height: calc(100dvh - var(--page-spacing) - 96px - env(safe-area-inset-bottom));
}
body.show-sticky-side-menus .page-content.user.settings .flex-container-nav > .ui.vertical.menu > .item,
body.show-sticky-side-menus .page-content.user.settings .flex-container-nav > .ui.vertical.menu > details.toggleable-item > summary {
padding: 0.9em 1.14285714em;
}
@media (min-width: 768px) {
body.show-sticky-side-menus .page-content.user.settings .flex-container-nav > .ui.vertical.menu,
body.show-sticky-side-menus .page-content.admin .flex-container-nav > .ui.vertical.menu {
scrollbar-width: none;
-ms-overflow-style: none;
}
body.show-sticky-side-menus .page-content.user.settings .flex-container-nav > .ui.vertical.menu::-webkit-scrollbar,
body.show-sticky-side-menus .page-content.admin .flex-container-nav > .ui.vertical.menu::-webkit-scrollbar {
width: 0;
height: 0;
}
body.show-sticky-side-menus .page-content.user.settings .flex-container-nav > .ui.vertical.menu.has-scroll-hint-top,
body.show-sticky-side-menus .page-content.admin .flex-container-nav > .ui.vertical.menu.has-scroll-hint-top {
box-shadow: inset 0 1px 0 var(--color-green), inset 0 16px 14px -10px var(--color-green-badge-hover-bg);
}
body.show-sticky-side-menus .page-content.user.settings .flex-container-nav > .ui.vertical.menu.has-scroll-hint-bottom,
body.show-sticky-side-menus .page-content.admin .flex-container-nav > .ui.vertical.menu.has-scroll-hint-bottom {
box-shadow: inset 0 -1px 0 var(--color-green), inset 0 -16px 14px -10px var(--color-green-badge-hover-bg);
}
body.show-sticky-side-menus .page-content.user.settings .flex-container-nav > .ui.vertical.menu.has-scroll-hint-top.has-scroll-hint-bottom,
body.show-sticky-side-menus .page-content.admin .flex-container-nav > .ui.vertical.menu.has-scroll-hint-top.has-scroll-hint-bottom {
box-shadow: inset 0 1px 0 var(--color-green), inset 0 -1px 0 var(--color-green), inset 0 16px 14px -10px var(--color-green-badge-hover-bg), inset 0 -16px 14px -10px var(--color-green-badge-hover-bg);
}
}
@media (max-width: 767.98px) {
.flex-container {
flex-direction: column;
@@ -30,4 +86,11 @@
order: -1;
width: auto;
}
body.show-sticky-side-menus .page-content.user.settings .flex-container-nav > .ui.vertical.menu,
body.show-sticky-side-menus .page-content.admin .flex-container-nav > .ui.vertical.menu {
position: static;
max-height: none;
overflow-y: visible;
}
}
+28
View File
@@ -45,10 +45,38 @@ function initFooterThemeSelector() {
});
}
function initStickySideMenuIndicators() {
const menus = queryElems<HTMLDivElement>(document, [
'.show-sticky-side-menus .page-content.user.settings .flex-container-nav > .ui.vertical.menu',
'.show-sticky-side-menus .page-content.admin .flex-container-nav > .ui.vertical.menu',
].join(', '));
for (const menu of menus) {
const updateIndicators = () => {
const maxScrollTop = menu.scrollHeight - menu.clientHeight;
const isScrollable = maxScrollTop > 1;
menu.classList.toggle('has-scroll-hint-top', isScrollable && menu.scrollTop > 1);
menu.classList.toggle('has-scroll-hint-bottom', isScrollable && menu.scrollTop < maxScrollTop - 1);
};
const scheduleUpdate = () => window.requestAnimationFrame(updateIndicators);
menu.addEventListener('scroll', updateIndicators);
window.addEventListener('resize', scheduleUpdate);
new ResizeObserver(scheduleUpdate).observe(menu);
new MutationObserver(scheduleUpdate).observe(menu, {
subtree: true,
attributes: true,
attributeFilter: ['open'],
});
scheduleUpdate();
}
}
export function initCommmPageComponents() {
initHeadNavbarContentToggle();
initFooterLanguageMenu();
initFooterThemeSelector();
initStickySideMenuIndicators();
}
export function initGlobalDropdown() {