ultra_settings 2.8.1 → 2.9.1

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