@tomorrowos/sdk 1.1.4 → 1.1.6

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 (36) hide show
  1. package/PLAYER_INSTALL.md +32 -4
  2. package/README.md +1 -1
  3. package/dist/index.d.ts +3 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1 -0
  6. package/dist/playlist-catalog.d.ts +5 -0
  7. package/dist/playlist-catalog.d.ts.map +1 -1
  8. package/dist/playlist-catalog.js +22 -1
  9. package/dist/screen-tags.d.ts +95 -0
  10. package/dist/screen-tags.d.ts.map +1 -0
  11. package/dist/screen-tags.js +835 -0
  12. package/dist/store/memory-store.d.ts +2 -0
  13. package/dist/store/memory-store.d.ts.map +1 -1
  14. package/dist/store/memory-store.js +11 -0
  15. package/dist/store/postgres-store.d.ts +2 -0
  16. package/dist/store/postgres-store.d.ts.map +1 -1
  17. package/dist/store/postgres-store.js +9 -0
  18. package/dist/store/sqlite-store.d.ts +2 -0
  19. package/dist/store/sqlite-store.d.ts.map +1 -1
  20. package/dist/store/sqlite-store.js +12 -0
  21. package/dist/store/types.d.ts +20 -0
  22. package/dist/store/types.d.ts.map +1 -1
  23. package/dist/tomorrowos.d.ts +6 -0
  24. package/dist/tomorrowos.d.ts.map +1 -1
  25. package/dist/tomorrowos.js +44 -1
  26. package/package.json +1 -1
  27. package/templates/cms-starter/package-lock.json +3 -3
  28. package/templates/cms-starter/package.json +2 -2
  29. package/templates/cms-starter/public/index.html +86 -1
  30. package/templates/cms-starter/public/methods.js +782 -11
  31. package/templates/cms-starter/public/panel.css +356 -0
  32. package/templates/cms-starter-v0/cms-panel/index.html +86 -1
  33. package/templates/cms-starter-v0/cms-panel/methods.js +782 -11
  34. package/templates/cms-starter-v0/cms-panel/panel.css +356 -0
  35. package/templates/cms-starter-v0/package-lock.json +3 -3
  36. package/templates/cms-starter-v0/package.json +2 -2
@@ -12,9 +12,36 @@ let selectedPlaylistId = null;
12
12
  /** True while creating a new playlist locally (assets allowed before Save). */
13
13
  let playlistDraftActive = false;
14
14
 
15
- /** @type {{ id: string, assetId?: string, url: string, name: string, type: string, durationMs: number }[]} */
15
+ /** @type {{ id: string, assetId?: string, url: string, name: string, type: string, durationMs: number, tagRule: { match: string, tags: string[] } | null, schedule: { startDate?: string, endDate?: string, startTime?: string, endTime?: string, days?: boolean[] } | null }[]} */
16
16
  let editorItems = [];
17
17
 
18
+ /** @type {Record<string, string[]>} */
19
+ let screenTagsByDevice = {};
20
+
21
+ /** Draft text for the per-device tag field, kept across the device list refresh. */
22
+ const tagDraftByDevice = {};
23
+ /** @type {string|null} */
24
+ let tagInputFocusDeviceId = null;
25
+ let renderingDeviceCards = false;
26
+
27
+ const TAG_RULE_OPS = [
28
+ { value: "any", label: "any of" },
29
+ { value: "all", label: "all of" },
30
+ { value: "notAll", label: "not all of" },
31
+ { value: "notAny", label: "none of" }
32
+ ];
33
+
34
+ const ITEM_SCHEDULE_DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
35
+
36
+ /** @type {string[]} */
37
+ let tagCatalog = [];
38
+ /** Tags created in this page session before the catalog refresh returns. */
39
+ const createdScreenTags = new Set();
40
+ /** @type {string|null} */
41
+ let tagRuleModalItemId = null;
42
+ /** @type {{ tagRule: { match: string, tags: string[] } | null, schedule: { startDate?: string, endDate?: string, startTime?: string, endTime?: string, days?: boolean[] } | null } | null} */
43
+ let tagRuleModalSnapshot = null;
44
+
18
45
  /** Item id currently being dragged in the asset list (reorder). */
19
46
  let draggingEditorItemId = null;
20
47
 
@@ -783,6 +810,329 @@ function updatePlaylistEditorVisibility() {
783
810
  section.classList.toggle("hidden", !isPlaylistEditorOpen());
784
811
  }
785
812
 
