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.
Files changed (112) hide show
  1. package/dist/app.js +9 -8
  2. package/dist/app.js.map +1 -1
  3. package/dist/browser/login.js +11 -8
  4. package/dist/browser/login.js.map +1 -1
  5. package/dist/channels/index.d.ts +2 -0
  6. package/dist/channels/index.js +208 -22
  7. package/dist/channels/index.js.map +1 -1
  8. package/dist/config/health.js +10 -15
  9. package/dist/config/health.js.map +1 -1
  10. package/dist/config/index.js +9 -0
  11. package/dist/config/index.js.map +1 -1
  12. package/dist/config/profiles.js +10 -10
  13. package/dist/config/profiles.js.map +1 -1
  14. package/dist/config/providers.js +30 -17
  15. package/dist/config/providers.js.map +1 -1
  16. package/dist/config/userChannels.d.ts +62 -9
  17. package/dist/config/userChannels.js +172 -44
  18. package/dist/config/userChannels.js.map +1 -1
  19. package/dist/config/userConfig.d.ts +2 -0
  20. package/dist/config/userConfig.js +29 -4
  21. package/dist/config/userConfig.js.map +1 -1
  22. package/dist/config/userProfiles.js +27 -39
  23. package/dist/config/userProfiles.js.map +1 -1
  24. package/dist/hdhr/discover.js +7 -15
  25. package/dist/hdhr/discover.js.map +1 -1
  26. package/dist/native/index.d.ts +2 -2
  27. package/dist/native/index.js +14 -24
  28. package/dist/native/index.js.map +1 -1
  29. package/dist/native/proxy.d.ts +2 -2
  30. package/dist/native/proxy.js.map +1 -1
  31. package/dist/routes/assets.js +26 -48
  32. package/dist/routes/assets.js.map +1 -1
  33. package/dist/routes/channels.js +5 -11
  34. package/dist/routes/channels.js.map +1 -1
  35. package/dist/routes/components.d.ts +0 -8
  36. package/dist/routes/components.js +0 -19
  37. package/dist/routes/components.js.map +1 -1
  38. package/dist/routes/config/channels/index.d.ts +1 -1
  39. package/dist/routes/config/channels/index.js +1 -1
  40. package/dist/routes/config/channels/index.js.map +1 -1
  41. package/dist/routes/config/channels/routes.js +335 -35
  42. package/dist/routes/config/channels/routes.js.map +1 -1
  43. package/dist/routes/config/channels/table.d.ts +17 -2
  44. package/dist/routes/config/channels/table.js +270 -66
  45. package/dist/routes/config/channels/table.js.map +1 -1
  46. package/dist/routes/config/providers.js +7 -7
  47. package/dist/routes/config/providers.js.map +1 -1
  48. package/dist/routes/config/settings.js +57 -36
  49. package/dist/routes/config/settings.js.map +1 -1
  50. package/dist/routes/icons.d.ts +16 -0
  51. package/dist/routes/icons.js +44 -0
  52. package/dist/routes/icons.js.map +1 -0
  53. package/dist/routes/playlist.d.ts +6 -4
  54. package/dist/routes/playlist.js +68 -27
  55. package/dist/routes/playlist.js.map +1 -1
  56. package/dist/routes/root/content.js +41 -2
  57. package/dist/routes/root/content.js.map +1 -1
  58. package/dist/routes/root/scripts/channels.js +185 -0
  59. package/dist/routes/root/scripts/channels.js.map +1 -1
  60. package/dist/routes/root/scripts/config.js +115 -41
  61. package/dist/routes/root/scripts/config.js.map +1 -1
  62. package/dist/routes/root/scripts/shared.js +69 -23
  63. package/dist/routes/root/scripts/shared.js.map +1 -1
  64. package/dist/routes/root/scripts/status.js +6 -12
  65. package/dist/routes/root/scripts/status.js.map +1 -1
  66. package/dist/routes/root/styles.js +45 -4
  67. package/dist/routes/root/styles.js.map +1 -1
  68. package/dist/routes/ui.d.ts +1 -1
  69. package/dist/routes/ui.js +1 -1
  70. package/dist/routes/ui.js.map +1 -1
  71. package/dist/service/commands.js +3 -35
  72. package/dist/service/commands.js.map +1 -1
  73. package/dist/streaming/codec.d.ts +20 -0
  74. package/dist/streaming/codec.js +68 -0
  75. package/dist/streaming/codec.js.map +1 -0
  76. package/dist/streaming/fmp4Segmenter.d.ts +2 -2
  77. package/dist/streaming/fmp4Segmenter.js.map +1 -1
  78. package/dist/streaming/hls.js +11 -6
  79. package/dist/streaming/hls.js.map +1 -1
  80. package/dist/streaming/hlsResume.js +2 -2
  81. package/dist/streaming/hlsResume.js.map +1 -1
  82. package/dist/streaming/preroll.d.ts +15 -14
  83. package/dist/streaming/preroll.js +31 -11
  84. package/dist/streaming/preroll.js.map +1 -1
  85. package/dist/streaming/registry.d.ts +2 -2
  86. package/dist/streaming/registry.js.map +1 -1
  87. package/dist/streaming/setup.d.ts +2 -2
  88. package/dist/streaming/setup.js +5 -12
  89. package/dist/streaming/setup.js.map +1 -1
  90. package/dist/types/channels.d.ts +3 -0
  91. package/dist/types/config.d.ts +1 -0
  92. package/dist/types/index.d.ts +2 -1
  93. package/dist/types/index.js +1 -0
  94. package/dist/types/index.js.map +1 -1
  95. package/dist/types/shared.d.ts +1 -1
  96. package/dist/types/streaming.d.ts +7 -15
  97. package/dist/types/streaming.js +5 -1
  98. package/dist/types/streaming.js.map +1 -1
  99. package/dist/upgrade/commands.js +1 -16
  100. package/dist/upgrade/commands.js.map +1 -1
  101. package/dist/utils/cliOutput.d.ts +10 -0
  102. package/dist/utils/cliOutput.js +17 -0
  103. package/dist/utils/cliOutput.js.map +1 -0
  104. package/dist/utils/format.d.ts +14 -3
  105. package/dist/utils/format.js +24 -6
  106. package/dist/utils/format.js.map +1 -1
  107. package/dist/utils/index.d.ts +1 -0
  108. package/dist/utils/index.js +1 -0
  109. package/dist/utils/index.js.map +1 -1
  110. package/dist/utils/retry.js +2 -4
  111. package/dist/utils/retry.js.map +1 -1
  112. 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 + 5 optional).
