const ADMIN_THEMES = new Set(["dark", "light"]); const ADMIN_TIME_FORMATS = new Set(["24h", "12h"]); const ADMIN_CHART_STYLES = new Set(["default", "soft", "technical", "high_contrast", "neon", "monochrome", "warm", "cool"]); const ADMIN_DENSITIES = new Set(["normal", "compact", "ultra"]); const ADMIN_BUTTON_STYLES = new Set(["normal", "compact"]); const ADMIN_DASHBOARD_WIDGETS = Object.freeze(["requests", "active", "bandwidth", "errors", "cache", "memory", "websocket", "http2", "http3", "tls", "http_protocol", "websocket_protocol"]); const ADMIN_APPEARANCE_DEFAULTS = Object.freeze({ theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", chart_style: "default", button_style: "normal", table_density: "normal", dashboard_density: "compact", dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [], }); const CHART_STYLE_KEYS = Object.freeze({ default: "chartStyleDefault", soft: "chartStyleSoft", technical: "chartStyleTechnical", high_contrast: "chartStyleHighContrast", neon: "chartStyleNeon", monochrome: "chartStyleMonochrome", warm: "chartStyleWarm", cool: "chartStyleCool" }); let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve(); function normalizeAdminTheme(value) { return ADMIN_THEMES.has(value) ? value : "dark"; } function normalizeAdminLanguage(value) { return value === "auto" || /^[A-Za-z0-9_-]{1,64}$/.test(value || "") ? value : "auto"; } function normalizeAdminUiScale(value) { const numeric = Number(value); return Number.isInteger(numeric) ? Math.min(125, Math.max(70, numeric)) : 100; } function normalizeAdminTimeFormat(value) { return ADMIN_TIME_FORMATS.has(value) ? value : "24h"; } function normalizeAdminChartStyle(value) { return ADMIN_CHART_STYLES.has(value) ? value : "default"; } function normalizeAdminButtonStyle(value) { return ADMIN_BUTTON_STYLES.has(value) ? value : "normal"; } function normalizeAdminDensity(value, fallback = "normal") { return ADMIN_DENSITIES.has(value) ? value : fallback; } function normalizeDashboardWidgets(value) { const result = [], seen = new Set(); for (const id of Array.isArray(value) ? value : []) if (ADMIN_DASHBOARD_WIDGETS.includes(id) && !seen.has(id)) { seen.add(id); result.push(id); } for (const id of ADMIN_DASHBOARD_WIDGETS) if (!seen.has(id)) result.push(id); return result; } function normalizeHiddenDashboardWidgets(value) { const seen = new Set(); return (Array.isArray(value) ? value : []).filter(id => ADMIN_DASHBOARD_WIDGETS.includes(id) && !seen.has(id) && seen.add(id)); } function browserLanguage() { const packs = [...languagePacks.values()], candidates = (navigator.languages || [navigator.language]).filter(Boolean).map(value => value.replaceAll("_", "-").toLowerCase()); return candidates.map(value => packs.find(pack => pack.meta.locale.toLowerCase() === value || pack.id.replaceAll("_", "-").toLowerCase() === value) || packs.find(pack => pack.meta.locale.toLowerCase().split("-")[0] === value.split("-")[0])).find(Boolean)?.id || defaultLanguage; } function applyAdminTheme(value, cache = true) { const theme = normalizeAdminTheme(value); document.documentElement.dataset.theme = theme; const select = $("ui-theme"); if (select) select.value = theme; if (cache) { try { localStorage.setItem("rp-theme", theme); } catch { } } return theme; } function applyAdminLanguage(value, cache = true, rerender = true) { const preference = normalizeAdminLanguage(value); languagePreference = preference; if (languagePacks.size) language = preference === "auto" ? browserLanguage() : (languagePacks.has(preference) ? preference : browserLanguage()); const select = $("language"); if (select) select.value = preference === "auto" ? "auto" : language; if (cache) { try { localStorage.setItem("rp-language", preference); } catch { } } if (rerender && languagePacks.size) translate(); return preference; } function applyAdminUiScale(value, cache = true) { const scale = normalizeAdminUiScale(value), root = document.documentElement, fieldScale = Math.min(1.1, Math.max(.9, 1 + (scale - 100) / 300)); root.style.setProperty("--ui-scale", String(scale / 100)); root.style.setProperty("--ui-field-font-size", `${(12 * fieldScale).toFixed(2)}px`); root.style.setProperty("--ui-field-pad-y", `${(6 * fieldScale).toFixed(2)}px`); root.style.setProperty("--ui-field-pad-x", `${(8 * fieldScale).toFixed(2)}px`); root.style.setProperty("--ui-field-min-height", `${(27 * fieldScale).toFixed(2)}px`); root.style.setProperty("--ui-check-size", `${(15 * fieldScale).toFixed(2)}px`); root.style.setProperty("--ui-range-height", `${(18 * fieldScale).toFixed(2)}px`); const slider = $("ui-scale"), output = $("ui-scale-value"); if (slider) slider.value = String(scale); if (output) output.textContent = `${scale}%`; if (cache) { try { localStorage.setItem("rp-ui-scale", String(scale)); } catch { } } return scale; } function applyAdminTimeFormat(value) { const format = normalizeAdminTimeFormat(value); const select = $("ui-time-format"); if (select) select.value = format; return format; } function applyAdminChartStyle(value) { const style = normalizeAdminChartStyle(value); document.documentElement.dataset.chartStyle = style; const select = $("ui-chart-style"); if (select) select.value = style; refreshChartStylePalette(style); return style; } function applyAdminButtonStyle(value) { const style = normalizeAdminButtonStyle(value); document.documentElement.dataset.buttonStyle = style; const select = $("ui-button-style"); if (select) select.value = style; return style; } function applyAdminTableDensity(value) { const density = normalizeAdminDensity(value); document.documentElement.dataset.tableDensity = density; const select = $("ui-table-density"); if (select) select.value = density; return density; } function applyAdminDashboardDensity(value) { const density = normalizeAdminDensity(value, "compact"); document.documentElement.dataset.dashboardDensity = density; const select = $("ui-dashboard-density"); if (select) select.value = density; return density; } function applyDashboardLayout(order, hidden) { const normalizedOrder = normalizeDashboardWidgets(order), hiddenSet = new Set(normalizeHiddenDashboardWidgets(hidden)); const grid = document.querySelector("#panel-overview .stats-grid"); if (!grid) return { order: normalizedOrder, hidden: [...hiddenSet] }; const nodes = new Map([...grid.querySelectorAll("[data-dashboard-widget]")].map(node => [node.dataset.dashboardWidget, node])); for (const id of normalizedOrder) { const node = nodes.get(id); if (!node) continue; node.hidden = hiddenSet.has(id); grid.append(node); } return { order: normalizedOrder, hidden: [...hiddenSet] }; } function dashboardWidgetLabel(id) { const node = document.querySelector(`[data-dashboard-widget="${CSS.escape(id)}"]`); return node?.querySelector("[data-i18n]")?.textContent?.trim() || id; } function renderDashboardWidgetSettings() { const list = $("dashboard-widget-list"); if (!list) return; const current = adminPreferences || ADMIN_APPEARANCE_DEFAULTS, order = normalizeDashboardWidgets(current.dashboard_widgets), hidden = new Set(normalizeHiddenDashboardWidgets(current.dashboard_hidden_widgets)); const rows = order.map((id, index) => { const row = el("div", undefined, "dashboard-widget-setting"); row.dataset.dashboardWidgetSetting = id; const visible = !hidden.has(id); const toggle = el("button", undefined, "dashboard-widget-toggle"); toggle.type = "button"; toggle.classList.toggle("is-hidden", !visible); toggle.setAttribute("aria-pressed", String(visible)); toggle.setAttribute("aria-label", `${t("dashboardWidgetVisible")}: ${dashboardWidgetLabel(id)}`); toggle.append(el("span", undefined, "dashboard-widget-visibility"), el("span", dashboardWidgetLabel(id), "dashboard-widget-setting-label")); const actions = el("span", undefined, "dashboard-widget-setting-actions"); const up = el("button", "↑", "quiet"); up.type = "button"; up.disabled = index === 0; up.setAttribute("aria-label", t("moveUp")); const down = el("button", "↓", "quiet"); down.type = "button"; down.disabled = index === order.length - 1; down.setAttribute("aria-label", t("moveDown")); toggle.addEventListener("click", () => { const previous = [...hidden], next = new Set(hidden); if (visible) next.add(id); else next.delete(id); if (adminPreferences) adminPreferences.dashboard_hidden_widgets = [...next]; applyDashboardLayout(order, [...next]); renderDashboardWidgetSettings(); void saveAppearancePreference({ dashboard_hidden_widgets: [...next] }, toggle, () => { if (adminPreferences) adminPreferences.dashboard_hidden_widgets = previous; applyDashboardLayout(order, previous); renderDashboardWidgetSettings(); }); }); const move = delta => { const previous = [...order], next = [...order], at = next.indexOf(id), target = at + delta; if (at < 0 || target < 0 || target >= next.length) return; [next[at], next[target]] = [next[target], next[at]]; if (adminPreferences) adminPreferences.dashboard_widgets = next; applyDashboardLayout(next, [...hidden]); renderDashboardWidgetSettings(); void saveAppearancePreference({ dashboard_widgets: next }, null, () => { if (adminPreferences) adminPreferences.dashboard_widgets = previous; applyDashboardLayout(previous, [...hidden]); renderDashboardWidgetSettings(); }); }; up.addEventListener("click", () => move(-1)); down.addEventListener("click", () => move(1)); actions.append(up, down); row.append(toggle, actions); return row; }); list.replaceChildren(...rows); } function refreshChartStylePalette(active = adminPreferences?.chart_style || "default") { const target = $("chart-style-palette"); if (!target || !languagePacks.size) return; const buttons = [...ADMIN_CHART_STYLES].map(style => { const button = el("button", undefined, "chart-style-choice"); button.type = "button"; button.dataset.chartPreset = style; button.classList.toggle("active", style === active); button.setAttribute("aria-pressed", String(style === active)); const swatches = el("span", undefined, "chart-style-swatches"); for (let i = 1; i <= 4; i++) swatches.append(el("i")); button.append(swatches, el("span", t(CHART_STYLE_KEYS[style]))); button.addEventListener("click", () => { const select = $("ui-chart-style"); if (!select) return; select.value = style; select.dispatchEvent(new Event("change", { bubbles: true })); }); return button; }); target.replaceChildren(...buttons); } function refreshAppearanceUi() { refreshChartStylePalette(); renderDashboardWidgetSettings(); window.rustProxySettings?.refreshSearch?.(); } function formatDateTime(value) { const date = value instanceof Date ? value : new Date(value); return date.toLocaleString(locale(), { hour12: (adminPreferences?.time_format || "24h") === "12h" }); } function formatTime(value) { const date = value instanceof Date ? value : new Date(value); return date.toLocaleTimeString(locale(), { hour12: (adminPreferences?.time_format || "24h") === "12h" }); } function applyAdminPreferences(value, cache = true) { if (!value) return value; const dashboard = applyDashboardLayout(value.dashboard_widgets, value.dashboard_hidden_widgets); const normalized = { ...value, theme: applyAdminTheme(value.theme, cache), language: applyAdminLanguage(value.language, cache, false), ui_scale: applyAdminUiScale(value.ui_scale, cache), time_format: applyAdminTimeFormat(value.time_format), chart_style: applyAdminChartStyle(value.chart_style), button_style: applyAdminButtonStyle(value.button_style), table_density: applyAdminTableDensity(value.table_density), dashboard_density: applyAdminDashboardDensity(value.dashboard_density), dashboard_widgets: dashboard.order, dashboard_hidden_widgets: dashboard.hidden, }; adminPreferences = normalized; window.rustProxyVhosts?.applyPreferences(normalized); window.rustProxyDocker?.applyPreferences(normalized); window.rustProxyRedirects?.applyPreferences(normalized); window.rustProxyNotFound?.applyPreferences(normalized); window.rustProxyStreams?.applyPreferences(normalized); if (languagePacks.size) translate(); refreshAppearanceUi(); return normalized; } try { applyAdminTheme(localStorage.getItem("rp-theme") || "dark", false); } catch { applyAdminTheme("dark", false); } try { applyAdminUiScale(localStorage.getItem("rp-ui-scale") || 100, false); } catch { applyAdminUiScale(100, false); } async function loadAdminPreferences(force = false) { if (!authenticated) return adminPreferences; if (adminPreferences && !force) return adminPreferences; if (adminPreferencesLoading) return adminPreferencesLoading; const epoch = authEpoch; adminPreferencesLoading = (async () => { const value = await api("/api/preferences"); if (!authenticated || epoch !== authEpoch) return adminPreferences; return applyAdminPreferences(value); })(); try { return await adminPreferencesLoading; } finally { adminPreferencesLoading = null; } } function updateAdminPreferences(patch) { const epoch = authEpoch; const task = adminPreferencesWrite.then(async () => { if (!authenticated || epoch !== authEpoch) throw new Error(t("auth.required")); const current = await loadAdminPreferences(); if (!current || !authenticated || epoch !== authEpoch) throw new Error(t("auth.required")); const next = { ...current, ...patch }; 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.button_style = normalizeAdminButtonStyle(next.button_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); const saved = await api("/api/preferences", { method: "PUT", body: JSON.stringify(next) }); if (!authenticated || epoch !== authEpoch) return adminPreferences; return applyAdminPreferences(saved); }); adminPreferencesWrite = task.catch(() => undefined); return task; } function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesWrite = Promise.resolve(); } window.rustProxyPreferences = { load: loadAdminPreferences, update: updateAdminPreferences, current: () => adminPreferences, applyTheme: applyAdminTheme, refreshAppearanceUi }; async function saveAppearancePreference(patch, control, rollback) { if (control) control.disabled = true; try { await updateAdminPreferences(patch); } catch (error) { rollback?.(); if (error.status !== 401) toast(error.message, true); } finally { if (control) control.disabled = false; } } $("language")?.addEventListener("change", () => { const control = $("language"), previous = adminPreferences?.language || "auto", next = normalizeAdminLanguage(control.value); applyAdminLanguage(next); void saveAppearancePreference({ language: next }, control, () => applyAdminLanguage(previous)); }); $("ui-theme")?.addEventListener("change", () => { const control = $("ui-theme"), previous = adminPreferences?.theme || "dark", next = normalizeAdminTheme(control.value); applyAdminTheme(next); void saveAppearancePreference({ theme: next }, control, () => applyAdminTheme(previous)); }); $("ui-scale")?.addEventListener("input", () => applyAdminUiScale($("ui-scale").value)); $("ui-scale")?.addEventListener("change", () => { const control = $("ui-scale"), previous = adminPreferences?.ui_scale || 100, next = normalizeAdminUiScale(control.value); void saveAppearancePreference({ ui_scale: next }, control, () => applyAdminUiScale(previous)); }); $("ui-time-format")?.addEventListener("change", () => { const control = $("ui-time-format"), previous = adminPreferences?.time_format || "24h", next = normalizeAdminTimeFormat(control.value); applyAdminTimeFormat(next); if (adminPreferences) adminPreferences.time_format = next; translate(); void saveAppearancePreference({ time_format: next }, control, () => { if (adminPreferences) adminPreferences.time_format = previous; applyAdminTimeFormat(previous); translate(); }); }); $("ui-chart-style")?.addEventListener("change", () => { const control = $("ui-chart-style"), previous = adminPreferences?.chart_style || "default", next = normalizeAdminChartStyle(control.value); applyAdminChartStyle(next); if (adminPreferences) adminPreferences.chart_style = next; void saveAppearancePreference({ chart_style: next }, control, () => { if (adminPreferences) adminPreferences.chart_style = previous; applyAdminChartStyle(previous); }); }); $("ui-button-style")?.addEventListener("change", () => { const control = $("ui-button-style"), previous = adminPreferences?.button_style || "normal", next = normalizeAdminButtonStyle(control.value); applyAdminButtonStyle(next); if (adminPreferences) adminPreferences.button_style = next; void saveAppearancePreference({ button_style: next }, control, () => { if (adminPreferences) adminPreferences.button_style = previous; applyAdminButtonStyle(previous); }); }); $("ui-table-density")?.addEventListener("change", () => { const control = $("ui-table-density"), previous = adminPreferences?.table_density || "normal", next = normalizeAdminDensity(control.value); applyAdminTableDensity(next); if (adminPreferences) adminPreferences.table_density = next; void saveAppearancePreference({ table_density: next }, control, () => { if (adminPreferences) adminPreferences.table_density = previous; applyAdminTableDensity(previous); }); }); $("ui-dashboard-density")?.addEventListener("change", () => { const control = $("ui-dashboard-density"), previous = adminPreferences?.dashboard_density || "compact", next = normalizeAdminDensity(control.value, "compact"); applyAdminDashboardDensity(next); if (adminPreferences) adminPreferences.dashboard_density = next; void saveAppearancePreference({ dashboard_density: next }, control, () => { if (adminPreferences) adminPreferences.dashboard_density = previous; applyAdminDashboardDensity(previous); }); }); async function resetAppearancePatch(patch, control, messageKey = "appearanceSectionResetDone") { if (control) control.disabled = true; let previous = null; try { const current = await loadAdminPreferences(); previous = current ? { ...current, dashboard_widgets: [...current.dashboard_widgets], dashboard_hidden_widgets: [...current.dashboard_hidden_widgets] } : null; if (current) applyAdminPreferences({ ...current, ...patch }); await updateAdminPreferences(patch); toast(t(messageKey)); } catch (error) { if (previous) applyAdminPreferences(previous); if (error.status !== 401) toast(error.message, true); } finally { if (control) control.disabled = false; } } document.querySelectorAll("[data-appearance-reset]").forEach(control => control.addEventListener("click", () => { const patches = { interface: { theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", button_style: "normal" }, charts: { chart_style: "default" }, tables: { table_density: "normal" }, dashboard: { dashboard_density: "compact", dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [] }, }; void resetAppearancePatch(patches[control.dataset.appearanceReset] || {}, control); })); $("appearance-reset")?.addEventListener("click", () => void resetAppearancePatch({ ...ADMIN_APPEARANCE_DEFAULTS, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [] }, $("appearance-reset"), "appearanceResetDone")); refreshAppearanceUi();