prismcast 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +9 -8
- package/dist/app.js.map +1 -1
- package/dist/browser/login.js +11 -8
- package/dist/browser/login.js.map +1 -1
- package/dist/channels/index.d.ts +2 -0
- package/dist/channels/index.js +208 -22
- package/dist/channels/index.js.map +1 -1
- package/dist/config/health.js +10 -15
- package/dist/config/health.js.map +1 -1
- package/dist/config/index.js +9 -0
- package/dist/config/index.js.map +1 -1
- package/dist/config/profiles.js +10 -10
- package/dist/config/profiles.js.map +1 -1
- package/dist/config/providers.js +30 -17
- package/dist/config/providers.js.map +1 -1
- package/dist/config/userChannels.d.ts +62 -9
- package/dist/config/userChannels.js +172 -44
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +2 -0
- package/dist/config/userConfig.js +29 -4
- package/dist/config/userConfig.js.map +1 -1
- package/dist/config/userProfiles.js +27 -39
- package/dist/config/userProfiles.js.map +1 -1
- package/dist/hdhr/discover.js +7 -15
- package/dist/hdhr/discover.js.map +1 -1
- package/dist/native/index.d.ts +2 -2
- package/dist/native/index.js +14 -24
- package/dist/native/index.js.map +1 -1
- package/dist/native/proxy.d.ts +2 -2
- package/dist/native/proxy.js.map +1 -1
- package/dist/routes/assets.js +26 -48
- package/dist/routes/assets.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 +335 -35
- package/dist/routes/config/channels/routes.js.map +1 -1
- package/dist/routes/config/channels/table.d.ts +17 -2
- package/dist/routes/config/channels/table.js +270 -66
- package/dist/routes/config/channels/table.js.map +1 -1
- package/dist/routes/config/providers.js +7 -7
- package/dist/routes/config/providers.js.map +1 -1
- package/dist/routes/config/settings.js +57 -36
- package/dist/routes/config/settings.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/playlist.d.ts +6 -4
- package/dist/routes/playlist.js +68 -27
- package/dist/routes/playlist.js.map +1 -1
- package/dist/routes/root/content.js +41 -2
- package/dist/routes/root/content.js.map +1 -1
- package/dist/routes/root/scripts/channels.js +185 -0
- package/dist/routes/root/scripts/channels.js.map +1 -1
- package/dist/routes/root/scripts/config.js +115 -41
- package/dist/routes/root/scripts/config.js.map +1 -1
- package/dist/routes/root/scripts/shared.js +69 -23
- package/dist/routes/root/scripts/shared.js.map +1 -1
- package/dist/routes/root/scripts/status.js +6 -12
- package/dist/routes/root/scripts/status.js.map +1 -1
- package/dist/routes/root/styles.js +45 -4
- package/dist/routes/root/styles.js.map +1 -1
- package/dist/routes/ui.d.ts +1 -1
- package/dist/routes/ui.js +1 -1
- package/dist/routes/ui.js.map +1 -1
- package/dist/service/commands.js +3 -35
- package/dist/service/commands.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.js +11 -6
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsResume.js +2 -2
- package/dist/streaming/hlsResume.js.map +1 -1
- package/dist/streaming/preroll.d.ts +15 -14
- package/dist/streaming/preroll.js +31 -11
- package/dist/streaming/preroll.js.map +1 -1
- package/dist/streaming/registry.d.ts +2 -2
- package/dist/streaming/registry.js.map +1 -1
- package/dist/streaming/setup.d.ts +2 -2
- package/dist/streaming/setup.js +5 -12
- package/dist/streaming/setup.js.map +1 -1
- package/dist/types/channels.d.ts +3 -0
- package/dist/types/config.d.ts +1 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/shared.d.ts +1 -1
- package/dist/types/streaming.d.ts +7 -15
- 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/cliOutput.d.ts +10 -0
- package/dist/utils/cliOutput.js +17 -0
- package/dist/utils/cliOutput.js.map +1 -0
- package/dist/utils/format.d.ts +14 -3
- package/dist/utils/format.js +24 -6
- 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/retry.js +2 -4
- package/dist/utils/retry.js.map +1 -1
- package/package.json +2 -2
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
*
|
|
3
3
|
* table.ts: Channel table rendering for the PrismCast configuration interface.
|
|
4
4
|
*/
|
|
5
|
+
import { ICON_BOLT, ICON_COPY, ICON_DELETE, ICON_DISABLE, ICON_EDIT, ICON_ENABLE, ICON_FILTER, ICON_HEALTH, ICON_LINK, ICON_LOGIN, ICON_MANAGE, ICON_REVERT, ICON_TRANSFER } from "../../icons.js";
|
|
5
6
|
import { compareChannelSort, getAllProviderTags, getAuthDomainForChannel, getChannelProviderLabel, getChannelProviderTags, getChannelSortKey, getEnabledProviders, getProviderGroup, hasMultipleProviders, isChannelAvailableByProvider, isProviderTagEnabled, resolvePredefinedVariant, resolveProviderKey } from "../../../config/providers.js";
|
|
6
7
|
import { escapeHtml, formatTimeAgo } from "../../../utils/index.js";
|
|
8
|
+
import { getActiveTagVocabulary, getChannelEffectiveTags, getChannelListing, getChannelLogo, getChannelsParseErrorMessage, getPredefinedScopeCounts, getTagRegistry, getUserChannelsFilePath, hasChannelsParseError, isPredefinedChannel, isPredefinedChannelDisabled, isUserChannel } from "../../../config/userChannels.js";
|
|
7
9
|
import { getCachedProviderChannels, getProviderDomainMap, getProviderGuideUrls, getProviderModuleInfo } from "../../../browser/channelSelection.js";
|
|
8
10
|
import { getChannelHealth, getDomainAuth } from "../../../config/health.js";
|
|
9
|
-
import { getChannelListing, getChannelLogo, getChannelsParseErrorMessage, getPredefinedScopeCounts, getUserChannelsFilePath, hasChannelsParseError, isPredefinedChannel, isPredefinedChannelDisabled, isUserChannel } from "../../../config/userChannels.js";
|
|
10
11
|
import { getProfileForChannel, getProfiles } from "../../../config/profiles.js";
|
|
11
12
|
import { CONFIG } from "../../../config/index.js";
|
|
12
13
|
import { PREDEFINED_CHANNELS } from "../../../channels/index.js";
|
|
@@ -27,24 +28,6 @@ function providerDisplaySpan(name, domain, iconUrl, small) {
|
|
|
27
28
|
const sizeAttr = small ? " data-sm" : "";
|
|
28
29
|
return "<span class=\"provider-display\"" + domainAttr + iconAttr + sizeAttr + ">" + escapeHtml(name) + "</span>";
|
|
29
30
|
}
|
|
30
|
-
// SVG icon constants for channel action buttons. Each icon is 14x14px with a 16x16 viewBox, stroke-based with currentColor, and uses round line caps/joins.
|
|
31
|
-
export const ICON_EDIT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
|
|
32
|
-
"stroke-linejoin=\"round\"><path d=\"M11.5 1.5l3 3L5 14H2v-3L11.5 1.5z\"/></svg>";
|
|
33
|
-
const ICON_LOGIN = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
|
|
34
|
-
"stroke-linejoin=\"round\"><path d=\"M6.5 2H3.5a1 1 0 00-1 1v10a1 1 0 001 1h3\"/><path d=\"M10.5 11l3-3-3-3\"/><path d=\"M13.5 8H6.5\"/></svg>";
|
|
35
|
-
export const ICON_DELETE = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
|
|
36
|
-
"stroke-linejoin=\"round\"><path d=\"M2 4h12\"/><path d=\"M5 4V2.5a.5.5 0 01.5-.5h5a.5.5 0 01.5.5V4\"/><path d=\"M12.5 4l-.5 9.5a1 1 0 01-1 .5H5a1 1 0 " +
|
|
37
|
-
"01-1-.5L3.5 4\"/></svg>";
|
|
38
|
-
const ICON_ENABLE = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
|
|
39
|
-
"stroke-linejoin=\"round\"><circle cx=\"8\" cy=\"8\" r=\"6\"/><path d=\"M5.5 8l2 2 3.5-4\"/></svg>";
|
|
40
|
-
const ICON_DISABLE = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
|
|
41
|
-
"stroke-linejoin=\"round\"><circle cx=\"8\" cy=\"8\" r=\"6\"/><path d=\"M5.5 5.5l5 5\"/></svg>";
|
|
42
|
-
const ICON_HEALTH = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" " +
|
|
43
|
-
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"1,9 4,9 6,4 8,12 10,7 12,9 15,9\"/></svg>";
|
|
44
|
-
const ICON_REVERT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
|
|
45
|
-
"stroke-linejoin=\"round\"><path d=\"M3 8a5 5 0 1 1 1.5 3.5\"/><path d=\"M3 4v4h4\"/></svg>";
|
|
46
|
-
const ICON_COPY = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
|
|
47
|
-
"stroke-linejoin=\"round\"><rect x=\"5\" y=\"5\" width=\"9\" height=\"9\" rx=\"1\"/><path d=\"M5 11H3a1 1 0 01-1-1V3a1 1 0 011-1h7a1 1 0 011 1v2\"/></svg>";
|
|
48
31
|
// Optional column definitions for the channels table. These columns can be shown or hidden by the user via the column picker. The order here determines the
|
|
49
32
|
// column order in the table, slotted between Provider and Actions.
|
|
50
33
|
export const OPTIONAL_COLUMNS = [
|
|
@@ -52,10 +35,11 @@ export const OPTIONAL_COLUMNS = [
|
|
|
52
35
|
{ align: "center", cssClass: "col-hdhr", field: "hdhrEnabled", label: "HDHR", width: "55px" },
|
|
53
36
|
{ align: "center", cssClass: "col-stationid", field: "stationId", label: "Station ID", width: "100px" },
|
|
54
37
|
{ align: "left", cssClass: "col-profile", field: "profile", label: "Profile", width: "130px" },
|
|
55
|
-
{ align: "left", cssClass: "col-selector", field: "channelSelector", label: "Selector", width: "130px" }
|
|
38
|
+
{ align: "left", cssClass: "col-selector", field: "channelSelector", label: "Selector", width: "130px" },
|
|
39
|
+
{ align: "left", cssClass: "col-tags", field: "tags", label: "Tags", width: "150px" }
|
|
56
40
|
];
|
|
57
|
-
// Total number of columns in the channels table (4 required +
|
|
58
|
-
const TOTAL_COLUMN_COUNT =
|
|
41
|
+
// Total number of columns in the channels table (4 required + 6 optional).
|
|
42
|
+
const TOTAL_COLUMN_COUNT = 10;
|
|
59
43
|
// Valid optional column field names.
|
|
60
44
|
export const VALID_OPTIONAL_COLUMNS = new Set(OPTIONAL_COLUMNS.map((c) => c.field));
|
|
61
45
|
/**
|
|
@@ -277,7 +261,7 @@ function generateProfileReference(profiles) {
|
|
|
277
261
|
return lines.join("\n");
|
|
278
262
|
}
|
|
279
263
|
function generateAdvancedFields(idPrefix, options = {}) {
|
|
280
|
-
const { channelNumberValue = "", channelSelectorValue = "", hdhrEnabled = true, showHints = true, stationIdValue = "" } = options;
|
|
264
|
+
const { channelNumberValue = "", channelSelectorValue = "", hdhrEnabled = true, showHints = true, stationIdValue = "", tagsValue = "" } = options;
|
|
281
265
|
const lines = [];
|
|
282
266
|
// Advanced fields toggle.
|
|
283
267
|
lines.push("<div class=\"advanced-toggle\" onclick=\"document.getElementById('" + idPrefix +
|
|
@@ -301,6 +285,28 @@ function generateAdvancedFields(idPrefix, options = {}) {
|
|
|
301
285
|
"Optional numeric channel number for guide matching in Channels DVR and Plex." :
|
|
302
286
|
undefined;
|
|
303
287
|
lines.push(...generateTextField(idPrefix + "-channelNumber", "channelNumber", "Channel Number", channelNumberValue, { hint: channelNumberHint, placeholder: showHints ? "e.g., 501" : undefined }));
|
|
288
|
+
// Tags field: checkbox grid of the active tag vocabulary. Checked tags are collected into a hidden input as a comma-separated value on form submission.
|
|
289
|
+
// This prevents users from entering tag names that don't exist in the managed vocabulary.
|
|
290
|
+
const vocabulary = getActiveTagVocabulary();
|
|
291
|
+
const currentTags = new Set(tagsValue ? tagsValue.split(",").map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0) : []);
|
|
292
|
+
if (vocabulary.length > 0) {
|
|
293
|
+
lines.push("<div class=\"form-row\">");
|
|
294
|
+
lines.push("<label>Tags</label>");
|
|
295
|
+
lines.push("<input type=\"hidden\" name=\"tags\" id=\"" + idPrefix + "-tags-hidden\" value=\"" + escapeHtml(tagsValue) + "\">");
|
|
296
|
+
lines.push("<div class=\"tag-checkbox-grid\">");
|
|
297
|
+
for (const tag of vocabulary) {
|
|
298
|
+
const checked = currentTags.has(tag) ? " checked" : "";
|
|
299
|
+
lines.push("<label class=\"tag-checkbox-label\">" +
|
|
300
|
+
"<input type=\"checkbox\" class=\"tag-checkbox\" data-tag=\"" + escapeHtml(tag) + "\"" + checked +
|
|
301
|
+
" onchange=\"updateTagsHidden(this.closest('.form-row'))\">" +
|
|
302
|
+
"<span class=\"tag-badge\">" + escapeHtml(tag) + "</span></label>");
|
|
303
|
+
}
|
|
304
|
+
lines.push("</div>");
|
|
305
|
+
if (showHints) {
|
|
306
|
+
lines.push("<div class=\"hint\">Select tags for playlist filtering. Manage available tags via Manage Channels > Manage Tags.</div>");
|
|
307
|
+
}
|
|
308
|
+
lines.push("</div>");
|
|
309
|
+
}
|
|
304
310
|
// HDHomeRun lineup inclusion. A hidden input provides the "false" value when the checkbox is unchecked (unchecked checkboxes are not submitted in FormData).
|
|
305
311
|
// When checked, the checkbox value "true" overwrites the hidden input's "false" since it appears later in DOM order.
|
|
306
312
|
const hdhrChecked = hdhrEnabled ? " checked" : "";
|
|
@@ -368,6 +374,83 @@ function generateChannelSelectorData() {
|
|
|
368
374
|
"var providerByDomain = " + JSON.stringify(providerByDomain) + ";\n" +
|
|
369
375
|
"var providerGuideUrl = " + JSON.stringify(providerGuideUrl) + ";";
|
|
370
376
|
}
|
|
377
|
+
/**
|
|
378
|
+
* Generates the tag column filter dropdown content. This is the single source of truth for the filter checkbox list — used for both the initial header render
|
|
379
|
+
* and for incremental updates after tag CRUD operations. The dropdown shell (button, dropdown-menu wrapper) is rendered by the header generator; this function
|
|
380
|
+
* provides only the inner content (checkbox labels + "Show All" item).
|
|
381
|
+
* @returns HTML string for the filter dropdown content.
|
|
382
|
+
*/
|
|
383
|
+
export function generateTagFilterContent() {
|
|
384
|
+
const vocabulary = getActiveTagVocabulary();
|
|
385
|
+
const lines = [];
|
|
386
|
+
for (const tag of vocabulary) {
|
|
387
|
+
lines.push("<label class=\"provider-option\" onclick=\"event.stopPropagation()\">" +
|
|
388
|
+
"<input type=\"checkbox\" class=\"tag-filter-checkbox\" data-tag=\"" + escapeHtml(tag) + "\" checked onchange=\"applyTagColumnFilter()\"> " +
|
|
389
|
+
escapeHtml(tag) + "</label>");
|
|
390
|
+
}
|
|
391
|
+
if (vocabulary.length > 0) {
|
|
392
|
+
lines.push("<div class=\"dropdown-divider\"></div>");
|
|
393
|
+
lines.push("<div class=\"dropdown-item\" id=\"tag-filter-toggle\" onclick=\"event.stopPropagation(); toggleTagColumnFilter()\">Show None</div>");
|
|
394
|
+
}
|
|
395
|
+
return lines.join("");
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Generates the Tag Management modal body HTML. This is the single source of truth for tag manager content — used for both the initial server render and for
|
|
399
|
+
* incremental updates after tag CRUD operations. The endpoints return this HTML in the response so the client can replace the modal content without a page reload.
|
|
400
|
+
* @returns HTML string for the tag manager body (tag list, input field, deleted tags section).
|
|
401
|
+
*/
|
|
402
|
+
export function generateTagManagerBody() {
|
|
403
|
+
const vocabulary = getActiveTagVocabulary();
|
|
404
|
+
const registry = getTagRegistry();
|
|
405
|
+
// Build the tag list HTML. Each tag is a badge with a delete button and inline rename on click.
|
|
406
|
+
const tagListItems = [];
|
|
407
|
+
for (const tag of vocabulary) {
|
|
408
|
+
tagListItems.push("<div class=\"tag-manager-item\" data-tag=\"" + escapeHtml(tag) + "\">" +
|
|
409
|
+
"<span class=\"tag-badge tag-editable\" title=\"Click to rename\" onclick=\"startTagRename(this, '" + escapeHtml(tag) +
|
|
410
|
+
"')\">" + escapeHtml(tag) + "</span>" +
|
|
411
|
+
"<button type=\"button\" class=\"btn-icon btn-icon-delete\" title=\"Delete tag\" onclick=\"deleteTag('" + escapeHtml(tag) +
|
|
412
|
+
"')\">" + ICON_DELETE + "</button></div>");
|
|
413
|
+
}
|
|
414
|
+
// Deleted predefined tags section — show restore option for tags the user has deleted.
|
|
415
|
+
const deletedItems = [];
|
|
416
|
+
for (const tag of registry.deletedTags) {
|
|
417
|
+
deletedItems.push("<div class=\"tag-manager-item tag-deleted\" data-tag=\"" + escapeHtml(tag) + "\">" +
|
|
418
|
+
"<span class=\"tag-badge tag-badge-deleted\">" + escapeHtml(tag) + "</span> <span class=\"tag-annotation\">(deleted)</span>" +
|
|
419
|
+
"<button type=\"button\" class=\"btn-icon\" title=\"Restore tag\" onclick=\"restoreTag('" + escapeHtml(tag) +
|
|
420
|
+
"')\">" + ICON_REVERT + "</button></div>");
|
|
421
|
+
}
|
|
422
|
+
return "<div class=\"tag-manager\">" +
|
|
423
|
+
"<p class=\"wizard-hint\">Create and manage the tag vocabulary. Tags can be assigned to channels via the channel table " +
|
|
424
|
+
"for playlist filtering and organization.</p>" +
|
|
425
|
+
"<div class=\"tag-manager-add\">" +
|
|
426
|
+
"<input type=\"text\" id=\"tag-manager-input\" placeholder=\"New tag name\" maxlength=\"30\" " +
|
|
427
|
+
"pattern=\"[a-z0-9]([a-z0-9-]*[a-z0-9])?\" onkeydown=\"if(event.key==='Enter'){event.preventDefault();createTag();}\">" +
|
|
428
|
+
"<button type=\"button\" class=\"btn btn-primary btn-sm\" onclick=\"createTag()\">Add</button>" +
|
|
429
|
+
"</div>" +
|
|
430
|
+
"<div id=\"tag-manager-error\" class=\"wizard-error\" style=\"display: none;\"></div>" +
|
|
431
|
+
"<div id=\"tag-manager-list\" class=\"tag-manager-list\">" +
|
|
432
|
+
(tagListItems.length > 0 ? tagListItems.join("") : "<div class=\"empty-state-text\">No tags defined.</div>") +
|
|
433
|
+
"</div>" +
|
|
434
|
+
(deletedItems.length > 0 ? "<div class=\"tag-manager-section-label\">Deleted Predefined Tags</div>" +
|
|
435
|
+
"<div id=\"tag-manager-deleted\" class=\"tag-manager-list\">" + deletedItems.join("") + "</div>" : "") +
|
|
436
|
+
"</div>";
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Generates the Tag Management modal shell using the wizard modal infrastructure for consistent styling.
|
|
440
|
+
* @returns HTML string for the complete tag management modal.
|
|
441
|
+
*/
|
|
442
|
+
function generateTagManagementModal() {
|
|
443
|
+
return generateWizardModal({
|
|
444
|
+
body: generateTagManagerBody(),
|
|
445
|
+
buttons: [
|
|
446
|
+
{ label: "Done", onclick: "closeTagManager()", position: "right", variant: "primary" }
|
|
447
|
+
],
|
|
448
|
+
id: "tag-manager-modal",
|
|
449
|
+
maxWidth: "420px",
|
|
450
|
+
onClose: "closeTagManager()",
|
|
451
|
+
title: "Manage Tags"
|
|
452
|
+
});
|
|
453
|
+
}
|
|
371
454
|
/**
|
|
372
455
|
* Generates the Browse Channels 2-step wizard modal. Step 1 is the provider picker grid, step 2 is channel discovery and management. Channel state detection
|
|
373
456
|
* (new, switch, current) is handled server-side by the annotated discovery response - no client-side matching logic is needed. Provider and guide URL data are
|
|
@@ -469,7 +552,11 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
469
552
|
rowClasses.push("channel-unavailable");
|
|
470
553
|
}
|
|
471
554
|
const rowClassAttr = (rowClasses.length > 0) ? " class=\"" + rowClasses.join(" ") + "\"" : "";
|
|
472
|
-
|
|
555
|
+
// Compute effective tags early — used for both the row data attribute (tag column filter) and the Tags column cell rendering below.
|
|
556
|
+
const effectiveTags = getChannelEffectiveTags(channel);
|
|
557
|
+
const channelTagsAttr = (effectiveTags.length > 0) ? " data-channel-tags=\"" + escapeHtml(effectiveTags.join(",")) + "\"" : "";
|
|
558
|
+
displayLines.push("<tr id=\"display-row-" + escapeHtml(key) + "\"" + rowClassAttr + " data-provider-tags=\"" + escapeHtml(providerTags) +
|
|
559
|
+
"\"" + channelTagsAttr + ">");
|
|
473
560
|
displayLines.push("<td class=\"ch-key\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "key")) + "\">" + escapeHtml(key) + "</td>");
|
|
474
561
|
const channelLogoUrl = getChannelLogo(key) ?? "";
|
|
475
562
|
const logoAttr = channelLogoUrl ? " data-logo=\"" + escapeHtml(channelLogoUrl) + "\"" : "";
|
|
@@ -535,6 +622,14 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
535
622
|
}
|
|
536
623
|
displayLines.push("<td class=\"col-selector\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "channelSelector")) + "\">" +
|
|
537
624
|
(channel.channelSelector ? escapeHtml(channel.channelSelector) : "<span class=\"text-muted\">–</span>") + "</td>");
|
|
625
|
+
// Tags column: render effective tags as pills. Clicking the cell opens a shared portal dropdown (rendered in <body>, positioned via getBoundingClientRect)
|
|
626
|
+
// for inline tag editing. The cell carries data-key and data-tags so the dropdown can populate the correct checked state.
|
|
627
|
+
const tagsHtml = (effectiveTags.length > 0) ?
|
|
628
|
+
effectiveTags.map((tag) => "<span class=\"tag-badge\">" + escapeHtml(tag) + "</span>").join(" ") :
|
|
629
|
+
"<span class=\"text-muted\">–</span>";
|
|
630
|
+
displayLines.push("<td class=\"col-tags editable-cell dropdown\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "tags")) +
|
|
631
|
+
"\" data-key=\"" + cellKey + "\" data-tags=\"" + escapeHtml(effectiveTags.join(",")) +
|
|
632
|
+
"\" onclick=\"toggleInlineTagDropdown(this)\">" + tagsHtml + "</td>");
|
|
538
633
|
// Actions column with icon buttons. Five positions per row: Edit (always), Login/placeholder, Health/placeholder, context-sensitive, and Copy URL.
|
|
539
634
|
displayLines.push("<td>");
|
|
540
635
|
displayLines.push("<div class=\"btn-group\">");
|
|
@@ -570,19 +665,9 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
570
665
|
else {
|
|
571
666
|
displayLines.push("<span class=\"btn-icon-placeholder\"></span>");
|
|
572
667
|
}
|
|
573
|
-
// Position 4:
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-revert\" title=\"Revert to defaults\" aria-label=\"Revert to defaults\"" +
|
|
577
|
-
" onclick=\"revertChannel('" + escapedKey + "')\">" + ICON_REVERT + "</button>");
|
|
578
|
-
}
|
|
579
|
-
else if (isUser && !isPredefined) {
|
|
580
|
-
// User-defined (not an override): delete.
|
|
581
|
-
displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-delete\" title=\"Delete\" aria-label=\"Delete\" onclick=\"deleteChannel('" +
|
|
582
|
-
escapedKey + "')\">" + ICON_DELETE + "</button>");
|
|
583
|
-
}
|
|
584
|
-
else if (isPredefined) {
|
|
585
|
-
// Predefined (no override): enable/disable toggle.
|
|
668
|
+
// Position 4: enable/disable toggle for predefined channels (regardless of override state), delete for user-defined channels. The enable/disable state and
|
|
669
|
+
// property overrides are independent concerns — a predefined channel can be disabled AND have customizations, and the toggle always reflects the visibility state.
|
|
670
|
+
if (isPredefined) {
|
|
586
671
|
if (isDisabled) {
|
|
587
672
|
displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-enable\" title=\"Enable\" aria-label=\"Enable\" onclick=\"togglePredefinedChannel('" +
|
|
588
673
|
escapedKey + "', true)\">" + ICON_ENABLE + "</button>");
|
|
@@ -592,6 +677,10 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
592
677
|
escapedKey + "', false)\">" + ICON_DISABLE + "</button>");
|
|
593
678
|
}
|
|
594
679
|
}
|
|
680
|
+
else if (isUser) {
|
|
681
|
+
displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-delete\" title=\"Delete\" aria-label=\"Delete\" onclick=\"deleteChannel('" +
|
|
682
|
+
escapedKey + "')\">" + ICON_DELETE + "</button>");
|
|
683
|
+
}
|
|
595
684
|
// Position 5: Copy URL dropdown (all channels).
|
|
596
685
|
displayLines.push("<div class=\"dropdown copy-dropdown\">");
|
|
597
686
|
displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-copy\" title=\"Copy stream URL\" aria-label=\"Copy stream URL\" " +
|
|
@@ -633,12 +722,17 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
633
722
|
channelNumberValue: channel.channelNumber ? String(channel.channelNumber) : "",
|
|
634
723
|
channelSelectorValue: channel.channelSelector ?? "",
|
|
635
724
|
hdhrEnabled: channel.hdhrEnabled !== false,
|
|
636
|
-
stationIdValue: channel.stationId ?? ""
|
|
725
|
+
stationIdValue: channel.stationId ?? "",
|
|
726
|
+
tagsValue: effectiveTags.join(", ")
|
|
637
727
|
}));
|
|
638
728
|
// Form buttons.
|
|
639
729
|
editLines.push("<div class=\"form-buttons\">");
|
|
640
730
|
editLines.push("<button type=\"submit\" class=\"btn btn-primary\">Save Changes</button>");
|
|
641
731
|
editLines.push("<button type=\"button\" class=\"btn btn-secondary\" onclick=\"hideEditForm('" + escapedKey + "')\">Cancel</button>");
|
|
732
|
+
if (isOverride) {
|
|
733
|
+
editLines.push("<button type=\"button\" class=\"btn btn-secondary btn-revert\" onclick=\"revertChannel('" + escapedKey +
|
|
734
|
+
"')\">Revert to Defaults</button>");
|
|
735
|
+
}
|
|
642
736
|
editLines.push("</div>");
|
|
643
737
|
editLines.push("</form>");
|
|
644
738
|
editLines.push("</div>");
|
|
@@ -696,6 +790,47 @@ function getHdhrCounts(listing) {
|
|
|
696
790
|
}
|
|
697
791
|
return { enabled, total };
|
|
698
792
|
}
|
|
793
|
+
/**
|
|
794
|
+
* Computes per-tag channel counts for the Quick Actions tag bulk toggles. For each tag in the active vocabulary, counts how many enabled, provider-available
|
|
795
|
+
* channels have that tag in their effective tags. Returns undefined when the tags column is not visible (no tag toggles to update).
|
|
796
|
+
* @param listing - The channel listing entries.
|
|
797
|
+
* @returns A record mapping tag names to { count, total }, or undefined when hidden.
|
|
798
|
+
*/
|
|
799
|
+
function getTagCounts(listing) {
|
|
800
|
+
const visibleCols = new Set(CONFIG.channels.visibleColumns);
|
|
801
|
+
if (!visibleCols.has("tags")) {
|
|
802
|
+
return undefined;
|
|
803
|
+
}
|
|
804
|
+
const vocabulary = getActiveTagVocabulary();
|
|
805
|
+
if (vocabulary.length === 0) {
|
|
806
|
+
return undefined;
|
|
807
|
+
}
|
|
808
|
+
const result = {};
|
|
809
|
+
let total = 0;
|
|
810
|
+
// Pre-compute the vocabulary Set once for the per-channel effective tags filter.
|
|
811
|
+
const vocabularySet = new Set(vocabulary);
|
|
812
|
+
const counts = new Map();
|
|
813
|
+
for (const tag of vocabulary) {
|
|
814
|
+
counts.set(tag, 0);
|
|
815
|
+
}
|
|
816
|
+
for (const entry of listing) {
|
|
817
|
+
if (!entry.enabled || !entry.availableByProvider) {
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
total++;
|
|
821
|
+
const effectiveTags = entry.channel.tags ? entry.channel.tags.filter((tag) => vocabularySet.has(tag)) : [];
|
|
822
|
+
for (const tag of effectiveTags) {
|
|
823
|
+
const current = counts.get(tag);
|
|
824
|
+
if (current !== undefined) {
|
|
825
|
+
counts.set(tag, current + 1);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
for (const tag of vocabulary) {
|
|
830
|
+
result[tag] = { count: counts.get(tag) ?? 0, total };
|
|
831
|
+
}
|
|
832
|
+
return result;
|
|
833
|
+
}
|
|
699
834
|
/**
|
|
700
835
|
* Builds a complete channel table patch for the given set of affected channel keys. Generates row HTML for keys that exist in the listing (update action) and
|
|
701
836
|
* remove actions for keys that no longer exist. Includes the current summary counts and scope toggle counts. This is the single function mutation endpoints
|
|
@@ -719,7 +854,7 @@ export function buildChannelTablePatch(affectedKeys, profiles) {
|
|
|
719
854
|
}
|
|
720
855
|
}
|
|
721
856
|
const { counts, scopeCounts } = buildChannelTableState(listing);
|
|
722
|
-
return { counts, hdhrCounts: getHdhrCounts(listing), rows, scopeCounts };
|
|
857
|
+
return { counts, hdhrCounts: getHdhrCounts(listing), rows, scopeCounts, tagCounts: getTagCounts(listing) };
|
|
723
858
|
}
|
|
724
859
|
/**
|
|
725
860
|
* Generates the provider filter toolbar HTML with a multi-select dropdown and dismissable chips.
|
|
@@ -731,13 +866,12 @@ export function generateProviderFilterToolbar() {
|
|
|
731
866
|
const hasFilter = enabled.length > 0;
|
|
732
867
|
const lines = [];
|
|
733
868
|
lines.push("<div class=\"provider-toolbar\">");
|
|
734
|
-
// Provider filter dropdown and chips.
|
|
869
|
+
// Provider filter dropdown and chips. The button follows the toolbar dropdown pattern: icon + label + caret in a single button.
|
|
735
870
|
lines.push("<div class=\"toolbar-group\">");
|
|
736
|
-
lines.push("<span class=\"toolbar-label\">Providers:</span>");
|
|
737
871
|
lines.push("<div class=\"dropdown provider-dropdown\">");
|
|
738
872
|
const buttonText = hasFilter ? "Filtered" : "All Providers";
|
|
739
|
-
lines.push("<button type=\"button\" class=\"btn btn-sm\" id=\"provider-filter-btn\"
|
|
740
|
-
"onclick=\"toggleDropdown(this)\">" + buttonText + " ▾</button>");
|
|
873
|
+
lines.push("<button type=\"button\" class=\"btn btn-sm toolbar-dropdown-btn\" id=\"provider-filter-btn\" " +
|
|
874
|
+
"title=\"Filter channels by streaming provider\" onclick=\"toggleDropdown(this)\">" + ICON_FILTER + " " + buttonText + " ▾</button>");
|
|
741
875
|
lines.push("<div class=\"dropdown-menu provider-dropdown-menu\">");
|
|
742
876
|
for (const tagInfo of allTags) {
|
|
743
877
|
const isDirectTag = tagInfo.tag === "direct";
|
|
@@ -797,14 +931,6 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
797
931
|
// Toolbar with three dropdown menus: Manage Channels (primary actions), Import/Export (data I/O), and Quick Actions (bulk operations). Each dropdown has an
|
|
798
932
|
// inline SVG icon + label + chevron for visual discoverability. Grouped menus reduce visual clutter and separate channel management from data I/O and bulk
|
|
799
933
|
// operations.
|
|
800
|
-
// Toolbar-level SVG icons (14x14, stroke-based, currentColor). Defined here since they're toolbar-specific and not reused elsewhere.
|
|
801
|
-
const ICON_MANAGE = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" " +
|
|
802
|
-
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"1.5\" y=\"5\" width=\"13\" height=\"9.5\" rx=\"1.5\"/>" +
|
|
803
|
-
"<path d=\"M7 4.5L3.5 1\"/><path d=\"M9 4.5L12.5 1\"/></svg>";
|
|
804
|
-
const ICON_TRANSFER = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" " +
|
|
805
|
-
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 5h10M10 2l3 3-3 3\"/><path d=\"M13 11H3M6 8l-3 3 3 3\"/></svg>";
|
|
806
|
-
const ICON_BOLT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" " +
|
|
807
|
-
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 1.5L4 9h4l-1 5.5L12 7H8l1-5.5z\"/></svg>";
|
|
808
934
|
lines.push("<div class=\"channel-toolbar\">");
|
|
809
935
|
lines.push("<div class=\"toolbar-group\">");
|
|
810
936
|
// Manage Channels dropdown — primary channel creation and setup actions.
|
|
@@ -812,20 +938,26 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
812
938
|
lines.push("<button type=\"button\" class=\"btn btn-primary btn-sm toolbar-dropdown-btn\" title=\"Add, browse, or set up channels\" " +
|
|
813
939
|
"onclick=\"toggleDropdown(this)\">" + ICON_MANAGE + " Manage Channels ▾</button>");
|
|
814
940
|
lines.push("<div class=\"dropdown-menu\">");
|
|
815
|
-
// Dropdown item icons
|
|
816
|
-
|
|
941
|
+
// Dropdown item icons: tinted variants of the shared icons for visual differentiation in menu items. Each uses a distinct color so items are scannable at
|
|
942
|
+
// a glance. These are local to this function because the tint colors are context-specific to this dropdown.
|
|
943
|
+
const TINTED_ADD = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#22a563\" stroke-width=\"1.5\" " +
|
|
817
944
|
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M8 3v10M3 8h10\"/></svg>";
|
|
818
|
-
const
|
|
945
|
+
const TINTED_BROWSE = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#5b8def\" stroke-width=\"1.5\" " +
|
|
819
946
|
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2\" y=\"2\" width=\"5\" height=\"5\" rx=\"1\"/><rect x=\"9\" y=\"2\" width=\"5\" " +
|
|
820
947
|
"height=\"5\" rx=\"1\"/><rect x=\"2\" y=\"9\" width=\"5\" height=\"5\" rx=\"1\"/><rect x=\"9\" y=\"9\" width=\"5\" height=\"5\" rx=\"1\"/></svg>";
|
|
821
|
-
const
|
|
948
|
+
const TINTED_SETUP = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#9b8cd8\" stroke-width=\"1.5\" " +
|
|
822
949
|
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"8\" cy=\"8\" r=\"2.5\"/><path d=\"M8 1.5v2M8 12.5v2M1.5 8h2M12.5 8h2M3.1 " +
|
|
823
950
|
"3.1l1.4 1.4M11.5 11.5l1.4 1.4M3.1 12.9l1.4-1.4M11.5 4.5l1.4-1.4\"/></svg>";
|
|
951
|
+
const TINTED_TAG = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#9b59b6\" stroke-width=\"1.5\" " +
|
|
952
|
+
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M1.5 8.7V2.5a1 1 0 011-1h6.2a1 1 0 01.7.3l5.1 5.1a1 1 0 010 1.4l-5.5 5.5a1 " +
|
|
953
|
+
"1 0 01-1.4 0L1.8 9.4a1 1 0 01-.3-.7z\"/><circle cx=\"5\" cy=\"5\" r=\"1\"/></svg>";
|
|
824
954
|
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); " +
|
|
825
|
-
"document.getElementById('add-channel-form').style.display='block';\">" +
|
|
826
|
-
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); openBrowseModal()\">" +
|
|
955
|
+
"document.getElementById('add-channel-form').style.display='block';\">" + TINTED_ADD + " Add Channel</div>");
|
|
956
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); openBrowseModal()\">" + TINTED_BROWSE +
|
|
827
957
|
" Browse Provider Channels</div>");
|
|
828
|
-
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns();
|
|
958
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); openTagManager()\">" + TINTED_TAG +
|
|
959
|
+
" Manage Tags</div>");
|
|
960
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); openSetupWizard()\">" + TINTED_SETUP +
|
|
829
961
|
" Provider Setup</div>");
|
|
830
962
|
lines.push("</div>");
|
|
831
963
|
lines.push("</div>");
|
|
@@ -834,18 +966,18 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
834
966
|
lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm toolbar-dropdown-btn\" title=\"Import or export channel data\" " +
|
|
835
967
|
"onclick=\"toggleDropdown(this)\">" + ICON_TRANSFER + " Import / Export ▾</button>");
|
|
836
968
|
lines.push("<div class=\"dropdown-menu\">");
|
|
837
|
-
const
|
|
969
|
+
const TINTED_IMPORT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#5b8def\" stroke-width=\"1.5\" " +
|
|
838
970
|
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M8 2v8M5 7l3 3 3-3\"/><path d=\"M2 11v2a1 1 0 001 1h10a1 1 0 001-1v-2\"/></svg>";
|
|
839
|
-
const
|
|
971
|
+
const TINTED_EXPORT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#22a563\" stroke-width=\"1.5\" " +
|
|
840
972
|
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M8 10V2M5 5l3-3 3 3\"/><path d=\"M2 11v2a1 1 0 001 1h10a1 1 0 001-1v-2\"/></svg>";
|
|
841
973
|
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); document.getElementById('import-channels-file').click()\">" +
|
|
842
|
-
|
|
974
|
+
TINTED_IMPORT + " Import Channels (JSON)</div>");
|
|
843
975
|
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); document.getElementById('import-m3u-file').click()\">" +
|
|
844
|
-
|
|
976
|
+
TINTED_IMPORT + " Import M3U Playlist</div>");
|
|
845
977
|
lines.push("<label class=\"dropdown-option\"><input type=\"checkbox\" id=\"m3u-replace-duplicates\"> Replace duplicates on M3U import</label>");
|
|
846
978
|
lines.push("<div class=\"dropdown-divider\"></div>");
|
|
847
979
|
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); exportChannels()\">" +
|
|
848
|
-
|
|
980
|
+
TINTED_EXPORT + " Export Channels (JSON)</div>");
|
|
849
981
|
lines.push("</div>");
|
|
850
982
|
lines.push("</div>");
|
|
851
983
|
lines.push("<input type=\"file\" id=\"import-m3u-file\" accept=\".m3u,.m3u8\" style=\"display: none;\" onchange=\"importM3U(this)\">");
|
|
@@ -914,6 +1046,43 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
914
1046
|
lines.push("<span class=\"quick-action-count\" id=\"hdhr-bulk-count\">" + String(hdhrCounts.enabled) + " of " + String(hdhrCounts.total) + "</span>");
|
|
915
1047
|
lines.push("</label>");
|
|
916
1048
|
lines.push("</div>");
|
|
1049
|
+
// Bulk tag toggles — tristate checkboxes for each tag in the active vocabulary. Visible when the tags column is shown. Each checkbox shows how many enabled
|
|
1050
|
+
// channels have the tag, and clicking it adds or removes the tag on all visible channels.
|
|
1051
|
+
const tagsVisible = visibleCols.has("tags") ? "" : " style=\"display: none;\"";
|
|
1052
|
+
const tagsVocabulary = getActiveTagVocabulary();
|
|
1053
|
+
if (tagsVocabulary.length > 0) {
|
|
1054
|
+
// Count how many enabled, provider-available channels have each tag.
|
|
1055
|
+
const enabledListing = listing.filter((entry) => entry.enabled && entry.availableByProvider);
|
|
1056
|
+
const totalEnabled = enabledListing.length;
|
|
1057
|
+
const tagCountMap = new Map();
|
|
1058
|
+
for (const tag of tagsVocabulary) {
|
|
1059
|
+
tagCountMap.set(tag, 0);
|
|
1060
|
+
}
|
|
1061
|
+
for (const entry of enabledListing) {
|
|
1062
|
+
const effectiveTags = getChannelEffectiveTags(entry.channel);
|
|
1063
|
+
for (const tag of effectiveTags) {
|
|
1064
|
+
const current = tagCountMap.get(tag);
|
|
1065
|
+
if (current !== undefined) {
|
|
1066
|
+
tagCountMap.set(tag, current + 1);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
lines.push("<div class=\"dropdown-divider\" id=\"quick-action-tags-divider\"" + tagsVisible + "></div>");
|
|
1071
|
+
lines.push("<div id=\"quick-action-tags\"" + tagsVisible + ">");
|
|
1072
|
+
lines.push("<div class=\"quick-action-section-label\">Tag Channels</div>");
|
|
1073
|
+
for (const tag of tagsVocabulary) {
|
|
1074
|
+
const count = tagCountMap.get(tag) ?? 0;
|
|
1075
|
+
const checkedAttr = (count === totalEnabled) ? " checked" : "";
|
|
1076
|
+
const indeterminate = ((count > 0) && (count < totalEnabled)) ? " data-indeterminate=\"true\"" : "";
|
|
1077
|
+
lines.push("<label class=\"provider-option\" onclick=\"event.preventDefault(); bulkToggleTag('" + escapeHtml(tag) +
|
|
1078
|
+
"', !this.querySelector('input').checked)\">" +
|
|
1079
|
+
"<input type=\"checkbox\" class=\"tag-bulk-toggle\" data-tag=\"" + escapeHtml(tag) + "\"" + checkedAttr + indeterminate + "> " +
|
|
1080
|
+
escapeHtml(tag) +
|
|
1081
|
+
"<span class=\"quick-action-count\" data-tag-count=\"" + escapeHtml(tag) + "\">" +
|
|
1082
|
+
String(count) + " of " + String(totalEnabled) + "</span></label>");
|
|
1083
|
+
}
|
|
1084
|
+
lines.push("</div>");
|
|
1085
|
+
}
|
|
917
1086
|
lines.push("</div>");
|
|
918
1087
|
lines.push("</div>");
|
|
919
1088
|
lines.push("</div>");
|
|
@@ -1050,11 +1219,34 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1050
1219
|
{ cssClass: "col-provider", field: "provider", label: "Provider" },
|
|
1051
1220
|
...OPTIONAL_COLUMNS
|
|
1052
1221
|
];
|
|
1222
|
+
// All sortable headers use the same DOM structure: <th onclick> wraps a <span class="sort-label"> for the label text. The sort update logic targets
|
|
1223
|
+
// .sort-label to modify only the label — never touching other children like the Tags filter dropdown. Clicking anywhere on the <th> triggers sort;
|
|
1224
|
+
// additional children (like the filter button) use event.stopPropagation() to prevent sort when interacting with them.
|
|
1053
1225
|
for (const hdr of sortableHeaders) {
|
|
1054
1226
|
const isActive = (sortField === hdr.field);
|
|
1055
|
-
const
|
|
1056
|
-
lines.push("<th class=\"" + hdr.cssClass + " sortable\" data-sort-field=\"" + hdr.field +
|
|
1057
|
-
|
|
1227
|
+
const activeIndicator = isActive ? ((sortDir === "asc") ? " ▲" : " ▼") : "";
|
|
1228
|
+
lines.push("<th class=\"" + hdr.cssClass + " sortable\" data-sort-field=\"" + hdr.field +
|
|
1229
|
+
"\" onclick=\"sortChannelTable('" + hdr.field + "')\">");
|
|
1230
|
+
lines.push("<span class=\"sort-label\">" + hdr.label + activeIndicator + "</span>");
|
|
1231
|
+
// Tags header: additional filter dropdown alongside the sort label. The dropdown is a client-side-only view filter (transient, not persisted) that
|
|
1232
|
+
// shows/hides rows based on their data-channel-tags attribute. The dropdown content comes from generateTagFilterContent() — the single source of truth
|
|
1233
|
+
// shared with the tag CRUD incremental update path.
|
|
1234
|
+
if (hdr.field === "tags") {
|
|
1235
|
+
lines.push("<div class=\"dropdown tag-filter-dropdown\" style=\"display: inline;\">");
|
|
1236
|
+
lines.push("<button type=\"button\" class=\"btn-icon btn-tag-filter\" title=\"Filter by tag\" " +
|
|
1237
|
+
"onclick=\"event.stopPropagation(); toggleDropdown(this)\">" + ICON_FILTER + "</button>");
|
|
1238
|
+
lines.push("<div class=\"dropdown-menu\" id=\"tag-filter-menu\">" + generateTagFilterContent() + "</div>");
|
|
1239
|
+
lines.push("</div>");
|
|
1240
|
+
// Playlist hint icon. Hidden by default, shown by applyTagColumnFilter() when the filter is active. Clicking opens a popover with the playlist URL
|
|
1241
|
+
// that corresponds to the current tag filter, so users can copy it for Channels DVR configuration.
|
|
1242
|
+
lines.push("<div class=\"dropdown\" style=\"display: inline;\">");
|
|
1243
|
+
lines.push("<button type=\"button\" class=\"btn-icon btn-playlist-hint\" id=\"playlist-hint-btn\" " +
|
|
1244
|
+
"title=\"Playlist URL for this filter\" style=\"display: none;\" " +
|
|
1245
|
+
"onclick=\"event.stopPropagation(); showPlaylistHint(this)\">" + ICON_LINK + "</button>");
|
|
1246
|
+
lines.push("<div class=\"dropdown-menu playlist-hint-menu\"></div>");
|
|
1247
|
+
lines.push("</div>");
|
|
1248
|
+
}
|
|
1249
|
+
lines.push("</th>");
|
|
1058
1250
|
}
|
|
1059
1251
|
// Actions header with table options dropdown (kebab menu). Contains the show-disabled toggle and column visibility checkboxes.
|
|
1060
1252
|
lines.push("<th class=\"col-actions\"><span>Actions</span>");
|
|
@@ -1096,6 +1288,18 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1096
1288
|
// Provider Setup wizard modal. Follows the same wizard pattern as the provider profile builder. Three steps: pick providers, authenticate, browse channels.
|
|
1097
1289
|
// The setupCompleted flag is embedded as a data attribute so the client can auto-show the wizard on first visit.
|
|
1098
1290
|
lines.push(generateSetupWizardModal());
|
|
1291
|
+
// Tag Management modal. A simple dialog for creating, deleting, and restoring organizational tags. Client-side handlers drive the CRUD operations via the
|
|
1292
|
+
// tag management API endpoints.
|
|
1293
|
+
lines.push(generateTagManagementModal());
|
|
1294
|
+
// Inline tag edit portal — a shared dropdown portaled to <body> on first use by getTagPortal() in config.ts. One instance shared across all channel rows;
|
|
1295
|
+
// populated dynamically from the clicked cell's data-tags attribute. Positioned via getBoundingClientRect() to escape the table wrapper's overflow:auto clipping.
|
|
1296
|
+
const vocabulary = getActiveTagVocabulary();
|
|
1297
|
+
lines.push("<div id=\"inline-tag-portal\" class=\"dropdown-menu inline-tag-menu\">");
|
|
1298
|
+
for (const tag of vocabulary) {
|
|
1299
|
+
lines.push("<label class=\"provider-option\" onclick=\"event.stopPropagation()\">" +
|
|
1300
|
+
"<input type=\"checkbox\" class=\"inline-tag-checkbox\" data-tag=\"" + escapeHtml(tag) + "\"> " + escapeHtml(tag) + "</label>");
|
|
1301
|
+
}
|
|
1302
|
+
lines.push("</div>");
|
|
1099
1303
|
return lines.join("\n");
|
|
1100
1304
|
}
|
|
1101
1305
|
//# sourceMappingURL=table.js.map
|