813
+ function normalizeEditorTag(raw) {
814
+ const tag = String(raw || "").trim().toLowerCase();
815
+ if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(tag)) return "";
816
+ return tag;
817
+ }
818
+
819
+ function parseEditorTagList(text) {
820
+ const tags = [];
821
+ const seen = new Set();
822
+ for (const part of String(text || "").split(",")) {
823
+ const tag = normalizeEditorTag(part);
824
+ if (!tag || seen.has(tag)) continue;
825
+ seen.add(tag);
826
+ tags.push(tag);
827
+ }
828
+ return tags;
829
+ }
830
+
831
+ function editorTagRuleFromItem(item) {
832
+ const rule = item?.tagRule;
833
+ if (!rule || typeof rule !== "object") return null;
834
+ const matchValue = rule.match || rule.op;
835
+ const match = TAG_RULE_OPS.some((option) => option.value === matchValue) ? matchValue : "";
836
+ const tags = Array.isArray(rule.tags) ? parseEditorTagList(rule.tags.join(",")) : [];
837
+ if (!match || tags.length === 0) return null;
838
+ return { match, tags };
839
+ }
840
+
841
+ function knownScreenTags() {
842
+ const tags = [];
843
+ const seen = new Set();
844
+ const add = (raw) => {
845
+ const tag = normalizeEditorTag(raw);
846
+ if (!tag || seen.has(tag)) return;
847
+ seen.add(tag);
848
+ tags.push(tag);
849
+ };
850
+ for (const list of Object.values(screenTagsByDevice)) {
851
+ for (const tag of list || []) add(tag);
852
+ }
853
+ for (const playlist of playlistsCatalog) {
854
+ for (const entry of playlist.items || []) {
855
+ for (const tag of entry.tagRule?.tags || []) add(tag);
856
+ }
857
+ }
858
+ for (const entry of editorItems) {
859
+ for (const tag of entry.tagRule?.tags || []) add(tag);
860
+ }
861
+ for (const tag of tagCatalog) add(tag);
862
+ for (const tag of createdScreenTags) add(tag);
863
+ tags.sort();
864
+ return tags;
865
+ }
866
+
867
+ async function registerCatalogTag(tag) {
868
+ const res = await fetch("/tags", {
869
+ method: "POST",
870
+ headers: { "Content-Type": "application/json" },
871
+ body: JSON.stringify({ tag })
872
+ });
873
+ const data = await res.json();
874
+ if (!res.ok) {
875
+ alert(data.error || "Could not create tag");
876
+ return false;
877
+ }
878
+ if (Array.isArray(data.tags)) tagCatalog = data.tags;
879
+ createdScreenTags.add(tag);
880
+ return true;
881
+ }
882
+
883
+ function normalizeClockValue(value) {
884
+ const match = /^(\d{2}):(\d{2})/.exec(String(value || "").trim());
885
+ if (!match) return "";
886
+ const hour = Number(match[1]);
887
+ const minute = Number(match[2]);
888
+ if (hour > 23 || minute > 59) return "";
889
+ return `${match[1]}:${match[2]}`;
890
+ }
891
+
892
+ function editorScheduleFromItem(item) {
893
+ const schedule = item?.schedule;
894
+ if (!schedule || typeof schedule !== "object") return null;
895
+ const next = {};
896
+ if (/^\d{4}-\d{2}-\d{2}$/.test(schedule.startDate || "")) next.startDate = schedule.startDate;
897
+ if (/^\d{4}-\d{2}-\d{2}$/.test(schedule.endDate || "")) next.endDate = schedule.endDate;
898
+ const startTime = normalizeClockValue(schedule.startTime);
899
+ const endTime = normalizeClockValue(schedule.endTime);
900
+ if (startTime) next.startTime = startTime;
901
+ if (endTime) next.endTime = endTime;
902
+ if (
903
+ Array.isArray(schedule.days) &&
904
+ schedule.days.length === 7 &&
905
+ schedule.days.every((day) => typeof day === "boolean") &&
906
+ schedule.days.some((day) => !day)
907
+ ) {
908
+ next.days = schedule.days.slice();
909
+ }
910
+ return Object.keys(next).length ? next : null;
911
+ }
912
+
913
+ function formatEditorSchedule(schedule) {
914
+ if (!schedule) return "";
915
+ const parts = [];
916
+ if (schedule.startDate || schedule.endDate) {
917
+ parts.push(`${schedule.startDate || "any date"}–${schedule.endDate || "any date"}`);
918
+ }
919
+ if (schedule.startTime || schedule.endTime) {
920
+ parts.push(`${schedule.startTime || "00:00"}–${schedule.endTime || "24:00"}`);
921
+ }
922
+ if (Array.isArray(schedule.days)) {
923
+ const labels = ITEM_SCHEDULE_DAYS.filter((_, index) => schedule.days[index]);
924
+ parts.push(labels.length ? labels.join(" ") : "no days");
925
+ }
926
+ return parts.join(" · ");
927
+ }
928
+
929
+ function readItemScheduleFromForm() {
930
+ const startDate = document.getElementById("itemScheduleStartDate")?.value || "";
931
+ const endDate = document.getElementById("itemScheduleEndDate")?.value || "";
932
+ const startTime = normalizeClockValue(document.getElementById("itemScheduleStartTime")?.value);
933
+ const endTime = normalizeClockValue(document.getElementById("itemScheduleEndTime")?.value);
934
+ const days = [...document.querySelectorAll("[data-item-schedule-day]")].map(
935
+ (button) => button.getAttribute("aria-pressed") === "true"
936
+ );
937
+ const schedule = {};
938
+ if (startDate) schedule.startDate = startDate;
939
+ if (endDate) schedule.endDate = endDate;
940
+ if (startTime) schedule.startTime = startTime;
941
+ if (endTime) schedule.endTime = endTime;
942
+ if (days.length === 7 && days.some((day) => !day)) schedule.days = days;
943
+ return Object.keys(schedule).length ? schedule : null;
944
+ }
945
+
946
+ function loadItemScheduleIntoForm(schedule) {
947
+ const startDate = document.getElementById("itemScheduleStartDate");
948
+ const endDate = document.getElementById("itemScheduleEndDate");
949
+ const startTime = document.getElementById("itemScheduleStartTime");
950
+ const endTime = document.getElementById("itemScheduleEndTime");
951
+ if (startDate) startDate.value = schedule?.startDate || "";
952
+ if (endDate) endDate.value = schedule?.endDate || "";
953
+ if (startTime) startTime.value = schedule?.startTime || "";
954
+ if (endTime) endTime.value = schedule?.endTime || "";
955
+ const days = Array.isArray(schedule?.days) && schedule.days.length === 7
956
+ ? schedule.days
957
+ : [true, true, true, true, true, true, true];
958
+ document.querySelectorAll("[data-item-schedule-day]").forEach((button, index) => {
959
+ const on = days[index] !== false;
960
+ button.setAttribute("aria-pressed", on ? "true" : "false");
961
+ button.classList.toggle("is-on", on);
962
+ });
963
+ const error = document.getElementById("itemScheduleError");
964
+ if (error) error.textContent = "";
965
+ }
966
+
967
+ function applyItemScheduleFromForm() {
968
+ const item = tagRuleModalItem();
969
+ if (!item) return;
970
+ item.schedule = readItemScheduleFromForm();
971
+ const error = document.getElementById("itemScheduleError");
972
+ if (!error) return;
973
+ const schedule = item.schedule;
974
+ error.textContent = schedule?.startDate && schedule?.endDate && schedule.startDate > schedule.endDate
975
+ ? "The until date is before the from date."
976
+ : "";
977
+ }
978
+
979
+ function tagRuleModalItem() {
980
+ return editorItems.find((entry) => entry.id === tagRuleModalItemId) || null;
981
+ }
982
+
983
+ function snapshotTagRuleState(item) {
984
+ const schedule = editorScheduleFromItem(item);
985
+ return {
986
+ tagRule: item.tagRule?.tags?.length
987
+ ? { match: item.tagRule.match, tags: item.tagRule.tags.slice() }
988
+ : null,
989
+ schedule: schedule
990
+ ? { ...schedule, ...(Array.isArray(schedule.days) ? { days: schedule.days.slice() } : {}) }
991
+ : null
992
+ };
993
+ }
994
+
995
+ function openTagRuleModal(item) {
996
+ tagRuleModalItemId = item.id;
997
+ tagRuleModalSnapshot = snapshotTagRuleState(item);
998
+ const modal = document.getElementById("tagRuleModal");
999
+ const name = document.getElementById("tagRuleAssetName");
1000
+ const match = document.getElementById("tagRuleMatch");
1001
+ const input = document.getElementById("tagRuleAddInput");
1002
+ if (!modal || !name || !match || !input) return;
1003
+ name.textContent = item.name || "asset";
1004
+ match.value = TAG_RULE_OPS.some((option) => option.value === item.tagRule?.match)
1005
+ ? item.tagRule.match
1006
+ : "any";
1007
+ input.value = "";
1008
+ renderTagRuleSelection();
1009
+ hideTagRuleOptions();
1010
+ loadItemScheduleIntoForm(item.schedule);
1011
+ modal.classList.remove("hidden");
1012
+ }
1013
+
1014
+ function closeTagRuleModal() {
1015
+ document.getElementById("tagRuleModal")?.classList.add("hidden");
1016
+ hideTagRuleOptions();
1017
+ tagRuleModalItemId = null;
1018
+ tagRuleModalSnapshot = null;
1019
+ renderEditorAssets();
1020
+ }
1021
+
1022
+ function cancelTagRuleModal() {
1023
+ const item = tagRuleModalItem();
1024
+ if (item && tagRuleModalSnapshot) {
1025
+ item.tagRule = tagRuleModalSnapshot.tagRule
1026
+ ? { match: tagRuleModalSnapshot.tagRule.match, tags: tagRuleModalSnapshot.tagRule.tags.slice() }
1027
+ : null;
1028
+ const schedule = tagRuleModalSnapshot.schedule;
1029
+ item.schedule = schedule
1030
+ ? { ...schedule, ...(Array.isArray(schedule.days) ? { days: schedule.days.slice() } : {}) }
1031
+ : null;
1032
+ }
1033
+ closeTagRuleModal();
1034
+ }
1035
+
1036
+ function applyTagRuleModal() {
1037
+ applyItemScheduleFromForm();
1038
+ const error = document.getElementById("itemScheduleError");
1039
+ if (error?.textContent) return;
1040
+ const item = tagRuleModalItem();
1041
+ const match = document.getElementById("tagRuleMatch");
1042
+ if (item?.tagRule?.tags?.length && match) {
1043
+ item.tagRule = { match: match.value, tags: item.tagRule.tags.slice() };
1044
+ }
1045
+ closeTagRuleModal();
1046
+ }
1047
+
1048
+ function renderTagRuleSelection() {
1049
+ const item = tagRuleModalItem();
1050
+ const selected = document.getElementById("tagRuleSelected");
1051
+ if (!item || !selected) return;
1052
+ selected.innerHTML = "";
1053
+ for (const tag of item.tagRule?.tags || []) {
1054
+ const chip = document.createElement("button");
1055
+ chip.type = "button";
1056
+ chip.className = "device-tag-chip";
1057
+ chip.textContent = `${tag} ×`;
1058
+ chip.title = `Remove ${tag}`;
1059
+ chip.addEventListener("click", () => removeTagFromOpenRule(tag));
1060
+ selected.appendChild(chip);
1061
+ }
1062
+ }
1063
+
1064
+ function hideTagRuleOptions() {
1065
+ document.getElementById("tagRuleOptions")?.classList.add("hidden");
1066
+ }
1067
+
1068
+ function showTagRuleOptions() {
1069
+ const item = tagRuleModalItem();
1070
+ const input = document.getElementById("tagRuleAddInput");
1071
+ const list = document.getElementById("tagRuleOptions");
1072
+ if (!item || !input || !list) return;
1073
+ const query = String(input.value || "").trim().toLowerCase();
1074
+ const selected = new Set(item.tagRule?.tags || []);
1075
+ const choices = knownScreenTags().filter((tag) => !selected.has(tag) && (!query || tag.includes(query)));
1076
+ const typed = normalizeEditorTag(input.value);
1077
+ list.innerHTML = "";
1078
+
1079
+ for (const tag of choices) {
1080
+ const li = document.createElement("li");
1081
+ const button = document.createElement("button");
1082
+ button.type = "button";
1083
+ button.textContent = tag;
1084
+ button.addEventListener("mousedown", (event) => {
1085
+ event.preventDefault();
1086
+ addTagToOpenRule(tag);
1087
+ });
1088
+ li.appendChild(button);
1089
+ list.appendChild(li);
1090
+ }
1091
+
1092
+ if (typed && !knownScreenTags().includes(typed) && !selected.has(typed)) {
1093
+ const li = document.createElement("li");
1094
+ const button = document.createElement("button");
1095
+ button.type = "button";
1096
+ button.textContent = `Create “${typed}”`;
1097
+ button.addEventListener("mousedown", (event) => {
1098
+ event.preventDefault();
1099
+ void (async () => {
1100
+ if (!(await registerCatalogTag(typed))) return;
1101
+ addTagToOpenRule(typed);
1102
+ })();
1103
+ });
1104
+ li.appendChild(button);
1105
+ list.appendChild(li);
1106
+ }
1107
+
1108
+ list.classList.toggle("hidden", list.childElementCount === 0);
1109
+ }
1110
+
1111
+ function addTagToOpenRule(tag) {
1112
+ const item = tagRuleModalItem();
1113
+ const match = document.getElementById("tagRuleMatch");
1114
+ const input = document.getElementById("tagRuleAddInput");
1115
+ if (!item || !match) return;
1116
+ const normalized = normalizeEditorTag(tag);
1117
+ if (!normalized) return;
1118
+ const tags = [...(item.tagRule?.tags || [])];
1119
+ if (!tags.includes(normalized)) tags.push(normalized);
1120
+ item.tagRule = { match: match.value || "any", tags };
1121
+ if (input) input.value = "";
1122
+ renderTagRuleSelection();
1123
+ showTagRuleOptions();
1124
+ }
1125
+
1126
+ function removeTagFromOpenRule(tag) {
1127
+ const item = tagRuleModalItem();
1128
+ const match = document.getElementById("tagRuleMatch");
1129
+ if (!item || !match) return;
1130
+ const tags = (item.tagRule?.tags || []).filter((entry) => entry !== tag);
1131
+ item.tagRule = tags.length ? { match: match.value || item.tagRule?.match || "any", tags } : null;
1132
+ renderTagRuleSelection();
1133
+ showTagRuleOptions();
1134
+ }
1135
+
786
1136
  function loadEditorFromSelection() {
787
1137
  const pl = getSelectedPlaylist();
788
1138
  const nameInput = document.getElementById("playlistName");
@@ -815,7 +1165,9 @@ function loadEditorFromSelection() {
815
1165
  url: item.url,
816
1166
  name: item.url?.split("/").pop() || "asset",
817
1167
  type: item.type || "image",
818
- durationMs: normalizeDurationMs(item)
1168
+ durationMs: normalizeDurationMs(item),
1169
+ tagRule: editorTagRuleFromItem(item),
1170
+ schedule: editorScheduleFromItem(item)
819
1171
  }));
