prismcast 1.7.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 +24 -13
- 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/login.js +11 -8
- package/dist/browser/login.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 +2 -0
- package/dist/channels/index.js +438 -252
- package/dist/channels/index.js.map +1 -1
- package/dist/config/health.d.ts +3 -3
- package/dist/config/health.js +13 -18
- package/dist/config/health.js.map +1 -1
- package/dist/config/index.js +11 -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/profiles.js +10 -10
- package/dist/config/profiles.js.map +1 -1
- 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/services.js +758 -0
- 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 +89 -27
- package/dist/config/userChannels.js +400 -184
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +13 -9
- package/dist/config/userConfig.js +78 -60
- package/dist/config/userConfig.js.map +1 -1
- package/dist/config/userProfiles.d.ts +31 -15
- package/dist/config/userProfiles.js +154 -123
- 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/discover.js +7 -15
- package/dist/hdhr/discover.js.map +1 -1
- package/dist/hdhr/index.js +6 -9
- package/dist/hdhr/index.js.map +1 -1
- package/dist/native/index.d.ts +2 -2
- package/dist/native/index.js +17 -27
- 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.d.ts +2 -2
- package/dist/native/proxy.js +4 -4
- package/dist/native/proxy.js.map +1 -1
- package/dist/routes/assets.js +29 -51
- 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/channels.js +5 -11
- package/dist/routes/channels.js.map +1 -1
- package/dist/routes/components.d.ts +0 -8
- package/dist/routes/components.js +0 -19
- package/dist/routes/components.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 +748 -420
- package/dist/routes/config/channels/routes.js.map +1 -1
- package/dist/routes/config/channels/table.d.ts +21 -6
- package/dist/routes/config/channels/table.js +456 -193
- 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} +47 -42
- package/dist/routes/config/services.js.map +1 -0
- package/dist/routes/config/settings.js +88 -49
- 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/icons.d.ts +16 -0
- package/dist/routes/icons.js +44 -0
- package/dist/routes/icons.js.map +1 -0
- 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 +6 -4
- package/dist/routes/playlist.js +98 -45
- package/dist/routes/playlist.js.map +1 -1
- package/dist/routes/root/content.js +94 -55
- package/dist/routes/root/content.js.map +1 -1
- package/dist/routes/root/scripts/channels.js +620 -478
- package/dist/routes/root/scripts/channels.js.map +1 -1
- package/dist/routes/root/scripts/config.js +884 -964
- package/dist/routes/root/scripts/config.js.map +1 -1
- package/dist/routes/root/scripts/shared.js +468 -188
- package/dist/routes/root/scripts/shared.js.map +1 -1
- package/dist/routes/root/scripts/status.js +252 -207
- package/dist/routes/root/scripts/status.js.map +1 -1
- package/dist/routes/root/styles.js +57 -8
- 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.d.ts +1 -1
- package/dist/routes/ui.js +3 -1
- package/dist/routes/ui.js.map +1 -1
- package/dist/service/commands.js +8 -43
- 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/codec.d.ts +20 -0
- package/dist/streaming/codec.js +68 -0
- package/dist/streaming/codec.js.map +1 -0
- package/dist/streaming/fmp4Segmenter.d.ts +2 -2
- package/dist/streaming/fmp4Segmenter.js.map +1 -1
- package/dist/streaming/hls.d.ts +4 -4
- package/dist/streaming/hls.js +35 -29
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsResume.js +3 -3
- 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.d.ts +15 -14
- package/dist/streaming/preroll.js +32 -12
- package/dist/streaming/preroll.js.map +1 -1
- package/dist/streaming/registry.d.ts +2 -2
- package/dist/streaming/registry.js +4 -8
- package/dist/streaming/registry.js.map +1 -1
- package/dist/streaming/setup.d.ts +4 -4
- package/dist/streaming/setup.js +28 -34
- 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 +18 -11
- package/dist/types/config.d.ts +3 -2
- package/dist/types/index.d.ts +4 -3
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- 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 +8 -16
- package/dist/types/streaming.js +5 -1
- package/dist/types/streaming.js.map +1 -1
- package/dist/upgrade/commands.js +1 -16
- package/dist/upgrade/commands.js.map +1 -1
- package/dist/utils/chromeFetch.js +1 -1
- package/dist/utils/cliOutput.d.ts +10 -0
- package/dist/utils/cliOutput.js +17 -0
- package/dist/utils/cliOutput.js.map +1 -0
- 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 +21 -4
- package/dist/utils/format.js +45 -7
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.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/retry.js +2 -4
- package/dist/utils/retry.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 +0 -678
- 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,79 +15,194 @@ 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
|
-
|
|
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);",
|
|
64
|
+
" }",
|
|
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;",
|
|
56
134
|
" };",
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
"
|
|
69
|
-
"
|
|
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
|
+
" };",
|
|
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
|
|
145
|
+
// access via IP address.
|
|
146
|
+
" window.copyToClipboard = async (text, successMessage) => {",
|
|
147
|
+
" if(navigator.clipboard?.writeText) {",
|
|
148
|
+
" try { await navigator.clipboard.writeText(text); showToast(successMessage, 'success'); }",
|
|
149
|
+
" catch(e) { showToast('Failed to copy to clipboard.', 'error'); }",
|
|
150
|
+
" } else {",
|
|
151
|
+
" const ta = document.createElement('textarea');",
|
|
152
|
+
" ta.value = text;",
|
|
153
|
+
" ta.style.position = 'fixed';",
|
|
154
|
+
" ta.style.opacity = '0';",
|
|
155
|
+
" document.body.appendChild(ta);",
|
|
156
|
+
" ta.select();",
|
|
157
|
+
" try { document.execCommand('copy'); showToast(successMessage, 'success'); }",
|
|
158
|
+
" catch(e) { showToast('Failed to copy to clipboard.', 'error'); }",
|
|
159
|
+
" document.body.removeChild(ta);",
|
|
160
|
+
" }",
|
|
161
|
+
" };",
|
|
162
|
+
// Layer 1: Portal positioning. Positions a menu element below an anchor with viewport edge clamping and above-anchor flip. Uses position: absolute with
|
|
163
|
+
// scroll offsets for reliable behavior at all browser zoom levels. The menu must be visible (display: block) for offsetWidth/offsetHeight measurement.
|
|
164
|
+
// This is the single source of truth for all portal positioning math.
|
|
165
|
+
" function positionPortal(menu, anchor) {",
|
|
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;",
|
|
170
|
+
" if(left < 4) left = 4;",
|
|
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;",
|
|
174
|
+
" menu.style.top = (top + sy) + 'px';",
|
|
175
|
+
" menu.style.left = (left + sx) + 'px';",
|
|
176
|
+
" }",
|
|
177
|
+
" window.positionPortal = positionPortal;",
|
|
178
|
+
// Layer 2: Dropdown toggle with lifecycle hooks. Manages portal append, toggle state, scroll/resize dismissal, and optional lifecycle callbacks. The
|
|
179
|
+
// zero-argument form (toggleDropdown(btn)) works unchanged for standard dropdowns. The options form adds: menu (explicit menu element), onOpen (called
|
|
180
|
+
// after show but before positioning so content can be set and measured correctly for viewport clamping).
|
|
181
|
+
" window.toggleDropdown = (btn, opts) => {",
|
|
182
|
+
" const o = opts || {};",
|
|
183
|
+
" const menu = btn._portalMenu || o.menu || btn.nextElementSibling;",
|
|
184
|
+
" if(!menu) return;",
|
|
185
|
+
" const isOpen = menu.classList.contains('show');",
|
|
186
|
+
" closeAllDropdowns();",
|
|
187
|
+
" if(isOpen) return;",
|
|
188
|
+
" btn._portalMenu = menu;",
|
|
189
|
+
" if(!menu._portaled) {",
|
|
70
190
|
" document.body.appendChild(menu);",
|
|
71
|
-
" menu.style.position = '
|
|
191
|
+
" menu.style.position = 'absolute';",
|
|
72
192
|
" menu.style.marginTop = '0';",
|
|
193
|
+
" menu._portaled = true;",
|
|
73
194
|
" }",
|
|
74
195
|
" menu.classList.add('show');",
|
|
75
|
-
"
|
|
76
|
-
"
|
|
77
|
-
"
|
|
78
|
-
"
|
|
79
|
-
" if (left < 4) left = 4;",
|
|
80
|
-
" if (top + menu.offsetHeight > window.innerHeight - 4) top = rect.top - menu.offsetHeight - 2;",
|
|
81
|
-
" menu.style.top = top + 'px';",
|
|
82
|
-
" menu.style.left = left + 'px';",
|
|
83
|
-
" window.addEventListener('scroll', closeDropdowns, true);",
|
|
84
|
-
" window.addEventListener('resize', closeDropdowns);",
|
|
196
|
+
" if(o.onOpen) o.onOpen(menu);",
|
|
197
|
+
" positionPortal(menu, btn);",
|
|
198
|
+
" window.addEventListener('scroll', closeAllDropdowns, true);",
|
|
199
|
+
" window.addEventListener('resize', closeAllDropdowns);",
|
|
85
200
|
" };",
|
|
86
201
|
// Shared channel display renderer with three modes. The mode parameter controls presentation: 'logo' (default) shows the logo with text as an onerror
|
|
87
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.
|
|
88
|
-
" window.channelDisplayHtml =
|
|
89
|
-
"
|
|
90
|
-
" if(m === 'text' || !logoUrl) {",
|
|
203
|
+
" window.channelDisplayHtml = (logoUrl, name, logoClass, textClass, mode) => {",
|
|
204
|
+
" const m = mode || 'logo';",
|
|
205
|
+
" if((m === 'text') || !logoUrl) {",
|
|
91
206
|
" return '<span class=\"' + textClass + '\">' + name + '</span>';",
|
|
92
207
|
" }",
|
|
93
208
|
" if(m === 'both') {",
|
|
@@ -101,29 +216,29 @@ export function generateSharedUtilitiesScript() {
|
|
|
101
216
|
" };",
|
|
102
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
|
|
103
218
|
// exhausted, hides the image and reveals the text sibling if it was hidden (logo mode).
|
|
104
|
-
" window.imgFallback =
|
|
105
|
-
"
|
|
106
|
-
"
|
|
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);",
|
|
107
222
|
" if(idx < fallbacks.length) {",
|
|
108
223
|
" img.setAttribute('data-fb-idx', String(idx + 1));",
|
|
109
224
|
" img.src = fallbacks[idx];",
|
|
110
225
|
" } else {",
|
|
111
226
|
" img.style.display = 'none';",
|
|
112
|
-
"
|
|
113
|
-
" 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';",
|
|
114
229
|
" }",
|
|
115
230
|
" };",
|
|
116
|
-
//
|
|
231
|
+
// Service icon renderer with three modes, mirroring channelDisplayHtml. The icon source chain is: iconUrl (if specified) → Apple touch icon → favicon.
|
|
117
232
|
// Fallback URLs are stored in a data-fallbacks attribute and processed by the shared imgFallback handler.
|
|
118
|
-
" window.
|
|
119
|
-
"
|
|
120
|
-
" if(m === 'text' || !domain) {",
|
|
233
|
+
" window.serviceIconHtml = (domain, name, iconClass, textClass, mode, iconUrl) => {",
|
|
234
|
+
" const m = mode || 'logo';",
|
|
235
|
+
" if((m === 'text') || !domain) {",
|
|
121
236
|
" return '<span class=\"' + textClass + '\">' + name + '</span>';",
|
|
122
237
|
" }",
|
|
123
|
-
"
|
|
124
|
-
"
|
|
125
|
-
"
|
|
126
|
-
"
|
|
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('|');",
|
|
127
242
|
" if(m === 'both') {",
|
|
128
243
|
" return '<img src=\"' + src + '\" class=\"' + iconClass + '\" alt=\"\" title=\"' + name + '\" ' +",
|
|
129
244
|
" 'data-fallbacks=\"' + fallbacks + '\" onerror=\"imgFallback(this)\">' +",
|
|
@@ -133,33 +248,17 @@ export function generateSharedUtilitiesScript() {
|
|
|
133
248
|
" 'data-fallbacks=\"' + fallbacks + '\" onerror=\"imgFallback(this)\">' +",
|
|
134
249
|
" '<span class=\"' + textClass + '\" style=\"display:none\">' + name + '</span>';",
|
|
135
250
|
" };",
|
|
136
|
-
// Process
|
|
137
|
-
//
|
|
138
|
-
" window.
|
|
139
|
-
"
|
|
140
|
-
" for(
|
|
141
|
-
" var cell = cells[i];",
|
|
142
|
-
" if(cell.getAttribute('data-logo-processed')) continue;",
|
|
143
|
-
" var url = cell.getAttribute('data-logo');",
|
|
144
|
-
" var nameSpan = cell.querySelector('.channel-name-cell');",
|
|
145
|
-
" if(url && nameSpan) {",
|
|
146
|
-
" nameSpan.innerHTML = channelDisplayHtml(url, nameSpan.textContent || '', 'channel-table-logo', 'channel-table-text', 'both');",
|
|
147
|
-
" cell.setAttribute('data-logo-processed', '1');",
|
|
148
|
-
" }",
|
|
149
|
-
" }",
|
|
150
|
-
" };",
|
|
151
|
-
// Process provider display spans. Finds all .provider-display elements and renders them via providerIconHtml in 'both' mode. Called on page load and after
|
|
152
|
-
// any DOM mutation that introduces new provider display elements (chip rebuild, filter updates).
|
|
153
|
-
" window.processProviderDisplays = function() {",
|
|
154
|
-
" var els = document.querySelectorAll('.provider-display');",
|
|
155
|
-
" for(var i = 0; i < els.length; i++) {",
|
|
156
|
-
" 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) {",
|
|
157
256
|
" if(el.getAttribute('data-processed')) continue;",
|
|
158
|
-
"
|
|
159
|
-
"
|
|
160
|
-
"
|
|
161
|
-
"
|
|
162
|
-
" 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',",
|
|
163
262
|
" sm ? 'provider-chip-text' : 'provider-icon-text', 'both', iconUrl);",
|
|
164
263
|
" el.setAttribute('data-processed', '1');",
|
|
165
264
|
" }",
|
|
@@ -181,29 +280,29 @@ export function generateSharedUtilitiesScript() {
|
|
|
181
280
|
// - titleId: ID of the title h3 element (optional, for dynamic title updates).
|
|
182
281
|
//
|
|
183
282
|
// Returns: { back, close, getStep, goToStep, hide, next, open, setError, setTitle, show, state }
|
|
184
|
-
" window.createWizardController =
|
|
185
|
-
"
|
|
186
|
-
"
|
|
187
|
-
"
|
|
283
|
+
" window.createWizardController = (config) => {",
|
|
284
|
+
" let currentStep = 1;",
|
|
285
|
+
" let highestStep = 1;",
|
|
286
|
+
" const stepsContainerId = config.stepsId || (config.modalId + '-steps');",
|
|
188
287
|
// Update the step indicator DOM. Iterates .wizard-step children within the scoped container, setting active/completed/clickable classes and attaching
|
|
189
288
|
// click handlers for visited steps.
|
|
190
289
|
" function updateStepIndicator() {",
|
|
191
|
-
"
|
|
290
|
+
" const container = document.getElementById(stepsContainerId);",
|
|
192
291
|
" if(!container) return;",
|
|
193
|
-
"
|
|
194
|
-
" for(
|
|
195
|
-
"
|
|
196
|
-
"
|
|
197
|
-
" if(stepNum < currentStep)
|
|
198
|
-
" if(stepNum === currentStep)
|
|
199
|
-
" if(stepNum !== currentStep && stepNum <= highestStep)
|
|
200
|
-
"
|
|
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);",
|
|
201
300
|
" }",
|
|
202
301
|
" }",
|
|
203
302
|
// Clear the error display.
|
|
204
303
|
" function clearError() {",
|
|
205
304
|
" if(!config.errorId) return;",
|
|
206
|
-
"
|
|
305
|
+
" const el = document.getElementById(config.errorId);",
|
|
207
306
|
" if(el) { el.textContent = ''; el.style.display = 'none'; }",
|
|
208
307
|
" }",
|
|
209
308
|
// Render the current step. Clears errors, updates the step indicator, and delegates to the caller's onRender callback.
|
|
@@ -222,30 +321,32 @@ export function generateSharedUtilitiesScript() {
|
|
|
222
321
|
// return is always a Promise, so callers can uniformly await the result regardless of whether onValidate is sync or async.
|
|
223
322
|
" async function validateAndAdvance(target) {",
|
|
224
323
|
" try {",
|
|
225
|
-
"
|
|
324
|
+
" const err = await config.onValidate(currentStep);",
|
|
226
325
|
" if(err) { ctrl.setError(err); return false; }",
|
|
227
326
|
" advance(target);",
|
|
228
327
|
" return true;",
|
|
229
|
-
" } 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; }",
|
|
230
329
|
" }",
|
|
231
|
-
"
|
|
330
|
+
" const ctrl = {",
|
|
232
331
|
// Arbitrary state object for the caller to store wizard-specific data. Reset to {} on close.
|
|
233
332
|
" state: {},",
|
|
234
333
|
// Navigate backward one step.
|
|
235
|
-
" back
|
|
334
|
+
" back() {",
|
|
236
335
|
" if(currentStep > 1) { currentStep--; render(); }",
|
|
237
336
|
" },",
|
|
238
337
|
// Close the modal. Invokes the optional onClose callback before hiding, then resets state.
|
|
239
|
-
" close
|
|
338
|
+
" close() {",
|
|
240
339
|
" if(config.onClose) config.onClose();",
|
|
241
340
|
" document.getElementById(config.modalId).style.display = 'none';",
|
|
242
341
|
" ctrl.state = {};",
|
|
243
342
|
" },",
|
|
244
343
|
// Return the current step number.
|
|
245
|
-
" getStep
|
|
344
|
+
" getStep() {",
|
|
345
|
+
" return currentStep;",
|
|
346
|
+
" },",
|
|
246
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
|
|
247
348
|
// and only allows jumps to previously visited steps. Supports async validation - returns a Promise when onValidate is async.
|
|
248
|
-
" goToStep
|
|
349
|
+
" goToStep(n) {",
|
|
249
350
|
" if(n === currentStep) return;",
|
|
250
351
|
" if(n > highestStep) return;",
|
|
251
352
|
" if(n > currentStep) {",
|
|
@@ -255,133 +356,312 @@ export function generateSharedUtilitiesScript() {
|
|
|
255
356
|
" render();",
|
|
256
357
|
" },",
|
|
257
358
|
// Hide a button by ID.
|
|
258
|
-
" hide
|
|
259
|
-
"
|
|
359
|
+
" hide(id) {",
|
|
360
|
+
" const el = document.getElementById(id);",
|
|
260
361
|
" if(el) el.style.display = 'none';",
|
|
261
362
|
" },",
|
|
262
363
|
// Navigate forward one step. Validates the current step first. Returns false (sync) or a Promise resolving to false (async) if validation failed.
|
|
263
|
-
" next
|
|
364
|
+
" next() {",
|
|
264
365
|
" if(currentStep >= config.stepCount) return true;",
|
|
265
366
|
" return validateAndAdvance(currentStep + 1);",
|
|
266
367
|
" },",
|
|
267
368
|
// Open the modal and render. Options: { step, highestStep, title } - all optional.
|
|
268
|
-
" open
|
|
269
|
-
"
|
|
369
|
+
" open(options) {",
|
|
370
|
+
" const opts = options || {};",
|
|
270
371
|
" currentStep = opts.step || 1;",
|
|
271
372
|
" highestStep = opts.highestStep || currentStep;",
|
|
272
373
|
" if(opts.title && config.titleId) {",
|
|
273
|
-
"
|
|
374
|
+
" const titleEl = document.getElementById(config.titleId);",
|
|
274
375
|
" if(titleEl) titleEl.textContent = opts.title;",
|
|
275
376
|
" }",
|
|
276
377
|
" document.getElementById(config.modalId).style.display = 'flex';",
|
|
277
378
|
" render();",
|
|
278
379
|
" },",
|
|
279
380
|
// Show a validation error message.
|
|
280
|
-
" setError
|
|
381
|
+
" setError(msg) {",
|
|
281
382
|
" if(!config.errorId) return;",
|
|
282
|
-
"
|
|
383
|
+
" const el = document.getElementById(config.errorId);",
|
|
283
384
|
" if(el) { el.textContent = msg; el.style.display = msg ? '' : 'none'; }",
|
|
284
385
|
" },",
|
|
285
386
|
// Update the modal title dynamically.
|
|
286
|
-
" setTitle
|
|
387
|
+
" setTitle(html) {",
|
|
287
388
|
" if(!config.titleId) return;",
|
|
288
|
-
"
|
|
389
|
+
" const el = document.getElementById(config.titleId);",
|
|
289
390
|
" if(el) el.innerHTML = html;",
|
|
290
391
|
" },",
|
|
291
392
|
// Show a button by ID.
|
|
292
|
-
" show
|
|
293
|
-
"
|
|
393
|
+
" show(id) {",
|
|
394
|
+
" const el = document.getElementById(id);",
|
|
294
395
|
" if(el) el.style.display = '';",
|
|
295
396
|
" }",
|
|
296
397
|
" };",
|
|
297
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
|
|
298
399
|
// handlers attached from inside the IIFE closure, where the controller variable is accessible. This is the same pattern used for step indicator click
|
|
299
400
|
// handlers and avoids the inline-onclick-to-global-scope problem that breaks when controller instances are IIFE-scoped.
|
|
300
|
-
"
|
|
401
|
+
" const modal = document.getElementById(config.modalId);",
|
|
301
402
|
" if(modal) {",
|
|
302
|
-
"
|
|
303
|
-
" for(
|
|
304
|
-
" (
|
|
305
|
-
"
|
|
306
|
-
"
|
|
307
|
-
"
|
|
308
|
-
" else if(role === 'close') { btn.onclick = function() { ctrl.close(); }; }",
|
|
309
|
-
" })(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(); }; }",
|
|
310
409
|
" }",
|
|
311
|
-
"
|
|
312
|
-
" if(closeBtn) { closeBtn.onclick =
|
|
410
|
+
" const closeBtn = modal.querySelector('.wizard-close');",
|
|
411
|
+
" if(closeBtn) { closeBtn.onclick = () => { ctrl.close(); }; }",
|
|
313
412
|
" }",
|
|
314
413
|
" return ctrl;",
|
|
315
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
|
+
" },",
|
|
316
438
|
// Apply a channel table patch from a mutation response or SSE event. Handles whichever fields are present: rows (insert/remove), counts (summary header),
|
|
317
|
-
// scopeCounts (Quick Actions toggles),
|
|
318
|
-
"
|
|
319
|
-
"
|
|
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;",
|
|
320
442
|
// Apply row updates (insert, replace, or remove).
|
|
321
|
-
"
|
|
322
|
-
"
|
|
323
|
-
"
|
|
324
|
-
"
|
|
325
|
-
"
|
|
326
|
-
"
|
|
327
|
-
" 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
|
+
" }",
|
|
328
449
|
" }",
|
|
329
450
|
" }",
|
|
330
|
-
|
|
331
|
-
//
|
|
332
|
-
"
|
|
333
|
-
"
|
|
334
|
-
"
|
|
335
|
-
"
|
|
336
|
-
"
|
|
337
|
-
"
|
|
338
|
-
"
|
|
339
|
-
"
|
|
340
|
-
"
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
"
|
|
345
|
-
"
|
|
346
|
-
"
|
|
347
|
-
"
|
|
348
|
-
"
|
|
349
|
-
"
|
|
350
|
-
"
|
|
351
|
-
"
|
|
352
|
-
"
|
|
353
|
-
" }",
|
|
354
|
-
" 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); " +
|
|
355
474
|
"span.textContent = sc.enabled + ' of ' + sc.total + ' enabled'; }",
|
|
475
|
+
" }",
|
|
356
476
|
" }",
|
|
357
|
-
" }",
|
|
358
477
|
// Apply HDHR bulk toggle counts from server-computed values.
|
|
359
|
-
"
|
|
360
|
-
"
|
|
361
|
-
"
|
|
362
|
-
"
|
|
363
|
-
"
|
|
364
|
-
"
|
|
365
|
-
"
|
|
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;",
|
|
366
487
|
" }",
|
|
367
|
-
|
|
368
|
-
"
|
|
369
|
-
|
|
370
|
-
"
|
|
371
|
-
"
|
|
372
|
-
"
|
|
373
|
-
"
|
|
374
|
-
"
|
|
375
|
-
"
|
|
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;",
|
|
498
|
+
" }",
|
|
499
|
+
" }",
|
|
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');",
|
|
376
663
|
" }",
|
|
377
664
|
" }",
|
|
378
|
-
" }",
|
|
379
|
-
// Post-update: refilter rows for provider filter and render any new logos.
|
|
380
|
-
" if(patch.rows && patch.rows.length > 0) {",
|
|
381
|
-
" if(window.refilterChannelRows) window.refilterChannelRows();",
|
|
382
|
-
" }",
|
|
383
|
-
" if(patch.logos || (patch.rows && patch.rows.length > 0)) {",
|
|
384
|
-
" if(window.processChannelLogos) window.processChannelLogos();",
|
|
385
665
|
" }",
|
|
386
666
|
" };",
|
|
387
667
|
"})();",
|