ultra_settings 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -1
  3. data/MIT-LICENSE.txt +1 -1
  4. data/README.md +108 -1
  5. data/VERSION +1 -1
  6. data/app/AGENTS.md +7 -0
  7. data/app/_config_description.html.erb +22 -25
  8. data/app/_config_list.html.erb +2 -14
  9. data/app/_data_source.html.erb +53 -0
  10. data/app/application.css +1078 -259
  11. data/app/application.js +818 -91
  12. data/app/application_vars.css.erb +136 -81
  13. data/app/configuration.html.erb +60 -107
  14. data/app/index.html.erb +164 -20
  15. data/app/layout.css +81 -16
  16. data/app/layout.html.erb +67 -5
  17. data/app/layout_vars.css.erb +29 -5
  18. data/app/locales/ar.json +71 -0
  19. data/app/locales/cs.json +71 -0
  20. data/app/locales/da.json +71 -0
  21. data/app/locales/de.json +71 -0
  22. data/app/locales/el.json +71 -0
  23. data/app/locales/en.json +85 -0
  24. data/app/locales/es.json +71 -0
  25. data/app/locales/fa.json +71 -0
  26. data/app/locales/fr.json +71 -0
  27. data/app/locales/gd.json +71 -0
  28. data/app/locales/he.json +71 -0
  29. data/app/locales/hi.json +71 -0
  30. data/app/locales/it.json +71 -0
  31. data/app/locales/ja.json +71 -0
  32. data/app/locales/ko.json +71 -0
  33. data/app/locales/lt.json +71 -0
  34. data/app/locales/nb.json +71 -0
  35. data/app/locales/nl.json +71 -0
  36. data/app/locales/pl.json +71 -0
  37. data/app/locales/pt-br.json +71 -0
  38. data/app/locales/pt.json +71 -0
  39. data/app/locales/ru.json +71 -0
  40. data/app/locales/sv.json +71 -0
  41. data/app/locales/ta.json +71 -0
  42. data/app/locales/tr.json +71 -0
  43. data/app/locales/uk.json +71 -0
  44. data/app/locales/ur.json +71 -0
  45. data/app/locales/vi.json +71 -0
  46. data/app/locales/zh-cn.json +71 -0
  47. data/app/locales/zh-tw.json +71 -0
  48. data/lib/ultra_settings/application_view.rb +21 -3
  49. data/lib/ultra_settings/audit_data_sources.rb +98 -0
  50. data/lib/ultra_settings/coerce.rb +0 -6
  51. data/lib/ultra_settings/config_helper.rb +4 -4
  52. data/lib/ultra_settings/configuration.rb +28 -7
  53. data/lib/ultra_settings/configuration_view.rb +117 -56
  54. data/lib/ultra_settings/mini_i18n.rb +110 -0
  55. data/lib/ultra_settings/rack_app.rb +51 -1
  56. data/lib/ultra_settings/railtie.rb +8 -0
  57. data/lib/ultra_settings/tasks/audit_data_sources.rake +76 -0
  58. data/lib/ultra_settings/tasks/utils.rb +23 -0
  59. data/lib/ultra_settings/version.rb +1 -1
  60. data/lib/ultra_settings/web_view.rb +33 -2
  61. data/lib/ultra_settings.rb +56 -22
  62. data/ultra_settings.gemspec +3 -0
  63. metadata +38 -3
  64. data/app/_select_menu.html.erb +0 -53