820
1172
  renderEditorAssets();
821
1173
  updatePlaylistEditorVisibility();
@@ -972,11 +1324,31 @@ function renderEditorAssets() {
972
1324
  renderEditorAssets();
973
1325
  });
974
1326
 
1327
+ const ruleSummary = document.createElement("p");
1328
+ ruleSummary.className = "playlist-item-rule-summary";
1329
+ const summaryLines = [];
1330
+ if (item.tagRule?.tags?.length) {
1331
+ const label = TAG_RULE_OPS.find((option) => option.value === item.tagRule.match)?.label || item.tagRule.match;
1332
+ summaryLines.push(`${label} ${item.tagRule.tags.join(", ")}`);
1333
+ }
1334
+ const when = formatEditorSchedule(item.schedule);
1335
+ if (when) summaryLines.push(when);
1336
+ if (summaryLines.length) ruleSummary.textContent = summaryLines.join("\n");
1337
+ else ruleSummary.classList.add("hidden");
1338
+
1339
+ const addRuleBtn = document.createElement("button");
1340
+ addRuleBtn.type = "button";
1341
+ addRuleBtn.className = "playlist-item-btn playlist-item-add-rule";
1342
+ addRuleBtn.textContent = "Add rule";
1343
+ addRuleBtn.addEventListener("click", () => openTagRuleModal(item));
1344
+
975
1345
  actions.appendChild(durInput);
