diff --git a/CHANGELOG.md b/CHANGELOG.md index 75d187f..81d5584 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ Release history focused on **features, settings, default behavior, compatibility - **Fit to screen (auto-arrange):** a new **Fit to screen** switch in the unlocked layout toolbar (account preference `dashboard_auto_fit`, **on by default**) re-arranges a custom Overview for the current screen width, so the layout designed on a wide screen stays tidy on laptops and tablets. Each card keeps its saved width (but at least a readable minimum for its type), cards are placed in saved order into the first free spot from the top, stat tiles that start on one row get one height, a card whose side neighbour ends slightly lower is extended to that edge (only while the dashboard grows by at most two stat tiles), remaining holes are filled by widening a neighbouring card sideways or growing it into free space above or below. When a card is placed, a free strip beside it that is narrower than every card still to be placed is taken by that card at once, and at the end of the grid a chart or panel reaches the last row instead of leaving an empty area. The saved widths, heights and order are never changed; switch it off to see the layout exactly as saved. While a block is dragged the normal flow is shown so the drop placeholder stays accurate. - **No layout jump on reload:** the Overview grid stays invisible until the account's layout from the server has been applied and the first live data has been rendered, then appears already arranged; meanwhile the panel's startup screen (logo, spinner, "Loading RustProxy…") is shown in its place. Nothing about the layout is stored in the browser (an older cached layout key is removed). - **Overview layout with row heights:** in a custom (unlocked/saved) layout, card heights now snap to a shared row unit — half the height of the tallest compact stat card, so stat tiles always span two rows and line up — instead of every card in a row stretching to the tallest one. A tall panel (Process, RAM cache, Network…) can sit beside several rows of small cards or charts with aligned edges. Each card automatically gets the rows its content needs; in the layout editor, the new **↕ − / +** control next to the width makes a card taller (`A` marks automatic height, lowering it to the content height returns to automatic). Charts (also with chart scales enabled), chart stacks and tables grow into the extra height, and a card is stretched over the empty cells below it down to the next card or the end of the grid, so rows end on straight edges. **Fill gaps** packs blocks with their real heights. Small content changes (for example a growing table) cannot make the grid jump: a card grows at once but shrinks only after the smaller size is seen several times. Below a 700 px wide dashboard, cards stack by content height as before. -- **Preferences API:** additive optional `dashboard_widget_heights` (widget ID → 1–32 rows) in `/api/preferences` and dashboard presets, and `dashboard_auto_fit` (boolean, default `true`) in `/api/preferences`. Older documents and presets without it keep automatic heights. +- **Collapsible left menu:** a **Collapse menu** button at the bottom of the left menu turns it into a narrow rail (logo, section numbers or icons with the section name as a tooltip, status dot). On screens wider than 1300 px the choice is stored in the account preference `sidebar_collapsed` (off by default); between 851 and 1300 px the menu is always a rail and the button opens the full menu temporarily as an overlay that closes after choosing a section, clicking outside or pressing Esc. The mobile menu (850 px and below) is unchanged. The startup screen now stays until the account preferences are applied, so the panel never appears first with default settings. +- **Preferences API:** additive optional `dashboard_widget_heights` (widget ID → 1–32 rows) in `/api/preferences` and dashboard presets, `dashboard_auto_fit` (boolean, default `true`) and `sidebar_collapsed` (boolean, default `false`) in `/api/preferences`. Older documents and presets without it keep automatic heights. - **Upgrade notes:** no configuration or database migration is required; existing saved layouts receive the new card hidden at the end. Legacy (never customized) layouts keep their grouped rows until the layout is edited. ## 0.36.8 — editor forms, toasts and CSP fixes diff --git a/langs/en_US.json b/langs/en_US.json index db53172..07a0f68 100644 --- a/langs/en_US.json +++ b/langs/en_US.json @@ -66,6 +66,8 @@ "configHistoryChangedFields": "Changed fields only", "overview": "Overview", "mainMenu": "Menu", + "sidebarCollapse": "Collapse menu", + "sidebarExpand": "Expand menu", "routing": "Legacy routes", "configuration": "Configuration", "configStorageLabel": "CONFIGURATION SOURCE", diff --git a/langs/pl_pl.json b/langs/pl_pl.json index 9353e11..5d1e6ae 100644 --- a/langs/pl_pl.json +++ b/langs/pl_pl.json @@ -66,6 +66,8 @@ "configHistoryChangedFields": "Tylko zmienione pola", "overview": "Przegląd", "mainMenu": "Menu", + "sidebarCollapse": "Zwiń menu", + "sidebarExpand": "Rozwiń menu", "routing": "Starsze trasy", "configuration": "Konfiguracja", "configStorageLabel": "ŹRÓDŁO KONFIGURACJI", diff --git a/src/admin/openapi.json b/src/admin/openapi.json index 5a0dbe9..48031be 100644 --- a/src/admin/openapi.json +++ b/src/admin/openapi.json @@ -4115,6 +4115,11 @@ "default": true, "description": "Re-flow the custom Overview layout for the current screen width: cards keep a readable minimum width, holes at the end of a row are filled by widening the neighbouring cards and small steps at the bottom are closed. The saved widths and order are not changed." }, + "sidebar_collapsed": { + "type": "boolean", + "default": false, + "description": "Account-scoped: collapse the left menu to a narrow rail on wide screens. Below 1300 px the menu is always shown as a rail and can be expanded temporarily." + }, "dashboard_hidden_widgets": { "type": "array", "maxItems": 78, diff --git a/src/admin/preferences.rs b/src/admin/preferences.rs index 610a01a..52ca5a9 100644 --- a/src/admin/preferences.rs +++ b/src/admin/preferences.rs @@ -147,6 +147,9 @@ struct AdminPreferences { /// Re-flow the custom Overview layout for the current screen width (on by default). #[serde(default = "default_admin_dashboard_auto_fit")] dashboard_auto_fit: bool, + /// Left menu collapsed to a narrow rail on wide screens (off by default). + #[serde(default)] + sidebar_collapsed: bool, /// Optional per-card heights in Overview row units (1..=32, a compact stat card is 2); missing cards size to content. #[serde(default)] dashboard_widget_heights: Option>, @@ -209,6 +212,7 @@ impl Default for AdminPreferences { dashboard_widget_sizes: None, dashboard_widget_heights: None, dashboard_auto_fit: true, + sidebar_collapsed: false, file_log_columns: default_file_log_columns(), file_log_sort: default_file_log_sort(), file_log_descending: default_file_log_descending(), diff --git a/src/admin/preferences_tests.rs b/src/admin/preferences_tests.rs index 6b2fe3d..74fe4d1 100644 --- a/src/admin/preferences_tests.rs +++ b/src/admin/preferences_tests.rs @@ -382,3 +382,13 @@ fn dashboard_auto_fit_defaults_on_for_new_and_legacy_documents() { assert!(!off.dashboard_auto_fit); assert!(validate_admin_preferences(&off).is_ok()); } + +#[test] +fn sidebar_collapsed_defaults_off_for_new_and_legacy_documents() { + assert!(!AdminPreferences::default().sidebar_collapsed); + let legacy: AdminPreferences = serde_json::from_str(r#"{"theme":"dark"}"#).unwrap(); + assert!(!legacy.sidebar_collapsed); + let on: AdminPreferences = serde_json::from_str(r#"{"sidebar_collapsed":true}"#).unwrap(); + assert!(on.sidebar_collapsed); + assert!(validate_admin_preferences(&on).is_ok()); +} diff --git a/src/admin/router.rs b/src/admin/router.rs index 5e02cc0..4583227 100644 --- a/src/admin/router.rs +++ b/src/admin/router.rs @@ -65,6 +65,7 @@ fn admin_app_javascript() -> &'static str { include_str!("web/app/rate-sampling.js"), include_str!("web/app/chart-scales.js"), include_str!("web/app/overview-rows.js"), + include_str!("web/app/sidebar.js"), "const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(", include_str!("web/dashboard-widgets.json"), ");", diff --git a/src/admin/web/app/core.js b/src/admin/web/app/core.js index 91bec45..479ff7c 100644 --- a/src/admin/web/app/core.js +++ b/src/admin/web/app/core.js @@ -53,6 +53,7 @@ function translate() { document.querySelectorAll("[data-i18n-placeholder]").forEach(e => e.placeholder = t(e.dataset.i18nPlaceholder)); document.querySelectorAll("[data-i18n-aria]").forEach(e => e.setAttribute("aria-label", t(e.dataset.i18nAria))); document.querySelectorAll("[data-i18n-title]").forEach(e => { e.title = t(e.dataset.i18nTitle); }); + window.rustProxySidebar?.refresh?.(); window.rustProxyAclProfiles?.refreshTranslations?.(); $("language").value = languagePreference === "auto" ? "auto" : language; setPanel(currentPanel, false); syncAuthForm(); renderSession(); window.rustProxyDrafts?.refresh(); @@ -511,10 +512,13 @@ async function activate(info) { for (const id of ["password", "password-confirm", "setup-token"]) $(id).value = ""; $("login").hidden = true; $("workspace").hidden = false; $("logout").hidden = false; renderSession(); - document.documentElement.classList.remove("app-loading"); document.documentElement.classList.remove("app-startup-failed"); rustProxyRefresh.start(); - const preferences = await loadAdminPreferences(); + // Keep the startup screen until the account preferences (menu, layout, density) + // are applied, so the panel never appears first with defaults and then changes. + let preferences; + try { preferences = await loadAdminPreferences(); } + finally { document.documentElement.classList.remove("app-loading"); } if (!authenticated || epoch !== authEpoch) return false; window.rustProxyLogs?.applyPreferences(preferences); window.rustProxyFileLogs?.applyPreferences(preferences); diff --git a/src/admin/web/app/icons.js b/src/admin/web/app/icons.js index 7ef652a..9fa9cf6 100644 --- a/src/admin/web/app/icons.js +++ b/src/admin/web/app/icons.js @@ -168,7 +168,7 @@ function uiIconForTarget(target) { if (manual && UI_ICON_SHAPES[manual]) return manual; if (target.matches(".chart-style-choice")) return null; if (target.matches(".overview-inline-grip, .overview-inline-narrow, .overview-inline-widen, .overview-inline-up, .overview-inline-down, .overview-inline-hide, .overview-layout-narrow, .overview-layout-widen, .overview-layout-up, .overview-layout-down, .overview-layout-hide")) return null; - if (target.matches("#tunnels-open, .vhost-backend-protocol-badge, .config-history-day, .draft-dock-toggle, .mobile-menu-toggle")) return null; + if (target.matches("#tunnels-open, .vhost-backend-protocol-badge, .config-history-day, .draft-dock-toggle, .mobile-menu-toggle, .sidebar-collapse")) return null; if (UI_GLYPH_LABEL.test(String(target.textContent || "").trim())) return null; if (UI_ID_ICONS[target.id]) return UI_ID_ICONS[target.id]; if (target.matches("#web-service-preset-apply, [data-cache-memory-preset], [data-cache-cold-preset], [data-transform-preset]")) return "wand"; diff --git a/src/admin/web/app/preferences.js b/src/admin/web/app/preferences.js index 014ce33..1a5e0a8 100644 --- a/src/admin/web/app/preferences.js +++ b/src/admin/web/app/preferences.js @@ -14,7 +14,7 @@ const ADMIN_DEFAULT_HIDDEN_WIDGETS = Object.freeze([...new Set([ const ADMIN_APPEARANCE_DEFAULTS = Object.freeze({ theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", chart_style: "default", chart_axes: false, interface_icons: false, logo_animation: false, - button_style: "normal", corner_style: "default", table_density: "normal", dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_DEFAULT_HIDDEN_WIDGETS], dashboard_widget_sizes: null, dashboard_widget_heights: null, dashboard_auto_fit: true, + button_style: "normal", corner_style: "default", table_density: "normal", dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_DEFAULT_HIDDEN_WIDGETS], dashboard_widget_sizes: null, dashboard_widget_heights: null, dashboard_auto_fit: true, sidebar_collapsed: false, }); const CHART_STYLE_KEYS = Object.freeze({ default: "chartStyleDefault", soft: "chartStyleSoft", technical: "chartStyleTechnical", high_contrast: "chartStyleHighContrast", neon: "chartStyleNeon", monochrome: "chartStyleMonochrome", warm: "chartStyleWarm", cool: "chartStyleCool", sunset: "chartStyleSunset", aurora: "chartStyleAurora", ocean: "chartStyleOcean", forest: "chartStyleForest", pastel: "chartStylePastel", terminal: "chartStyleTerminal", blueprint: "chartStyleBlueprint", colorblind: "chartStyleColorblind" }); let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve(), adminPreferencesUnavailable = false; @@ -316,6 +316,7 @@ function applyAdminPreferences(value, cache = true) { window.rustProxyStreams?.applyPreferences(normalized); if (languagePacks.size) translate(); refreshAppearanceUi(); + window.rustProxySidebar?.applyPreference?.(normalized.sidebar_collapsed === true); window.rustProxyOverviewRows?.reveal?.(); return normalized; } @@ -357,7 +358,7 @@ function updateAdminPreferences(patch) { next.refresh = normalizeRefreshPreferences(next.refresh); next.theme = normalizeAdminTheme(next.theme); next.language = normalizeAdminLanguage(next.language); next.ui_scale = normalizeAdminUiScale(next.ui_scale); next.time_format = normalizeAdminTimeFormat(next.time_format); next.chart_style = normalizeAdminChartStyle(next.chart_style); next.chart_axes = next.chart_axes === true; next.interface_icons = next.interface_icons === true; next.logo_animation = next.logo_animation === true; next.button_style = normalizeAdminButtonStyle(next.button_style); next.corner_style = normalizeAdminCornerStyle(next.corner_style); next.table_density = normalizeAdminDensity(next.table_density); - next.dashboard_density = normalizeAdminDensity(next.dashboard_density, "compact"); next.dashboard_widgets = normalizeDashboardWidgets(next.dashboard_widgets); next.dashboard_hidden_widgets = normalizeHiddenDashboardWidgets(next.dashboard_hidden_widgets); next.dashboard_widget_sizes = normalizeDashboardWidgetSizes(next.dashboard_widget_sizes); next.dashboard_widget_heights = normalizeDashboardWidgetHeights(next.dashboard_widget_heights); next.dashboard_auto_fit = next.dashboard_auto_fit !== false; + next.dashboard_density = normalizeAdminDensity(next.dashboard_density, "compact"); next.dashboard_widgets = normalizeDashboardWidgets(next.dashboard_widgets); next.dashboard_hidden_widgets = normalizeHiddenDashboardWidgets(next.dashboard_hidden_widgets); next.dashboard_widget_sizes = normalizeDashboardWidgetSizes(next.dashboard_widget_sizes); next.dashboard_widget_heights = normalizeDashboardWidgetHeights(next.dashboard_widget_heights); next.dashboard_auto_fit = next.dashboard_auto_fit !== false; next.sidebar_collapsed = next.sidebar_collapsed === true; const saved = await api("/api/preferences", { method: "PUT", body: JSON.stringify(next) }); if (!authenticated || epoch !== authEpoch) return adminPreferences; return applyAdminPreferences(saved); diff --git a/src/admin/web/app/sidebar.js b/src/admin/web/app/sidebar.js new file mode 100644 index 0000000..4369b64 --- /dev/null +++ b/src/admin/web/app/sidebar.js @@ -0,0 +1,54 @@ +// Collapsible left menu. On wide screens (above 1300 px) the account preference +// sidebar_collapsed shows the menu as a narrow rail (logo, section numbers or icons, +// status dot). Between 851 and 1300 px the menu is always a rail and the toggle +// opens it temporarily as an overlay (closed by choosing a section, clicking +// outside or Esc). At 850 px and below the existing mobile menu is used. +window.rustProxySidebar = (() => { + const root = document.documentElement; + const sidebar = document.querySelector(".sidebar"); + const toggle = document.getElementById("sidebar-collapse-toggle"); + if (!sidebar || !toggle) return { applyPreference() { }, refresh() { } }; + const desktop = window.matchMedia("(min-width: 851px)"); + const rail = window.matchMedia("(min-width: 851px) and (max-width: 1300px)"); + const label = key => (typeof t === "function" ? t(key) : key); + let preferred = false, peek = false; + + function render() { + if (!desktop.matches || !rail.matches) peek = false; + const collapsed = desktop.matches && (rail.matches ? !peek : preferred); + root.classList.toggle("sidebar-collapsed", collapsed); + root.classList.toggle("sidebar-peek", peek); + const key = collapsed ? "sidebarExpand" : "sidebarCollapse"; + toggle.setAttribute("aria-expanded", String(!collapsed)); + toggle.setAttribute("aria-label", label(key)); + toggle.title = label(key); + toggle.querySelector(".sidebar-collapse-label").textContent = label(key); + toggle.querySelector(".sidebar-collapse-icon").textContent = collapsed ? "»" : "«"; + for (const nav of sidebar.querySelectorAll(".nav")) { + if (collapsed) nav.title = nav.querySelector("b")?.textContent.replace(/\s+/g, " ").trim() || ""; + else nav.removeAttribute("title"); + } + } + function setPeek(open) { + if (peek === open) return; + peek = open; + render(); + } + toggle.addEventListener("click", event => { + event.stopPropagation(); + if (rail.matches) { setPeek(!peek); return; } + preferred = !preferred; + render(); + window.rustProxyPreferences?.update?.({ sidebar_collapsed: preferred })?.catch?.(() => { }); + }); + sidebar.querySelectorAll(".nav, .brand").forEach(item => item.addEventListener("click", () => setPeek(false))); + document.addEventListener("click", event => { if (peek && !sidebar.contains(event.target)) setPeek(false); }); + document.addEventListener("keydown", event => { if (event.key === "Escape" && peek) { setPeek(false); toggle.focus(); } }); + desktop.addEventListener?.("change", render); + rail.addEventListener?.("change", render); + render(); + return { + applyPreference(value) { preferred = value === true; render(); }, + refresh: render, + }; +})(); diff --git a/src/admin/web/css/20-shell.css b/src/admin/web/css/20-shell.css index 5509577..1541064 100644 --- a/src/admin/web/css/20-shell.css +++ b/src/admin/web/css/20-shell.css @@ -995,3 +995,125 @@ a.brand>.brand-mark[data-logo-effect="wobble"]>span { animation: none !important; } } + +/* Collapsible left menu (sidebar.js). Above 850 px the menu can be a narrow rail: + html.sidebar-collapsed (account preference on wide screens, always between 851 + and 1300 px) and html.sidebar-peek (rail temporarily opened as an overlay). */ +.sidebar-collapse { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + margin-top: 8px; + padding: 7px 10px; + border: 1px solid var(--line); + border-radius: var(--rp-radius-control, 0); + background: transparent; + color: var(--muted); + font: 11px var(--mono); + text-align: left; + cursor: pointer; +} + +.sidebar-collapse:hover, +.sidebar-collapse:focus-visible { + color: var(--text); + background: var(--rp-nav-hover-bg); +} + +.sidebar-collapse-icon { + width: 14px; + font-size: 14px; + line-height: 1; + text-align: center; +} + +.sidebar-collapse-label { + font-weight: 450; + white-space: nowrap; +} + +.sidebar-bottom[hidden]+.sidebar-collapse { + margin-top: auto; +} + +@media (min-width: 851px) { + html.sidebar-collapsed .shell, + html.sidebar-peek .shell { + grid-template-columns: 58px minmax(0, 1fr); + } + + html.sidebar-peek .shell>main { + grid-column: 2; + } + + html.sidebar-collapsed .sidebar { + align-items: center; + padding: 14px 7px; + } + + html.sidebar-collapsed .brand>span:not(.brand-mark), + html.sidebar-collapsed .edition, + html.sidebar-collapsed .sidebar .nav b, + html.sidebar-collapsed .sidebar-collapse-label, + html.sidebar-collapsed #app-health-label { + display: none; + } + + html.sidebar-collapsed .brand-mark { + width: 40px; + height: 40px; + font-size: 24px; + } + + html.sidebar-collapsed .brand-mark span { + font-size: 18px; + } + + html.sidebar-collapsed .sidebar nav { + width: 100%; + margin-top: 18px; + } + + html.sidebar-collapsed .sidebar .nav { + justify-content: center; + gap: 0; + padding: 9px 0; + } + + html.sidebar-collapsed .sidebar .nav span { + opacity: .9; + } + + html.sidebar-collapsed .sidebar .nav:has(>svg.ui-icon)>span { + display: none; + } + + html.sidebar-collapsed .sidebar-bottom { + width: 100%; + } + + html.sidebar-collapsed .app-health-button { + justify-content: center; + } + + html.sidebar-collapsed .sidebar-collapse { + justify-content: center; + padding: 7px 0; + } + + html.sidebar-peek .sidebar { + position: fixed; + top: 0; + left: 0; + z-index: 80; + width: 182px; + box-shadow: 10px 0 28px var(--shadow-strong, #0009); + } +} + +@media (max-width: 850px) { + .sidebar-collapse { + display: none; + } +} diff --git a/src/admin/web/index.html b/src/admin/web/index.html index c3c5a2e..91aea1c 100644 --- a/src/admin/web/index.html +++ b/src/admin/web/index.html @@ -65,6 +65,9 @@ data-i18n="appHealthSidebarChecking">CHECKING +
CONTROL ROOM /