homebridge-unifi-protect 7.21.0 → 7.23.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.
- package/dist/devices/protect-camera-package.js +3 -3
- package/dist/devices/protect-camera-package.js.map +1 -1
- package/dist/devices/protect-camera.d.ts +2 -1
- package/dist/devices/protect-camera.js +78 -49
- package/dist/devices/protect-camera.js.map +1 -1
- package/dist/devices/protect-chime.js +3 -2
- package/dist/devices/protect-chime.js.map +1 -1
- package/dist/devices/protect-device.js +19 -22
- package/dist/devices/protect-device.js.map +1 -1
- package/dist/devices/protect-doorbell.d.ts +4 -2
- package/dist/devices/protect-doorbell.js +37 -22
- package/dist/devices/protect-doorbell.js.map +1 -1
- package/dist/devices/protect-light.js +6 -6
- package/dist/devices/protect-light.js.map +1 -1
- package/dist/devices/protect-liveviews.js +8 -26
- package/dist/devices/protect-liveviews.js.map +1 -1
- package/dist/devices/protect-nvr-systeminfo.d.ts +0 -2
- package/dist/devices/protect-nvr-systeminfo.js +13 -47
- package/dist/devices/protect-nvr-systeminfo.js.map +1 -1
- package/dist/devices/protect-securitysystem.js +17 -36
- package/dist/devices/protect-securitysystem.js.map +1 -1
- package/dist/devices/protect-sensor.d.ts +4 -4
- package/dist/devices/protect-sensor.js +56 -44
- package/dist/devices/protect-sensor.js.map +1 -1
- package/dist/devices/protect-viewer.js +8 -8
- package/dist/devices/protect-viewer.js.map +1 -1
- package/dist/protect-events.js +12 -18
- package/dist/protect-events.js.map +1 -1
- package/dist/protect-livestream.js +10 -4
- package/dist/protect-livestream.js.map +1 -1
- package/dist/protect-nvr.d.ts +5 -5
- package/dist/protect-nvr.js +36 -34
- package/dist/protect-nvr.js.map +1 -1
- package/dist/protect-options.d.ts +4 -4
- package/dist/protect-options.js +3 -2
- package/dist/protect-options.js.map +1 -1
- package/dist/protect-platform.d.ts +1 -1
- package/dist/protect-platform.js +8 -14
- package/dist/protect-platform.js.map +1 -1
- package/dist/protect-record.d.ts +1 -2
- package/dist/protect-record.js +9 -14
- package/dist/protect-record.js.map +1 -1
- package/dist/protect-stream.js +39 -59
- package/dist/protect-stream.js.map +1 -1
- package/dist/protect-timeshift.d.ts +0 -2
- package/dist/protect-timeshift.js +7 -15
- package/dist/protect-timeshift.js.map +1 -1
- package/dist/protect-types.d.ts +2 -0
- package/dist/protect-types.js +3 -0
- package/dist/protect-types.js.map +1 -1
- package/dist/settings.d.ts +4 -4
- package/dist/settings.js +5 -5
- package/dist/settings.js.map +1 -1
- package/homebridge-ui/public/lib/featureoptions.js +7 -8
- package/homebridge-ui/public/lib/featureoptions.js.map +1 -1
- package/homebridge-ui/public/lib/webUi-featureoptions.mjs +488 -213
- package/homebridge-ui/public/ui.mjs +1 -1
- package/package.json +9 -9
|
@@ -156,6 +156,9 @@ import { FeatureOptions} from "./featureoptions.js";
|
|
|
156
156
|
*/
|
|
157
157
|
export class webUiFeatureOptions {
|
|
158
158
|
|
|
159
|
+
// Map of category UI state indexed by context key (serial number or "global").
|
|
160
|
+
#categoryStates;
|
|
161
|
+
|
|
159
162
|
// Table containing the currently displayed feature options.
|
|
160
163
|
#configTable;
|
|
161
164
|
|
|
@@ -241,6 +244,7 @@ export class webUiFeatureOptions {
|
|
|
241
244
|
} = options;
|
|
242
245
|
|
|
243
246
|
// Initialize all our properties. We cache DOM elements for performance and maintain state for the current controller and device context.
|
|
247
|
+
this.#categoryStates = {};
|
|
244
248
|
this.#configTable = document.getElementById("configTable");
|
|
245
249
|
this.#controller = null;
|
|
246
250
|
this.#controllersContainer = document.getElementById("controllersContainer");
|
|
@@ -476,19 +480,18 @@ export class webUiFeatureOptions {
|
|
|
476
480
|
|
|
477
481
|
const isCollapsed = tbody.style.display === "none";
|
|
478
482
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const arrow = table.querySelector(".arrow");
|
|
483
|
+
// Use the shared method to update the state.
|
|
484
|
+
this.#setCategoryState(table, !isCollapsed);
|
|
482
485
|
|
|
483
|
-
|
|
486
|
+
document.getElementById("toggleAllCategories")?.updateState?.();
|
|
484
487
|
|
|
485
|
-
|
|
486
|
-
|
|
488
|
+
// Save the state after toggling this category.
|
|
489
|
+
const currentContext = this.#getCurrentContextKey();
|
|
487
490
|
|
|
488
|
-
|
|
489
|
-
headerCell.setAttribute("aria-expanded", isCollapsed ? "true" : "false");
|
|
491
|
+
if(currentContext) {
|
|
490
492
|
|
|
491
|
-
|
|
493
|
+
this.#saveCategoryStates(currentContext);
|
|
494
|
+
}
|
|
492
495
|
}
|
|
493
496
|
|
|
494
497
|
return;
|
|
@@ -556,13 +559,11 @@ export class webUiFeatureOptions {
|
|
|
556
559
|
|
|
557
560
|
const searchInput = event.target;
|
|
558
561
|
|
|
559
|
-
if(
|
|
562
|
+
if(searchInput._searchTimeout) {
|
|
560
563
|
|
|
561
|
-
searchInput._searchTimeout
|
|
564
|
+
clearTimeout(searchInput._searchTimeout);
|
|
562
565
|
}
|
|
563
566
|
|
|
564
|
-
clearTimeout(searchInput._searchTimeout);
|
|
565
|
-
|
|
566
567
|
searchInput._searchTimeout = setTimeout(() => {
|
|
567
568
|
|
|
568
569
|
this.#handleSearch(searchInput.value.trim(),
|
|
@@ -691,6 +692,35 @@ export class webUiFeatureOptions {
|
|
|
691
692
|
}
|
|
692
693
|
}
|
|
693
694
|
|
|
695
|
+
/**
|
|
696
|
+
* Set the expansion state of a category table.
|
|
697
|
+
*
|
|
698
|
+
* @param {HTMLTableElement} table - The category table element.
|
|
699
|
+
* @param {boolean} isCollapsed - True to collapse, false to expand.
|
|
700
|
+
* @private
|
|
701
|
+
*/
|
|
702
|
+
#setCategoryState(table, isCollapsed) {
|
|
703
|
+
|
|
704
|
+
const tbody = table.querySelector("tbody");
|
|
705
|
+
const arrow = table.querySelector(".arrow");
|
|
706
|
+
const headerCell = table.querySelector("thead th[role='button']");
|
|
707
|
+
|
|
708
|
+
if(tbody) {
|
|
709
|
+
|
|
710
|
+
tbody.style.display = isCollapsed ? "none" : "";
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if(arrow) {
|
|
714
|
+
|
|
715
|
+
arrow.textContent = isCollapsed ? "\u25B6 " : "\u25BC ";
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if(headerCell) {
|
|
719
|
+
|
|
720
|
+
headerCell.setAttribute("aria-expanded", isCollapsed ? "false" : "true");
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
694
724
|
/**
|
|
695
725
|
* Hide the feature options webUI and clean up all resources.
|
|
696
726
|
*
|
|
@@ -726,6 +756,13 @@ export class webUiFeatureOptions {
|
|
|
726
756
|
*/
|
|
727
757
|
#showGlobalOptions() {
|
|
728
758
|
|
|
759
|
+
// Save the current UI state before switching contexts.
|
|
760
|
+
const previousContext = this.#getCurrentContextKey();
|
|
761
|
+
|
|
762
|
+
if(previousContext && this.#configTable.querySelector("table[data-category]")) {
|
|
763
|
+
|
|
764
|
+
this.#saveCategoryStates(previousContext);
|
|
765
|
+
}
|
|
729
766
|
|
|
730
767
|
// Clear the devices container since global options don't have associated devices, but only when we have controllers defined.
|
|
731
768
|
if(this.#getControllers) {
|
|
@@ -750,6 +787,14 @@ export class webUiFeatureOptions {
|
|
|
750
787
|
*/
|
|
751
788
|
async #showControllerOptions(controllerSerial) {
|
|
752
789
|
|
|
790
|
+
// Save the current UI state before switching contexts.
|
|
791
|
+
const previousContext = this.#getCurrentContextKey();
|
|
792
|
+
|
|
793
|
+
if(previousContext && this.#configTable.querySelector("table[data-category]")) {
|
|
794
|
+
|
|
795
|
+
this.#saveCategoryStates(previousContext);
|
|
796
|
+
}
|
|
797
|
+
|
|
753
798
|
const entry = (await this.#getControllers())?.find(c => c.serialNumber === controllerSerial);
|
|
754
799
|
|
|
755
800
|
if(!entry) {
|
|
@@ -788,6 +833,23 @@ export class webUiFeatureOptions {
|
|
|
788
833
|
// Make sure we have the refreshed configuration. This ensures we're always working with the latest saved settings.
|
|
789
834
|
this.currentConfig = await homebridge.getPluginConfig();
|
|
790
835
|
|
|
836
|
+
// Load any persisted UI states from localStorage.
|
|
837
|
+
try {
|
|
838
|
+
|
|
839
|
+
const storageKey = "homebridge-" + (this.currentConfig[0]?.platform ?? "plugin") + "-category-states";
|
|
840
|
+
const stored = window.localStorage.getItem(storageKey);
|
|
841
|
+
|
|
842
|
+
if(stored) {
|
|
843
|
+
|
|
844
|
+
this.#categoryStates = JSON.parse(stored);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// eslint-disable-next-line no-unused-vars
|
|
848
|
+
} catch(error) {
|
|
849
|
+
|
|
850
|
+
this.#categoryStates = {};
|
|
851
|
+
}
|
|
852
|
+
|
|
791
853
|
// Keep our revert snapshot aligned with whatever was *last saved* (not just first render).
|
|
792
854
|
// We compare to the current config and update the snapshot if it differs, so "Revert to Saved" reflects the latest saved state.
|
|
793
855
|
const loadedOptions = (this.currentConfig[0]?.options ?? []);
|
|
@@ -809,6 +871,9 @@ export class webUiFeatureOptions {
|
|
|
809
871
|
// Ensure the DOM is ready before we render our UI. We wait for Bootstrap styles to be applied before proceeding.
|
|
810
872
|
await this.#waitForBootstrap();
|
|
811
873
|
|
|
874
|
+
// Initialize theme sync before injecting styles so CSS variables are defined and current.
|
|
875
|
+
await this.#setupThemeAutoUpdate();
|
|
876
|
+
|
|
812
877
|
// Add our custom styles for hover effects, dark mode support, and modern layouts. These enhance the visual experience and ensure consistency with the
|
|
813
878
|
// Homebridge UI theme.
|
|
814
879
|
this.#injectCustomStyles();
|
|
@@ -969,11 +1034,14 @@ export class webUiFeatureOptions {
|
|
|
969
1034
|
|
|
970
1035
|
const headerInfo = document.getElementById("headerInfo");
|
|
971
1036
|
|
|
972
|
-
headerInfo
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1037
|
+
if(headerInfo) {
|
|
1038
|
+
|
|
1039
|
+
headerInfo.style.fontWeight = "bold";
|
|
1040
|
+
headerInfo.innerHTML = "Feature options are applied in prioritized order, from global to device-specific options:" +
|
|
1041
|
+
"<br><i class=\"text-warning\">Global options</i> (lowest priority) → " +
|
|
1042
|
+
(this.#getControllers ? "<i class=\"text-success\">Controller options</i> → " : "") +
|
|
1043
|
+
"<i class=\"text-info\">Device options</i> (highest priority)";
|
|
1044
|
+
}
|
|
977
1045
|
}
|
|
978
1046
|
|
|
979
1047
|
/**
|
|
@@ -1061,198 +1129,6 @@ export class webUiFeatureOptions {
|
|
|
1061
1129
|
}
|
|
1062
1130
|
}
|
|
1063
1131
|
|
|
1064
|
-
/**
|
|
1065
|
-
* Inject custom styles for hover effects, dark mode support, and modern layouts.
|
|
1066
|
-
*
|
|
1067
|
-
* These styles enhance the visual experience and ensure our UI integrates well with both light and dark modes. We use media queries to automatically adapt
|
|
1068
|
-
* to the user's system preferences. The styles include support for flexbox layouts, responsive design, and theme-aware coloring.
|
|
1069
|
-
*
|
|
1070
|
-
* @private
|
|
1071
|
-
*/
|
|
1072
|
-
#injectCustomStyles() {
|
|
1073
|
-
|
|
1074
|
-
// Ensure we do not inject duplicate styles when re-entering this view. We make this idempotent for stability across navigations.
|
|
1075
|
-
if(document.getElementById("feature-options-styles")) {
|
|
1076
|
-
|
|
1077
|
-
return;
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
// Extract our theme color from .btn-primary to ensure consistency with the Homebridge theme.
|
|
1081
|
-
const probeBtn = document.createElement("button");
|
|
1082
|
-
|
|
1083
|
-
probeBtn.className = "btn btn-primary";
|
|
1084
|
-
probeBtn.style.display = "none";
|
|
1085
|
-
document.body.appendChild(probeBtn);
|
|
1086
|
-
|
|
1087
|
-
this.#themeColor.background = getComputedStyle(probeBtn).backgroundColor;
|
|
1088
|
-
this.#themeColor.text = getComputedStyle(probeBtn).color;
|
|
1089
|
-
|
|
1090
|
-
document.body.removeChild(probeBtn);
|
|
1091
|
-
|
|
1092
|
-
// Quick utility to help us convert RGB values to RGBA for use in CSS.
|
|
1093
|
-
const rgba = (rgb, alpha) => {
|
|
1094
|
-
|
|
1095
|
-
const match = rgb.match(/\d+/g);
|
|
1096
|
-
|
|
1097
|
-
if(!match || (match.length < 3)) {
|
|
1098
|
-
|
|
1099
|
-
return rgb;
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
return "rgba(" + match[0] + ", " + match[1] + ", " + match[2] + ", " + alpha + ")";
|
|
1103
|
-
};
|
|
1104
|
-
|
|
1105
|
-
const styles = [
|
|
1106
|
-
|
|
1107
|
-
/* eslint-disable @stylistic/max-len */
|
|
1108
|
-
// Remove margin collapse and enable clean layout flow.
|
|
1109
|
-
"html, body { margin: 0; padding: 0; }",
|
|
1110
|
-
|
|
1111
|
-
// Compensate for misbehavior in Homebridge Config UI X when switching to or from dark mode.
|
|
1112
|
-
"body { background-color: #fff !important; }",
|
|
1113
|
-
|
|
1114
|
-
// Page root uses a column layout with full width.
|
|
1115
|
-
"#pageFeatureOptions { display: flex !important; flex-direction: column; width: 100%; }",
|
|
1116
|
-
|
|
1117
|
-
// Sidebar + content layout is horizontal (row).
|
|
1118
|
-
".feature-main-content { display: flex !important; flex-direction: row !important; width: 100%; }",
|
|
1119
|
-
|
|
1120
|
-
// Sidebar layout and appearance.
|
|
1121
|
-
"#sidebar { display: block; width: 200px; min-width: 200px; max-width: 200px; background-color: var(--bs-gray-100); position: relative; }",
|
|
1122
|
-
|
|
1123
|
-
// Remove internal scrolling from sidebar content.
|
|
1124
|
-
"#sidebar .sidebar-content { padding: 0rem; overflow: unset; }",
|
|
1125
|
-
|
|
1126
|
-
// Sidebar containers.
|
|
1127
|
-
"#controllersContainer { padding: 0; margin-bottom: 0; }",
|
|
1128
|
-
"#devicesContainer { padding: 0; margin-top: 0; padding-top: 0 !important; }",
|
|
1129
|
-
|
|
1130
|
-
// Feature content (right-hand pane).
|
|
1131
|
-
".feature-content { display: flex !important; flex-direction: column !important; flex: 1 1 auto; min-width: 0; }",
|
|
1132
|
-
".category-border { border: 1px solid " + this.#themeColor.background + " !important; box-shadow: 0 0 0 1px " + rgba(this.#themeColor.background, 0.1) + "; }",
|
|
1133
|
-
|
|
1134
|
-
// Ensure the table itself uses separate borders when we have rounded tbody elements. This is necessary for border-radius to work properly.
|
|
1135
|
-
"table[data-category] { border-collapse: separate !important; border-spacing: 0; }",
|
|
1136
|
-
|
|
1137
|
-
// How we define row visibility for feature options. We need this complexity because we hide or make visible rows depending on what the user has chosen to expose.
|
|
1138
|
-
//
|
|
1139
|
-
// "table[data-category] tbody tr.fo-visible,"
|
|
1140
|
-
// "table[data-category] tbody tr:not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none']){}",
|
|
1141
|
-
|
|
1142
|
-
// Create the outer border of the table on the left and right sides.
|
|
1143
|
-
"table[data-category] tbody tr:not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none']) td:first-child{",
|
|
1144
|
-
" border-left:1px solid " + this.#themeColor.background + ";",
|
|
1145
|
-
"}",
|
|
1146
|
-
"table[data-category] tbody tr:not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none']) td:last-child{",
|
|
1147
|
-
" border-right:1px solid " + this.#themeColor.background + ";",
|
|
1148
|
-
"}",
|
|
1149
|
-
|
|
1150
|
-
// Provide the top border on the first visible row.
|
|
1151
|
-
"table[data-category] tbody tr:nth-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td{",
|
|
1152
|
-
" border-top:1px solid " + this.#themeColor.background + ";",
|
|
1153
|
-
"}",
|
|
1154
|
-
|
|
1155
|
-
// Provide the bottom border on the last visible row.
|
|
1156
|
-
"table[data-category] tbody tr:nth-last-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td{",
|
|
1157
|
-
" border-bottom:1px solid " + this.#themeColor.background + ";",
|
|
1158
|
-
"}",
|
|
1159
|
-
|
|
1160
|
-
// Create rounded corners at the top and bottom rows.
|
|
1161
|
-
"table[data-category] tbody tr:nth-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td:first-child{",
|
|
1162
|
-
" border-top-left-radius:.5rem;",
|
|
1163
|
-
"}",
|
|
1164
|
-
"table[data-category] tbody tr:nth-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td:last-child{",
|
|
1165
|
-
" border-top-right-radius:.5rem;",
|
|
1166
|
-
"}",
|
|
1167
|
-
"table[data-category] tbody tr:nth-last-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td:first-child{",
|
|
1168
|
-
" border-bottom-left-radius:.5rem;",
|
|
1169
|
-
"}",
|
|
1170
|
-
"table[data-category] tbody tr:nth-last-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td:last-child{",
|
|
1171
|
-
" border-bottom-right-radius:.5rem;",
|
|
1172
|
-
"}",
|
|
1173
|
-
|
|
1174
|
-
// Main options area - remove scroll behavior, just layout styling.
|
|
1175
|
-
".options-content { padding: 1rem; margin: 0; }",
|
|
1176
|
-
|
|
1177
|
-
// Info header styling.
|
|
1178
|
-
"#headerInfo { flex-shrink: 0; padding: 0.5rem !important; margin-bottom: 0.5rem !important; }",
|
|
1179
|
-
|
|
1180
|
-
// Device stats grid layout.
|
|
1181
|
-
".device-stats-grid { display: flex; justify-content: space-between; gap: 0.75rem; margin-bottom: 0.5rem; padding: 0 0.75rem; flex-wrap: nowrap; overflow: hidden; }",
|
|
1182
|
-
".device-stats-grid .stat-item:first-child { flex: 0 0 25% }",
|
|
1183
|
-
".device-stats-grid .stat-item:not(:first-child) { flex-grow: 1; min-width: 0; }",
|
|
1184
|
-
|
|
1185
|
-
".stat-item { display: flex; flex-direction: column; gap: 0.125rem; }",
|
|
1186
|
-
".stat-label { font-weight: 600; color: var(--bs-gray-600); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }",
|
|
1187
|
-
".stat-value { font-size: 0.875rem; color: var(--bs-body-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }",
|
|
1188
|
-
|
|
1189
|
-
// Responsive hiding for our device stats grid.
|
|
1190
|
-
"@media (max-width: 700px) { .device-stats-grid .stat-item:nth-last-of-type(1) { display: none !important; } }",
|
|
1191
|
-
"@media (max-width: 500px) { .device-stats-grid .stat-item:nth-last-of-type(2) { display: none !important; } }",
|
|
1192
|
-
"@media (max-width: 300px) { .device-stats-grid .stat-item:nth-last-of-type(3) { display: none !important; } }",
|
|
1193
|
-
|
|
1194
|
-
// Responsive hiding for feature option status information.
|
|
1195
|
-
"@media (max-width: 400px) { #statusInfo { display: none !important; } }",
|
|
1196
|
-
|
|
1197
|
-
// Navigation styles.
|
|
1198
|
-
".nav-link { border-radius: 0.375rem; transition: all 0.2s; position: relative; padding: 0.25rem 0.75rem !important; line-height: 1.2; font-size: 0.8125rem; }",
|
|
1199
|
-
".nav-link:hover { background-color: " + rgba(this.#themeColor.background, 0.1) + "; color: " + this.#themeColor.background + " !important; }",
|
|
1200
|
-
".nav-link.active { background-color: " + this.#themeColor.background + "; color: " + this.#themeColor.text + " !important; }",
|
|
1201
|
-
".nav-header { border-bottom: 1px solid rgba(0, 0, 0, 0.1); margin-bottom: 0.125rem; padding: 0.25rem 0.75rem !important; font-size: 0.75rem !important; line-height: 1.2; }",
|
|
1202
|
-
"#devicesContainer .nav-header { font-weight: 600; margin-top: 0 !important; padding-top: 0.5rem !important; }",
|
|
1203
|
-
"#controllersContainer .nav-header { font-weight: 600; margin-top: 0 !important; padding-top: 0.5rem !important; }",
|
|
1204
|
-
|
|
1205
|
-
// Search bar.
|
|
1206
|
-
".search-toolbar { border-radius: 0.5rem; padding: 0 0 0.5rem 0; }",
|
|
1207
|
-
".search-input-wrapper { min-width: 0; }",
|
|
1208
|
-
".filter-pills { display: flex; gap: 0.5rem; flex-wrap: wrap; }",
|
|
1209
|
-
|
|
1210
|
-
// Grouped option visual indicator.
|
|
1211
|
-
".grouped-option { background-color: " + rgba(this.#themeColor.background, 0.08) + "; }",
|
|
1212
|
-
".grouped-option td:nth-child(2) label { padding-left: 20px; position: relative; }",
|
|
1213
|
-
".grouped-option td:nth-child(2) label::before { content: \"\\21B3\"; position: absolute; left: 4px; color: #666; }",
|
|
1214
|
-
|
|
1215
|
-
// Dark mode refinements.
|
|
1216
|
-
"@media (prefers-color-scheme: dark) {",
|
|
1217
|
-
|
|
1218
|
-
// Compensate for misbehavior in Homebridge Config UI X when switching to or from dark mode.
|
|
1219
|
-
" body { background-color: #242424 !important; }",
|
|
1220
|
-
" #sidebar { background-color: #1A1A1A !important; }",
|
|
1221
|
-
" .nav-header { border-bottom-color: rgba(255, 255, 255, 0.1); }",
|
|
1222
|
-
" .text-body { color: #999 !important; }",
|
|
1223
|
-
" .text-muted { color: #999 !important; }",
|
|
1224
|
-
" .device-stats-grid { background-color: #1A1A1A; border-color: #444; }",
|
|
1225
|
-
" .stat-label { color: #999; }",
|
|
1226
|
-
" .stat-value { color: #999; }",
|
|
1227
|
-
" #search .form-control { background-color: #1A1A1A; border-color: #444; color: #F8F9FA; }",
|
|
1228
|
-
" #search .form-control:focus { background-color: #1A1A1A; border-color: #666; color: #F8F9FA; box-shadow: 0 0 0 0.2rem rgba(255, 160, 0, 0.25); }",
|
|
1229
|
-
" #search .form-control::placeholder { color: #999; }",
|
|
1230
|
-
" #statusInfo .text-muted { color: #B8B8B8 !important; }",
|
|
1231
|
-
"}",
|
|
1232
|
-
|
|
1233
|
-
// Table hover styling.
|
|
1234
|
-
".table-hover tbody tr { transition: background-color 0.15s; }",
|
|
1235
|
-
".table-hover tbody tr:hover { background-color: rgba(0, 0, 0, 0.03); }",
|
|
1236
|
-
"@media (prefers-color-scheme: dark) { .table-hover tbody tr:hover { background-color: rgba(255, 255, 255, 0.20); } }",
|
|
1237
|
-
|
|
1238
|
-
// Utility styles.
|
|
1239
|
-
".btn-xs { font-size: 0.75rem !important; padding: 0.125rem 0.5rem !important; line-height: 1.5; touch-action: manipulation; }",
|
|
1240
|
-
".cursor-pointer { cursor: pointer; }",
|
|
1241
|
-
".user-select-none { user-select: none; -webkit-user-select: none; }",
|
|
1242
|
-
|
|
1243
|
-
// Use CSS for the category header hover emphasis to avoid JS event handlers for simple hover effects.
|
|
1244
|
-
"table[data-category] thead th[role='button']:hover { color: " + this.#themeColor.background + " !important; }",
|
|
1245
|
-
|
|
1246
|
-
// Respect reduced motion settings for accessibility.
|
|
1247
|
-
"@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }"
|
|
1248
|
-
/* eslint-enable @stylistic/max-len */
|
|
1249
|
-
];
|
|
1250
|
-
|
|
1251
|
-
const styleElement = this.#createElement("style", { id: "feature-options-styles" }, [styles.join("\n")]);
|
|
1252
|
-
|
|
1253
|
-
document.head.appendChild(styleElement);
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
1132
|
/**
|
|
1257
1133
|
* Show the device list taking the controller context into account.
|
|
1258
1134
|
*
|
|
@@ -1467,6 +1343,15 @@ export class webUiFeatureOptions {
|
|
|
1467
1343
|
|
|
1468
1344
|
homebridge.showSpinner();
|
|
1469
1345
|
|
|
1346
|
+
// Retrieve our current context before we change the active link.
|
|
1347
|
+
const previousActive = this.#devicesContainer.querySelector(".nav-link.active[data-navigation='device']");
|
|
1348
|
+
|
|
1349
|
+
// Save the current category UI state before switching contexts.
|
|
1350
|
+
if(previousActive && (previousActive.name !== deviceId) && this.#configTable.querySelector("table[data-category]")) {
|
|
1351
|
+
|
|
1352
|
+
this.#saveCategoryStates(previousActive.name);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1470
1355
|
// Clean up event listeners from previous option displays. This ensures we don't accumulate listeners as users navigate between devices.
|
|
1471
1356
|
this.#cleanupOptionEventListeners();
|
|
1472
1357
|
|
|
@@ -1487,6 +1372,9 @@ export class webUiFeatureOptions {
|
|
|
1487
1372
|
// Create option tables for each category. Categories group related options together for better organization.
|
|
1488
1373
|
this.#createOptionTables(currentDevice);
|
|
1489
1374
|
|
|
1375
|
+
// Restore saved category UI state context for this device.
|
|
1376
|
+
this.#restoreCategoryStates(deviceId);
|
|
1377
|
+
|
|
1490
1378
|
// Set up search functionality if available. This includes debounced search and keyboard shortcuts.
|
|
1491
1379
|
this.#setupSearchFunctionality();
|
|
1492
1380
|
|
|
@@ -1565,7 +1453,7 @@ export class webUiFeatureOptions {
|
|
|
1565
1453
|
return;
|
|
1566
1454
|
}
|
|
1567
1455
|
|
|
1568
|
-
this.#deviceStatsContainer.style.display =
|
|
1456
|
+
this.#deviceStatsContainer.style.display = "";
|
|
1569
1457
|
this.#infoPanel(device);
|
|
1570
1458
|
}
|
|
1571
1459
|
|
|
@@ -1995,6 +1883,14 @@ export class webUiFeatureOptions {
|
|
|
1995
1883
|
}
|
|
1996
1884
|
|
|
1997
1885
|
toggleBtn.updateState();
|
|
1886
|
+
|
|
1887
|
+
// Save the category UI state after toggling all categories.
|
|
1888
|
+
const currentContext = this.#getCurrentContextKey();
|
|
1889
|
+
|
|
1890
|
+
if(currentContext) {
|
|
1891
|
+
|
|
1892
|
+
this.#saveCategoryStates(currentContext);
|
|
1893
|
+
}
|
|
1998
1894
|
}
|
|
1999
1895
|
|
|
2000
1896
|
/**
|
|
@@ -2041,6 +1937,7 @@ export class webUiFeatureOptions {
|
|
|
2041
1937
|
// Create a unique id for the tbody so that the header can reference it for accessibility.
|
|
2042
1938
|
const tbodyId = "tbody-" + category.name.replace(/\s+/g, "-");
|
|
2043
1939
|
|
|
1940
|
+
// We default category tables to collapsed.
|
|
2044
1941
|
const tbody = this.#createElement("tbody", {
|
|
2045
1942
|
|
|
2046
1943
|
classList: [ "border", "category-border" ],
|
|
@@ -2121,6 +2018,7 @@ export class webUiFeatureOptions {
|
|
|
2121
2018
|
|
|
2122
2019
|
const scopeLabel = !currentDevice ? " (Global)" : (this.#ui.isController(currentDevice) ? " (Controller-specific)" : " (Device-specific)");
|
|
2123
2020
|
|
|
2021
|
+
// We default category tables to collapsed.
|
|
2124
2022
|
const th = this.#createElement("th", {
|
|
2125
2023
|
|
|
2126
2024
|
"aria-controls": tbodyId,
|
|
@@ -2390,6 +2288,98 @@ export class webUiFeatureOptions {
|
|
|
2390
2288
|
return label;
|
|
2391
2289
|
}
|
|
2392
2290
|
|
|
2291
|
+
/**
|
|
2292
|
+
* Save the current category UI state for the given context.
|
|
2293
|
+
*
|
|
2294
|
+
* @param {string} contextKey - The context identifier (device serial or "Global Options").
|
|
2295
|
+
* @private
|
|
2296
|
+
*/
|
|
2297
|
+
#saveCategoryStates(contextKey) {
|
|
2298
|
+
|
|
2299
|
+
const states = {};
|
|
2300
|
+
const tables = document.querySelectorAll("#configTable table[data-category]");
|
|
2301
|
+
|
|
2302
|
+
for(const table of tables) {
|
|
2303
|
+
|
|
2304
|
+
const categoryName = table.getAttribute("data-category");
|
|
2305
|
+
const tbody = table.querySelector("tbody");
|
|
2306
|
+
|
|
2307
|
+
if(tbody) {
|
|
2308
|
+
|
|
2309
|
+
// Store true if collapsed (display: none), false if expanded.
|
|
2310
|
+
states[categoryName] = tbody.style.display === "none";
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
this.#categoryStates[contextKey] = states;
|
|
2315
|
+
|
|
2316
|
+
// Finally, we persist to localStorage to remember user preferences.
|
|
2317
|
+
try {
|
|
2318
|
+
|
|
2319
|
+
const storageKey = "homebridge-" + (this.currentConfig[0]?.platform ?? "plugin") + "-category-states";
|
|
2320
|
+
|
|
2321
|
+
window.localStorage.setItem(storageKey, JSON.stringify(this.#categoryStates));
|
|
2322
|
+
|
|
2323
|
+
// eslint-disable-next-line no-empty, no-unused-vars
|
|
2324
|
+
} catch(error) {
|
|
2325
|
+
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
/**
|
|
2330
|
+
* Restore saved category UI state for the given context.
|
|
2331
|
+
*
|
|
2332
|
+
* @param {string} contextKey - The context identifier (device serial or "Global Options").
|
|
2333
|
+
* @private
|
|
2334
|
+
*/
|
|
2335
|
+
#restoreCategoryStates(contextKey) {
|
|
2336
|
+
|
|
2337
|
+
const savedStates = this.#categoryStates[contextKey];
|
|
2338
|
+
|
|
2339
|
+
if(!savedStates) {
|
|
2340
|
+
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
const tables = document.querySelectorAll("#configTable table[data-category]");
|
|
2345
|
+
|
|
2346
|
+
for(const table of tables) {
|
|
2347
|
+
|
|
2348
|
+
const categoryName = table.getAttribute("data-category");
|
|
2349
|
+
|
|
2350
|
+
if(categoryName in savedStates) {
|
|
2351
|
+
|
|
2352
|
+
this.#setCategoryState(table, savedStates[categoryName]);
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
// Update the toggle button state after restoring.
|
|
2357
|
+
document.getElementById("toggleAllCategories")?.updateState?.();
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
/**
|
|
2361
|
+
* Get the current context key for UI state tracking.
|
|
2362
|
+
*
|
|
2363
|
+
* @returns {string} The context key for the current view.
|
|
2364
|
+
* @private
|
|
2365
|
+
*/
|
|
2366
|
+
#getCurrentContextKey() {
|
|
2367
|
+
|
|
2368
|
+
// Check devices first - if there's an active device, that's our context.
|
|
2369
|
+
const activeDevice = this.#devicesContainer.querySelector(".nav-link.active[data-navigation='device']");
|
|
2370
|
+
|
|
2371
|
+
if(activeDevice) {
|
|
2372
|
+
|
|
2373
|
+
return activeDevice.name;
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
// No active device, so check for either the active controller or global context.
|
|
2377
|
+
const activeController = this.#controllersContainer.querySelector(".nav-link.active[data-navigation]");
|
|
2378
|
+
|
|
2379
|
+
// Default to our global options if we don't have a current controller.
|
|
2380
|
+
return (activeController?.name !== "Global Options") ? this.#devices[0].serialNumber : "Global Options";
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2393
2383
|
/**
|
|
2394
2384
|
* Handle option state changes with full hierarchy and dependency management.
|
|
2395
2385
|
*
|
|
@@ -2930,6 +2920,11 @@ export class webUiFeatureOptions {
|
|
|
2930
2920
|
*/
|
|
2931
2921
|
#showDeviceInfoPanel(device) {
|
|
2932
2922
|
|
|
2923
|
+
if(!this.#deviceStatsContainer) {
|
|
2924
|
+
|
|
2925
|
+
return;
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2933
2928
|
if(!device) {
|
|
2934
2929
|
|
|
2935
2930
|
this.#deviceStatsContainer.textContent = "";
|
|
@@ -3109,10 +3104,11 @@ export class webUiFeatureOptions {
|
|
|
3109
3104
|
|
|
3110
3105
|
row.style.display = this.#rowMatchesFilter(row, filterType) ? "" : "none";
|
|
3111
3106
|
}
|
|
3112
|
-
} else {
|
|
3113
3107
|
|
|
3114
|
-
|
|
3108
|
+
continue;
|
|
3115
3109
|
}
|
|
3110
|
+
|
|
3111
|
+
row.style.display = this.#rowMatchesFilter(row, filterType) ? "" : "none";
|
|
3116
3112
|
}
|
|
3117
3113
|
|
|
3118
3114
|
// Update counts and visibility. We need to update various UI elements to reflect the filtered view.
|
|
@@ -3145,11 +3141,13 @@ export class webUiFeatureOptions {
|
|
|
3145
3141
|
switch(filterType) {
|
|
3146
3142
|
|
|
3147
3143
|
case "modified":
|
|
3144
|
+
|
|
3148
3145
|
// Modified options have the text-info class to indicate they differ from defaults.
|
|
3149
3146
|
return row.querySelector("label")?.classList.contains("text-info") ?? false;
|
|
3150
3147
|
|
|
3151
3148
|
case "all":
|
|
3152
3149
|
default:
|
|
3150
|
+
|
|
3153
3151
|
return true;
|
|
3154
3152
|
}
|
|
3155
3153
|
}
|
|
@@ -3403,6 +3401,282 @@ export class webUiFeatureOptions {
|
|
|
3403
3401
|
}
|
|
3404
3402
|
}
|
|
3405
3403
|
|
|
3404
|
+
/**
|
|
3405
|
+
* Inject custom styles for hover effects, dark mode support, and modern layouts.
|
|
3406
|
+
*
|
|
3407
|
+
* These styles enhance the visual experience and ensure our UI integrates well with both light and dark modes. We use media queries to automatically adapt
|
|
3408
|
+
* to the user's system preferences. The styles include support for flexbox layouts, responsive design, and theme-aware coloring.
|
|
3409
|
+
*
|
|
3410
|
+
* @private
|
|
3411
|
+
*/
|
|
3412
|
+
#injectCustomStyles() {
|
|
3413
|
+
|
|
3414
|
+
// Ensure we do not inject duplicate styles when re-entering this view. We make this idempotent for stability across navigations.
|
|
3415
|
+
if(document.getElementById("feature-options-styles")) {
|
|
3416
|
+
|
|
3417
|
+
return;
|
|
3418
|
+
}
|
|
3419
|
+
|
|
3420
|
+
const styles = [
|
|
3421
|
+
|
|
3422
|
+
/* eslint-disable @stylistic/max-len */
|
|
3423
|
+
// Define CSS variables used throughout our webUI. We update these when the theme changes so the UI can respond according to the visual environment.
|
|
3424
|
+
":root {",
|
|
3425
|
+
" --plugin-primary-bg: " + this.#themeColor.background + ";",
|
|
3426
|
+
" --plugin-primary-fg: " + this.#themeColor.text + ";",
|
|
3427
|
+
" --plugin-primary-hover: rgba(0,0,0,0.05); /* placeholder, JS will override with a color-specific value */",
|
|
3428
|
+
" --plugin-primary-subtle: rgba(0,0,0,0.03); /* placeholder, JS will override with a color-specific value */",
|
|
3429
|
+
" --plugin-body-bg-light: #ffffff;",
|
|
3430
|
+
" --plugin-body-bg-dark: #242424;",
|
|
3431
|
+
" --plugin-sidebar-bg-light: var(--bs-gray-100);",
|
|
3432
|
+
" --plugin-sidebar-bg-dark: #1A1A1A;",
|
|
3433
|
+
"}",
|
|
3434
|
+
|
|
3435
|
+
// We start with a base layout reset - remove margin collapse and enable clean layout flow.
|
|
3436
|
+
"html, body { margin: 0; padding: 0; }",
|
|
3437
|
+
|
|
3438
|
+
// Theme-scoped body and sidebar backgrounds. We scope to app-driven theme first.
|
|
3439
|
+
":root[data-plugin-theme='light'] body { background-color: var(--plugin-body-bg-light) !important; }",
|
|
3440
|
+
":root[data-plugin-theme='dark'] body { background-color: var(--plugin-body-bg-dark) !important; }",
|
|
3441
|
+
":root[data-plugin-theme='light'] #sidebar { background-color: var(--plugin-sidebar-bg-light) !important; }",
|
|
3442
|
+
":root[data-plugin-theme='dark'] #sidebar { background-color: var(--plugin-sidebar-bg-dark) !important; }",
|
|
3443
|
+
|
|
3444
|
+
// Page root uses a column layout with full width.
|
|
3445
|
+
"#pageFeatureOptions { display: flex !important; flex-direction: column; width: 100%; }",
|
|
3446
|
+
|
|
3447
|
+
// Sidebar + content layout is horizontal (row).
|
|
3448
|
+
".feature-main-content { display: flex !important; flex-direction: row !important; width: 100%; }",
|
|
3449
|
+
|
|
3450
|
+
// Sidebar layout and appearance.
|
|
3451
|
+
"#sidebar { display: block; width: 200px; min-width: 200px; max-width: 200px; position: relative; }",
|
|
3452
|
+
|
|
3453
|
+
// Remove internal scrolling from sidebar content.
|
|
3454
|
+
"#sidebar .sidebar-content { padding: 0rem; overflow: unset; }",
|
|
3455
|
+
|
|
3456
|
+
// Sidebar containers.
|
|
3457
|
+
"#controllersContainer { padding: 0; margin-bottom: 0; }",
|
|
3458
|
+
"#devicesContainer { padding: 0; margin-top: 0; padding-top: 0 !important; }",
|
|
3459
|
+
|
|
3460
|
+
// Feature content (right-hand pane).
|
|
3461
|
+
".feature-content { display: flex !important; flex-direction: column !important; flex: 1 1 auto; min-width: 0; }",
|
|
3462
|
+
// ".category-border { border: 1px solid " + this.#themeColor.background + " !important; box-shadow: 0 0 0 1px " + rgba(this.#themeColor.background, 0.1) + "; }",
|
|
3463
|
+
".category-border { border: 1px solid var(--plugin-primary-bg) !important; box-shadow: 0 0 0 1px var(--plugin-primary-hover); }",
|
|
3464
|
+
|
|
3465
|
+
// Ensure the table itself uses separate borders when we have rounded tbody elements. This is necessary for border-radius to work properly.
|
|
3466
|
+
"table[data-category] { border-collapse: separate !important; border-spacing: 0; }",
|
|
3467
|
+
|
|
3468
|
+
// How we define row visibility for feature options. We need this complexity because we hide or make visible rows depending on what the user has chosen to expose.
|
|
3469
|
+
//
|
|
3470
|
+
// "table[data-category] tbody tr.fo-visible,"
|
|
3471
|
+
// "table[data-category] tbody tr:not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none']){}",
|
|
3472
|
+
|
|
3473
|
+
// Create the outer border of the table on the left and right sides.
|
|
3474
|
+
"table[data-category] tbody tr:not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none']) td:first-child{",
|
|
3475
|
+
" border-left:1px solid var(--plugin-primary-bg);",
|
|
3476
|
+
"}",
|
|
3477
|
+
"table[data-category] tbody tr:not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none']) td:last-child{",
|
|
3478
|
+
" border-right:1px solid var(--plugin-primary-bg);",
|
|
3479
|
+
"}",
|
|
3480
|
+
|
|
3481
|
+
// Provide the top border on the first visible row.
|
|
3482
|
+
"table[data-category] tbody tr:nth-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td{",
|
|
3483
|
+
" border-top:1px solid var(--plugin-primary-bg);",
|
|
3484
|
+
"}",
|
|
3485
|
+
|
|
3486
|
+
// Provide the bottom border on the last visible row.
|
|
3487
|
+
"table[data-category] tbody tr:nth-last-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td{",
|
|
3488
|
+
" border-bottom:1px solid var(--plugin-primary-bg);",
|
|
3489
|
+
"}",
|
|
3490
|
+
|
|
3491
|
+
// Create rounded corners at the top and bottom rows.
|
|
3492
|
+
"table[data-category] tbody tr:nth-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td:first-child{",
|
|
3493
|
+
" border-top-left-radius:.5rem;",
|
|
3494
|
+
"}",
|
|
3495
|
+
"table[data-category] tbody tr:nth-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td:last-child{",
|
|
3496
|
+
" border-top-right-radius:.5rem;",
|
|
3497
|
+
"}",
|
|
3498
|
+
"table[data-category] tbody tr:nth-last-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td:first-child{",
|
|
3499
|
+
" border-bottom-left-radius:.5rem;",
|
|
3500
|
+
"}",
|
|
3501
|
+
"table[data-category] tbody tr:nth-last-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td:last-child{",
|
|
3502
|
+
" border-bottom-right-radius:.5rem;",
|
|
3503
|
+
"}",
|
|
3504
|
+
|
|
3505
|
+
// Main options area - remove scroll behavior, just layout styling.
|
|
3506
|
+
".options-content { padding: 1rem; margin: 0; }",
|
|
3507
|
+
|
|
3508
|
+
// Info header styling.
|
|
3509
|
+
"#headerInfo { flex-shrink: 0; padding: 0.5rem !important; margin-bottom: 0.5rem !important; }",
|
|
3510
|
+
|
|
3511
|
+
// Device stats grid layout.
|
|
3512
|
+
".device-stats-grid { display: flex; justify-content: space-between; gap: 0.75rem; margin-bottom: 0.5rem; padding: 0 0.75rem; flex-wrap: nowrap; overflow: hidden; }",
|
|
3513
|
+
".device-stats-grid .stat-item:first-child { flex: 0 0 25% }",
|
|
3514
|
+
".device-stats-grid .stat-item:not(:first-child) { flex-grow: 1; min-width: 0; }",
|
|
3515
|
+
|
|
3516
|
+
".stat-item { display: flex; flex-direction: column; gap: 0.125rem; }",
|
|
3517
|
+
".stat-label { font-weight: 600; color: var(--bs-gray-600); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }",
|
|
3518
|
+
".stat-value { font-size: 0.875rem; color: var(--bs-body-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }",
|
|
3519
|
+
|
|
3520
|
+
// Responsive hiding for our device stats grid.
|
|
3521
|
+
"@media (max-width: 700px) { .device-stats-grid .stat-item:nth-last-of-type(1) { display: none !important; } }",
|
|
3522
|
+
"@media (max-width: 500px) { .device-stats-grid .stat-item:nth-last-of-type(2) { display: none !important; } }",
|
|
3523
|
+
"@media (max-width: 300px) { .device-stats-grid .stat-item:nth-last-of-type(3) { display: none !important; } }",
|
|
3524
|
+
|
|
3525
|
+
// Responsive hiding for feature option status information.
|
|
3526
|
+
"@media (max-width: 400px) { #statusInfo { display: none !important; } }",
|
|
3527
|
+
|
|
3528
|
+
// Navigation styles.
|
|
3529
|
+
".nav-link { border-radius: 0.375rem; transition: all 0.2s; position: relative; padding: 0.25rem 0.75rem !important; line-height: 1.2; font-size: 0.8125rem; }",
|
|
3530
|
+
".nav-link:hover { background-color: var(--plugin-primary-hover); color: var(--plugin-primary-bg) !important; }",
|
|
3531
|
+
".nav-link.active { background-color: var(--plugin-primary-bg); color: var(--plugin-primary-fg) !important; }",
|
|
3532
|
+
".nav-header { border-bottom: 1px solid rgba(0, 0, 0, 0.1); margin-bottom: 0.125rem; padding: 0.25rem 0.75rem !important; font-size: 0.75rem !important; line-height: 1.2; }",
|
|
3533
|
+
"#devicesContainer .nav-header { font-weight: 600; margin-top: 0 !important; padding-top: 0.5rem !important; }",
|
|
3534
|
+
"#controllersContainer .nav-header { font-weight: 600; margin-top: 0 !important; padding-top: 0.5rem !important; }",
|
|
3535
|
+
|
|
3536
|
+
// Search bar.
|
|
3537
|
+
".search-toolbar { border-radius: 0.5rem; padding: 0 0 0.5rem 0; }",
|
|
3538
|
+
".search-input-wrapper { min-width: 0; }",
|
|
3539
|
+
".filter-pills { display: flex; gap: 0.5rem; flex-wrap: wrap; }",
|
|
3540
|
+
|
|
3541
|
+
// Grouped option visual indicator.
|
|
3542
|
+
".grouped-option { background-color: var(--plugin-primary-subtle); }",
|
|
3543
|
+
".grouped-option td:nth-child(2) label { padding-left: 20px; position: relative; }",
|
|
3544
|
+
".grouped-option td:nth-child(2) label::before { content: \"\\21B3\"; position: absolute; left: 4px; color: #666; }",
|
|
3545
|
+
|
|
3546
|
+
// Dark-mode refinements.
|
|
3547
|
+
":root[data-plugin-theme='dark'] .nav-header { border-bottom-color: rgba(255, 255, 255, 0.1); }",
|
|
3548
|
+
":root[data-plugin-theme='dark'] .text-body { color: #999 !important; }",
|
|
3549
|
+
":root[data-plugin-theme='dark'] .text-muted { color: #999 !important; }",
|
|
3550
|
+
":root[data-plugin-theme='dark'] .device-stats-grid { background-color: #1A1A1A; border-color: #444; }",
|
|
3551
|
+
":root[data-plugin-theme='dark'] .stat-label { color: #999; }",
|
|
3552
|
+
":root[data-plugin-theme='dark'] .stat-value { color: #999; }",
|
|
3553
|
+
":root[data-plugin-theme='dark'] #search .form-control { background-color: #1A1A1A; border-color: #444; color: #F8F9FA; }",
|
|
3554
|
+
":root[data-plugin-theme='dark'] #search .form-control:focus { background-color: #1A1A1A; border-color: #666; color: #F8F9FA; box-shadow: 0 0 0 0.2rem rgba(255, 160, 0, 0.25); }",
|
|
3555
|
+
":root[data-plugin-theme='dark'] #search .form-control::placeholder { color: #999; }",
|
|
3556
|
+
":root[data-plugin-theme='dark'] #statusInfo .text-muted { color: #B8B8B8 !important; }",
|
|
3557
|
+
|
|
3558
|
+
// Table hover styling.
|
|
3559
|
+
".table-hover tbody tr { transition: background-color 0.15s; }",
|
|
3560
|
+
":root[data-plugin-theme='light'] .table-hover tbody tr:hover { background-color: rgba(0, 0, 0, 0.03); }",
|
|
3561
|
+
":root[data-plugin-theme='dark'] .table-hover tbody tr:hover { background-color: rgba(255, 255, 255, 0.20); }",
|
|
3562
|
+
|
|
3563
|
+
|
|
3564
|
+
// Utility styles.
|
|
3565
|
+
".btn-xs { font-size: 0.75rem !important; padding: 0.125rem 0.5rem !important; line-height: 1.5; touch-action: manipulation; }",
|
|
3566
|
+
".cursor-pointer { cursor: pointer; }",
|
|
3567
|
+
".user-select-none { user-select: none; -webkit-user-select: none; }",
|
|
3568
|
+
|
|
3569
|
+
// Use CSS for the category header hover emphasis to avoid JS event handlers for simple hover effects.
|
|
3570
|
+
"table[data-category] thead th[role='button']:hover { color: var(--plugin-primary-bg) !important; }",
|
|
3571
|
+
|
|
3572
|
+
// Respect reduced motion settings for accessibility.
|
|
3573
|
+
"@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }"
|
|
3574
|
+
/* eslint-enable @stylistic/max-len */
|
|
3575
|
+
];
|
|
3576
|
+
|
|
3577
|
+
const styleElement = this.#createElement("style", { id: "feature-options-styles" }, [styles.join("\n")]);
|
|
3578
|
+
|
|
3579
|
+
document.head.appendChild(styleElement);
|
|
3580
|
+
}
|
|
3581
|
+
|
|
3582
|
+
/**
|
|
3583
|
+
* Set up automatic theme detection and live updates.
|
|
3584
|
+
*
|
|
3585
|
+
* We rely on Config UI X to tell us what the preferred color scheme is. Once we have it, we recompute our color variables derived from .btn-primary whenever the theme
|
|
3586
|
+
* changes before mirroring all that to :root[data-plugin-theme="light|dark"] so our injected CSS is instantly reflected in our webUI.
|
|
3587
|
+
*
|
|
3588
|
+
* @private
|
|
3589
|
+
*/
|
|
3590
|
+
async #setupThemeAutoUpdate() {
|
|
3591
|
+
|
|
3592
|
+
// Apply the current theme lighting mode and compute color variables.
|
|
3593
|
+
this.#setPluginTheme(await homebridge.userCurrentLightingMode());
|
|
3594
|
+
|
|
3595
|
+
// Finally, we listen for system and browser changes to the current dark mode setting.
|
|
3596
|
+
this.#addEventListener(window.matchMedia("(prefers-color-scheme: dark)"), "change", async () => this.#setPluginTheme(await homebridge.userCurrentLightingMode()));
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
/**
|
|
3600
|
+
* Apply the current theme to :root and recompute JS-derived CSS variables.
|
|
3601
|
+
*
|
|
3602
|
+
* @param {"light"|"dark"} mode
|
|
3603
|
+
* @private
|
|
3604
|
+
*/
|
|
3605
|
+
#setPluginTheme(mode) {
|
|
3606
|
+
|
|
3607
|
+
// Sanity check.
|
|
3608
|
+
if(![ "dark", "light" ].includes(mode)) {
|
|
3609
|
+
|
|
3610
|
+
return;
|
|
3611
|
+
}
|
|
3612
|
+
|
|
3613
|
+
// See if we're already set appropriately. If so, we're done.
|
|
3614
|
+
const current = document.documentElement.getAttribute("data-plugin-theme");
|
|
3615
|
+
|
|
3616
|
+
if(current === mode) {
|
|
3617
|
+
|
|
3618
|
+
return;
|
|
3619
|
+
}
|
|
3620
|
+
|
|
3621
|
+
// Update our theme.
|
|
3622
|
+
document.documentElement.setAttribute("data-plugin-theme", mode);
|
|
3623
|
+
|
|
3624
|
+
this.#computeThemeColors();
|
|
3625
|
+
this.#updateCssVariablesFromTheme();
|
|
3626
|
+
}
|
|
3627
|
+
|
|
3628
|
+
/**
|
|
3629
|
+
* Compute current primary background and foreground from Bootstrap's .btn-primary.
|
|
3630
|
+
*
|
|
3631
|
+
* This gives us a theme-correct color pair regardless of the configured palette in Config UI X.
|
|
3632
|
+
*
|
|
3633
|
+
* @private
|
|
3634
|
+
*/
|
|
3635
|
+
#computeThemeColors() {
|
|
3636
|
+
|
|
3637
|
+
const probeBtn = document.createElement("button");
|
|
3638
|
+
|
|
3639
|
+
probeBtn.className = "btn btn-primary";
|
|
3640
|
+
probeBtn.style.display = "none";
|
|
3641
|
+
document.body.appendChild(probeBtn);
|
|
3642
|
+
|
|
3643
|
+
this.#themeColor.background = getComputedStyle(probeBtn).backgroundColor;
|
|
3644
|
+
this.#themeColor.text = getComputedStyle(probeBtn).color;
|
|
3645
|
+
|
|
3646
|
+
document.body.removeChild(probeBtn);
|
|
3647
|
+
}
|
|
3648
|
+
|
|
3649
|
+
/**
|
|
3650
|
+
* Update CSS custom properties used by our injected styles so they immediately reflect the current theme.
|
|
3651
|
+
*
|
|
3652
|
+
* @private
|
|
3653
|
+
*/
|
|
3654
|
+
#updateCssVariablesFromTheme() {
|
|
3655
|
+
|
|
3656
|
+
const rootStyle = document.documentElement.style;
|
|
3657
|
+
|
|
3658
|
+
const rgba = (rgb, alpha) => {
|
|
3659
|
+
|
|
3660
|
+
const match = rgb.match(/\d+/g);
|
|
3661
|
+
|
|
3662
|
+
if(!match || (match.length < 3)) {
|
|
3663
|
+
|
|
3664
|
+
return rgb;
|
|
3665
|
+
}
|
|
3666
|
+
|
|
3667
|
+
return "rgba(" + match[0] + ", " + match[1] + ", " + match[2] + ", " + alpha + ")";
|
|
3668
|
+
};
|
|
3669
|
+
|
|
3670
|
+
|
|
3671
|
+
// These variables are consumed by our injected CSS.
|
|
3672
|
+
rootStyle.setProperty("--plugin-primary-bg", this.#themeColor.background);
|
|
3673
|
+
rootStyle.setProperty("--plugin-primary-fg", this.#themeColor.text);
|
|
3674
|
+
|
|
3675
|
+
// Derivatives used for hover/subtle surfaces.
|
|
3676
|
+
rootStyle.setProperty("--plugin-primary-hover", rgba(this.#themeColor.background, 0.10));
|
|
3677
|
+
rootStyle.setProperty("--plugin-primary-subtle", rgba(this.#themeColor.background, 0.08));
|
|
3678
|
+
}
|
|
3679
|
+
|
|
3406
3680
|
/**
|
|
3407
3681
|
* Clean up all resources when the instance is no longer needed.
|
|
3408
3682
|
*
|
|
@@ -3413,6 +3687,7 @@ export class webUiFeatureOptions {
|
|
|
3413
3687
|
*/
|
|
3414
3688
|
cleanup() {
|
|
3415
3689
|
|
|
3690
|
+
this.#categoryStates = {};
|
|
3416
3691
|
this.#cleanupEventListeners();
|
|
3417
3692
|
this.#eventListeners.clear();
|
|
3418
3693
|
|