prismcast 1.8.0 → 1.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.
- package/README.md +6 -6
- package/dist/app.js +15 -5
- package/dist/app.js.map +1 -1
- package/dist/browser/cdp.d.ts +4 -6
- package/dist/browser/cdp.js +13 -17
- package/dist/browser/cdp.js.map +1 -1
- package/dist/browser/index.js +3 -3
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/precaching.d.ts +1 -1
- package/dist/browser/precaching.js +23 -23
- package/dist/browser/precaching.js.map +1 -1
- package/dist/browser/tuning/directv.js +4 -7
- package/dist/browser/tuning/directv.js.map +1 -1
- package/dist/browser/tuning/fox.js +1 -1
- package/dist/browser/tuning/fox.js.map +1 -1
- package/dist/browser/tuning/hbo.js +1 -1
- package/dist/browser/tuning/hbo.js.map +1 -1
- package/dist/browser/tuning/hulu.js +3 -3
- package/dist/browser/tuning/hulu.js.map +1 -1
- package/dist/browser/tuning/sling.js +2 -2
- package/dist/browser/tuning/sling.js.map +1 -1
- package/dist/channels/index.d.ts +1 -1
- package/dist/channels/index.js +416 -416
- package/dist/channels/index.js.map +1 -1
- package/dist/config/health.d.ts +3 -3
- package/dist/config/health.js +3 -3
- package/dist/config/index.js +2 -2
- package/dist/config/index.js.map +1 -1
- package/dist/config/persistence.d.ts +59 -0
- package/dist/config/persistence.js +127 -0
- package/dist/config/persistence.js.map +1 -0
- package/dist/config/servicePacks.d.ts +46 -0
- package/dist/config/{providerPacks.js → servicePacks.js} +28 -31
- package/dist/config/servicePacks.js.map +1 -0
- package/dist/config/services.d.ts +202 -0
- package/dist/config/{providers.js → services.js} +277 -210
- package/dist/config/services.js.map +1 -0
- package/dist/config/sites.js +61 -61
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.d.ts +36 -27
- package/dist/config/userChannels.js +300 -212
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +11 -9
- package/dist/config/userConfig.js +53 -60
- package/dist/config/userConfig.js.map +1 -1
- package/dist/config/userProfiles.d.ts +31 -15
- package/dist/config/userProfiles.js +139 -96
- package/dist/config/userProfiles.js.map +1 -1
- package/dist/hdhr/channelMap.d.ts +1 -1
- package/dist/hdhr/channelMap.js +4 -7
- package/dist/hdhr/channelMap.js.map +1 -1
- package/dist/hdhr/index.js +6 -9
- package/dist/hdhr/index.js.map +1 -1
- package/dist/native/index.js +3 -3
- package/dist/native/index.js.map +1 -1
- package/dist/native/intercept.js +86 -89
- package/dist/native/intercept.js.map +1 -1
- package/dist/native/probe.js +2 -2
- package/dist/native/probe.js.map +1 -1
- package/dist/native/proxy.js +4 -4
- package/dist/native/proxy.js.map +1 -1
- package/dist/routes/assets.js +3 -3
- package/dist/routes/assets.js.map +1 -1
- package/dist/routes/auth.js +3 -3
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/config/channels/index.d.ts +1 -1
- package/dist/routes/config/channels/index.js +1 -1
- package/dist/routes/config/channels/index.js.map +1 -1
- package/dist/routes/config/channels/routes.js +488 -460
- package/dist/routes/config/channels/routes.js.map +1 -1
- package/dist/routes/config/channels/table.d.ts +4 -4
- package/dist/routes/config/channels/table.js +206 -147
- package/dist/routes/config/channels/table.js.map +1 -1
- package/dist/routes/config/index.d.ts +2 -2
- package/dist/routes/config/index.js +3 -3
- package/dist/routes/config/index.js.map +1 -1
- package/dist/routes/config/{providers.js → services.js} +44 -39
- package/dist/routes/config/services.js.map +1 -0
- package/dist/routes/config/settings.js +47 -29
- package/dist/routes/config/settings.js.map +1 -1
- package/dist/routes/debug.js +5 -7
- package/dist/routes/debug.js.map +1 -1
- package/dist/routes/health.js +1 -1
- package/dist/routes/health.js.map +1 -1
- package/dist/routes/index.js +2 -2
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/logs.js +4 -4
- package/dist/routes/logs.js.map +1 -1
- package/dist/routes/playlist.d.ts +4 -4
- package/dist/routes/playlist.js +56 -44
- package/dist/routes/playlist.js.map +1 -1
- package/dist/routes/root/content.js +58 -58
- package/dist/routes/root/content.js.map +1 -1
- package/dist/routes/root/scripts/channels.js +529 -572
- package/dist/routes/root/scripts/channels.js.map +1 -1
- package/dist/routes/root/scripts/config.js +838 -992
- package/dist/routes/root/scripts/config.js.map +1 -1
- package/dist/routes/root/scripts/shared.js +431 -197
- package/dist/routes/root/scripts/shared.js.map +1 -1
- package/dist/routes/root/scripts/status.js +247 -196
- package/dist/routes/root/scripts/status.js.map +1 -1
- package/dist/routes/root/styles.js +12 -4
- package/dist/routes/root/styles.js.map +1 -1
- package/dist/routes/services.d.ts +6 -0
- package/dist/routes/{providers.js → services.js} +43 -43
- package/dist/routes/services.js.map +1 -0
- package/dist/routes/ui.js +2 -0
- package/dist/routes/ui.js.map +1 -1
- package/dist/service/commands.js +5 -8
- package/dist/service/commands.js.map +1 -1
- package/dist/service/generators.d.ts +1 -1
- package/dist/service/generators.js +143 -41
- package/dist/service/generators.js.map +1 -1
- package/dist/streaming/hls.d.ts +4 -4
- package/dist/streaming/hls.js +24 -23
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsResume.js +1 -1
- package/dist/streaming/hlsResume.js.map +1 -1
- package/dist/streaming/monitor.d.ts +2 -2
- package/dist/streaming/monitor.js +19 -13
- package/dist/streaming/monitor.js.map +1 -1
- package/dist/streaming/preroll.js +1 -1
- package/dist/streaming/preroll.js.map +1 -1
- package/dist/streaming/registry.js +4 -8
- package/dist/streaming/registry.js.map +1 -1
- package/dist/streaming/setup.d.ts +2 -2
- package/dist/streaming/setup.js +23 -22
- package/dist/streaming/setup.js.map +1 -1
- package/dist/streaming/showInfo.js +8 -10
- package/dist/streaming/showInfo.js.map +1 -1
- package/dist/streaming/statusEmitter.d.ts +4 -3
- package/dist/streaming/statusEmitter.js +3 -3
- package/dist/streaming/statusEmitter.js.map +1 -1
- package/dist/types/channels.d.ts +15 -11
- package/dist/types/config.d.ts +2 -2
- package/dist/types/index.d.ts +2 -2
- package/dist/types/profiles.d.ts +9 -9
- package/dist/types/selection.d.ts +1 -1
- package/dist/types/shared.d.ts +1 -1
- package/dist/types/streaming.d.ts +1 -1
- package/dist/utils/chromeFetch.js +1 -1
- package/dist/utils/debugFilter.js +3 -3
- package/dist/utils/debugFilter.js.map +1 -1
- package/dist/utils/fileLogger.js +4 -3
- package/dist/utils/fileLogger.js.map +1 -1
- package/dist/utils/format.d.ts +7 -1
- package/dist/utils/format.js +21 -1
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/logger.js +17 -27
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/morganStream.js +2 -2
- package/dist/utils/morganStream.js.map +1 -1
- package/dist/utils/platform.js +3 -2
- package/dist/utils/platform.js.map +1 -1
- package/dist/utils/streamContext.d.ts +7 -0
- package/dist/utils/streamContext.js +10 -1
- package/dist/utils/streamContext.js.map +1 -1
- package/dist/utils/version.js +4 -4
- package/dist/utils/version.js.map +1 -1
- package/package.json +5 -7
- package/dist/config/providerPacks.d.ts +0 -46
- package/dist/config/providerPacks.js.map +0 -1
- package/dist/config/providers.d.ts +0 -175
- package/dist/config/providers.js.map +0 -1
- package/dist/routes/config/providers.js.map +0 -1
- package/dist/routes/providers.d.ts +0 -6
- package/dist/routes/providers.js.map +0 -1
- /package/dist/routes/config/{providers.d.ts → services.d.ts} +0 -0
|
@@ -15,58 +15,140 @@ export function generateSharedUtilitiesScript() {
|
|
|
15
15
|
// Show a toast notification. Auto-dismiss durations: success/info = 5s, warning = 8s, error = no auto-dismiss. Optional action: { label, onclick } appends an
|
|
16
16
|
// inline button between the message text and the close button.
|
|
17
17
|
" function showToast(message, type, duration, action) {",
|
|
18
|
-
"
|
|
19
|
-
" if
|
|
20
|
-
"
|
|
18
|
+
" const container = document.getElementById('toast-container');",
|
|
19
|
+
" if(!container) return;",
|
|
20
|
+
" const toast = document.createElement('div');",
|
|
21
21
|
" toast.className = 'toast ' + (type || 'info');",
|
|
22
22
|
" toast.textContent = message;",
|
|
23
|
-
" toast.setAttribute('role', (type === 'error' || type === 'warning') ? 'alert' : 'status');",
|
|
24
|
-
" if
|
|
25
|
-
"
|
|
23
|
+
" toast.setAttribute('role', ((type === 'error') || (type === 'warning')) ? 'alert' : 'status');",
|
|
24
|
+
" if(action && action.label) {",
|
|
25
|
+
" const actionBtn = document.createElement('button');",
|
|
26
26
|
" actionBtn.type = 'button';",
|
|
27
27
|
" actionBtn.className = 'toast-action';",
|
|
28
28
|
" actionBtn.textContent = action.label;",
|
|
29
|
-
" actionBtn.onclick =
|
|
29
|
+
" actionBtn.onclick = () => { if(action.onclick) action.onclick(); dismissToast(toast); };",
|
|
30
30
|
" toast.appendChild(actionBtn);",
|
|
31
31
|
" }",
|
|
32
|
-
"
|
|
32
|
+
" const closeBtn = document.createElement('button');",
|
|
33
33
|
" closeBtn.type = 'button';",
|
|
34
34
|
" closeBtn.className = 'toast-close';",
|
|
35
35
|
" closeBtn.textContent = '\\u00d7';",
|
|
36
36
|
" closeBtn.setAttribute('aria-label', 'Dismiss');",
|
|
37
|
-
" closeBtn.onclick =
|
|
37
|
+
" closeBtn.onclick = () => { dismissToast(toast); };",
|
|
38
38
|
" toast.appendChild(closeBtn);",
|
|
39
39
|
" container.appendChild(toast);",
|
|
40
|
-
"
|
|
41
|
-
" if
|
|
40
|
+
" const ms = (duration !== undefined) ? duration : ((type === 'error') ? 0 : ((type === 'warning') ? 8000 : 5000));",
|
|
41
|
+
" if(ms > 0) { setTimeout(() => { dismissToast(toast); }, ms); }",
|
|
42
42
|
" }",
|
|
43
43
|
" window.showToast = showToast;",
|
|
44
44
|
// Dismiss a toast with slide-out animation.
|
|
45
45
|
" function dismissToast(toast) {",
|
|
46
|
-
" if
|
|
46
|
+
" if(toast.classList.contains('toast-exit')) return;",
|
|
47
47
|
" toast.classList.add('toast-exit');",
|
|
48
|
-
" toast.addEventListener('animationend',
|
|
48
|
+
" toast.addEventListener('animationend', () => { toast.remove(); });",
|
|
49
49
|
" }",
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
50
|
+
/* Client-side namespace SSOT for all dropdown operations — close, and before-close hook registration. Private state (the hook array and both functions)
|
|
51
|
+
* lives in module-scope closures so the scroll/resize listener references stay stable across open/close cycles. The namespace object exposes the two
|
|
52
|
+
* operations as property references into those module-scope functions, which keeps both entries structurally symmetric and preserves identity for
|
|
53
|
+
* addEventListener / removeEventListener. Consumers call dropdowns.close() to close all open menus and dropdowns.addHook(fn) to register a before-close
|
|
54
|
+
* callback — for example, the inline tag editor registers a batch-save callback so pending changes flush on any close path (click outside, scroll,
|
|
55
|
+
* resize, next toggle). Registration is de-duplicated so accidental double-registration is a no-op.
|
|
56
|
+
*
|
|
57
|
+
* This is the "closure-backed" variant of the client-side namespace pattern documented in CLAUDE.md. The channelTable namespace uses the this.*-backed
|
|
58
|
+
* variant because its methods are only ever called as methods, never as detached listener references; dropdowns cannot use that variant because close()
|
|
59
|
+
* must survive being passed to addEventListener and later matched in removeEventListener.
|
|
60
|
+
*/
|
|
61
|
+
" const dropdownHooks = [];",
|
|
62
|
+
" function addDropdownHook(fn) {",
|
|
63
|
+
" if(!dropdownHooks.includes(fn)) dropdownHooks.push(fn);",
|
|
59
64
|
" }",
|
|
60
|
-
"
|
|
61
|
-
"
|
|
65
|
+
" function closeAllDropdowns() {",
|
|
66
|
+
" for(const hook of dropdownHooks) hook();",
|
|
67
|
+
" const menus = document.querySelectorAll('.dropdown-menu.show');",
|
|
68
|
+
" for(const menu of menus) menu.classList.remove('show');",
|
|
69
|
+
" window.removeEventListener('scroll', closeAllDropdowns, true);",
|
|
70
|
+
" window.removeEventListener('resize', closeAllDropdowns);",
|
|
71
|
+
" }",
|
|
72
|
+
" window.dropdowns = {",
|
|
73
|
+
" addHook: addDropdownHook,",
|
|
74
|
+
" close: closeAllDropdowns",
|
|
75
|
+
" };",
|
|
76
|
+
/* Safe localStorage wrappers. localStorage access can throw in private browsing mode or when storage quota is exceeded. Centralizing the try/catch here
|
|
77
|
+
* means every call site treats localStorage as best-effort persistence without repeating the same try/catch at every site. Failures are silently ignored
|
|
78
|
+
* because they are not actionable by the client — there is no way to recover quota or exit private browsing from JavaScript, and log noise from every
|
|
79
|
+
* subtab switch or page load would dominate useful diagnostics. Callers that need to react to failure should use the raw localStorage API directly.
|
|
80
|
+
*/
|
|
81
|
+
" window.safeStorageGet = (key) => {",
|
|
82
|
+
" try { return localStorage.getItem(key); }",
|
|
83
|
+
" catch(e) { return null; }",
|
|
84
|
+
" };",
|
|
85
|
+
" window.safeStorageSet = (key, value) => {",
|
|
86
|
+
" try { localStorage.setItem(key, value); }",
|
|
87
|
+
" catch(e) {}",
|
|
88
|
+
" };",
|
|
89
|
+
" window.safeStorageRemove = (key) => {",
|
|
90
|
+
" try { localStorage.removeItem(key); }",
|
|
91
|
+
" catch(e) {}",
|
|
92
|
+
" };",
|
|
93
|
+
/* Persist channel table display preferences to the server. Single source of truth for POSTs to /config/channels/display-prefs so every call site (sort,
|
|
94
|
+
* column visibility, and any future preference) shares one fetch path and one error-handling decision. Fire-and-forget — the caller does not await the
|
|
95
|
+
* round-trip because the client state is already updated and the persist is a best-effort sync to the server.
|
|
96
|
+
*/
|
|
97
|
+
" window.persistDisplayPrefs = (body) => {",
|
|
98
|
+
" fetch('/config/channels/display-prefs', {",
|
|
99
|
+
" body: JSON.stringify(body),",
|
|
100
|
+
" headers: { 'Content-Type': 'application/json' },",
|
|
101
|
+
" method: 'POST'",
|
|
102
|
+
" }).catch((err) => { console.warn('Display preferences failed to persist.', err); });",
|
|
103
|
+
" };",
|
|
104
|
+
/* Subtab switching factory. Creates a reusable switch function parameterized by CSS selectors, storage key, and hash format, and attaches click event
|
|
105
|
+
* handlers to all matching buttons so consumers need not wire the click-to-switch binding separately. The returned function handles button active states,
|
|
106
|
+
* panel visibility, localStorage persistence (via safeStorageSet), and URL hash updates. An optional onSwitch callback lets the caller inject per-tab-category
|
|
107
|
+
* logic (e.g., hiding settings buttons on the backup subtab in config). Used by both the Config and Channels tab subtab systems.
|
|
108
|
+
*/
|
|
109
|
+
" window.createSubtabSwitcher = (config) => {",
|
|
110
|
+
" const switchFn = (subtab, updateUrl) => {",
|
|
111
|
+
" for(const btn of document.querySelectorAll(config.btnSelector)) {",
|
|
112
|
+
" btn.classList.remove('active');",
|
|
113
|
+
" btn.setAttribute('aria-selected', 'false');",
|
|
114
|
+
" if(btn.getAttribute(config.dataAttr) === subtab) {",
|
|
115
|
+
" btn.classList.add('active');",
|
|
116
|
+
" btn.setAttribute('aria-selected', 'true');",
|
|
117
|
+
" }",
|
|
118
|
+
" }",
|
|
119
|
+
" for(const panel of document.querySelectorAll(config.panelSelector)) {",
|
|
120
|
+
" panel.classList.remove('active');",
|
|
121
|
+
" if(panel.id === (config.panelPrefix + subtab)) panel.classList.add('active');",
|
|
122
|
+
" }",
|
|
123
|
+
" if(config.onSwitch) config.onSwitch(subtab);",
|
|
124
|
+
" safeStorageSet(config.storageKey, subtab);",
|
|
125
|
+
" if(updateUrl !== false) {",
|
|
126
|
+
" const newHash = config.hashFn(subtab);",
|
|
127
|
+
" if(window.location.hash !== newHash) window.location.hash = newHash;",
|
|
128
|
+
" }",
|
|
129
|
+
" };",
|
|
130
|
+
" for(const btn of document.querySelectorAll(config.btnSelector)) {",
|
|
131
|
+
" btn.addEventListener('click', function() { switchFn(this.getAttribute(config.dataAttr)); });",
|
|
132
|
+
" }",
|
|
133
|
+
" return switchFn;",
|
|
134
|
+
" };",
|
|
135
|
+
/* Initialize a subtab system on page load. Reads the initial subtab from the hash (set by the main tab script) or localStorage, validates the button
|
|
136
|
+
* exists in the DOM, and calls the switch function to activate it. Called once per subtab system at the end of the IIFE.
|
|
137
|
+
*/
|
|
138
|
+
" window.initSubtab = (config) => {",
|
|
139
|
+
" const initial = config.hashVar ?? safeStorageGet(config.storageKey);",
|
|
140
|
+
" if(initial && document.querySelector(config.btnSelector + '[' + config.dataAttr + '=\"' + initial + '\"]')) {",
|
|
141
|
+
" config.switchFn(initial, false);",
|
|
142
|
+
" }",
|
|
143
|
+
" };",
|
|
62
144
|
// Copy text to the clipboard and show a toast. Uses the modern Clipboard API when available (secure contexts), falling back to execCommand for plain HTTP
|
|
63
145
|
// access via IP address.
|
|
64
|
-
" window.copyToClipboard = async
|
|
65
|
-
" if(navigator.clipboard
|
|
146
|
+
" window.copyToClipboard = async (text, successMessage) => {",
|
|
147
|
+
" if(navigator.clipboard?.writeText) {",
|
|
66
148
|
" try { await navigator.clipboard.writeText(text); showToast(successMessage, 'success'); }",
|
|
67
149
|
" catch(e) { showToast('Failed to copy to clipboard.', 'error'); }",
|
|
68
150
|
" } else {",
|
|
69
|
-
"
|
|
151
|
+
" const ta = document.createElement('textarea');",
|
|
70
152
|
" ta.value = text;",
|
|
71
153
|
" ta.style.position = 'fixed';",
|
|
72
154
|
" ta.style.opacity = '0';",
|
|
@@ -81,14 +163,14 @@ export function generateSharedUtilitiesScript() {
|
|
|
81
163
|
// scroll offsets for reliable behavior at all browser zoom levels. The menu must be visible (display: block) for offsetWidth/offsetHeight measurement.
|
|
82
164
|
// This is the single source of truth for all portal positioning math.
|
|
83
165
|
" function positionPortal(menu, anchor) {",
|
|
84
|
-
"
|
|
85
|
-
"
|
|
86
|
-
"
|
|
87
|
-
" if(left + menu.offsetWidth > window.innerWidth - 4) left = rect.right - menu.offsetWidth;",
|
|
166
|
+
" const rect = anchor.getBoundingClientRect();",
|
|
167
|
+
" let top = rect.bottom + 2;",
|
|
168
|
+
" let left = rect.left;",
|
|
169
|
+
" if((left + menu.offsetWidth) > (window.innerWidth - 4)) left = rect.right - menu.offsetWidth;",
|
|
88
170
|
" if(left < 4) left = 4;",
|
|
89
|
-
" if(top + menu.offsetHeight > window.innerHeight - 4) top = rect.top - menu.offsetHeight - 2;",
|
|
90
|
-
"
|
|
91
|
-
"
|
|
171
|
+
" if((top + menu.offsetHeight) > (window.innerHeight - 4)) top = rect.top - menu.offsetHeight - 2;",
|
|
172
|
+
" const sx = window.scrollX || 0;",
|
|
173
|
+
" const sy = window.scrollY || 0;",
|
|
92
174
|
" menu.style.top = (top + sy) + 'px';",
|
|
93
175
|
" menu.style.left = (left + sx) + 'px';",
|
|
94
176
|
" }",
|
|
@@ -96,12 +178,12 @@ export function generateSharedUtilitiesScript() {
|
|
|
96
178
|
// Layer 2: Dropdown toggle with lifecycle hooks. Manages portal append, toggle state, scroll/resize dismissal, and optional lifecycle callbacks. The
|
|
97
179
|
// zero-argument form (toggleDropdown(btn)) works unchanged for standard dropdowns. The options form adds: menu (explicit menu element), onOpen (called
|
|
98
180
|
// after show but before positioning so content can be set and measured correctly for viewport clamping).
|
|
99
|
-
" window.toggleDropdown =
|
|
100
|
-
"
|
|
101
|
-
"
|
|
181
|
+
" window.toggleDropdown = (btn, opts) => {",
|
|
182
|
+
" const o = opts || {};",
|
|
183
|
+
" const menu = btn._portalMenu || o.menu || btn.nextElementSibling;",
|
|
102
184
|
" if(!menu) return;",
|
|
103
|
-
"
|
|
104
|
-
"
|
|
185
|
+
" const isOpen = menu.classList.contains('show');",
|
|
186
|
+
" closeAllDropdowns();",
|
|
105
187
|
" if(isOpen) return;",
|
|
106
188
|
" btn._portalMenu = menu;",
|
|
107
189
|
" if(!menu._portaled) {",
|
|
@@ -113,14 +195,14 @@ export function generateSharedUtilitiesScript() {
|
|
|
113
195
|
" menu.classList.add('show');",
|
|
114
196
|
" if(o.onOpen) o.onOpen(menu);",
|
|
115
197
|
" positionPortal(menu, btn);",
|
|
116
|
-
" window.addEventListener('scroll',
|
|
117
|
-
" window.addEventListener('resize',
|
|
198
|
+
" window.addEventListener('scroll', closeAllDropdowns, true);",
|
|
199
|
+
" window.addEventListener('resize', closeAllDropdowns);",
|
|
118
200
|
" };",
|
|
119
201
|
// Shared channel display renderer with three modes. The mode parameter controls presentation: 'logo' (default) shows the logo with text as an onerror
|
|
120
202
|
// fallback, 'both' shows the logo and text side by side with onerror hiding the broken image, 'text' shows only the text and ignores the logo URL.
|
|
121
|
-
" window.channelDisplayHtml =
|
|
122
|
-
"
|
|
123
|
-
" if(m === 'text' || !logoUrl) {",
|
|
203
|
+
" window.channelDisplayHtml = (logoUrl, name, logoClass, textClass, mode) => {",
|
|
204
|
+
" const m = mode || 'logo';",
|
|
205
|
+
" if((m === 'text') || !logoUrl) {",
|
|
124
206
|
" return '<span class=\"' + textClass + '\">' + name + '</span>';",
|
|
125
207
|
" }",
|
|
126
208
|
" if(m === 'both') {",
|
|
@@ -134,29 +216,29 @@ export function generateSharedUtilitiesScript() {
|
|
|
134
216
|
" };",
|
|
135
217
|
// Image fallback handler. Reads pipe-separated fallback URLs from a data-fallbacks attribute and tries each in sequence on error. When all fallbacks are
|
|
136
218
|
// exhausted, hides the image and reveals the text sibling if it was hidden (logo mode).
|
|
137
|
-
" window.imgFallback =
|
|
138
|
-
"
|
|
139
|
-
"
|
|
219
|
+
" window.imgFallback = (img) => {",
|
|
220
|
+
" const fallbacks = (img.getAttribute('data-fallbacks') || '').split('|').filter(Boolean);",
|
|
221
|
+
" const idx = parseInt(img.getAttribute('data-fb-idx') || '0', 10);",
|
|
140
222
|
" if(idx < fallbacks.length) {",
|
|
141
223
|
" img.setAttribute('data-fb-idx', String(idx + 1));",
|
|
142
224
|
" img.src = fallbacks[idx];",
|
|
143
225
|
" } else {",
|
|
144
226
|
" img.style.display = 'none';",
|
|
145
|
-
"
|
|
146
|
-
" if(sib && sib.style.display === 'none') sib.style.display = 'inline';",
|
|
227
|
+
" const sib = img.nextElementSibling;",
|
|
228
|
+
" if(sib && (sib.style.display === 'none')) sib.style.display = 'inline';",
|
|
147
229
|
" }",
|
|
148
230
|
" };",
|
|
149
|
-
//
|
|
231
|
+
// Service icon renderer with three modes, mirroring channelDisplayHtml. The icon source chain is: iconUrl (if specified) → Apple touch icon → favicon.
|
|
150
232
|
// Fallback URLs are stored in a data-fallbacks attribute and processed by the shared imgFallback handler.
|
|
151
|
-
" window.
|
|
152
|
-
"
|
|
153
|
-
" if(m === 'text' || !domain) {",
|
|
233
|
+
" window.serviceIconHtml = (domain, name, iconClass, textClass, mode, iconUrl) => {",
|
|
234
|
+
" const m = mode || 'logo';",
|
|
235
|
+
" if((m === 'text') || !domain) {",
|
|
154
236
|
" return '<span class=\"' + textClass + '\">' + name + '</span>';",
|
|
155
237
|
" }",
|
|
156
|
-
"
|
|
157
|
-
"
|
|
158
|
-
"
|
|
159
|
-
"
|
|
238
|
+
" const touchIcon = 'https://' + domain + '/apple-touch-icon.png';",
|
|
239
|
+
" const favicon = 'https://' + domain + '/favicon.ico';",
|
|
240
|
+
" const src = iconUrl || touchIcon;",
|
|
241
|
+
" const fallbacks = (iconUrl ? [ touchIcon, favicon ] : [ favicon ]).join('|');",
|
|
160
242
|
" if(m === 'both') {",
|
|
161
243
|
" return '<img src=\"' + src + '\" class=\"' + iconClass + '\" alt=\"\" title=\"' + name + '\" ' +",
|
|
162
244
|
" 'data-fallbacks=\"' + fallbacks + '\" onerror=\"imgFallback(this)\">' +",
|
|
@@ -166,33 +248,17 @@ export function generateSharedUtilitiesScript() {
|
|
|
166
248
|
" 'data-fallbacks=\"' + fallbacks + '\" onerror=\"imgFallback(this)\">' +",
|
|
167
249
|
" '<span class=\"' + textClass + '\" style=\"display:none\">' + name + '</span>';",
|
|
168
250
|
" };",
|
|
169
|
-
// Process
|
|
170
|
-
//
|
|
171
|
-
" window.
|
|
172
|
-
"
|
|
173
|
-
" for(
|
|
174
|
-
" var cell = cells[i];",
|
|
175
|
-
" if(cell.getAttribute('data-logo-processed')) continue;",
|
|
176
|
-
" var url = cell.getAttribute('data-logo');",
|
|
177
|
-
" var nameSpan = cell.querySelector('.channel-name-cell');",
|
|
178
|
-
" if(url && nameSpan) {",
|
|
179
|
-
" nameSpan.innerHTML = channelDisplayHtml(url, nameSpan.textContent || '', 'channel-table-logo', 'channel-table-text', 'both');",
|
|
180
|
-
" cell.setAttribute('data-logo-processed', '1');",
|
|
181
|
-
" }",
|
|
182
|
-
" }",
|
|
183
|
-
" };",
|
|
184
|
-
// Process provider display spans. Finds all .provider-display elements and renders them via providerIconHtml in 'both' mode. Called on page load and after
|
|
185
|
-
// any DOM mutation that introduces new provider display elements (chip rebuild, filter updates).
|
|
186
|
-
" window.processProviderDisplays = function() {",
|
|
187
|
-
" var els = document.querySelectorAll('.provider-display');",
|
|
188
|
-
" for(var i = 0; i < els.length; i++) {",
|
|
189
|
-
" var el = els[i];",
|
|
251
|
+
// Process service display spans. Finds all .provider-display elements and renders them via serviceIconHtml in 'both' mode. Called on page load and after
|
|
252
|
+
// any DOM mutation that introduces new service display elements (chip rebuild, filter updates).
|
|
253
|
+
" window.processServiceDisplays = () => {",
|
|
254
|
+
" const els = document.querySelectorAll('.provider-display');",
|
|
255
|
+
" for(const el of els) {",
|
|
190
256
|
" if(el.getAttribute('data-processed')) continue;",
|
|
191
|
-
"
|
|
192
|
-
"
|
|
193
|
-
"
|
|
194
|
-
"
|
|
195
|
-
" el.innerHTML =
|
|
257
|
+
" const domain = el.getAttribute('data-domain') || '';",
|
|
258
|
+
" const iconUrl = el.getAttribute('data-icon-url') || '';",
|
|
259
|
+
" const name = el.textContent || '';",
|
|
260
|
+
" const sm = el.hasAttribute('data-sm');",
|
|
261
|
+
" el.innerHTML = serviceIconHtml(domain, name, sm ? 'provider-icon-sm' : 'provider-icon',",
|
|
196
262
|
" sm ? 'provider-chip-text' : 'provider-icon-text', 'both', iconUrl);",
|
|
197
263
|
" el.setAttribute('data-processed', '1');",
|
|
198
264
|
" }",
|
|
@@ -214,29 +280,29 @@ export function generateSharedUtilitiesScript() {
|
|
|
214
280
|
// - titleId: ID of the title h3 element (optional, for dynamic title updates).
|
|
215
281
|
//
|
|
216
282
|
// Returns: { back, close, getStep, goToStep, hide, next, open, setError, setTitle, show, state }
|
|
217
|
-
" window.createWizardController =
|
|
218
|
-
"
|
|
219
|
-
"
|
|
220
|
-
"
|
|
283
|
+
" window.createWizardController = (config) => {",
|
|
284
|
+
" let currentStep = 1;",
|
|
285
|
+
" let highestStep = 1;",
|
|
286
|
+
" const stepsContainerId = config.stepsId || (config.modalId + '-steps');",
|
|
221
287
|
// Update the step indicator DOM. Iterates .wizard-step children within the scoped container, setting active/completed/clickable classes and attaching
|
|
222
288
|
// click handlers for visited steps.
|
|
223
289
|
" function updateStepIndicator() {",
|
|
224
|
-
"
|
|
290
|
+
" const container = document.getElementById(stepsContainerId);",
|
|
225
291
|
" if(!container) return;",
|
|
226
|
-
"
|
|
227
|
-
" for(
|
|
228
|
-
"
|
|
229
|
-
"
|
|
230
|
-
" if(stepNum < currentStep)
|
|
231
|
-
" if(stepNum === currentStep)
|
|
232
|
-
" if(stepNum !== currentStep && stepNum <= highestStep)
|
|
233
|
-
"
|
|
292
|
+
" const steps = container.querySelectorAll('.wizard-step');",
|
|
293
|
+
" for(const step of steps) {",
|
|
294
|
+
" const stepNum = parseInt(step.getAttribute('data-step'), 10);",
|
|
295
|
+
" step.classList.remove('active', 'completed', 'clickable');",
|
|
296
|
+
" if(stepNum < currentStep) step.classList.add('completed');",
|
|
297
|
+
" if(stepNum === currentStep) step.classList.add('active');",
|
|
298
|
+
" if((stepNum !== currentStep) && (stepNum <= highestStep)) step.classList.add('clickable');",
|
|
299
|
+
" step.onclick = () => ctrl.goToStep(stepNum);",
|
|
234
300
|
" }",
|
|
235
301
|
" }",
|
|
236
302
|
// Clear the error display.
|
|
237
303
|
" function clearError() {",
|
|
238
304
|
" if(!config.errorId) return;",
|
|
239
|
-
"
|
|
305
|
+
" const el = document.getElementById(config.errorId);",
|
|
240
306
|
" if(el) { el.textContent = ''; el.style.display = 'none'; }",
|
|
241
307
|
" }",
|
|
242
308
|
// Render the current step. Clears errors, updates the step indicator, and delegates to the caller's onRender callback.
|
|
@@ -255,30 +321,32 @@ export function generateSharedUtilitiesScript() {
|
|
|
255
321
|
// return is always a Promise, so callers can uniformly await the result regardless of whether onValidate is sync or async.
|
|
256
322
|
" async function validateAndAdvance(target) {",
|
|
257
323
|
" try {",
|
|
258
|
-
"
|
|
324
|
+
" const err = await config.onValidate(currentStep);",
|
|
259
325
|
" if(err) { ctrl.setError(err); return false; }",
|
|
260
326
|
" advance(target);",
|
|
261
327
|
" return true;",
|
|
262
|
-
" } catch(e) { ctrl.setError('Validation failed.'); return false; }",
|
|
328
|
+
" } catch(e) { console.error('Wizard validation threw an unexpected error.', e); ctrl.setError('Validation failed.'); return false; }",
|
|
263
329
|
" }",
|
|
264
|
-
"
|
|
330
|
+
" const ctrl = {",
|
|
265
331
|
// Arbitrary state object for the caller to store wizard-specific data. Reset to {} on close.
|
|
266
332
|
" state: {},",
|
|
267
333
|
// Navigate backward one step.
|
|
268
|
-
" back
|
|
334
|
+
" back() {",
|
|
269
335
|
" if(currentStep > 1) { currentStep--; render(); }",
|
|
270
336
|
" },",
|
|
271
337
|
// Close the modal. Invokes the optional onClose callback before hiding, then resets state.
|
|
272
|
-
" close
|
|
338
|
+
" close() {",
|
|
273
339
|
" if(config.onClose) config.onClose();",
|
|
274
340
|
" document.getElementById(config.modalId).style.display = 'none';",
|
|
275
341
|
" ctrl.state = {};",
|
|
276
342
|
" },",
|
|
277
343
|
// Return the current step number.
|
|
278
|
-
" getStep
|
|
344
|
+
" getStep() {",
|
|
345
|
+
" return currentStep;",
|
|
346
|
+
" },",
|
|
279
347
|
// Navigate to a specific step by clicking the step indicator. Going backward is always allowed for visited steps. Going forward validates the current step
|
|
280
348
|
// and only allows jumps to previously visited steps. Supports async validation - returns a Promise when onValidate is async.
|
|
281
|
-
" goToStep
|
|
349
|
+
" goToStep(n) {",
|
|
282
350
|
" if(n === currentStep) return;",
|
|
283
351
|
" if(n > highestStep) return;",
|
|
284
352
|
" if(n > currentStep) {",
|
|
@@ -288,146 +356,312 @@ export function generateSharedUtilitiesScript() {
|
|
|
288
356
|
" render();",
|
|
289
357
|
" },",
|
|
290
358
|
// Hide a button by ID.
|
|
291
|
-
" hide
|
|
292
|
-
"
|
|
359
|
+
" hide(id) {",
|
|
360
|
+
" const el = document.getElementById(id);",
|
|
293
361
|
" if(el) el.style.display = 'none';",
|
|
294
362
|
" },",
|
|
295
363
|
// Navigate forward one step. Validates the current step first. Returns false (sync) or a Promise resolving to false (async) if validation failed.
|
|
296
|
-
" next
|
|
364
|
+
" next() {",
|
|
297
365
|
" if(currentStep >= config.stepCount) return true;",
|
|
298
366
|
" return validateAndAdvance(currentStep + 1);",
|
|
299
367
|
" },",
|
|
300
368
|
// Open the modal and render. Options: { step, highestStep, title } - all optional.
|
|
301
|
-
" open
|
|
302
|
-
"
|
|
369
|
+
" open(options) {",
|
|
370
|
+
" const opts = options || {};",
|
|
303
371
|
" currentStep = opts.step || 1;",
|
|
304
372
|
" highestStep = opts.highestStep || currentStep;",
|
|
305
373
|
" if(opts.title && config.titleId) {",
|
|
306
|
-
"
|
|
374
|
+
" const titleEl = document.getElementById(config.titleId);",
|
|
307
375
|
" if(titleEl) titleEl.textContent = opts.title;",
|
|
308
376
|
" }",
|
|
309
377
|
" document.getElementById(config.modalId).style.display = 'flex';",
|
|
310
378
|
" render();",
|
|
311
379
|
" },",
|
|
312
380
|
// Show a validation error message.
|
|
313
|
-
" setError
|
|
381
|
+
" setError(msg) {",
|
|
314
382
|
" if(!config.errorId) return;",
|
|
315
|
-
"
|
|
383
|
+
" const el = document.getElementById(config.errorId);",
|
|
316
384
|
" if(el) { el.textContent = msg; el.style.display = msg ? '' : 'none'; }",
|
|
317
385
|
" },",
|
|
318
386
|
// Update the modal title dynamically.
|
|
319
|
-
" setTitle
|
|
387
|
+
" setTitle(html) {",
|
|
320
388
|
" if(!config.titleId) return;",
|
|
321
|
-
"
|
|
389
|
+
" const el = document.getElementById(config.titleId);",
|
|
322
390
|
" if(el) el.innerHTML = html;",
|
|
323
391
|
" },",
|
|
324
392
|
// Show a button by ID.
|
|
325
|
-
" show
|
|
326
|
-
"
|
|
393
|
+
" show(id) {",
|
|
394
|
+
" const el = document.getElementById(id);",
|
|
327
395
|
" if(el) el.style.display = '';",
|
|
328
396
|
" }",
|
|
329
397
|
" };",
|
|
330
398
|
// Attach click handlers to controller-managed buttons within the modal. Role-tagged buttons (data-wizard-role) and the X close button (.wizard-close) get
|
|
331
399
|
// handlers attached from inside the IIFE closure, where the controller variable is accessible. This is the same pattern used for step indicator click
|
|
332
400
|
// handlers and avoids the inline-onclick-to-global-scope problem that breaks when controller instances are IIFE-scoped.
|
|
333
|
-
"
|
|
401
|
+
" const modal = document.getElementById(config.modalId);",
|
|
334
402
|
" if(modal) {",
|
|
335
|
-
"
|
|
336
|
-
" for(
|
|
337
|
-
" (
|
|
338
|
-
"
|
|
339
|
-
"
|
|
340
|
-
"
|
|
341
|
-
" else if(role === 'close') { btn.onclick = function() { ctrl.close(); }; }",
|
|
342
|
-
" })(roleButtons[i]);",
|
|
403
|
+
" const roleButtons = modal.querySelectorAll('[data-wizard-role]');",
|
|
404
|
+
" for(const btn of roleButtons) {",
|
|
405
|
+
" const role = btn.getAttribute('data-wizard-role');",
|
|
406
|
+
" if(role === 'back') { btn.onclick = () => { ctrl.back(); }; }",
|
|
407
|
+
" else if(role === 'next') { btn.onclick = () => { ctrl.next(); }; }",
|
|
408
|
+
" else if(role === 'close') { btn.onclick = () => { ctrl.close(); }; }",
|
|
343
409
|
" }",
|
|
344
|
-
"
|
|
345
|
-
" if(closeBtn) { closeBtn.onclick =
|
|
410
|
+
" const closeBtn = modal.querySelector('.wizard-close');",
|
|
411
|
+
" if(closeBtn) { closeBtn.onclick = () => { ctrl.close(); }; }",
|
|
346
412
|
" }",
|
|
347
413
|
" return ctrl;",
|
|
348
414
|
" };",
|
|
415
|
+
/* Channel table DOM manipulation namespace. All functions that insert, remove, filter, sort, or update channel table rows live here as methods of a single
|
|
416
|
+
* namespace object. Internal collaborators invoke each other via this.* which keeps the call graph contained inside the namespace. External callers across
|
|
417
|
+
* all client scripts use channelTable.* (e.g., channelTable.applyPatch, channelTable.filter).
|
|
418
|
+
*/
|
|
419
|
+
" window.channelTable = {",
|
|
420
|
+
/* Cached column index map for _getSortValue. Built lazily on first access by scanning the table header row's data-sort-field attributes, then held for
|
|
421
|
+
* the page lifetime with no invalidation path. This relies on a load-bearing invariant: the channel table header is rendered once by the server and
|
|
422
|
+
* never rebuilt client-side — only CSS visibility classes are toggled by toggleColumn, never DOM structure. Do not introduce dynamic thead mutation
|
|
423
|
+
* without also adding an explicit cache invalidation (e.g., set _colIndexCache = null after rewriting the header row).
|
|
424
|
+
*/
|
|
425
|
+
" _colIndexCache: null,",
|
|
426
|
+
// Read the server-stamped sort value from a row's cell for a given field. Private helper used by sort() and insertRow() to compute insertion order.
|
|
427
|
+
" _getSortValue(row, field) {",
|
|
428
|
+
" if(!this._colIndexCache) {",
|
|
429
|
+
" this._colIndexCache = {};",
|
|
430
|
+
" const ths = document.querySelectorAll('.channel-table th[data-sort-field]');",
|
|
431
|
+
" for(let i = 0; i < ths.length; i++) { this._colIndexCache[ths[i].getAttribute('data-sort-field')] = i; }",
|
|
432
|
+
" }",
|
|
433
|
+
" const idx = this._colIndexCache[field];",
|
|
434
|
+
" if(idx === undefined) return '';",
|
|
435
|
+
" const cell = row.children[idx];",
|
|
436
|
+
" return cell?.getAttribute('data-sort-value') || '';",
|
|
437
|
+
" },",
|
|
349
438
|
// Apply a channel table patch from a mutation response or SSE event. Handles whichever fields are present: rows (insert/remove), counts (summary header),
|
|
350
|
-
// scopeCounts (Quick Actions toggles),
|
|
351
|
-
"
|
|
352
|
-
"
|
|
439
|
+
// scopeCounts (Quick Actions toggles), hdhrCounts, tagCounts, logos. This is the single entry point for all channel table updates.
|
|
440
|
+
" applyPatch(patch) {",
|
|
441
|
+
" if(!patch) return;",
|
|
353
442
|
// Apply row updates (insert, replace, or remove).
|
|
354
|
-
"
|
|
355
|
-
"
|
|
356
|
-
"
|
|
357
|
-
"
|
|
358
|
-
"
|
|
359
|
-
"
|
|
360
|
-
" if(window.insertChannelRow) window.insertChannelRow({ displayRow: row.displayRow, editRow: row.editRow || '' }, row.key);",
|
|
443
|
+
" if(patch.rows) {",
|
|
444
|
+
" for(const row of patch.rows) {",
|
|
445
|
+
" if(row.action === 'remove') this.removeRow(row.key);",
|
|
446
|
+
" else if((row.action === 'update') && row.displayRow) {",
|
|
447
|
+
" this.insertRow({ displayRow: row.displayRow, editRow: row.editRow || '' }, row.key);",
|
|
448
|
+
" }",
|
|
361
449
|
" }",
|
|
362
450
|
" }",
|
|
363
|
-
|
|
364
|
-
//
|
|
365
|
-
"
|
|
366
|
-
"
|
|
367
|
-
"
|
|
368
|
-
"
|
|
369
|
-
"
|
|
370
|
-
"
|
|
371
|
-
"
|
|
372
|
-
"
|
|
373
|
-
"
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
"
|
|
378
|
-
"
|
|
379
|
-
"
|
|
380
|
-
"
|
|
381
|
-
"
|
|
382
|
-
"
|
|
383
|
-
"
|
|
384
|
-
"
|
|
385
|
-
"
|
|
386
|
-
" }",
|
|
387
|
-
" if(span) { span.setAttribute('data-enabled', sc.enabled); span.setAttribute('data-total', sc.total); " +
|
|
451
|
+
// Apply summary counts directly from server-computed values. The entries array drives a single loop rather than repeating the getElementById + null-check
|
|
452
|
+
// pattern for each counter. Alphabetized by element ID.
|
|
453
|
+
" if(patch.counts) {",
|
|
454
|
+
" const c = patch.counts;",
|
|
455
|
+
" for(const [ id, val ] of [",
|
|
456
|
+
" [ 'disabled-count', String(c.disabled) ],",
|
|
457
|
+
" [ 'enabled-count', String(c.enabled) ],",
|
|
458
|
+
" [ 'predefined-count', String(c.predefined) ],",
|
|
459
|
+
" [ 'total-count', String(c.total) ],",
|
|
460
|
+
" [ 'user-count', (c.user > 0) ? ', ' + String(c.user) + ' user' : '' ]",
|
|
461
|
+
" ]) { const el = document.getElementById(id); if(el) el.textContent = val; }",
|
|
462
|
+
" }",
|
|
463
|
+
// Apply scope toggle counts from server-computed values. Iterates the server payload directly so this block stays symmetric with the tagCounts and logos
|
|
464
|
+
// blocks below, and so the client does not hardcode the set of scope names.
|
|
465
|
+
" if(patch.scopeCounts) {",
|
|
466
|
+
" for(const [ s, sc ] of Object.entries(patch.scopeCounts)) {",
|
|
467
|
+
" const cb = document.querySelector('.scope-toggle[data-scope=\"' + s + '\"]');",
|
|
468
|
+
" const span = document.querySelector('.quick-action-count[data-scope=\"' + s + '\"]');",
|
|
469
|
+
" if(cb) {",
|
|
470
|
+
" cb.checked = (sc.enabled === sc.total);",
|
|
471
|
+
" cb.indeterminate = (sc.enabled > 0) && (sc.enabled < sc.total);",
|
|
472
|
+
" }",
|
|
473
|
+
" if(span) { span.setAttribute('data-enabled', sc.enabled); span.setAttribute('data-total', sc.total); " +
|
|
388
474
|
"span.textContent = sc.enabled + ' of ' + sc.total + ' enabled'; }",
|
|
475
|
+
" }",
|
|
389
476
|
" }",
|
|
390
|
-
" }",
|
|
391
477
|
// Apply HDHR bulk toggle counts from server-computed values.
|
|
392
|
-
"
|
|
393
|
-
"
|
|
394
|
-
"
|
|
395
|
-
"
|
|
396
|
-
"
|
|
397
|
-
"
|
|
398
|
-
"
|
|
478
|
+
" if(patch.hdhrCounts) {",
|
|
479
|
+
" const hc = patch.hdhrCounts;",
|
|
480
|
+
" const htoggle = document.getElementById('hdhr-bulk-toggle');",
|
|
481
|
+
" const hcount = document.getElementById('hdhr-bulk-count');",
|
|
482
|
+
" if(htoggle) {",
|
|
483
|
+
" htoggle.checked = (hc.enabled === hc.total) && (hc.total > 0);",
|
|
484
|
+
" htoggle.indeterminate = (hc.enabled > 0) && (hc.enabled < hc.total);",
|
|
485
|
+
" }",
|
|
486
|
+
" if(hcount) hcount.textContent = hc.enabled + ' of ' + hc.total;",
|
|
399
487
|
" }",
|
|
400
|
-
|
|
401
|
-
"
|
|
402
|
-
|
|
403
|
-
"
|
|
404
|
-
"
|
|
405
|
-
"
|
|
406
|
-
"
|
|
407
|
-
"
|
|
408
|
-
"
|
|
409
|
-
"
|
|
410
|
-
" ttoggle.indeterminate = (tc.count > 0) && (tc.count < tc.total);",
|
|
488
|
+
// Apply tag bulk toggle counts from server-computed values.
|
|
489
|
+
" if(patch.tagCounts) {",
|
|
490
|
+
" for(const [ tagName, tc ] of Object.entries(patch.tagCounts)) {",
|
|
491
|
+
" const ttoggle = document.querySelector('.tag-bulk-toggle[data-tag=\"' + tagName + '\"]');",
|
|
492
|
+
" const tcount = document.querySelector('[data-tag-count=\"' + tagName + '\"]');",
|
|
493
|
+
" if(ttoggle) {",
|
|
494
|
+
" ttoggle.checked = (tc.count === tc.total) && (tc.total > 0);",
|
|
495
|
+
" ttoggle.indeterminate = (tc.count > 0) && (tc.count < tc.total);",
|
|
496
|
+
" }",
|
|
497
|
+
" if(tcount) tcount.textContent = tc.count + ' of ' + tc.total;",
|
|
411
498
|
" }",
|
|
412
|
-
" if(tcount) tcount.textContent = tc.count + ' of ' + tc.total;",
|
|
413
499
|
" }",
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
"
|
|
417
|
-
"
|
|
418
|
-
"
|
|
419
|
-
"
|
|
420
|
-
"
|
|
421
|
-
"
|
|
500
|
+
// Apply channel logos from a logo map (key → URL). Sets data-logo attributes on matching rows so processLogos() can render the images.
|
|
501
|
+
" if(patch.logos) {",
|
|
502
|
+
" for(const [ key, logoUrl ] of Object.entries(patch.logos)) {",
|
|
503
|
+
" const logoRow = document.getElementById('display-row-' + key);",
|
|
504
|
+
" const nameCell = logoRow?.querySelector('.channel-name-cell');",
|
|
505
|
+
" nameCell?.parentElement?.setAttribute('data-logo', logoUrl);",
|
|
506
|
+
" }",
|
|
507
|
+
" }",
|
|
508
|
+
// Post-update: refilter rows for the service filter and render any new logos. Row insertions reset the select options (Safari ignores hidden), so the
|
|
509
|
+
// filter must be re-applied. Logo rendering handles both logo-only patches and newly-inserted rows.
|
|
510
|
+
" if(patch.rows && (patch.rows.length > 0)) this.refilter();",
|
|
511
|
+
" if(patch.logos || (patch.rows && (patch.rows.length > 0))) this.processLogos();",
|
|
512
|
+
" },",
|
|
513
|
+
// Insert or replace a channel row in the table. Always removes existing rows with the same key first (handles edits and overrides of builtin channels).
|
|
514
|
+
// Uses the current table sort field and direction for correct insertion order.
|
|
515
|
+
" insertRow(html, key) {",
|
|
516
|
+
" const table = document.querySelector('.channel-table');",
|
|
517
|
+
" const tbody = table?.querySelector('tbody');",
|
|
518
|
+
" if(!table || !tbody || !html) return;",
|
|
519
|
+
" document.getElementById('edit-row-' + key)?.remove();",
|
|
520
|
+
" document.getElementById('display-row-' + key)?.remove();",
|
|
521
|
+
" const temp = document.createElement('tbody');",
|
|
522
|
+
" temp.innerHTML = html.displayRow + (html.editRow || '');",
|
|
523
|
+
" const newDisplayRow = temp.firstElementChild;",
|
|
524
|
+
" const newEditRow = temp.children[1] || null;",
|
|
525
|
+
" const sortField = table.getAttribute('data-sort-field') || 'name';",
|
|
526
|
+
" const sortDir = table.getAttribute('data-sort-dir') || 'asc';",
|
|
527
|
+
" const newVal = this._getSortValue(newDisplayRow, sortField);",
|
|
528
|
+
" const rows = tbody.querySelectorAll('tr[id^=\"display-row-\"]');",
|
|
529
|
+
" let inserted = false;",
|
|
530
|
+
" for(const row of rows) {",
|
|
531
|
+
" const rowVal = this._getSortValue(row, sortField);",
|
|
532
|
+
" const cmp = (sortDir === 'asc') ? (newVal < rowVal) : (newVal > rowVal);",
|
|
533
|
+
" if(cmp) {",
|
|
534
|
+
" tbody.insertBefore(newDisplayRow, row);",
|
|
535
|
+
" if(newEditRow) tbody.insertBefore(newEditRow, row);",
|
|
536
|
+
" inserted = true;",
|
|
537
|
+
" break;",
|
|
538
|
+
" }",
|
|
539
|
+
" }",
|
|
540
|
+
" if(!inserted) {",
|
|
541
|
+
" tbody.appendChild(newDisplayRow);",
|
|
542
|
+
" if(newEditRow) tbody.appendChild(newEditRow);",
|
|
543
|
+
" }",
|
|
544
|
+
" this.processLogos();",
|
|
545
|
+
" },",
|
|
546
|
+
// Remove a channel row (both display and edit variants) from the table.
|
|
547
|
+
" removeRow(key) {",
|
|
548
|
+
" document.getElementById('display-row-' + key)?.remove();",
|
|
549
|
+
" document.getElementById('edit-row-' + key)?.remove();",
|
|
550
|
+
" },",
|
|
551
|
+
/* Apply the service filter to all channel rows. Hides rows whose service tags aren't enabled and filters service dropdown options for multi-service channels.
|
|
552
|
+
* Uses a persistent _allOptions array on each select to remember all server-rendered options across filter applications — the server marks disabled options
|
|
553
|
+
* with the hidden attribute, but Safari ignores hidden on option elements, so we rebuild the select with only enabled options each time. Selection restore
|
|
554
|
+
* priority: (1) saved server choice (HTML selected attribute), (2) previous visual selection, (3) first option.
|
|
555
|
+
*
|
|
556
|
+
* Cache lifecycle: _allOptions is attached to the DOM select element itself, so its lifetime is tied to that element. Row replacement via insertRow() drops
|
|
557
|
+
* the old row (and its select) and inserts fresh server-rendered HTML, which transparently resets the cache with whatever option set the server just sent.
|
|
558
|
+
* This invariant is load-bearing — do not refactor insertRow() into an in-place row update without also adding an explicit cache invalidation path here.
|
|
559
|
+
*/
|
|
560
|
+
" filter(enabledTags) {",
|
|
561
|
+
" const rows = document.querySelectorAll('tr[data-provider-tags]');",
|
|
562
|
+
" for(const row of rows) {",
|
|
563
|
+
" const tags = row.getAttribute('data-provider-tags').split(',');",
|
|
564
|
+
" const available = (enabledTags.length === 0) || tags.some((t) => (t === 'direct') || enabledTags.includes(t));",
|
|
565
|
+
" row.classList.toggle('channel-unavailable', !available);",
|
|
566
|
+
" const label = row.querySelector('.no-provider-label');",
|
|
567
|
+
" const sel = row.querySelector('.provider-select');",
|
|
568
|
+
" const name = row.querySelector('.provider-name');",
|
|
569
|
+
" if(label) label.style.display = available ? 'none' : '';",
|
|
570
|
+
" if(name) name.style.display = available ? '' : 'none';",
|
|
571
|
+
" if(sel) {",
|
|
572
|
+
" sel.style.display = available ? '' : 'none';",
|
|
573
|
+
" sel._allOptions ??= [ ...sel.querySelectorAll('option') ];",
|
|
574
|
+
" const prevValue = sel.value;",
|
|
575
|
+
" sel.innerHTML = '';",
|
|
576
|
+
" let serverDefault = null;",
|
|
577
|
+
" let prevExists = false;",
|
|
578
|
+
" for(const opt of sel._allOptions) {",
|
|
579
|
+
" const oTag = opt.getAttribute('data-provider-tag');",
|
|
580
|
+
" const show = (enabledTags.length === 0) || (oTag === 'direct') || enabledTags.includes(oTag);",
|
|
581
|
+
" if(show) {",
|
|
582
|
+
" sel.appendChild(opt);",
|
|
583
|
+
" if(opt.hasAttribute('selected')) serverDefault = opt;",
|
|
584
|
+
" if(opt.value === prevValue) prevExists = true;",
|
|
585
|
+
" }",
|
|
586
|
+
" }",
|
|
587
|
+
" if(serverDefault) sel.value = serverDefault.value;",
|
|
588
|
+
" else if(prevExists) sel.value = prevValue;",
|
|
589
|
+
" else if(sel.options.length > 0) sel.selectedIndex = 0;",
|
|
590
|
+
" }",
|
|
591
|
+
" }",
|
|
592
|
+
" },",
|
|
593
|
+
// Returns the currently enabled service filter tags by walking the filter dropdown checkboxes. Returns an empty array when the menu is missing, when all
|
|
594
|
+
// checkboxes are checked (treated as no filter), or when no checkboxes are checked. The empty-when-all-checked semantics match the server-side filter model:
|
|
595
|
+
// an empty enabledServices array means "show all services" rather than "show none."
|
|
596
|
+
" getEnabledFilterTags() {",
|
|
597
|
+
" const menu = document.querySelector('.provider-dropdown-menu');",
|
|
598
|
+
" if(!menu) return [];",
|
|
599
|
+
" const cbs = menu.querySelectorAll('input[type=\"checkbox\"]:not(:disabled)');",
|
|
600
|
+
" const enabledTags = [];",
|
|
601
|
+
" let allChecked = true;",
|
|
602
|
+
" for(const cb of cbs) {",
|
|
603
|
+
" if(cb.checked) enabledTags.push(cb.getAttribute('data-tag'));",
|
|
604
|
+
" else allChecked = false;",
|
|
605
|
+
" }",
|
|
606
|
+
" return allChecked ? [] : enabledTags;",
|
|
607
|
+
" },",
|
|
608
|
+
// Re-apply the service filter using the current checkbox state from the filter dropdown menu. Called after row insertions because fresh server HTML contains
|
|
609
|
+
// all options with hidden attributes (which Safari ignores), so the filter must be re-established.
|
|
610
|
+
" refilter() {",
|
|
611
|
+
" const tags = this.getEnabledFilterTags();",
|
|
612
|
+
" if(tags.length > 0) this.filter(tags);",
|
|
613
|
+
" },",
|
|
614
|
+
// Sort the channel table by the specified field. Toggles direction if the same field is clicked again. Persists the sort preference to the server.
|
|
615
|
+
" sort(field) {",
|
|
616
|
+
" const table = document.querySelector('.channel-table');",
|
|
617
|
+
" if(!table) return;",
|
|
618
|
+
" const currentField = table.getAttribute('data-sort-field');",
|
|
619
|
+
" const currentDir = table.getAttribute('data-sort-dir') || 'asc';",
|
|
620
|
+
" const dir = (field === currentField) ? ((currentDir === 'asc') ? 'desc' : 'asc') : 'asc';",
|
|
621
|
+
" table.setAttribute('data-sort-field', field);",
|
|
622
|
+
" table.setAttribute('data-sort-dir', dir);",
|
|
623
|
+
" const tbody = table.querySelector('tbody');",
|
|
624
|
+
" const displayRows = tbody.querySelectorAll('tr[id^=\"display-row-\"]');",
|
|
625
|
+
" const pairs = [];",
|
|
626
|
+
" for(const dr of displayRows) {",
|
|
627
|
+
" const key = dr.id.replace('display-row-', '');",
|
|
628
|
+
" const er = document.getElementById('edit-row-' + key);",
|
|
629
|
+
" const name = dr.children[1]?.textContent.trim().toLowerCase() ?? '';",
|
|
630
|
+
" pairs.push({ displayRow: dr, editRow: er, name, val: this._getSortValue(dr, field) });",
|
|
631
|
+
" }",
|
|
632
|
+
// Secondary sort by channel name stabilizes rows with identical primary values. Always ascending regardless of primary direction so groups maintain
|
|
633
|
+
// consistent alphabetical order within the sort.
|
|
634
|
+
" pairs.sort((a, b) => {",
|
|
635
|
+
" const cmp = (dir === 'asc') ? a.val.localeCompare(b.val) : b.val.localeCompare(a.val);",
|
|
636
|
+
" return (cmp !== 0) ? cmp : a.name.localeCompare(b.name);",
|
|
637
|
+
" });",
|
|
638
|
+
" for(const pair of pairs) {",
|
|
639
|
+
" tbody.appendChild(pair.displayRow);",
|
|
640
|
+
" if(pair.editRow) tbody.appendChild(pair.editRow);",
|
|
641
|
+
" }",
|
|
642
|
+
// Update header sort indicators. Targets .sort-label within each th so other children (like tag filter dropdowns) are untouched.
|
|
643
|
+
" for(const th of table.querySelectorAll('th.sortable')) {",
|
|
644
|
+
" const hField = th.getAttribute('data-sort-field');",
|
|
645
|
+
" const sortLabel = th.querySelector('.sort-label');",
|
|
646
|
+
" if(!sortLabel) continue;",
|
|
647
|
+
" const label = sortLabel.textContent.replace(/[\\u25B2\\u25BC]/g, '').trim();",
|
|
648
|
+
" if(hField === field) sortLabel.innerHTML = label + ((dir === 'asc') ? ' ▲' : ' ▼');",
|
|
649
|
+
" else sortLabel.textContent = label;",
|
|
650
|
+
" }",
|
|
651
|
+
" persistDisplayPrefs({ sortDirection: dir, sortField: field });",
|
|
652
|
+
" },",
|
|
653
|
+
// Process channel logo data attributes. Finds all td[data-logo] elements and renders their channel-name-cell spans via channelDisplayHtml in 'both' mode.
|
|
654
|
+
// Called on page load and after any DOM mutation that inserts server-rendered channel rows (add, edit, delete-with-replacement).
|
|
655
|
+
" processLogos() {",
|
|
656
|
+
" for(const cell of document.querySelectorAll('td[data-logo]')) {",
|
|
657
|
+
" if(cell.getAttribute('data-logo-processed')) continue;",
|
|
658
|
+
" const url = cell.getAttribute('data-logo');",
|
|
659
|
+
" const nameSpan = cell.querySelector('.channel-name-cell');",
|
|
660
|
+
" if(url && nameSpan) {",
|
|
661
|
+
" nameSpan.innerHTML = channelDisplayHtml(url, nameSpan.textContent || '', 'channel-table-logo', 'channel-table-text', 'both');",
|
|
662
|
+
" cell.setAttribute('data-logo-processed', '1');",
|
|
422
663
|
" }",
|
|
423
664
|
" }",
|
|
424
|
-
" }",
|
|
425
|
-
// Post-update: refilter rows for provider filter and render any new logos.
|
|
426
|
-
" if(patch.rows && patch.rows.length > 0) {",
|
|
427
|
-
" if(window.refilterChannelRows) window.refilterChannelRows();",
|
|
428
|
-
" }",
|
|
429
|
-
" if(patch.logos || (patch.rows && patch.rows.length > 0)) {",
|
|
430
|
-
" if(window.processChannelLogos) window.processChannelLogos();",
|
|
431
665
|
" }",
|
|
432
666
|
" };",
|
|
433
667
|
"})();",
|