976
1346
  actions.appendChild(downloadBtn);
977
1347
  actions.appendChild(removeBtn);
978
1348
  li.appendChild(name);
979
1349
  li.appendChild(meta);
1350
+ li.appendChild(ruleSummary);
1351
+ li.appendChild(addRuleBtn);
980
1352
  li.appendChild(actions);
981
1353
  attachPlaylistItemDragDrop(li, item);
982
1354
  list.appendChild(li);
@@ -996,12 +1368,19 @@ async function saveCurrentPlaylist() {
996
1368
 
997
1369
  let items;
998
1370
  try {
999
- items = editorItems.map((item) => ({
1000
- url: absoluteMediaUrl(item.url),
1001
- assetId: item.assetId,
1002
- type: item.type,
1003
- durationMs: item.durationMs
1004
- }));
1371
+ items = editorItems.map((item) => {
1372
+ const payload = {
1373
+ url: absoluteMediaUrl(item.url),
1374
+ assetId: item.assetId,
1375
+ type: item.type,
1376
+ durationMs: item.durationMs
1377
+ };
1378
+ if (item.tagRule?.match && item.tagRule.tags?.length) {
1379
+ payload.tagRule = { match: item.tagRule.match, tags: item.tagRule.tags };
1380
+ }
1381
+ if (item.schedule) payload.schedule = item.schedule;
1382
+ return payload;
1383
+ });
1005
1384
  } catch (err) {
1006
1385
  alert(err.message);
1007
1386
  return;
@@ -1041,7 +1420,7 @@ async function deleteCurrentPlaylist() {
1041
1420
  const pl = getSelectedPlaylist();
1042
1421
  if (
1043
1422
  !confirm(
1044
- `Delete playlist "${pl?.name}"? Devices already playing it keep their cached copy until Clear or reboot without sync. New devices cannot receive it.`
1423
+ `Delete playlist "${pl?.name}"? Devices already playing it keep their cached copy until Clear or reboot without sync. Clear removes it from the database once no screen still has it. New devices cannot receive it.`
1045
1424
  )
1046
1425
  ) {
1047
1426
  return;
@@ -1086,6 +1465,18 @@ function newPlaylistDraft() {
1086
1465
  });
1087
1466
  }
1088
1467
 
1468
+ async function fetchScreenTags() {
1469
+ const [screenRes, catalogRes] = await Promise.all([fetch("/screen-tags"), fetch("/tags")]);
1470
+ const screenData = await screenRes.json();
1471
+ if (screenRes.ok && screenData.tagsByDevice && typeof screenData.tagsByDevice === "object") {
1472
+ screenTagsByDevice = screenData.tagsByDevice;
1473
+ }
1474
+ if (catalogRes.ok) {
1475
+ const catalogData = await catalogRes.json();
1476
+ if (Array.isArray(catalogData.tags)) tagCatalog = catalogData.tags;
1477
+ }
1478
+ }
1479
+
1089
1480
  async function fetchDevices() {
1090
1481
  try {
1091
1482
  const res = await fetch("/devices");
@@ -1100,6 +1491,11 @@ async function fetchDevices() {
1100
1491
  }
1101
1492
  if (Array.isArray(data.devices)) {
1102
1493
  devicesCache = data.devices;
1494
+ try {
1495
+ await fetchScreenTags();
1496
+ } catch {
1497
+ // Tags are optional for the card list; the next poll retries.
1498
+ }
1103
1499
  renderDeviceCards();
1104
1500
  scheduleReconnectGraceRerender();
1105
1501
  }
@@ -1207,20 +1603,249 @@ function createDeviceScreenshotThumb(device) {
1207
1603
  return slot;
1208
1604
  }
1209
1605
 
1606
+ function showDeviceTagOptions(deviceId) {
1607
+ const input = document.querySelector(`[data-tag-input="${CSS.escape(deviceId)}"]`);
1608
+ const list = document.querySelector(`[data-tag-options="${CSS.escape(deviceId)}"]`);
1609
+ if (!(input instanceof HTMLInputElement) || !list) return;
1610
+ const query = String(input.value || "").trim().toLowerCase();
1611
+ const selected = new Set(screenTagsByDevice[deviceId] || []);
1612
+ const choices = knownScreenTags().filter((tag) => !selected.has(tag) && (!query || tag.includes(query)));
1613
+ const typed = normalizeEditorTag(input.value);
1614
+ list.innerHTML = "";
1615
+ for (const tag of choices) {
1616
+ const li = document.createElement("li");
1617
+ const button = document.createElement("button");
1618
+ button.type = "button";
1619
+ button.textContent = tag;
1620
+ button.addEventListener("mousedown", (event) => {
1621
+ event.preventDefault();
1622
+ tagDraftByDevice[deviceId] = tag;
1623
+ void addDeviceTag(deviceId);
1624
+ });
1625
+ li.appendChild(button);
1626
+ list.appendChild(li);
1627
+ }
1628
+ if (typed && !knownScreenTags().includes(typed) && !selected.has(typed)) {
1629
+ const li = document.createElement("li");
1630
+ const button = document.createElement("button");
1631
+ button.type = "button";
1632
+ button.textContent = `Create “${typed}”`;
1633
+ button.addEventListener("mousedown", (event) => {
1634
+ event.preventDefault();
1635
+ tagDraftByDevice[deviceId] = typed;
1636
+ void addDeviceTag(deviceId);
1637
+ });
1638
+ li.appendChild(button);
1639
+ list.appendChild(li);
1640
+ }
1641
+ list.classList.toggle("hidden", list.childElementCount === 0);
1642
+ }
1643
+
1644
+ let manageTagEditing = "";
1645
+
1646
+ async function openManageTagsModal() {
1647
+ manageTagEditing = "";
1648
+ await fetchScreenTags();
1649
+ renderManageTags();
1650
+ const error = document.getElementById("manageTagError");
1651
+ if (error) error.textContent = "";
1652
+ document.getElementById("manageTagsModal")?.classList.remove("hidden");
1653
+ document.getElementById("manageTagInput")?.focus();
1654
+ }
1655
+
1656
+ function closeManageTagsModal() {
1657
+ manageTagEditing = "";
1658
+ document.getElementById("manageTagsModal")?.classList.add("hidden");
1659
+ }
1660
+
1661
+ function renderManageTags() {
1662
+ const list = document.getElementById("manageTagList");
1663
+ if (!list) return;
1664
+ list.innerHTML = "";
1665
+ if (tagCatalog.length === 0) {
1666
+ const empty = document.createElement("li");
1667
+ empty.className = "manage-tag-empty";
1668
+ empty.textContent = "No tags yet.";
1669
+ list.appendChild(empty);
1670
+ return;
1671
+ }
1672
+ for (const tag of tagCatalog) {
1673
+ const li = document.createElement("li");
1674
+ li.className = "manage-tag-row";
1675
+ if (manageTagEditing === tag) {
1676
+ const input = document.createElement("input");
1677
+ input.type = "text";
1678
+ input.value = tag;
1679
+ input.className = "manage-tag-rename";
1680
+ const save = document.createElement("button");
1681
+ save.type = "button";
1682
+ save.className = "primary";
1683
+ save.textContent = "Save";
1684
+ save.addEventListener("click", () => void renameManagedTag(tag, input.value));
1685
+ const cancel = document.createElement("button");
1686
+ cancel.type = "button";
1687
+ cancel.textContent = "Cancel";
1688
+ cancel.addEventListener("click", () => {
1689
+ manageTagEditing = "";
1690
+ renderManageTags();
1691
+ });
1692
+ li.appendChild(input);
1693
+ li.appendChild(save);
1694
+ li.appendChild(cancel);
1695
+ } else {
1696
+ const name = document.createElement("span");
1697
+ name.textContent = tag;
1698
+ const edit = document.createElement("button");
1699
+ edit.type = "button";
1700
+ edit.textContent = "Edit";
1701
+ edit.addEventListener("click", () => {
1702
+ manageTagEditing = tag;
1703
+ renderManageTags();
1704
+ });
1705
+ const remove = document.createElement("button");
1706
+ remove.type = "button";
1707
+ remove.className = "danger";
1708
+ remove.textContent = "Delete";
1709
+ remove.addEventListener("click", () => void deleteManagedTag(tag));
1710
+ li.appendChild(name);
1711
+ li.appendChild(edit);
1712
+ li.appendChild(remove);
1713
+ }
1714
+ list.appendChild(li);
1715
+ }
1716
+ }
1717
+
1718
+ async function addManagedTag() {
1719
+ const input = document.getElementById("manageTagInput");
1720
+ const tag = normalizeEditorTag(input?.value || "");
1721
+ const error = document.getElementById("manageTagError");
1722
+ if (!tag) {
1723
+ if (error) error.textContent = "Use letters, numbers, hyphens, or underscores.";
1724
+ return;
1725
+ }
1726
+ if (tagCatalog.includes(tag)) {
1727
+ if (error) error.textContent = `Tag “${tag}” already exists.`;
1728
+ return;
1729
+ }
1730
+ if (!(await registerCatalogTag(tag))) return;
1731
+ if (input) input.value = "";
1732
+ if (error) error.textContent = "";
1733
+ renderManageTags();
1734
+ renderDeviceCards();
1735
+ }
1736
+
1737
+ async function renameManagedTag(from, rawNext) {
1738
+ const next = normalizeEditorTag(rawNext);
1739
+ const error = document.getElementById("manageTagError");
1740
+ if (!next) {
1741
+ if (error) error.textContent = "Use letters, numbers, hyphens, or underscores.";
1742
+ return;
1743
+ }
1744
+ if (next !== from && tagCatalog.includes(next)) {
1745
+ if (error) error.textContent = `Tag “${next}” already exists.`;
1746
+ return;
1747
+ }
1748
+ const res = await fetch(`/tags/${encodeURIComponent(from)}`, {
1749
+ method: "PATCH",
1750
+ headers: { "Content-Type": "application/json" },
1751
+ body: JSON.stringify({ tag: next })
1752
+ });
1753
+ const data = await res.json();
1754
+ if (!res.ok) {
1755
+ if (error) error.textContent = data.error || "Could not rename tag";
1756
+ return;
1757
+ }
1758
+ if (Array.isArray(data.tags)) tagCatalog = data.tags;
1759
+ manageTagEditing = "";
1760
+ if (error) error.textContent = "";
1761
+ await fetchPlaylists();
1762
+ await fetchDevices();
1763
+ if (isPlaylistEditorOpen()) loadEditorFromSelection();
1764
+ renderManageTags();
1765
+ }
1766
+
1767
+ async function deleteManagedTag(tag) {
1768
+ const error = document.getElementById("manageTagError");
1769
+ const res = await fetch(`/tags/${encodeURIComponent(tag)}`, { method: "DELETE" });
1770
+ const data = await res.json();
1771
+ if (!res.ok) {
1772
+ if (error) error.textContent = data.error || "Could not delete tag";
1773
+ return;
1774
+ }
1775
+ if (Array.isArray(data.tags)) tagCatalog = data.tags;
1776
+ createdScreenTags.delete(tag);
1777
+ if (error) error.textContent = "";
1778
+ renderManageTags();
1779
+ renderDeviceCards();
1780
+ }
1781
+
1782
+ async function addDeviceTag(deviceId) {
1783
+ const tag = normalizeEditorTag(tagDraftByDevice[deviceId] || "");
1784
+ if (!tag) {
1785
+ alert("Enter a tag such as screen-a, drive-thru, or breakfast.");
1786
+ return;
1787
+ }
1788
+ const res = await fetch(`/device/${encodeURIComponent(deviceId)}/tags`, {
1789
+ method: "POST",
1790
+ headers: { "Content-Type": "application/json" },
1791
+ body: JSON.stringify({ tag })
1792
+ });
1793
+ const data = await res.json();
1794
+ showResult(data);
1795
+ if (!res.ok) {
1796
+ alert(data.error || "Could not add tag");
1797
+ return;
1798
+ }
1799
+ delete tagDraftByDevice[deviceId];
1800
+ await fetchDevices();
1801
+ }
1802
+
1803
+ async function removeDeviceTag(deviceId, tag) {
1804
+ const res = await fetch(
1805
+ `/device/${encodeURIComponent(deviceId)}/tags/${encodeURIComponent(tag)}`,
1806
+ { method: "DELETE" }
1807
+ );
1808
+ const data = await res.json();
1809
+ showResult(data);
1810
+ if (!res.ok) {
1811
+ alert(data.error || "Could not remove tag");
1812
+ return;
1813
+ }
1814
+ await fetchDevices();
1815
+ }
1816
+
1210
1817
  function renderDeviceCards() {
1211
1818
  const grid = document.getElementById("devicesGrid");
1212
1819
  if (!grid) return;
1820
+ renderingDeviceCards = true;
1213
1821
  grid.innerHTML = "";
1214
1822
 
1823
+ const filterTag = normalizeEditorTag(document.getElementById("screenTagFilter")?.value || "");
1824
+ const visibleDevices = filterTag
1825
+ ? devicesCache.filter((device) =>
1826
+ (screenTagsByDevice[device.deviceId] || []).includes(filterTag)
1827
+ )
1828
+ : devicesCache;
1829
+
1215
1830
  if (devicesCache.length === 0) {
1216
1831
  const empty = document.createElement("p");
1217
1832
  empty.className = "devices-empty";
1218
1833
  empty.textContent = "No paired devices yet.";
1219
1834
  grid.appendChild(empty);
1835
+ renderingDeviceCards = false;
1220
1836
  return;
1221
1837
  }
1222
1838
 
1223
- for (const device of devicesCache) {
1839
+ if (visibleDevices.length === 0) {
1840
+ const empty = document.createElement("p");
1841
+ empty.className = "devices-empty";
1842
+ empty.textContent = `No screens tagged ${filterTag}.`;
1843
+ grid.appendChild(empty);
1844
+ renderingDeviceCards = false;
1845
+ return;
1846
+ }
1847
+
1848
+ for (const device of visibleDevices) {
1224
1849
  const card = document.createElement("article");
1225
1850
  card.className = "device-card";
1226
1851
 
@@ -1428,12 +2053,100 @@ function renderDeviceCards() {
1428
2053
  actions.appendChild(timerBtn);
1429
2054
  actions.appendChild(unpairBtn);
1430
2055
 
2056
+ const tagsBlock = document.createElement("div");
2057
+ tagsBlock.className = "device-tags";
2058
+ const tagsLabel = document.createElement("div");
2059
+ tagsLabel.className = "device-tags-label";
2060
+ tagsLabel.textContent = "Screen tags";
2061
+ const chips = document.createElement("div");
2062
+ chips.className = "device-tag-chips";
2063
+ const deviceTags = screenTagsByDevice[device.deviceId] || [];
2064
+ if (deviceTags.length === 0) {
2065
+ const none = document.createElement("span");
2066
+ none.className = "device-tag-empty";
2067
+ none.textContent = "No tags — plays items with no screen rule.";
2068
+ chips.appendChild(none);
2069
+ } else {
2070
+ for (const tag of deviceTags) {
2071
+ const chip = document.createElement("button");
2072
+ chip.type = "button";
2073
+ chip.className = "device-tag-chip";
2074
+ chip.textContent = `${tag} ×`;
2075
+ chip.title = `Remove ${tag}`;
2076
+ chip.addEventListener("click", () => void removeDeviceTag(device.deviceId, tag));
2077
+ chips.appendChild(chip);
2078
+ }
2079
+ }
2080
+ const tagRow = document.createElement("div");
2081
+ tagRow.className = "device-tag-add";
2082
+ const picker = document.createElement("div");
2083
+ picker.className = "device-tag-picker";
2084
+ const tagInput = document.createElement("input");
2085
+ tagInput.type = "text";
2086
+ tagInput.placeholder = "Add a tag";
2087
+ tagInput.autocomplete = "off";
2088
+ tagInput.dataset.tagInput = device.deviceId;
2089
+ tagInput.value = tagDraftByDevice[device.deviceId] || "";
2090
+ const tagOptions = document.createElement("ul");
2091
+ tagOptions.className = "tag-rule-options hidden";
2092
+ tagOptions.dataset.tagOptions = device.deviceId;
2093
+ const showOptions = () => showDeviceTagOptions(device.deviceId);
2094
+ tagInput.addEventListener("input", () => {
2095
+ tagDraftByDevice[device.deviceId] = tagInput.value;
2096
+ showOptions();
2097
+ });
2098
+ tagInput.addEventListener("focus", () => {
2099
+ tagInputFocusDeviceId = device.deviceId;
2100
+ showOptions();
2101
+ });
2102
+ tagInput.addEventListener("click", showOptions);
2103
+ tagInput.addEventListener("blur", () => {
2104
+ if (!renderingDeviceCards && tagInputFocusDeviceId === device.deviceId) {
2105
+ tagInputFocusDeviceId = null;
2106
+ }
2107
+ setTimeout(() => {
2108
+ if (tagInputFocusDeviceId !== device.deviceId) tagOptions.classList.add("hidden");
2109
+ }, 150);
2110
+ });
2111
+ tagInput.addEventListener("keydown", (ev) => {
2112
+ if (ev.key === "Escape") {
2113
+ tagOptions.classList.add("hidden");
2114
+ return;
2115
+ }
2116
+ if (ev.key === "Enter") {
2117
+ ev.preventDefault();
2118
+ void addDeviceTag(device.deviceId);
2119
+ }
2120
+ });
2121
+ picker.appendChild(tagInput);
2122
+ picker.appendChild(tagOptions);
2123
+ const addTagBtn = document.createElement("button");
2124
+ addTagBtn.type = "button";
2125
+ addTagBtn.textContent = "Add tag";
2126
+ addTagBtn.addEventListener("click", () => void addDeviceTag(device.deviceId));
2127
+ tagRow.appendChild(picker);
2128
+ tagRow.appendChild(addTagBtn);
2129
+ tagsBlock.appendChild(tagsLabel);
2130
+ tagsBlock.appendChild(chips);
2131
+ tagsBlock.appendChild(tagRow);
2132
+
1431
2133
  card.appendChild(header);
1432
2134
  card.appendChild(published);
1433
2135
  card.appendChild(metaBlock);
2136
+ card.appendChild(tagsBlock);
1434
2137
  card.appendChild(actions);
1435
2138
  grid.appendChild(card);
1436
2139
  }
2140
+
2141
+ renderingDeviceCards = false;
2142
+ if (tagInputFocusDeviceId) {
2143
+ const el = grid.querySelector(`[data-tag-input="${CSS.escape(tagInputFocusDeviceId)}"]`);
2144
+ if (el instanceof HTMLInputElement) {
2145
+ el.focus();
2146
+ const end = el.value.length;
2147
+ el.setSelectionRange(end, end);
2148
+ }
2149
+ }
1437
2150
  }
1438
2151
 
1439
2152
  function openPublishModal(deviceId) {
@@ -2000,7 +2713,9 @@ async function addAssetFromFile(file, queueIndex, queueTotal) {
2000
2713
  url: data.url,
2001
2714
  name: file.name,
2002
2715
  type,
2003
- durationMs
2716
+ durationMs,
2717
+ tagRule: null,
2718
+ schedule: null
2004
2719
  });
2005
2720
  renderEditorAssets();
2006
2721
  showResult({
@@ -2650,6 +3365,18 @@ document.addEventListener("DOMContentLoaded", () => {
2650
3365
  ?.addEventListener("click", () => void fetchServerStatus());
2651
3366
 
2652
3367
  document.getElementById("newPlaylistBtn")?.addEventListener("click", newPlaylistDraft);
3368
+ document.getElementById("screenTagFilter")?.addEventListener("input", () => renderDeviceCards());
3369
+ document.getElementById("manageTagsBtn")?.addEventListener("click", () => void openManageTagsModal());
3370
+ document.querySelectorAll("[data-close-manage-tags]").forEach((el) => {
3371
+ el.addEventListener("click", closeManageTagsModal);
3372
+ });
3373
+ document.getElementById("manageTagAddBtn")?.addEventListener("click", () => void addManagedTag());
3374
+ document.getElementById("manageTagInput")?.addEventListener("keydown", (event) => {
3375
+ if (event.key === "Enter") {
3376
+ event.preventDefault();
3377
+ void addManagedTag();
3378
+ }
3379
+ });
2653
3380
  document.getElementById("savePlaylistBtn")?.addEventListener("click", () => void saveCurrentPlaylist());
2654
3381
  document.getElementById("deletePlaylistBtn")?.addEventListener("click", () => void deleteCurrentPlaylist());
2655
3382
  document.getElementById("publishConfirmBtn")?.addEventListener("click", () => void confirmPublishModal());
@@ -2657,6 +3384,50 @@ document.addEventListener("DOMContentLoaded", () => {
2657
3384
  document.querySelectorAll("[data-close-modal]").forEach((el) => {
2658
3385
  el.addEventListener("click", closePublishModal);
2659
3386
  });
3387
+ document.querySelectorAll("[data-close-tag-rule-modal]").forEach((el) => {
3388
+ el.addEventListener("click", cancelTagRuleModal);
3389
+ });
3390
+ document.getElementById("tagRuleCancelBtn")?.addEventListener("click", cancelTagRuleModal);
3391
+ document.getElementById("tagRuleApplyBtn")?.addEventListener("click", applyTagRuleModal);
3392
+ document.getElementById("tagRuleMatch")?.addEventListener("change", () => {
3393
+ const item = tagRuleModalItem();
3394
+ const match = document.getElementById("tagRuleMatch");
3395
+ if (!item?.tagRule?.tags?.length || !match) return;
3396
+ item.tagRule = { match: match.value, tags: item.tagRule.tags };
3397
+ });
3398
+ const tagRuleAddInput = document.getElementById("tagRuleAddInput");
3399
+ tagRuleAddInput?.addEventListener("focus", showTagRuleOptions);
3400
+ tagRuleAddInput?.addEventListener("click", showTagRuleOptions);
3401
+ tagRuleAddInput?.addEventListener("input", showTagRuleOptions);
3402
+ tagRuleAddInput?.addEventListener("keydown", async (event) => {
3403
+ if (event.key === "Escape") {
3404
+ hideTagRuleOptions();
3405
+ return;
3406
+ }
3407
+ if (event.key !== "Enter") return;
3408
+ event.preventDefault();
3409
+ const typed = normalizeEditorTag(tagRuleAddInput.value);
3410
+ if (!typed) return;
3411
+ if (!knownScreenTags().includes(typed)) {
3412
+ if (!(await registerCatalogTag(typed))) return;
3413
+ }
3414
+ addTagToOpenRule(typed);
3415
+ });
3416
+ tagRuleAddInput?.addEventListener("blur", () => {
3417
+ setTimeout(hideTagRuleOptions, 150);
3418
+ });
3419
+ ["itemScheduleStartDate", "itemScheduleEndDate", "itemScheduleStartTime", "itemScheduleEndTime"].forEach((id) => {
3420
+ document.getElementById(id)?.addEventListener("input", applyItemScheduleFromForm);
3421
+ document.getElementById(id)?.addEventListener("change", applyItemScheduleFromForm);
3422
+ });
3423
+ document.querySelectorAll("[data-item-schedule-day]").forEach((button) => {
3424
+ button.addEventListener("click", () => {
3425
+ const on = button.getAttribute("aria-pressed") !== "true";
3426
+ button.setAttribute("aria-pressed", on ? "true" : "false");
3427
+ button.classList.toggle("is-on", on);
3428
+ applyItemScheduleFromForm();
3429
+ });
3430
+ });
2660
3431
  document.querySelectorAll("[data-close-screenshot-modal]").forEach((el) => {
2661
3432
  el.addEventListener("click", closeScreenshotModal);
2662
3433
  });