data/app/application.js CHANGED
@@ -1,124 +1,851 @@
1
1
  document.addEventListener("DOMContentLoaded", () => {
2
- const dropdown = document.getElementById("config-dropdown");
3
- const button = document.getElementById("config-dropdown-button");
4
- const menu = document.getElementById("config-dropdown-menu");
5
- const searchInput = document.getElementById("config-search");
6
- const items = document.querySelectorAll(".ultra-settings-dropdown-item");
7
- const configurations = document.querySelectorAll(".ultra-settings-configuration");
8
- const configList = document.querySelector(".ultra-settings-configuration-list");
9
-
10
- // If no dropdown, we might be in single config mode or no configs.
11
- if (!dropdown) {
12
- // If there is exactly one configuration, show it.
13
- if (configurations.length === 1) {
14
- configurations[0].style.display = "block";
15
- }
16
- return;
17
- }
2
+ // ── i18n helper ──
3
+ const _i18n = window.__ultraSettingsI18n || {};
4
+ const t = (key) => _i18n[key] || key;
18
5
 
19
- const toggleMenu = () => {
20
- const isVisible = menu.style.display === "block";
21
- menu.style.display = isVisible ? "none" : "block";
22
- if (!isVisible) {
23
- searchInput.value = "";
24
- filterItems("");
25
- searchInput.focus();
26
- }
27
- };
6
+ const root = document.querySelector(".ultra-settings");
7
+ const searchInput = document.getElementById("ultra-settings-search-input");
8
+ const searchClear = document.getElementById("ultra-settings-search-clear");
9
+ const configList = document.getElementById("ultra-settings-config-list");
10
+ const configDetail = document.getElementById("ultra-settings-config-detail");
11
+ const configListItems = document.querySelectorAll(".ultra-settings-config-list-item");
12
+ const sections = document.querySelectorAll(".ultra-settings-config-section");
13
+ const panelBg = document.getElementById("ultra-settings-panel-bg");
14
+ const detailPanel = document.getElementById("ultra-settings-detail-panel");
15
+ const dpTitle = document.getElementById("ultra-settings-dp-title");
16
+ const dpValue = document.getElementById("ultra-settings-dp-value");
17
+ const dpMeta = document.getElementById("ultra-settings-dp-meta");
18
+ const dpClose = document.getElementById("ultra-settings-dp-close");
19
+ const languageMenu = document.getElementById("ultra-settings-language-menu");
20
+ const languageOptions = document.querySelectorAll(".ultra-settings-language-option");
28
21
 
29
- const closeMenu = () => {
30
- menu.style.display = "none";
22
+ const closeLanguageMenu = () => {
23
+ if (languageMenu) languageMenu.removeAttribute("open");
31
24
  };
32
25
 
33
- const filterItems = (query) => {
34
- const lowerQuery = query.toLowerCase();
35
- items.forEach(item => {
36
- const label = item.getAttribute("data-search").toLowerCase();
37
- if (label.includes(lowerQuery)) {
38
- item.style.display = "flex";
39
- } else {
40
- item.style.display = "none";
41
- }
42
- });
43
- };
26
+ if (!root) return;
44
27
 
45
- const showConfigList = () => {
46
- if (configList) configList.style.display = "grid";
47
- configurations.forEach(config => config.style.display = "none");
48
- items.forEach(item => item.classList.remove("selected"));
49
- button.textContent = "Select Configuration";
50
- closeMenu();
28
+ const singleConfig = root.dataset.singleConfig || null;
29
+ let selectedConfigId = null;
30
+ let initialLoad = true;
31
+
32
+ // ── View transition helper ──
33
+ let animating = false;
34
+ const animateView = (outEl, inEl, callback) => {
35
+ if (animating) return;
36
+ if (!outEl || !inEl) { if (callback) callback(); return; }
37
+
38
+ // Skip animations entirely when duration is forced to 0 (e.g. in tests)
39
+ const duration = parseFloat(getComputedStyle(outEl).animationDuration);
40
+ if (duration === 0) {
41
+ outEl.style.display = "none";
42
+ inEl.style.display = "";
43
+ if (callback) callback();
44
+ return;
45
+ }
46
+
47
+ animating = true;
48
+ // Ensure the incoming element is hidden until the exit animation finishes
49
+ inEl.style.display = "none";
50
+ outEl.classList.add("ultra-settings-view-exit");
51
+ outEl.addEventListener("animationend", function handler() {
52
+ outEl.removeEventListener("animationend", handler);
53
+ outEl.classList.remove("ultra-settings-view-exit");
54
+ outEl.style.display = "none";
55
+ inEl.style.display = "";
56
+ inEl.classList.add("ultra-settings-view-enter");
57
+ inEl.addEventListener("animationend", function handler2() {
58
+ inEl.removeEventListener("animationend", handler2);
59
+ inEl.classList.remove("ultra-settings-view-enter");
60
+ animating = false;
61
+ }, { once: true });
62
+ if (callback) callback();
63
+ }, { once: true });
51
64
  };
52
65
 
53
- const showConfig = (configId) => {
54
- if (configList) configList.style.display = "none";
66
+ // ── Config Selection ──
67
+ const selectConfig = (configId) => {
68
+ selectedConfigId = configId;
55
69
 
56
- configurations.forEach(config => {
57
- config.style.display = config.id === configId ? "block" : "none";
70
+ // Show only the selected section
71
+ sections.forEach(s => {
72
+ s.style.display = (s.dataset.configId === configId) ? "" : "none";
58
73
  });
59
74
 
60
- items.forEach(item => {
61
- if (item.getAttribute("data-value") === configId) {
62
- item.classList.add("selected");
63
- button.textContent = item.getAttribute("data-label");
75
+ // Update search field
76
+ if (searchInput) {
77
+ const configName = configId.replace(/^section-/, "");
78
+ searchInput.value = configName;
79
+ searchInput.readOnly = true;
80
+ }
81
+ if (searchClear) searchClear.classList.add("visible");
82
+
83
+ // Update hash
84
+ const configName = configId.replace(/^section-/, "");
85
+ if (window.location.hash !== "#" + configName) {
86
+ history.replaceState(null, "", "#" + configName);
87
+ }
88
+
89
+ // Scroll to top
90
+ window.scrollTo(0, 0);
91
+
92
+ // Animate list → detail
93
+ if (configList && configDetail && !initialLoad) {
94
+ if (configList.style.display === "none") {
95
+ // Already showing detail, just swap content
96
+ configDetail.classList.add("active");
64
97
  } else {
65
- item.classList.remove("selected");
98
+ configDetail.classList.add("active");
99
+ animateView(configList, configDetail);
66
100
  }
67
- });
68
-
69
- closeMenu();
101
+ } else {
102
+ if (configList) configList.style.display = "none";
103
+ if (configDetail) configDetail.classList.add("active");
104
+ }
70
105
  };
71
106
 
72
- // Event Listeners
73
- button.addEventListener("click", (e) => {
74
- e.stopPropagation();
75
- toggleMenu();
76
- });
107
+ const clearSelection = () => {
108
+ selectedConfigId = null;
77
109
 
78
- document.addEventListener("click", (e) => {
79
- if (!dropdown.contains(e.target)) {
80
- closeMenu();
110
+ // Hide all sections
111
+ sections.forEach(s => { s.style.display = "none"; });
112
+
113
+ // Reset search field
114
+ if (searchInput) {
115
+ searchInput.value = "";
116
+ searchInput.readOnly = false;
81
117
  }
82
- });
118
+ if (searchClear) searchClear.classList.remove("visible");
83
119
 
84
- searchInput.addEventListener("input", (e) => {
85
- filterItems(e.target.value);
86
- });
120
+ // Show all list items (clear any filter)
121
+ configListItems.forEach(item => item.classList.remove("hidden"));
87
122
 
88
- items.forEach(item => {
89
- item.addEventListener("click", () => {
90
- const configId = item.getAttribute("data-value");
91
- const hash = configId.replace(/^config-/, "");
123
+ // Clear hash
124
+ history.replaceState(null, "", window.location.pathname + window.location.search);
92
125
 
93
- if (item.classList.contains("selected")) {
94
- // Toggle off: clear hash
95
- history.pushState("", document.title, window.location.pathname + window.location.search);
96
- handleHashChange();
97
- } else {
98
- window.location.hash = hash;
99
- }
126
+ // Animate detail → list
127
+ if (configDetail && configList) {
128
+ animateView(configDetail, configList, () => {
129
+ // Clean up: let CSS classes control display again
130
+ configDetail.classList.remove("active");
131
+ configDetail.style.display = "";
132
+ configList.style.display = "";
133
+ });
134
+ } else {
135
+ if (configList) configList.style.display = "";
136
+ if (configDetail) configDetail.classList.remove("active");
137
+ }
138
+ };
139
+
140
+ // ── Config List Item Handlers ──
141
+ configListItems.forEach(item => {
142
+ item.addEventListener("click", () => selectConfig(item.dataset.configId));
143
+ item.addEventListener("keydown", (e) => {
144
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); selectConfig(item.dataset.configId); }
100
145
  });
101
146
  });
102
147
 
103
- // Initial Load & Hash Change
104
- const handleHashChange = () => {
148
+ // ── Search Filter (list view) ──
149
+ if (searchInput) {
150
+ searchInput.addEventListener("input", function() {
151
+ if (selectedConfigId) return; // Don't filter when a config is selected
152
+
153
+ const q = this.value.toLowerCase().trim();
154
+ configListItems.forEach(item => {
155
+ const searchData = item.dataset.search || "";
156
+ const match = !q || searchData.includes(q);
157
+ item.classList.toggle("hidden", !match);
158
+ });
159
+ });
160
+ }
161
+
162
+ // ── Clear Button ──
163
+ if (searchClear) {
164
+ searchClear.addEventListener("click", clearSelection);
165
+ }
166
+
167
+ // ── Hash-based Navigation ──
168
+ const handleHash = () => {
105
169
  const hash = window.location.hash.replace(/^#/, "");
106
170
  if (hash) {
107
- const configId = `config-${hash}`;
108
- // Check if config exists
109
- const exists = Array.from(items).some(item => item.getAttribute("data-value") === configId);
171
+ const configId = "section-" + hash;
172
+ const exists = Array.from(sections).some(s => s.dataset.configId === configId);
110
173
  if (exists) {
111
- showConfig(configId);
112
- } else {
113
- showConfigList();
174
+ selectConfig(configId);
175
+ return;
176
+ }
177
+ }
178
+ // No valid hash — if not single config, show list
179
+ if (!singleConfig && selectedConfigId) {
180
+ clearSelection();
181
+ }
182
+ };
183
+
184
+ window.addEventListener("hashchange", handleHash);
185
+
186
+ // ── Single Config Auto-Select ──
187
+ if (singleConfig) {
188
+ selectConfig(singleConfig);
189
+ } else {
190
+ // Check for stored config (after SuperSettings save reload)
191
+ const storedConfig = sessionStorage.getItem("ultra-settings-selected-config");
192
+ if (storedConfig) {
193
+ sessionStorage.removeItem("ultra-settings-selected-config");
194
+ const exists = Array.from(sections).some(s => s.dataset.configId === storedConfig);
195
+ if (exists) {
196
+ selectConfig(storedConfig);
114
197
  }
115
198
  } else {
116
- showConfigList();
199
+ handleHash();
200
+ }
201
+ }
202
+
203
+ initialLoad = false;
204
+
205
+ // ── Detail Panel ──
206
+ const openPanel = (name, value, type, isSecret) => {
207
+ if (dpTitle) dpTitle.textContent = name;
208
+ if (dpValue) dpValue.textContent = isSecret === "true" ? t("detail.secret_value") : value;
209
+ if (dpMeta) dpMeta.innerHTML = t("detail.type_label") + " <span>" + escapeHtml(type.toUpperCase()) + "</span>" + (isSecret === "true" ? ' \u00B7 <span style="color:var(--badge-secret-text)">' + t("detail.secret_badge") + "</span>" : "");
210
+ if (panelBg) panelBg.classList.add("open");
211
+ if (detailPanel) detailPanel.classList.add("open");
212
+ document.body.style.overflow = "hidden";
213
+ };
214
+
215
+ const closePanel = () => {
216
+ if (panelBg) panelBg.classList.remove("open");
217
+ if (detailPanel) detailPanel.classList.remove("open");
218
+ document.body.style.overflow = "";
219
+ };
220
+
221
+ if (panelBg) panelBg.addEventListener("click", closePanel);
222
+ if (dpClose) dpClose.addEventListener("click", closePanel);
223
+
224
+ // Delegate click on field values to open panel
225
+ document.addEventListener("click", (e) => {
226
+ const target = e.target.closest(".ultra-settings-field-value");
227
+ if (target) {
228
+ // If we're inside the full app shell with panel, use panel
229
+ if (detailPanel) {
230
+ openPanel(
231
+ target.dataset.name || "",
232
+ target.dataset.value || "",
233
+ target.dataset.type || "",
234
+ target.dataset.secret || "false"
235
+ );
236
+ } else {
237
+ // Fallback: use dialog if available
238
+ const block = target.closest(".ultra-settings-block");
239
+ if (block) {
240
+ const dialog = block.querySelector(".ultra-settings-dialog");
241
+ if (dialog) {
242
+ const title = dialog.querySelector(".ultra-settings-dialog-title");
243
+ const value = dialog.querySelector(".ultra-settings-dialog-value");
244
+ if (title) title.textContent = target.dataset.name || "";
245
+ if (value) value.textContent = target.dataset.value || "";
246
+ dialog.showModal();
247
+ }
248
+ }
249
+ }
117
250
  }
251
+ });
252
+
253
+ // ── Keyboard Shortcuts ──
254
+ let ssSubmittingRef = () => false;
255
+ document.addEventListener("keydown", (e) => {
256
+ if (e.key === "Escape") {
257
+ if (ssSubmittingRef()) return;
258
+ closeLanguageMenu();
259
+ closePanel();
260
+ // Close SuperSettings edit panel if open
261
+ const ssBg = document.getElementById("ultra-settings-ss-panel-bg");
262
+ const ssP = document.getElementById("ultra-settings-ss-panel");
263
+ if (ssBg) ssBg.classList.remove("open");
264
+ if (ssP) ssP.classList.remove("open");
265
+ document.body.style.overflow = "";
266
+ }
267
+ });
268
+
269
+ // ── Equalize chip widths ──
270
+ const equalizeChipWidths = () => {
271
+ const chips = document.querySelectorAll(".ultra-settings-source-chip");
272
+ if (!chips.length) return;
273
+ chips.forEach(c => c.style.minWidth = "auto");
274
+ let max = 0;
275
+ chips.forEach(c => { max = Math.max(max, c.offsetWidth); });
276
+ const container = document.querySelector(".ultra-settings");
277
+ if (container) container.style.setProperty("--chip-width", max + "px");
278
+ chips.forEach(c => c.style.minWidth = "");
118
279
  };
119
280
 
120
- window.addEventListener("hashchange", handleHashChange);
281
+ equalizeChipWidths();
282
+
283
+ // ── Restore selection & flash after setting save ──
284
+ const changedKey = sessionStorage.getItem("ultra-settings-changed-key");
285
+ if (changedKey) {
286
+ sessionStorage.removeItem("ultra-settings-changed-key");
287
+ const changedSection = sessionStorage.getItem("ultra-settings-changed-section");
288
+ sessionStorage.removeItem("ultra-settings-changed-section");
289
+ // Find the edit button with the matching key and highlight its field card
290
+ const scope = (changedSection && document.getElementById(changedSection)) || document;
291
+ const editBtn = scope.querySelector('.ultra-settings-ss-edit-btn[data-ss-key="' + CSS.escape(changedKey) + '"]');
292
+ if (editBtn) {
293
+ const card = editBtn.closest(".ultra-settings-field-card");
294
+ if (card) {
295
+ card.classList.add("ultra-settings-changed");
296
+ card.addEventListener("animationend", () => card.classList.remove("ultra-settings-changed"), { once: true });
297
+ }
298
+ }
299
+ }
300
+
301
+ // ══════════════════════════════════════════
302
+ // SuperSettings Inline Editing
303
+ // ══════════════════════════════════════════
304
+ const ssApiPath = root ? root.dataset.ssApiPath : null;
305
+ if (ssApiPath) {
306
+ // Load the SuperSettings api.js client library and check access
307
+ const apiBaseEl = document.createElement("div");
308
+ apiBaseEl.className = "super-settings";
309
+ apiBaseEl.dataset.apiBaseUrl = ssApiPath;
310
+ apiBaseEl.style.display = "none";
311
+ document.body.appendChild(apiBaseEl);
312
+
313
+ const script = document.createElement("script");
314
+ script.src = ssApiPath + "/api.js";
315
+ script.onload = () => {
316
+ if (!window.SuperSettingsAPI || !SuperSettingsAPI.authorized) {
317
+ console.log("SuperSettings: api.js loaded but SuperSettingsAPI not available");
318
+ return;
319
+ }
320
+
321
+ SuperSettingsAPI.baseUrl = ssApiPath;
322
+ SuperSettingsAPI.authorized(
323
+ (permission) => {
324
+ if (permission !== "read-write") {
325
+ console.log("SuperSettings: authorization is '" + permission + "', editing disabled (requires read-write)");
326
+ return;
327
+ }
328
+ enableSuperSettingsUI();
329
+ },
330
+ (err) => {
331
+ console.log("SuperSettings: access check failed, editing disabled", err);
332
+ }
333
+ );
334
+ };
335
+ script.onerror = () => {
336
+ console.log("SuperSettings: failed to load api.js, editing disabled");
337
+ };
338
+ document.head.appendChild(script);
339
+
340
+ // Enable the editing UI after api.js is loaded and access is confirmed
341
+ const enableSuperSettingsUI = () => {
342
+ const ssPanel = document.getElementById("ultra-settings-ss-panel");
343
+ const ssPanelBg = document.getElementById("ultra-settings-ss-panel-bg");
344
+ const ssForm = document.getElementById("ultra-settings-ss-form");
345
+ const ssLoading = document.getElementById("ultra-settings-ss-loading");
346
+ const ssErrors = document.getElementById("ultra-settings-ss-errors");
347
+ const ssKeyInput = document.getElementById("ultra-settings-ss-key");
348
+ const ssTitle = document.getElementById("ultra-settings-ss-title");
349
+ const ssValueTypeSelect = document.getElementById("ultra-settings-ss-value-type");
350
+ const ssValueTextarea = document.getElementById("ultra-settings-ss-value");
351
+ const ssValueField = document.getElementById("ultra-settings-ss-value-field");
352
+ const ssIntegerField = document.getElementById("ultra-settings-ss-integer-field");
353
+ const ssIntegerInput = document.getElementById("ultra-settings-ss-integer-value");
354
+ const ssFloatField = document.getElementById("ultra-settings-ss-float-field");
355
+ const ssFloatInput = document.getElementById("ultra-settings-ss-float-value");
356
+ const ssBooleanField = document.getElementById("ultra-settings-ss-boolean-field");
357
+ const ssBooleanCheckbox = document.getElementById("ultra-settings-ss-boolean-value");
358
+ const ssDatetimeField = document.getElementById("ultra-settings-ss-datetime-field");
359
+ const ssDatetimeInput = document.getElementById("ultra-settings-ss-datetime-value");
360
+ const ssTzLabel = document.getElementById("ultra-settings-ss-tz-label");
361
+ const ssDescriptionInput = document.getElementById("ultra-settings-ss-description");
362
+ const ssSaveBtn = document.getElementById("ultra-settings-ss-save");
363
+ const ssCancelBtn = document.getElementById("ultra-settings-ss-cancel");
364
+ const ssCloseBtn = document.getElementById("ultra-settings-ss-panel-close");
365
+ const ssExternalLink = document.getElementById("ultra-settings-ss-external-link");
366
+ const ssRuntimeUrlTemplate = root.dataset.runtimeSettingsUrl || "";
367
+
368
+ // Show all edit buttons (they are rendered hidden by default)
369
+ document.querySelectorAll(".ultra-settings-ss-edit-btn").forEach(btn => {
370
+ btn.style.display = "";
371
+ });
372
+
373
+ // Get the local timezone name for display
374
+ const localTz = (() => {
375
+ try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch(e) { return "UTC"; }
376
+ })();
377
+ if (ssTzLabel) ssTzLabel.textContent = localTz;
378
+
379
+ // Show/hide value fields based on type
380
+ const updateValueField = (type) => {
381
+ ssValueField.style.display = "none";
382
+ ssIntegerField.style.display = "none";
383
+ ssFloatField.style.display = "none";
384
+ ssBooleanField.style.display = "none";
385
+ ssDatetimeField.style.display = "none";
386
+
387
+ if (type === "boolean") {
388
+ ssBooleanField.style.display = "";
389
+ } else if (type === "integer") {
390
+ ssIntegerField.style.display = "";
391
+ } else if (type === "float") {
392
+ ssFloatField.style.display = "";
393
+ } else if (type === "datetime") {
394
+ ssDatetimeField.style.display = "";
395
+ } else if (type === "array") {
396
+ ssValueField.style.display = "";
397
+ ssValueTextarea.rows = 6;
398
+ ssValueTextarea.placeholder = t("edit.placeholder_array");
399
+ } else {
400
+ ssValueField.style.display = "";
401
+ ssValueTextarea.rows = 3;
402
+ ssValueTextarea.placeholder = "";
403
+ }
404
+ };
405
+
406
+ ssValueTypeSelect.addEventListener("change", () => {
407
+ updateValueField(ssValueTypeSelect.value);
408
+ });
409
+
410
+ // Enforce integer-only input: strip non-integer characters as the user types
411
+ if (ssIntegerInput) {
412
+ ssIntegerInput.addEventListener("input", () => {
413
+ const raw = ssIntegerInput.value;
414
+ // Allow empty, sole minus sign while typing, or valid integer
415
+ if (raw === "" || raw === "-") return;
416
+ const parsed = parseInt(raw, 10);
417
+ if (isNaN(parsed)) {
418
+ ssIntegerInput.value = "";
419
+ } else if (String(parsed) !== raw) {
420
+ ssIntegerInput.value = parsed;
421
+ }
422
+ });
423
+ }
424
+
425
+ // Convert the datetime-local input value (local time) to a UTC ISO 8601 string
426
+ const datetimeToISO = () => {
427
+ const localVal = ssDatetimeInput.value; // e.g. "2025-01-15T10:30:00"
428
+ if (!localVal) return "";
429
+ const d = new Date(localVal);
430
+ if (isNaN(d.getTime())) return localVal;
431
+ return d.toISOString();
432
+ };
433
+
434
+ // Parse a UTC ISO 8601 string and populate the datetime-local input in local time
435
+ const populateDatetime = (isoStr) => {
436
+ if (!isoStr) {
437
+ ssDatetimeInput.value = "";
438
+ return;
439
+ }
440
+ let str = String(isoStr).trim();
441
+ // Normalize Ruby Time#to_json format ("2026-03-20 01:07:56 UTC") to ISO 8601
442
+ str = str.replace(/ UTC$/, "Z").replace(/ /, "T");
443
+ // Ensure the string is treated as UTC if no timezone indicator present
444
+ if (!str.endsWith("Z") && !/[+-]\d{2}:?\d{2}$/.test(str)) {
445
+ str += "Z";
446
+ }
447
+ const d = new Date(str);
448
+ if (isNaN(d.getTime())) {
449
+ ssDatetimeInput.value = "";
450
+ return;
451
+ }
452
+ // Format as local datetime-local value: YYYY-MM-DDTHH:MM:SS
453
+ const pad = (n) => String(n).padStart(2, "0");
454
+ ssDatetimeInput.value = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) +
455
+ "T" + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds());
456
+ };
457
+
458
+ const getFormValue = () => {
459
+ if (ssValueTypeSelect.value === "boolean") {
460
+ return ssBooleanCheckbox.checked ? "true" : "false";
461
+ }
462
+ if (ssValueTypeSelect.value === "integer") {
463
+ return ssIntegerInput.value;
464
+ }
465
+ if (ssValueTypeSelect.value === "float") {
466
+ return ssFloatInput.value;
467
+ }
468
+ if (ssValueTypeSelect.value === "datetime") {
469
+ return datetimeToISO();
470
+ }
471
+ return ssValueTextarea.value;
472
+ };
473
+
474
+ // Fetch a setting using the SuperSettings API client
475
+ const fetchSetting = (key, callback) => {
476
+ SuperSettingsAPI.fetchSetting(key, callback, (status) => {
477
+ if (status === 404) {
478
+ callback(null);
479
+ } else {
480
+ console.error("SuperSettings: error fetching setting, status " + status);
481
+ callback(null);
482
+ }
483
+ });
484
+ };
485
+
486
+ // Save a setting via the SuperSettings API
487
+ const saveSetting = (params, callback) => {
488
+ SuperSettingsAPI.updateSettings(
489
+ {settings: [params]},
490
+ (data) => {
491
+ callback({status: 200, ok: true, data: data});
492
+ },
493
+ (error) => {
494
+ console.error("SuperSettings: error saving setting", error);
495
+ callback({status: 0, ok: false, data: {success: false, errors: {_http: [t("edit.network_error")]}}});
496
+ }
497
+ );
498
+ };
499
+
500
+ // Open the edit panel
501
+ const openSsPanel = (key, defaultType, defaultDescription) => {
502
+ if (!ssPanel) return;
503
+
504
+ // Close the detail panel if open
505
+ closePanel();
506
+
507
+ // Reset form
508
+ ssKeyInput.value = key;
509
+ if (ssTitle) ssTitle.textContent = key;
510
+ ssValueTextarea.value = "";
511
+ ssIntegerInput.value = "";
512
+ ssFloatInput.value = "";
513
+ ssDatetimeInput.value = "";
514
+ ssBooleanCheckbox.checked = false;
515
+ ssDescriptionInput.value = defaultDescription || "";
516
+ ssValueTypeSelect.value = defaultType || "string";
517
+ updateValueField(ssValueTypeSelect.value);
518
+ ssErrors.style.display = "none";
519
+ ssErrors.textContent = "";
520
+ ssForm.style.display = "none";
521
+ ssLoading.style.display = "";
522
+ ssSaveBtn.disabled = false;
523
+ ssSaveBtn.textContent = t("edit.save");
524
+
525
+ // Build and show external link if runtime_settings_url is configured
526
+ if (ssExternalLink && ssRuntimeUrlTemplate) {
527
+ const externalUrl = ssRuntimeUrlTemplate
528
+ .replace("${name}", encodeURIComponent(key))
529
+ .replace("${type}", encodeURIComponent(defaultType || ""))
530
+ .replace("${description}", encodeURIComponent(defaultDescription || ""));
531
+ ssExternalLink.href = externalUrl;
532
+ ssExternalLink.style.display = "";
533
+ } else if (ssExternalLink) {
534
+ ssExternalLink.style.display = "none";
535
+ }
536
+
537
+ // Reset history view (always start on edit form)
538
+ if (ssHistoryContainer) ssHistoryContainer.style.display = "none";
539
+
540
+ ssPanelBg.classList.add("open");
541
+ ssPanel.classList.add("open");
542
+ document.body.style.overflow = "hidden";
543
+
544
+ // Fetch existing setting
545
+ fetchSetting(key, (setting) => {
546
+ if (setting && !setting.error) {
547
+ // Existing setting — populate form with current values
548
+ ssValueTypeSelect.value = setting.value_type || defaultType || "string";
549
+ updateValueField(ssValueTypeSelect.value);
550
+
551
+ if (setting.value_type === "boolean") {
552
+ ssBooleanCheckbox.checked = (setting.value === true || setting.value === "true");
553
+ } else if (setting.value_type === "integer") {
554
+ ssIntegerInput.value = (setting.value != null) ? String(setting.value) : "";
555
+ } else if (setting.value_type === "float") {
556
+ ssFloatInput.value = (setting.value != null) ? String(setting.value) : "";
557
+ } else if (setting.value_type === "datetime") {
558
+ populateDatetime((setting.value != null) ? String(setting.value) : "");
559
+ } else if (setting.value_type === "array" && Array.isArray(setting.value)) {
560
+ ssValueTextarea.value = setting.value.join("\n");
561
+ } else {
562
+ ssValueTextarea.value = (setting.value != null) ? String(setting.value) : "";
563
+ }
564
+
565
+ if (setting.description) {
566
+ ssDescriptionInput.value = setting.description;
567
+ }
568
+ }
569
+ // If not found, defaults already applied
570
+
571
+ ssLoading.style.display = "none";
572
+ ssForm.style.display = "";
573
+ });
574
+ };
575
+
576
+ let ssSubmitting = false;
577
+ ssSubmittingRef = () => ssSubmitting;
578
+
579
+ const closeSsPanel = () => {
580
+ if (ssSubmitting) return;
581
+ if (ssPanelBg) ssPanelBg.classList.remove("open");
582
+ if (ssPanel) ssPanel.classList.remove("open");
583
+ document.body.style.overflow = "";
584
+ };
585
+
586
+ // Handle save
587
+ ssSaveBtn.addEventListener("click", () => {
588
+ const params = {
589
+ key: ssKeyInput.value,
590
+ value: getFormValue(),
591
+ value_type: ssValueTypeSelect.value,
592
+ description: ssDescriptionInput.value
593
+ };
594
+
595
+ ssSubmitting = true;
596
+ ssSaveBtn.disabled = true;
597
+ ssCancelBtn.disabled = true;
598
+ if (ssCloseBtn) ssCloseBtn.style.display = "none";
599
+ ssSaveBtn.innerHTML = '<svg class="ultra-settings-spinner" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-dasharray="31.4 31.4" /></svg>';
600
+ ssErrors.style.display = "none";
601
+
602
+ saveSetting(params, (result) => {
603
+ if (result.data && result.data.success) {
604
+ // Store selected config so we can restore it after reload
605
+ if (selectedConfigId) {
606
+ sessionStorage.setItem("ultra-settings-selected-config", selectedConfigId);
607
+ }
608
+ sessionStorage.setItem("ultra-settings-changed-key", params.key);
609
+ const activeSection = document.querySelector(".ultra-settings-config-section[style='']");
610
+ if (activeSection) {
611
+ sessionStorage.setItem("ultra-settings-changed-section", activeSection.id);
612
+ }
613
+ window.location.reload();
614
+ } else {
615
+ ssSubmitting = false;
616
+ ssSaveBtn.disabled = false;
617
+ ssCancelBtn.disabled = false;
618
+ if (ssCloseBtn) ssCloseBtn.style.display = "";
619
+ ssSaveBtn.textContent = t("edit.save");
620
+
621
+ // Collect all error messages from the response
622
+ const msgs = [];
623
+ if (result.data && result.data.errors) {
624
+ Object.entries(result.data.errors).forEach(([key, errs]) => {
625
+ if (Array.isArray(errs)) {
626
+ errs.forEach(e => msgs.push(String(e)));
627
+ } else {
628
+ msgs.push(String(errs));
629
+ }
630
+ });
631
+ } else if (result.data && result.data.error) {
632
+ msgs.push(String(result.data.error));
633
+ }
634
+
635
+ // Build the alert content
636
+ ssErrors.innerHTML = "";
637
+ if (msgs.length === 0) {
638
+ msgs.push(t("edit.save_error"));
639
+ }
640
+ if (msgs.length === 1) {
641
+ ssErrors.textContent = msgs[0];
642
+ } else {
643
+ const ul = document.createElement("ul");
644
+ ul.className = "ultra-settings-ss-error-list";
645
+ msgs.forEach(msg => {
646
+ const li = document.createElement("li");
647
+ li.textContent = msg;
648
+ ul.appendChild(li);
649
+ });
650
+ ssErrors.appendChild(ul);
651
+ }
652
+ ssErrors.style.display = "";
653
+ }
654
+ });
655
+ });
656
+
657
+ // Handle cancel / close
658
+ ssCancelBtn.addEventListener("click", closeSsPanel);
659
+ ssCloseBtn.addEventListener("click", closeSsPanel);
660
+ if (ssPanelBg) ssPanelBg.addEventListener("click", closeSsPanel);
121
661
 
122
- // Run once on load
123
- handleHashChange();
662
+ // ── History view ──
663
+ const ssHistoryLink = document.getElementById("ultra-settings-ss-history-link");
664
+ const ssHistoryContainer = document.getElementById("ultra-settings-ss-history");
665
+ const ssHistoryEntries = document.getElementById("ultra-settings-ss-history-entries");
666
+ const ssHistoryPagination = document.getElementById("ultra-settings-ss-history-pagination");
667
+ const ssHistoryPrevBtn = document.getElementById("ultra-settings-ss-history-prev");
668
+ const ssHistoryNextBtn = document.getElementById("ultra-settings-ss-history-next");
669
+ const ssHistoryBackLink = document.getElementById("ultra-settings-ss-history-back");
670
+ let ssHistoryCurrentKey = null;
671
+
672
+ const formatHistoryTime = (timestamp) => {
673
+ if (!timestamp) return "";
674
+ let str = String(timestamp).trim();
675
+ str = str.replace(/ UTC$/, "Z").replace(/ /, "T");
676
+ if (!str.endsWith("Z") && !/[+-]\d{2}:?\d{2}$/.test(str)) {
677
+ str += "Z";
678
+ }
679
+ const d = new Date(str);
680
+ if (isNaN(d.getTime())) return String(timestamp);
681
+ return d.toLocaleString();
682
+ };
683
+
684
+ const renderHistoryEntries = (entries) => {
685
+ ssHistoryEntries.innerHTML = "";
686
+ if (!entries || entries.length === 0) {
687
+ const empty = document.createElement("div");
688
+ empty.className = "ultra-settings-ss-history-empty";
689
+ empty.textContent = t("edit.history_empty");
690
+ ssHistoryEntries.appendChild(empty);
691
+ return;
692
+ }
693
+ entries.forEach((entry) => {
694
+ const div = document.createElement("div");
695
+ div.className = "ultra-settings-ss-history-entry";
696
+
697
+ const header = document.createElement("div");
698
+ header.className = "ultra-settings-ss-history-entry-header";
699
+
700
+ const timeSpan = document.createElement("span");
701
+ timeSpan.className = "ultra-settings-ss-history-entry-time";
702
+ timeSpan.textContent = formatHistoryTime(entry.created_at);
703
+ header.appendChild(timeSpan);
704
+
705
+ if (entry.changed_by) {
706
+ const bySpan = document.createElement("span");
707
+ bySpan.textContent = t("edit.history_by") + " " + entry.changed_by;
708
+ bySpan.className = "ultra-settings-ss-history-entry-who";
709
+ header.appendChild(bySpan);
710
+ }
711
+
712
+ if (entry.deleted) {
713
+ const badge = document.createElement("span");
714
+ badge.className = "ultra-settings-ss-history-entry-badge deleted";
715
+ badge.textContent = t("edit.history_deleted");
716
+ header.appendChild(badge);
717
+ } else if (entries.indexOf(entry) === entries.length - 1) {
718
+ const badge = document.createElement("span");
719
+ badge.className = "ultra-settings-ss-history-entry-badge created";
720
+ badge.textContent = t("edit.history_created");
721
+ header.appendChild(badge);
722
+ }
723
+
724
+ div.appendChild(header);
725
+
726
+ if (!entry.deleted) {
727
+ const val = document.createElement("div");
728
+ val.className = "ultra-settings-ss-history-entry-value";
729
+ if (entry.value == null) {
730
+ val.classList.add("nil");
731
+ val.textContent = "nil";
732
+ } else {
733
+ val.textContent = String(entry.value);
734
+ }
735
+ div.appendChild(val);
736
+ }
737
+
738
+ ssHistoryEntries.appendChild(div);
739
+ });
740
+ };
741
+
742
+ const HISTORY_LIMIT = 25;
743
+
744
+ const loadHistory = (key, offset) => {
745
+ ssHistoryEntries.innerHTML = "";
746
+ const loading = document.createElement("div");
747
+ loading.className = "ultra-settings-ss-history-empty";
748
+ loading.textContent = t("edit.history_loading");
749
+ ssHistoryEntries.appendChild(loading);
750
+ ssHistoryPagination.style.display = "none";
751
+
752
+ const params = {key: key, limit: HISTORY_LIMIT, offset: offset};
753
+
754
+ SuperSettingsAPI.fetchHistory(params, (data) => {
755
+ const entries = data.histories || data.history || [];
756
+ renderHistoryEntries(entries);
757
+
758
+ // Pagination based on whether we have a previous or next page
759
+ const hasPrev = offset > 0;
760
+ const hasNext = entries.length >= HISTORY_LIMIT;
761
+
762
+ if (hasPrev || hasNext) {
763
+ ssHistoryPagination.style.display = "";
764
+ ssHistoryPrevBtn.disabled = !hasPrev;
765
+ ssHistoryNextBtn.disabled = !hasNext;
766
+
767
+ ssHistoryPrevBtn.onclick = () => { loadHistory(key, Math.max(0, offset - HISTORY_LIMIT)); };
768
+ ssHistoryNextBtn.onclick = () => { loadHistory(key, offset + HISTORY_LIMIT); };
769
+ } else {
770
+ ssHistoryPagination.style.display = "none";
771
+ }
772
+ }, () => {
773
+ ssHistoryEntries.innerHTML = "";
774
+ const err = document.createElement("div");
775
+ err.className = "ultra-settings-ss-history-empty";
776
+ err.textContent = t("edit.history_empty");
777
+ ssHistoryEntries.appendChild(err);
778
+ ssHistoryPagination.style.display = "none";
779
+ });
780
+ };
781
+
782
+ const showHistory = (key) => {
783
+ ssHistoryCurrentKey = key;
784
+ ssForm.style.display = "none";
785
+ ssHistoryContainer.style.display = "";
786
+ loadHistory(key, 0);
787
+ };
788
+
789
+ const hideHistory = () => {
790
+ ssHistoryContainer.style.display = "none";
791
+ ssForm.style.display = "";
792
+ };
793
+
794
+ if (ssHistoryLink) {
795
+ ssHistoryLink.addEventListener("click", (e) => {
796
+ e.preventDefault();
797
+ showHistory(ssKeyInput.value);
798
+ });
799
+ }
800
+
801
+ if (ssHistoryBackLink) {
802
+ ssHistoryBackLink.addEventListener("click", (e) => {
803
+ e.preventDefault();
804
+ hideHistory();
805
+ });
806
+ }
807
+
808
+ // Delegate clicks on edit buttons
809
+ document.addEventListener("click", (e) => {
810
+ const btn = e.target.closest(".ultra-settings-ss-edit-btn");
811
+ if (btn) {
812
+ e.preventDefault();
813
+ openSsPanel(
814
+ btn.dataset.ssKey || "",
815
+ btn.dataset.ssDefaultType || "string",
816
+ btn.dataset.ssDefaultDescription || ""
817
+ );
818
+ }
819
+ });
820
+ };
821
+ }
822
+
823
+ // ── Language Menu ──
824
+ if (languageMenu && languageOptions.length > 0) {
825
+ languageOptions.forEach((option) => {
826
+ option.addEventListener("click", () => {
827
+ const locale = option.dataset.locale;
828
+ if (!locale) return;
829
+
830
+ closeLanguageMenu();
831
+
832
+ // Persist the choice in a cookie (accessible server-side)
833
+ document.cookie = "ultra_settings_locale=" + encodeURIComponent(locale) + ";path=/;max-age=31536000;SameSite=Lax";
834
+
835
+ // Also store in localStorage for client-side persistence
836
+ try { localStorage.setItem("ultra_settings_locale", locale); } catch(e) {}
837
+
838
+ // Reload with lang query param so server picks it up immediately
839
+ const url = new URL(window.location.href);
840
+ url.searchParams.set("lang", locale);
841
+ window.location.href = url.toString();
842
+ });
843
+ });
844
+
845
+ document.addEventListener("click", (e) => {
846
+ if (languageMenu.hasAttribute("open") && !e.target.closest("#ultra-settings-language-menu")) {
847
+ closeLanguageMenu();
848
+ }
849
+ });
850
+ }
124
851
  });