58
- const TOTAL_COLUMN_COUNT = 9;
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 &gt; 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
- displayLines.push("<tr id=\"display-row-" + escapeHtml(key) + "\"" + rowClassAttr + " data-provider-tags=\"" + escapeHtml(providerTags) + "\">");
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\">&ndash;</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\">&ndash;</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: varies by row type.
574
- if (isOverride) {
575
- // Override: revert to predefined defaults.
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\" title=\"Filter channels by streaming provider\" " +
740
- "onclick=\"toggleDropdown(this)\">" + buttonText + " &#9662;</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 + " &#9662;</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 &#9662;</button>");
814
940
  lines.push("<div class=\"dropdown-menu\">");
815
- // Dropdown item icons (14x14, stroke-based, tinted for visual context). Each icon uses a distinct color to differentiate menu items at a glance.
816
- const ICON_ADD = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#22a563\" stroke-width=\"1.5\" " +
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 ICON_BROWSE = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#5b8def\" stroke-width=\"1.5\" " +
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 ICON_SETUP = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#9b8cd8\" stroke-width=\"1.5\" " +
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';\">" + ICON_ADD + " Add Channel</div>");
826
- lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); openBrowseModal()\">" + ICON_BROWSE +
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(); openSetupWizard()\">" + ICON_SETUP +
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 &#9662;</button>");
836
968
  lines.push("<div class=\"dropdown-menu\">");
837
- const ICON_IMPORT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#5b8def\" stroke-width=\"1.5\" " +
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 ICON_EXPORT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#22a563\" stroke-width=\"1.5\" " +
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
- ICON_IMPORT + " Import Channels (JSON)</div>");
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
- ICON_IMPORT + " Import M3U Playlist</div>");
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
- ICON_EXPORT + " Export Channels (JSON)</div>");
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 indicator = isActive ? ((sortDir === "asc") ? " &#9650;" : " &#9660;") : "";
1056
- lines.push("<th class=\"" + hdr.cssClass + " sortable\" data-sort-field=\"" + hdr.field + "\" onclick=\"sortChannelTable('" + hdr.field + "')\">" +
1057
- hdr.label + indicator + "</th>");
1227
+ const activeIndicator = isActive ? ((sortDir === "asc") ? " &#9650;" : " &#9660;") : "";
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