@camstack/ui-library 1.2.222 → 1.2.224

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/index.cjs CHANGED
@@ -6758,6 +6758,12 @@ var useBrokerGetState = trpc.broker.getState.useQuery;
6758
6758
  var useBrokerGetStatus = trpc.broker.getStatus.useQuery;
6759
6759
  /** Generated alias around `trpc.button.press.useMutation`. */
6760
6760
  var useButtonPress = trpc.button.press.useMutation;
6761
+ /** Generated alias around `trpc.calendars.listCalendars.useQuery`. */
6762
+ var useCalendarsListCalendars = trpc.calendars.listCalendars.useQuery;
6763
+ /** Generated alias around `trpc.calendars.listEvents.useQuery`. */
6764
+ var useCalendarsListEvents = trpc.calendars.listEvents.useQuery;
6765
+ /** Generated alias around `trpc.calendars.importFromHomeAssistant.useMutation`. */
6766
+ var useCalendarsImportFromHomeAssistant = trpc.calendars.importFromHomeAssistant.useMutation;
6761
6767
  /** Generated alias around `trpc.cameraCredentials.getCredentials.useQuery`. */
6762
6768
  var useCameraCredentialsGetCredentials = trpc.cameraCredentials.getCredentials.useQuery;
6763
6769
  /** Generated alias around `trpc.cameraCredentials.getStatus.useQuery`. */
@@ -7670,6 +7676,12 @@ var useOsdManagerCopyDeviceConfiguration = trpc.osdManager.copyDeviceConfigurati
7670
7676
  var useOsdManagerPreviewSlot = trpc.osdManager.previewSlot.useMutation;
7671
7677
  /** Generated alias around `trpc.osdManager.renderDevice.useMutation`. */
7672
7678
  var useOsdManagerRenderDevice = trpc.osdManager.renderDevice.useMutation;
7679
+ /** Generated alias around `trpc.osdManager.listTemplates.useQuery`. */
7680
+ var useOsdManagerListTemplates = trpc.osdManager.listTemplates.useQuery;
7681
+ /** Generated alias around `trpc.osdManager.upsertTemplate.useMutation`. */
7682
+ var useOsdManagerUpsertTemplate = trpc.osdManager.upsertTemplate.useMutation;
7683
+ /** Generated alias around `trpc.osdManager.deleteTemplate.useMutation`. */
7684
+ var useOsdManagerDeleteTemplate = trpc.osdManager.deleteTemplate.useMutation;
7673
7685
  /** Generated alias around `trpc.petFeeder.feed.useMutation`. */
7674
7686
  var usePetFeederFeed = trpc.petFeeder.feed.useMutation;
7675
7687
  /** Generated alias around `trpc.petFeeder.cancelFeed.useMutation`. */
@@ -12355,8 +12367,8 @@ function selectedDeviceOptions(options, selected) {
12355
12367
  * renders the shared form field gets them without extra registration.
12356
12368
  */
12357
12369
  var INPUT_CLASS$1 = "w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-xs text-foreground focus:border-primary focus:ring-1 focus:ring-primary/30 outline-none disabled:opacity-50 disabled:cursor-not-allowed";
12358
- var INPUT_SHELL = "flex items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1.5 focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30";
12359
- var LABEL_CLASS$1 = "block text-[11px] font-medium text-foreground mb-1 break-words";
12370
+ var INPUT_SHELL$1 = "flex items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1.5 focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30";
12371
+ var LABEL_CLASS$2 = "block text-[11px] font-medium text-foreground mb-1 break-words";
12360
12372
  var DESC_CLASS$1 = "text-[10px] text-foreground-subtle mt-0.5 break-words";
12361
12373
  /** The compact projection of the listAll rows the device fields consume. */
12362
12374
  function toCandidates(rows) {
@@ -12393,7 +12405,7 @@ var FACET_ITEM = "flex h-7 w-full items-center gap-2 px-2 text-left text-xs text
12393
12405
  * The operator asked for dropdowns over rows of chips (2026-09-05): with
12394
12406
  * sixteen rooms and a dozen types the chips ate the panel.
12395
12407
  */
12396
- function FacetDropdown({ label, values, chosen, onChange }) {
12408
+ function FacetDropdown$1({ label, values, chosen, onChange }) {
12397
12409
  if (values.length < 2) return null;
12398
12410
  const summary = chosen.length === 0 ? "All" : chosen.join(", ");
12399
12411
  const toggle = (value) => {
@@ -12451,7 +12463,7 @@ function FieldShell({ label, description, required, translationFn, children }) {
12451
12463
  className: "min-w-0",
12452
12464
  children: [
12453
12465
  resolvedLabel !== void 0 && resolvedLabel !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
12454
- className: LABEL_CLASS$1,
12466
+ className: LABEL_CLASS$2,
12455
12467
  children: [resolvedLabel, required && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
12456
12468
  className: "text-danger ml-0.5",
12457
12469
  children: "*"
@@ -12547,7 +12559,7 @@ function DeviceMultiSelectField({ field, value, onChange, disabled, translationF
12547
12559
  className: "min-w-0",
12548
12560
  children: [
12549
12561
  label !== void 0 && label !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
12550
- className: LABEL_CLASS$1,
12562
+ className: LABEL_CLASS$2,
12551
12563
  children: label
12552
12564
  }),
12553
12565
  selectedOptions.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -12568,7 +12580,7 @@ function DeviceMultiSelectField({ field, value, onChange, disabled, translationF
12568
12580
  }),
12569
12581
  smart && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
12570
12582
  className: "mb-1.5 flex flex-wrap gap-1.5",
12571
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(FacetDropdown, {
12583
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(FacetDropdown$1, {
12572
12584
  label: "Location",
12573
12585
  values: facetValues.locations,
12574
12586
  chosen: facets.locations,
@@ -12576,7 +12588,7 @@ function DeviceMultiSelectField({ field, value, onChange, disabled, translationF
12576
12588
  ...f,
12577
12589
  locations
12578
12590
  }))
12579
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FacetDropdown, {
12591
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FacetDropdown$1, {
12580
12592
  label: "Type",
12581
12593
  values: facetValues.types,
12582
12594
  chosen: facets.types,
@@ -12587,7 +12599,7 @@ function DeviceMultiSelectField({ field, value, onChange, disabled, translationF
12587
12599
  })]
12588
12600
  }),
12589
12601
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
12590
- className: INPUT_SHELL + (isDisabled ? " opacity-50" : ""),
12602
+ className: INPUT_SHELL$1 + (isDisabled ? " opacity-50" : ""),
12591
12603
  children: [
12592
12604
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Search, { className: "h-3.5 w-3.5 shrink-0 text-foreground-subtle" }),
12593
12605
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
@@ -25810,7 +25822,7 @@ function TimezoneSelector({ value, onChange, disabled }) {
25810
25822
  //#endregion
25811
25823
  //#region src/composites/config-form-field.tsx
25812
25824
  var INPUT_CLASS = "w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-xs text-foreground focus:border-primary focus:ring-1 focus:ring-primary/30 outline-none disabled:opacity-50 disabled:cursor-not-allowed";
25813
- var LABEL_CLASS = "block text-[11px] font-medium text-foreground mb-1 break-words";
25825
+ var LABEL_CLASS$1 = "block text-[11px] font-medium text-foreground mb-1 break-words";
25814
25826
  var DESC_CLASS = "text-[10px] text-foreground-subtle mt-0.5 break-words";
25815
25827
  function FieldWrapper({ label, description, required, span, children, translationFn }) {
25816
25828
  const colSpanClass = span === 2 ? "col-span-1 @[480px]:col-span-2" : span === 3 ? "col-span-1 @[480px]:col-span-2 @[880px]:col-span-3" : span === 4 ? "col-span-1 @[480px]:col-span-2 @[1140px]:col-span-4" : "col-span-1";
@@ -25821,7 +25833,7 @@ function FieldWrapper({ label, description, required, span, children, translatio
25821
25833
  className: `${colSpanClass} min-w-0`,
25822
25834
  children: [
25823
25835
  hasLabel && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
25824
- className: LABEL_CLASS,
25836
+ className: LABEL_CLASS$1,
25825
25837
  children: [resolvedLabel, required && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
25826
25838
  className: "text-danger ml-0.5",
25827
25839
  children: "*"
@@ -26511,7 +26523,7 @@ function SubTabsField({ field, values, onChange, disabled, translationFn, onTest
26511
26523
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26512
26524
  className: `${colSpanClass} min-w-0`,
26513
26525
  children: [field.label && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26514
- className: LABEL_CLASS,
26526
+ className: LABEL_CLASS$1,
26515
26527
  children: resolveLabel(field.label, translationFn)
26516
26528
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26517
26529
  className: "@container rounded-lg border border-border-subtle bg-surface-hover/40 overflow-hidden",
@@ -27309,7 +27321,7 @@ function MultipleFieldWrapper({ field, values, onChange, disabled, translationFn
27309
27321
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
27310
27322
  className: "space-y-1",
27311
27323
  children: [itemLabel && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
27312
- className: LABEL_CLASS,
27324
+ className: LABEL_CLASS$1,
27313
27325
  children: itemLabel
27314
27326
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
27315
27327
  className: "flex items-center gap-2",
@@ -50579,7 +50591,7 @@ function SectionRows({ title, count, open, colSpan, onToggle, rows, trpc, parent
50579
50591
  //#region src/composites/device-list/sort.ts
50580
50592
  /** Human-facing type label used for the `type` comparator — matches the label
50581
50593
  * the row renders, so the sort order reads as the user sees it. */
50582
- function typeLabel(device) {
50594
+ function typeLabel$1(device) {
50583
50595
  return deviceTypeMetaOf(device.type).label.toLowerCase();
50584
50596
  }
50585
50597
  function nameKey(device) {
@@ -50588,7 +50600,7 @@ function nameKey(device) {
50588
50600
  /** Ascending comparator for a given sortable column. */
50589
50601
  function ascComparator(columnId) {
50590
50602
  if (columnId === "name") return (a, b) => nameKey(a).localeCompare(nameKey(b));
50591
- if (columnId === "type") return (a, b) => typeLabel(a).localeCompare(typeLabel(b));
50603
+ if (columnId === "type") return (a, b) => typeLabel$1(a).localeCompare(typeLabel$1(b));
50592
50604
  return compareHardware;
50593
50605
  }
50594
50606
  /**
@@ -55744,6 +55756,1366 @@ function VersionBadge({ version, preRelease, className }) {
55744
55756
  });
55745
55757
  }
55746
55758
  //#endregion
55759
+ //#region src/composites/device-picker/SelectedChip.tsx
55760
+ /**
55761
+ * Removable selection chip + the search-box shell shared by DevicePicker and
55762
+ * SearchableMultiSelectField (admin-ui). Lives here so addon MFEs can render
55763
+ * the same picker without importing admin-ui.
55764
+ */
55765
+ /** The search box's shell — shared with multiselect fields that mirror this picker. */
55766
+ var INPUT_SHELL = "flex items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1.5 focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30";
55767
+ /** One selected entry as a removable chip. */
55768
+ function SelectedChip({ label, onRemove, warning = false, iconId, testId }) {
55769
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
55770
+ "data-testid": testId,
55771
+ className: warning ? "inline-flex items-center gap-1 rounded-md bg-warning/10 px-1.5 py-0.5 text-[11px] text-warning max-w-full" : "inline-flex items-center gap-1 rounded-md bg-primary/10 px-1.5 py-0.5 text-[11px] text-primary max-w-full",
55772
+ children: [
55773
+ iconId !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EventKindGlyph, {
55774
+ iconId,
55775
+ size: 12
55776
+ }),
55777
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55778
+ className: "truncate",
55779
+ children: label
55780
+ }),
55781
+ onRemove !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
55782
+ type: "button",
55783
+ className: "shrink-0 hover:text-danger",
55784
+ "aria-label": `Remove ${label}`,
55785
+ onClick: onRemove,
55786
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(X, { className: "h-2.5 w-2.5" })
55787
+ })
55788
+ ]
55789
+ });
55790
+ }
55791
+ //#endregion
55792
+ //#region src/composites/device-picker/device-picker-model.ts
55793
+ /** Numbers numerically, anything else by its text — the name tie-break. */
55794
+ function compareIds(a, b) {
55795
+ if (typeof a === "number" && typeof b === "number") return a - b;
55796
+ return String(a).localeCompare(String(b));
55797
+ }
55798
+ /** How long the search waits after the last keystroke. */
55799
+ var PICKER_SEARCH_DEBOUNCE_MS = 200;
55800
+ /** A group with more devices than this starts collapsed (unless searching). */
55801
+ var PICKER_GROUP_COLLAPSE_AT = 8;
55802
+ /** How many selected chips are drawn before "+N more". */
55803
+ var PICKER_CHIP_LIMIT = 6;
55804
+ /** Rows a capped render draws per "show more". */
55805
+ var PICKER_PAGE_SIZE = 60;
55806
+ /** Rows a virtualised list renders per batch and initially. */
55807
+ var PICKER_WINDOW = 30;
55808
+ /** Everything a query can match on, lower-cased. */
55809
+ function pickerSearchText(device) {
55810
+ return `${[
55811
+ device.name,
55812
+ device.location ?? "",
55813
+ device.type ?? "",
55814
+ device.detail ?? ""
55815
+ ].join(" ")} ${String(device.id)}`.toLowerCase();
55816
+ }
55817
+ /** The query's tokens; empty for a blank query. */
55818
+ function pickerQueryTokens(query) {
55819
+ return query.trim().toLowerCase().split(/\s+/).filter((token) => token.length > 0);
55820
+ }
55821
+ /** Devices matching EVERY token. A blank query keeps the list as it is. */
55822
+ function filterPickerDevices(devices, query) {
55823
+ const tokens = pickerQueryTokens(query);
55824
+ if (tokens.length === 0) return devices;
55825
+ return devices.filter((device) => {
55826
+ const hay = pickerSearchText(device);
55827
+ return tokens.every((token) => hay.includes(token));
55828
+ });
55829
+ }
55830
+ function compareNullableLast(a, b) {
55831
+ if (a === b) return 0;
55832
+ if (a === null) return 1;
55833
+ if (b === null) return -1;
55834
+ return a.localeCompare(b);
55835
+ }
55836
+ function blankToNull(value) {
55837
+ return value === void 0 || value === null || value.trim() === "" ? null : value;
55838
+ }
55839
+ /** The key a group is remembered by (collapsed / expanded). */
55840
+ function pickerGroupKey(type, location) {
55841
+ return `${type ?? ""}|${location ?? ""}`;
55842
+ }
55843
+ /** Group by type, then room; both ascending, "none" last; devices by name. */
55844
+ function groupPickerDevices(devices, selected) {
55845
+ const picked = new Set(selected);
55846
+ const buckets = /* @__PURE__ */ new Map();
55847
+ for (const device of devices) {
55848
+ const type = blankToNull(device.type);
55849
+ const location = blankToNull(device.location);
55850
+ const key = pickerGroupKey(type, location);
55851
+ const bucket = buckets.get(key);
55852
+ if (bucket === void 0) buckets.set(key, {
55853
+ type,
55854
+ location,
55855
+ devices: [device]
55856
+ });
55857
+ else bucket.devices.push(device);
55858
+ }
55859
+ const groups = [];
55860
+ for (const [key, bucket] of buckets) {
55861
+ const sorted = [...bucket.devices].sort((a, b) => a.name.localeCompare(b.name) || compareIds(a.id, b.id));
55862
+ groups.push({
55863
+ key,
55864
+ type: bucket.type,
55865
+ location: bucket.location,
55866
+ devices: sorted,
55867
+ selectedCount: sorted.filter((device) => picked.has(device.id)).length
55868
+ });
55869
+ }
55870
+ return groups.sort((a, b) => compareNullableLast(a.type, b.type) || compareNullableLast(a.location, b.location));
55871
+ }
55872
+ /**
55873
+ * Whether a group is open. The operator's own toggle wins; otherwise a small
55874
+ * group is open, a big one is closed — unless a search is running (the result
55875
+ * IS what they asked to see) or it holds a selected device.
55876
+ */
55877
+ function isGroupExpanded(group, toggles, searching) {
55878
+ const own = toggles.get(group.key);
55879
+ if (own !== void 0) return own;
55880
+ if (searching || group.selectedCount > 0) return true;
55881
+ return group.devices.length <= 8;
55882
+ }
55883
+ /** The groups as ONE list of rows — what a virtualised list renders. */
55884
+ function flattenPickerRows(groups, selected, toggles, searching) {
55885
+ const picked = new Set(selected);
55886
+ const rows = [];
55887
+ for (const group of groups) {
55888
+ const expanded = isGroupExpanded(group, toggles, searching);
55889
+ rows.push({
55890
+ kind: "header",
55891
+ key: `h:${group.key}`,
55892
+ group,
55893
+ expanded
55894
+ });
55895
+ if (!expanded) continue;
55896
+ for (const device of group.devices) rows.push({
55897
+ kind: "device",
55898
+ key: `d:${String(device.id)}`,
55899
+ device,
55900
+ groupKey: group.key,
55901
+ selected: picked.has(device.id)
55902
+ });
55903
+ }
55904
+ return rows;
55905
+ }
55906
+ /** Tick or untick one id. The order of the rest is kept. */
55907
+ function togglePickerDevice(value, id) {
55908
+ return value.includes(id) ? value.filter((v) => v !== id) : [...value, id];
55909
+ }
55910
+ /** Add every device of the group (only the ones the current search shows). */
55911
+ function selectPickerGroup(value, group) {
55912
+ const next = [...value];
55913
+ const have = new Set(value);
55914
+ for (const device of group.devices) if (!have.has(device.id)) next.push(device.id);
55915
+ return next;
55916
+ }
55917
+ /** Remove every device of the group; ids outside it are untouched. */
55918
+ function clearPickerGroup(value, group) {
55919
+ const drop = new Set(group.devices.map((device) => device.id));
55920
+ return value.filter((id) => !drop.has(id));
55921
+ }
55922
+ function chipOverflow(items, expanded) {
55923
+ if (expanded || items.length <= 6) return {
55924
+ shown: items,
55925
+ hidden: 0
55926
+ };
55927
+ return {
55928
+ shown: items.slice(0, 6),
55929
+ hidden: items.length - 6
55930
+ };
55931
+ }
55932
+ function pageRows(rows, shown) {
55933
+ const count = Math.max(0, Math.min(rows.length, shown));
55934
+ return {
55935
+ rows: rows.slice(0, count),
55936
+ remaining: rows.length - count
55937
+ };
55938
+ }
55939
+ /** The modes, in the order the control draws them. */
55940
+ var PICKER_GROUP_BY_MODES = [
55941
+ "zone",
55942
+ "location",
55943
+ "none"
55944
+ ];
55945
+ /** What an operator who never chose gets. */
55946
+ var PICKER_DEFAULT_GROUP_BY = "zone";
55947
+ /** A stored choice; anything unreadable is the default. */
55948
+ function parsePickerGroupBy(raw) {
55949
+ return raw === "zone" || raw === "location" || raw === "none" ? raw : PICKER_DEFAULT_GROUP_BY;
55950
+ }
55951
+ /** The folded key a location label joins a zone by — `siteZoneKey`'s fold. */
55952
+ function pickerLocationKey(label) {
55953
+ return label.trim().toLowerCase();
55954
+ }
55955
+ var NO_ZONE_RANK = 1;
55956
+ var NO_LOCATION_RANK = 2;
55957
+ function byName(a, b) {
55958
+ return a.name.localeCompare(b.name) || compareIds(a.id, b.id);
55959
+ }
55960
+ function headingLabel(heading) {
55961
+ return heading.kind === "zone" || heading.kind === "location" ? heading.label : "";
55962
+ }
55963
+ var NO_LOCATION = {
55964
+ key: "-location",
55965
+ heading: { kind: "no-location" },
55966
+ rank: NO_LOCATION_RANK
55967
+ };
55968
+ var NO_ZONE = {
55969
+ key: "-zone",
55970
+ heading: { kind: "no-zone" },
55971
+ rank: NO_ZONE_RANK
55972
+ };
55973
+ /** Every zone by the folded location keys it holds; the first zone wins a key. */
55974
+ function zoneByLocation(zones) {
55975
+ const out = /* @__PURE__ */ new Map();
55976
+ for (const zone of zones) for (const key of zone.locationKeys) {
55977
+ const folded = pickerLocationKey(key);
55978
+ if (!out.has(folded)) out.set(folded, zone);
55979
+ }
55980
+ return out;
55981
+ }
55982
+ function placeByLocation(location) {
55983
+ if (location === null) return NO_LOCATION;
55984
+ return {
55985
+ key: `l:${pickerLocationKey(location)}`,
55986
+ heading: {
55987
+ kind: "location",
55988
+ label: location.trim()
55989
+ },
55990
+ rank: 0
55991
+ };
55992
+ }
55993
+ function placeByZone(location, zones) {
55994
+ if (location === null) return NO_LOCATION;
55995
+ const zone = zones.get(pickerLocationKey(location));
55996
+ if (zone === void 0) return NO_ZONE;
55997
+ return {
55998
+ key: `z:${zone.zoneKey}`,
55999
+ heading: {
56000
+ kind: "zone",
56001
+ label: zone.label
56002
+ },
56003
+ rank: 0
56004
+ };
56005
+ }
56006
+ /**
56007
+ * Group the list by zone, location or nothing.
56008
+ *
56009
+ * Zone: a group per site zone that holds a listed device (by label), then
56010
+ * "No zone" (a location no zone holds), then "No location". Location: a group
56011
+ * per location (folded; named after the first device's trimmed spelling), then "No
56012
+ * location". None: one flat list by name, then id.
56013
+ *
56014
+ * A zone read that is pending or failed can NOT say a location is in no zone
56015
+ * (D315), so Zone mode then groups by location and returns the reason as
56016
+ * {@link PickerGrouping.fallback} for the UI's one-line note.
56017
+ */
56018
+ function groupPickerDevicesBy(devices, selected, groupBy, zones) {
56019
+ const picked = new Set(selected);
56020
+ const sorted = [...devices].sort(byName);
56021
+ const count = (list) => list.filter((device) => picked.has(device.id)).length;
56022
+ if (groupBy === "none") return {
56023
+ groupBy,
56024
+ fallback: null,
56025
+ flat: true,
56026
+ groups: [{
56027
+ key: "all",
56028
+ type: null,
56029
+ location: null,
56030
+ devices: sorted,
56031
+ selectedCount: count(sorted)
56032
+ }]
56033
+ };
56034
+ const fallback = groupBy === "zone" && zones.state !== "answered" ? zones.state : null;
56035
+ const effective = fallback === null ? groupBy : "location";
56036
+ const zoneMap = zones.state === "answered" ? zoneByLocation(zones.zones) : /* @__PURE__ */ new Map();
56037
+ const buckets = /* @__PURE__ */ new Map();
56038
+ for (const device of sorted) {
56039
+ const location = blankToNull(device.location);
56040
+ const place = effective === "zone" ? placeByZone(location, zoneMap) : placeByLocation(location);
56041
+ const bucket = buckets.get(place.key);
56042
+ if (bucket === void 0) buckets.set(place.key, {
56043
+ heading: place.heading,
56044
+ rank: place.rank,
56045
+ devices: [device]
56046
+ });
56047
+ else bucket.devices.push(device);
56048
+ }
56049
+ return {
56050
+ groupBy: effective,
56051
+ fallback,
56052
+ flat: false,
56053
+ groups: [...buckets].sort(([, a], [, b]) => a.rank - b.rank || headingLabel(a.heading).localeCompare(headingLabel(b.heading))).map(([key, bucket]) => ({
56054
+ key,
56055
+ type: null,
56056
+ location: bucket.heading.kind === "location" ? bucket.heading.label : null,
56057
+ devices: bucket.devices,
56058
+ selectedCount: count(bucket.devices),
56059
+ heading: bucket.heading
56060
+ }))
56061
+ };
56062
+ }
56063
+ /** The grouping as rows: a flat grouping draws no header and is always open. */
56064
+ function flattenPickerGrouping(grouping, selected, toggles, searching) {
56065
+ if (!grouping.flat) return flattenPickerRows(grouping.groups, selected, toggles, searching);
56066
+ const picked = new Set(selected);
56067
+ const rows = [];
56068
+ for (const group of grouping.groups) for (const device of group.devices) rows.push({
56069
+ kind: "device",
56070
+ key: `d:${String(device.id)}`,
56071
+ device,
56072
+ groupKey: group.key,
56073
+ selected: picked.has(device.id)
56074
+ });
56075
+ return rows;
56076
+ }
56077
+ function pickerRowFacts(device, groupBy) {
56078
+ return {
56079
+ location: groupBy === "location" ? null : blankToNull(device.location),
56080
+ type: blankToNull(device.type)
56081
+ };
56082
+ }
56083
+ /** The filter key of an item with no type / no location / no zone. */
56084
+ var PICKER_NONE_KEY = "(none)";
56085
+ var PICKER_NO_FILTERS = {
56086
+ query: "",
56087
+ types: [],
56088
+ zones: [],
56089
+ locations: []
56090
+ };
56091
+ var NO_FACET_VALUE = {
56092
+ key: PICKER_NONE_KEY,
56093
+ label: null
56094
+ };
56095
+ function facetValue(item, facet, zones) {
56096
+ if (facet === "type") {
56097
+ const type = blankToNull(item.type);
56098
+ return type === null ? NO_FACET_VALUE : {
56099
+ key: type,
56100
+ label: type
56101
+ };
56102
+ }
56103
+ const location = blankToNull(item.location);
56104
+ if (location === null) return NO_FACET_VALUE;
56105
+ if (facet === "location") return {
56106
+ key: pickerLocationKey(location),
56107
+ label: location.trim()
56108
+ };
56109
+ if (zones.state !== "answered") return NO_FACET_VALUE;
56110
+ const zone = zoneByLocation(zones.zones).get(pickerLocationKey(location));
56111
+ return zone === void 0 ? NO_FACET_VALUE : {
56112
+ key: zone.zoneKey,
56113
+ label: zone.label
56114
+ };
56115
+ }
56116
+ /** The key an item filters by under one facet; `(none)` when it has no such value. */
56117
+ function pickerFacetKey(item, facet, zones) {
56118
+ return facetValue(item, facet, zones).key;
56119
+ }
56120
+ function compareFacetOptions(a, b) {
56121
+ if (a.key === "(none)" || b.key === "(none)") return a.key === b.key ? 0 : a.key === "(none)" ? 1 : -1;
56122
+ return (a.label ?? "").localeCompare(b.label ?? "");
56123
+ }
56124
+ /** Every value of a facet the list holds (plus chosen ones it no longer holds, at 0). */
56125
+ function pickerFacetOptions(items, facet, chosen, zones) {
56126
+ if (facet === "zone" && zones.state !== "answered") return [];
56127
+ const byKey = /* @__PURE__ */ new Map();
56128
+ for (const item of items) {
56129
+ const value = facetValue(item, facet, zones);
56130
+ const have = byKey.get(value.key);
56131
+ if (have === void 0) byKey.set(value.key, {
56132
+ label: value.label,
56133
+ count: 1
56134
+ });
56135
+ else have.count++;
56136
+ }
56137
+ for (const key of chosen) if (!byKey.has(key)) byKey.set(key, {
56138
+ label: key === "(none)" ? null : key,
56139
+ count: 0
56140
+ });
56141
+ const options = [];
56142
+ for (const [key, entry] of byKey) options.push({
56143
+ key,
56144
+ label: entry.label,
56145
+ count: entry.count
56146
+ });
56147
+ return options.sort(compareFacetOptions);
56148
+ }
56149
+ /** A zone filter is chosen but the zones read has not answered: it is paused, not applied. */
56150
+ function pickerZoneFilterSuspended(filters, zones) {
56151
+ return filters.zones.length > 0 && zones.state !== "answered";
56152
+ }
56153
+ function keepFacet(items, facet, chosen, zones) {
56154
+ if (chosen.length === 0) return items;
56155
+ return items.filter((item) => chosen.includes(pickerFacetKey(item, facet, zones)));
56156
+ }
56157
+ /** Search, then Type, Zone (only from an answered read), Location. No filter: the list itself. */
56158
+ function applyPickerFilters(items, filters, zones) {
56159
+ const typed = keepFacet(filterPickerDevices(items, filters.query), "type", filters.types, zones);
56160
+ return keepFacet(zones.state === "answered" ? keepFacet(typed, "zone", filters.zones, zones) : typed, "location", filters.locations, zones);
56161
+ }
56162
+ /** Tick or untick one filter key. */
56163
+ function togglePickerKey(chosen, key) {
56164
+ return chosen.includes(key) ? chosen.filter((k) => k !== key) : [...chosen, key];
56165
+ }
56166
+ function pickerFiltersActive(filters) {
56167
+ return filters.query.trim() !== "" || filters.types.length > 0 || filters.zones.length > 0 || filters.locations.length > 0;
56168
+ }
56169
+ /** Reset clears search, Type, Zone and Location — never "Group by". */
56170
+ function resetPickerFilters() {
56171
+ return PICKER_NO_FILTERS;
56172
+ }
56173
+ /**
56174
+ * Selected ids the current filters hide — they stay selected, they are only not listed. Only a
56175
+ * LISTED device can be hidden: an id the list does not hold at all (removed, unknown, off-offer)
56176
+ * is not "hidden", so it is not counted.
56177
+ */
56178
+ function pickerHiddenSelectedCount(selected, list, matches) {
56179
+ const listed = new Set(list.map((item) => item.id));
56180
+ const shown = new Set(matches.map((item) => item.id));
56181
+ return selected.filter((id) => listed.has(id) && !shown.has(id)).length;
56182
+ }
56183
+ /** A dropdown is drawn when it can narrow (two values or more) or a key is chosen. */
56184
+ function pickerFacetFilterShown(options, chosen) {
56185
+ return options.length >= 2 || chosen.length > 0;
56186
+ }
56187
+ //#endregion
56188
+ //#region src/composites/device-picker/device-filter-bar.tsx
56189
+ /**
56190
+ * The filter bar above a picker's list (D697): search, "Group by" (when the
56191
+ * list offers it), the Type / Zone / Location dropdowns and "Reset filters".
56192
+ *
56193
+ * It owns no filter state — the caller holds `PickerFilters` and applies them
56194
+ * (`applyPickerFilters`); this only draws the controls and reports changes.
56195
+ * The search box keeps its own text so typing is instant, and tells the
56196
+ * caller `PICKER_SEARCH_DEBOUNCE_MS` after the last keystroke.
56197
+ *
56198
+ * The Zone and Location dropdowns are provisional (operator answer Q1): to
56199
+ * drop them, delete the two `FacetDropdown` uses in {@link DeviceFilterBar}
56200
+ * and their option props — the model side is described in
56201
+ * `device-picker-model.ts`.
56202
+ *
56203
+ * - D315: a zones read that has not answered draws the Zone dropdown
56204
+ * DISABLED ("loading zones…" / "zones unavailable"), never empty, and a
56205
+ * chosen zone is reported as paused, not as matching nothing.
56206
+ * - "Reset filters" clears search, Type, Zone and Location and leaves the
56207
+ * Group-by choice alone.
56208
+ */
56209
+ /** "media-player" → "Media player"; null → "No type". */
56210
+ function typeLabel(type) {
56211
+ if (type === null) return "No type";
56212
+ const spaced = type.replaceAll("-", " ");
56213
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
56214
+ }
56215
+ var GROUP_BY_LABEL = {
56216
+ zone: "Zone",
56217
+ location: "Location",
56218
+ none: "None"
56219
+ };
56220
+ var FACET_TITLE = {
56221
+ type: "Type",
56222
+ zone: "Zone",
56223
+ location: "Location"
56224
+ };
56225
+ var NO_VALUE_LABEL = {
56226
+ type: "No type",
56227
+ zone: "No zone",
56228
+ location: "No location"
56229
+ };
56230
+ function optionLabel(facet, option) {
56231
+ if (option.key === "(none)" || option.label === null) return NO_VALUE_LABEL[facet];
56232
+ return facet === "type" ? typeLabel(option.label) : option.label;
56233
+ }
56234
+ /** "Group by: Zone | Location | None", and why Zone is showing locations. */
56235
+ function GroupByControl({ grouping }) {
56236
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56237
+ className: "mb-1.5",
56238
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56239
+ role: "radiogroup",
56240
+ "aria-label": "Group by",
56241
+ className: "flex flex-wrap items-center gap-1 text-[10px] text-foreground-subtle",
56242
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56243
+ className: "mr-1 font-semibold uppercase tracking-wide",
56244
+ children: "Group by"
56245
+ }), PICKER_GROUP_BY_MODES.map((mode) => {
56246
+ const active = mode === grouping.groupBy;
56247
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
56248
+ type: "button",
56249
+ role: "radio",
56250
+ "aria-checked": active,
56251
+ onClick: () => grouping.onGroupByChange(mode),
56252
+ className: active ? "rounded-md border border-primary bg-primary/10 px-1.5 py-0.5 text-[11px] text-primary" : "rounded-md border border-border px-1.5 py-0.5 text-[11px] text-foreground-subtle hover:bg-surface-hover",
56253
+ children: GROUP_BY_LABEL[mode]
56254
+ }, mode);
56255
+ })]
56256
+ }), grouping.fallback !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
56257
+ className: "mt-0.5 text-[10px] text-foreground-subtle",
56258
+ "data-testid": "picker-zones-note",
56259
+ children: grouping.fallback === "pending" ? "Loading zones — grouped by location for now" : "Zones unavailable — grouped by location"
56260
+ })]
56261
+ });
56262
+ }
56263
+ function FacetDropdown({ facet, options, chosen, onChange, unavailable, disabled }) {
56264
+ const title = FACET_TITLE[facet];
56265
+ const summary = unavailable !== void 0 ? unavailable : chosen.length === 0 ? "All" : `${String(chosen.length)} selected`;
56266
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
56267
+ "data-testid": `picker-filter-${facet}`,
56268
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Popover, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(PopoverTrigger, {
56269
+ disabled: disabled || unavailable !== void 0,
56270
+ className: "rounded-md border border-border px-1.5 py-0.5 text-[11px] text-foreground hover:bg-surface-hover disabled:opacity-50",
56271
+ children: [
56272
+ title,
56273
+ ": ",
56274
+ summary
56275
+ ]
56276
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PopoverContent, {
56277
+ className: "min-w-[200px] max-h-[40vh] overflow-y-auto p-1",
56278
+ children: options.map((option) => {
56279
+ const label = optionLabel(facet, option);
56280
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
56281
+ className: "flex cursor-pointer items-center gap-2 px-2 py-1 text-xs text-foreground hover:bg-surface-hover",
56282
+ children: [
56283
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Checkbox, {
56284
+ checked: chosen.includes(option.key),
56285
+ onChange: () => onChange(togglePickerKey(chosen, option.key)),
56286
+ "aria-label": label
56287
+ }),
56288
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56289
+ className: "min-w-0 flex-1 truncate",
56290
+ children: label
56291
+ }),
56292
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
56293
+ className: "text-[10px] text-foreground-subtle",
56294
+ children: [
56295
+ "(",
56296
+ option.count,
56297
+ ")"
56298
+ ]
56299
+ })
56300
+ ]
56301
+ }, option.key);
56302
+ })
56303
+ })] })
56304
+ });
56305
+ }
56306
+ /** Why the Zone dropdown cannot be used, or undefined when the read answered. */
56307
+ function zoneUnavailable(zones) {
56308
+ if (zones.state === "pending") return "loading zones…";
56309
+ if (zones.state === "failed") return "zones unavailable";
56310
+ }
56311
+ function DeviceFilterBar({ filters, onFiltersChange, typeOptions, zoneOptions, locationOptions, zones, grouping, searchPlaceholder, searchLabel, disabled = false }) {
56312
+ const [text, setText] = (0, react$1.useState)(filters.query);
56313
+ const latest = (0, react$1.useRef)({
56314
+ filters,
56315
+ onFiltersChange
56316
+ });
56317
+ latest.current = {
56318
+ filters,
56319
+ onFiltersChange
56320
+ };
56321
+ (0, react$1.useEffect)(() => {
56322
+ if (text === latest.current.filters.query) return;
56323
+ const timer = setTimeout(() => latest.current.onFiltersChange({
56324
+ ...latest.current.filters,
56325
+ query: text
56326
+ }), 200);
56327
+ return () => clearTimeout(timer);
56328
+ }, [text]);
56329
+ const unavailable = zoneUnavailable(zones);
56330
+ const zoneShown = unavailable !== void 0 || pickerFacetFilterShown(zoneOptions, filters.zones);
56331
+ const active = pickerFiltersActive(filters) || text.trim() !== "";
56332
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56333
+ className: "space-y-1.5",
56334
+ "data-testid": "device-filter-bar",
56335
+ children: [
56336
+ grouping !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GroupByControl, { grouping }),
56337
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56338
+ className: INPUT_SHELL,
56339
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Search, { className: "h-3.5 w-3.5 shrink-0 text-foreground-subtle" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
56340
+ type: "text",
56341
+ value: text,
56342
+ placeholder: searchPlaceholder,
56343
+ "aria-label": searchLabel,
56344
+ className: "min-w-0 flex-1 bg-transparent text-xs text-foreground outline-none placeholder:text-foreground-disabled",
56345
+ onChange: (e) => setText(e.target.value)
56346
+ })]
56347
+ }),
56348
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56349
+ className: "flex flex-wrap items-center gap-1",
56350
+ children: [
56351
+ pickerFacetFilterShown(typeOptions, filters.types) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FacetDropdown, {
56352
+ facet: "type",
56353
+ options: typeOptions,
56354
+ chosen: filters.types,
56355
+ onChange: (types) => onFiltersChange({
56356
+ ...filters,
56357
+ types
56358
+ }),
56359
+ disabled
56360
+ }),
56361
+ zoneShown && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FacetDropdown, {
56362
+ facet: "zone",
56363
+ options: zoneOptions,
56364
+ chosen: filters.zones,
56365
+ onChange: (next) => onFiltersChange({
56366
+ ...filters,
56367
+ zones: next
56368
+ }),
56369
+ ...unavailable !== void 0 ? { unavailable } : {},
56370
+ disabled
56371
+ }),
56372
+ pickerFacetFilterShown(locationOptions, filters.locations) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FacetDropdown, {
56373
+ facet: "location",
56374
+ options: locationOptions,
56375
+ chosen: filters.locations,
56376
+ onChange: (locations) => onFiltersChange({
56377
+ ...filters,
56378
+ locations
56379
+ }),
56380
+ disabled
56381
+ }),
56382
+ active && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
56383
+ variant: "ghost",
56384
+ "data-testid": "picker-reset-filters",
56385
+ onClick: () => {
56386
+ setText("");
56387
+ onFiltersChange(resetPickerFilters());
56388
+ },
56389
+ children: "Reset filters"
56390
+ })
56391
+ ]
56392
+ }),
56393
+ pickerZoneFilterSuspended(filters, zones) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
56394
+ className: "text-[10px] text-foreground-subtle",
56395
+ "data-testid": "picker-zone-filter-paused",
56396
+ children: "Zone filter paused — zones not loaded"
56397
+ })
56398
+ ]
56399
+ });
56400
+ }
56401
+ //#endregion
56402
+ //#region src/composites/device-picker/device-picker-grouping.ts
56403
+ /**
56404
+ * The admin side of the device picker's "Group by" (Zone | Location | None):
56405
+ * the remembered choice, and the site-zone read. What each mode DRAWS is
56406
+ * `device-picker-model.ts` (shared with the viewer); this only supplies what
56407
+ * is side-specific.
56408
+ *
56409
+ * - The choice lives in `localStorage`, one value for every device picker in
56410
+ * this browser. Storage that throws or holds an unknown value is the
56411
+ * default (`zone`), never an error.
56412
+ * - The zones are read whenever the picker's panel is open (the Zone filter
56413
+ * needs them even in Location or None mode) — the caller mounts this with
56414
+ * the panel — and a failure stays a FAILURE: an
56415
+ * empty list would put every device under "No zone", a claim of absence
56416
+ * from a read that did not answer (D315). The model then groups by
56417
+ * location and the panel says why.
56418
+ */
56419
+ var DEVICE_PICKER_GROUP_STORAGE_KEY = "camstack.admin.device-picker.group-by";
56420
+ /**
56421
+ * Under `listSiteZones`'s own prefix, so the Locations page's invalidation of
56422
+ * `[['deviceManager', 'listSiteZones']]` refreshes it too.
56423
+ */
56424
+ var PICKER_ZONES_QUERY_KEY = [["deviceManager", "listSiteZones"], "device-picker"];
56425
+ var PICKER_ZONES_STALE_MS = 6e4;
56426
+ /** The stored choice; the default when storage is absent, throws or is unreadable. */
56427
+ function readStoredGroupBy() {
56428
+ try {
56429
+ const raw = globalThis.localStorage?.getItem("camstack.admin.device-picker.group-by") ?? null;
56430
+ return raw === null ? PICKER_DEFAULT_GROUP_BY : parsePickerGroupBy(raw);
56431
+ } catch {
56432
+ return PICKER_DEFAULT_GROUP_BY;
56433
+ }
56434
+ }
56435
+ /** Remember the choice; a storage that refuses keeps it for this page only. */
56436
+ function writeStoredGroupBy(next) {
56437
+ try {
56438
+ globalThis.localStorage?.setItem(DEVICE_PICKER_GROUP_STORAGE_KEY, next);
56439
+ } catch {}
56440
+ }
56441
+ function useStoredGroupBy() {
56442
+ const [groupBy, setState] = (0, react$1.useState)(readStoredGroupBy);
56443
+ const setGroupBy = (next) => {
56444
+ setState(next);
56445
+ writeStoredGroupBy(next);
56446
+ };
56447
+ return {
56448
+ groupBy,
56449
+ setGroupBy
56450
+ };
56451
+ }
56452
+ /** The site zones, with whether the read ANSWERED — read only while `enabled`. */
56453
+ function usePickerSiteZones(enabled) {
56454
+ const trpcClient = useSystem().trpcClient;
56455
+ const { data, isError } = (0, _tanstack_react_query.useQuery)({
56456
+ queryKey: PICKER_ZONES_QUERY_KEY,
56457
+ enabled,
56458
+ staleTime: PICKER_ZONES_STALE_MS,
56459
+ queryFn: () => trpcClient.deviceManager.listSiteZones.query()
56460
+ });
56461
+ return (0, react$1.useMemo)(() => {
56462
+ if (data !== void 0) return {
56463
+ state: "answered",
56464
+ zones: data
56465
+ };
56466
+ return isError ? { state: "failed" } : { state: "pending" };
56467
+ }, [data, isError]);
56468
+ }
56469
+ //#endregion
56470
+ //#region src/composites/device-picker/device-selection.ts
56471
+ function deviceListStateOf(query) {
56472
+ if (query.data !== void 0) return "answered";
56473
+ return query.isError ? "failed" : "pending";
56474
+ }
56475
+ function resolveSelectedDevices(selectedIds, cameras, state) {
56476
+ if (state !== "answered" || cameras === void 0) return selectedIds.map((id) => ({
56477
+ id,
56478
+ removed: false
56479
+ }));
56480
+ const byId = new Map(cameras.map((cam) => [cam.id, cam.name]));
56481
+ return selectedIds.map((id) => {
56482
+ const name = byId.get(id);
56483
+ return name === void 0 ? {
56484
+ id,
56485
+ removed: true
56486
+ } : {
56487
+ id,
56488
+ name,
56489
+ removed: false
56490
+ };
56491
+ });
56492
+ }
56493
+ /**
56494
+ * The selected ids a picker's DRAWN list does not contain, judged against the
56495
+ * WHOLE fleet — never against the drawn list itself. A camera picker draws
56496
+ * cameras, but a rule's devices can be a sensor picked on another surface: the
56497
+ * camera list lacking it says nothing about whether it still exists. Each row
56498
+ * is `removed` (the answered fleet lacks it), named (the fleet has it) or
56499
+ * unnamed (the fleet has not answered).
56500
+ */
56501
+ function offListSelections(selectedIds, listed, fleet, fleetState) {
56502
+ const listedIds = new Set((listed ?? []).map((device) => device.id));
56503
+ return resolveSelectedDevices(selectedIds.filter((id) => !listedIds.has(id)), fleet, fleetState);
56504
+ }
56505
+ /** The "no cameras" hint: only an ANSWERED empty list with nothing selected. */
56506
+ function showsNoCamerasHint(state, cameraCount, selectedCount) {
56507
+ return state === "answered" && cameraCount === 0 && selectedCount === 0;
56508
+ }
56509
+ /**
56510
+ * The widening warning: on a picker where an empty selection means ALL
56511
+ * devices, an empty selection that once held devices must say so.
56512
+ */
56513
+ function emptyDevicesWidens(emptyMeansAll, selectedCount, hadDevices) {
56514
+ return emptyMeansAll && selectedCount === 0 && hadDevices;
56515
+ }
56516
+ //#endregion
56517
+ //#region src/composites/device-picker/device-selection-rows.tsx
56518
+ /** Each noun's plural. */
56519
+ var PICKER_NOUN_PLURAL = {
56520
+ camera: "cameras",
56521
+ device: "devices",
56522
+ node: "nodes",
56523
+ package: "packages",
56524
+ class: "classes",
56525
+ zone: "zones",
56526
+ face: "faces",
56527
+ calendar: "calendars"
56528
+ };
56529
+ /** The label a picker gives a selected id its drawn list does not contain. */
56530
+ function orphanDeviceLabel(row, noun = "device") {
56531
+ const id = String(row.id);
56532
+ switch (noun) {
56533
+ case "node":
56534
+ if (row.removed) return `Node ${id} — unknown to the hub`;
56535
+ return row.name ?? `Node ${id}`;
56536
+ case "package":
56537
+ if (row.removed) return `${id} — not installed`;
56538
+ return row.name ?? id;
56539
+ case "class":
56540
+ if (row.removed) return `${id} — not in the class list`;
56541
+ return row.name ?? id;
56542
+ case "zone":
56543
+ if (row.removed) return `${id} — zone not found`;
56544
+ return row.name ?? `${id} — zone unavailable`;
56545
+ case "face":
56546
+ if (row.removed) return `${id} — not in the face gallery`;
56547
+ return row.name ?? id;
56548
+ case "calendar":
56549
+ if (row.removed) return `${id} — calendar not found`;
56550
+ return row.name ?? id;
56551
+ case "camera":
56552
+ case "device":
56553
+ if (row.removed) return `Device #${id} — removed`;
56554
+ return row.name ?? `Device #${id}`;
56555
+ }
56556
+ }
56557
+ /** Said under a picker whose empty selection means EVERY device. */
56558
+ function DevicesWidenWarning({ noun = "camera" }) {
56559
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
56560
+ className: "mt-1 text-[11px] text-warning",
56561
+ "data-testid": "devices-widen-warning",
56562
+ children: [
56563
+ "No ",
56564
+ noun,
56565
+ " selected: the rule will apply to ALL ",
56566
+ PICKER_NOUN_PLURAL[noun],
56567
+ "."
56568
+ ]
56569
+ });
56570
+ }
56571
+ /** A list that has not answered: never an absence claim (D315). */
56572
+ function DevicesLoadingLine({ noun = "cameras" }) {
56573
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
56574
+ className: "text-[11px] text-foreground-subtle",
56575
+ children: [
56576
+ "Loading ",
56577
+ noun,
56578
+ "…"
56579
+ ]
56580
+ });
56581
+ }
56582
+ /** A list that failed: said, while the selected ids stay removable. */
56583
+ function DevicesFailedLine({ noun = "devices" }) {
56584
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
56585
+ className: "text-[11px] text-warning",
56586
+ children: [
56587
+ "The hub did not return its ",
56588
+ noun,
56589
+ " — selected ones are listed by id so they can still be removed."
56590
+ ]
56591
+ });
56592
+ }
56593
+ //#endregion
56594
+ //#region src/composites/device-picker/DevicePicker.tsx
56595
+ /**
56596
+ * The smart device picker every notification editor uses: the selection as
56597
+ * compact chips, and a searchable, grouped list opened only to change it.
56598
+ *
56599
+ * It replaces the long checklists (a rule's devices, a system rule's
56600
+ * include/exclude, the timelapse and digest cameras, the alarm's non-blocking
56601
+ * openings) with the same UX the viewer's picker has (D636). The decisions are
56602
+ * `device-picker-model.ts` (shared with the viewer, byte-identical); what a
56603
+ * selected id the list does not draw IS — removed, named, or unknown — is
56604
+ * `device-selection.ts`, and so are the two rules that shaped it:
56605
+ *
56606
+ * - D315: only a list that ANSWERED may call an id removed or say "no
56607
+ * cameras"; a pending list is a loading line and names nothing absent.
56608
+ * - D49: a read never drops a selected id; every one is a chip the operator
56609
+ * can remove, and only the operator removes one.
56610
+ *
56611
+ * It reads nothing. It filters the list its caller already holds, and judges
56612
+ * an off-list id against the caller's fleet (a camera picker draws cameras,
56613
+ * and a selected sensor missing from it still exists).
56614
+ *
56615
+ * Generic over the id since D637: the system family's node and package pickers
56616
+ * (`scope-pickers.tsx`) are this picker over string ids, with their own noun
56617
+ * and group titles.
56618
+ *
56619
+ * Bounded at hundreds of devices of one type: big groups start collapsed, the
56620
+ * rows are rendered a page at a time with "Show N more", and the chips stop at
56621
+ * `PICKER_CHIP_LIMIT` with a "+N more". `showAll` turns all three off, and the
56622
+ * list is always open: the rule editor's Devices tab exists to show every
56623
+ * device (R15, D690).
56624
+ *
56625
+ * "Group by" (Zone | Location | None) sits at the top of a DEVICE picker's
56626
+ * list only — the node and package pickers keep their own groups, and so does
56627
+ * the CLASS picker (D641), whose groups are the taxonomy's categories. The choice
56628
+ * is remembered in `localStorage`, and the site zones are read whenever the
56629
+ * panel is open (the Zone filter needs them; `device-picker-grouping.ts`); a zone read that has
56630
+ * not answered groups by location with a one-line note, never "No zone"
56631
+ * (D315).
56632
+ */
56633
+ /** A "Group by" group's title: the zone or room, or which one it lacks. */
56634
+ function headingTitle(heading) {
56635
+ switch (heading.kind) {
56636
+ case "zone":
56637
+ case "location": return heading.label;
56638
+ case "no-zone": return "No zone";
56639
+ case "no-location": return "No location";
56640
+ }
56641
+ }
56642
+ function deviceGroupTitle(group) {
56643
+ if (group.heading !== void 0) return headingTitle(group.heading);
56644
+ return `${typeLabel(group.type)} · ${group.location ?? "No room"}`;
56645
+ }
56646
+ /** Whether a picker lists devices — the only kind with "Group by". */
56647
+ function isDeviceNoun(noun) {
56648
+ return noun === "camera" || noun === "device";
56649
+ }
56650
+ function chipsOf(value, devices, fleet, noun) {
56651
+ const byId = new Map(devices.map((device) => [device.id, device.name]));
56652
+ const orphans = new Map(offListSelections(value, devices, fleet.devices, fleet.state).map((row) => [row.id, row]));
56653
+ return value.map((id) => {
56654
+ const name = byId.get(id);
56655
+ if (name !== void 0) return {
56656
+ id,
56657
+ label: name,
56658
+ removed: false
56659
+ };
56660
+ const row = orphans.get(id) ?? {
56661
+ id,
56662
+ removed: false
56663
+ };
56664
+ return {
56665
+ id,
56666
+ label: orphanDeviceLabel(row, noun),
56667
+ removed: row.removed
56668
+ };
56669
+ });
56670
+ }
56671
+ /** The empty-list sentence: the device pickers keep the words they shipped with. */
56672
+ function noItemsSentence(noun, emptyMeansAll) {
56673
+ const plural = PICKER_NOUN_PLURAL[noun];
56674
+ if (!emptyMeansAll) return `No ${plural} available.`;
56675
+ if (noun === "camera" || noun === "device") return "No cameras available — the rule applies to all devices.";
56676
+ return `No ${plural} available — the rule applies to every ${noun}.`;
56677
+ }
56678
+ function DevicePicker({ devices, listState, fleet, value, onChange, emptyMeansAll = false, savedHadDevices = false, noun = "camera", groupTitle = deviceGroupTitle, emptyLabel, annotate, disabled = false, testId, showAll = false }) {
56679
+ const judge = fleet ?? {
56680
+ state: listState,
56681
+ devices: listState === "answered" ? devices : void 0
56682
+ };
56683
+ const [open, setOpen] = (0, react$1.useState)(false);
56684
+ const [chipsExpanded, setChipsExpanded] = (0, react$1.useState)(false);
56685
+ const [hadDevices, setHadDevices] = (0, react$1.useState)(savedHadDevices || value.length > 0);
56686
+ if (value.length > 0 && !hadDevices) setHadDevices(true);
56687
+ const chips = chipsOf(value, devices, judge, noun);
56688
+ const overflow = chipOverflow(chips, chipsExpanded || showAll);
56689
+ const listOpen = open || showAll;
56690
+ const widening = emptyDevicesWidens(emptyMeansAll, value.length, hadDevices);
56691
+ const plural = PICKER_NOUN_PLURAL[noun];
56692
+ if (listState === "answered" && showsNoCamerasHint(listState, devices.length, value.length)) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56693
+ "data-testid": testId,
56694
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
56695
+ className: "text-[10px] text-foreground-subtle mt-0.5",
56696
+ children: noItemsSentence(noun, emptyMeansAll)
56697
+ }), widening && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DevicesWidenWarning, { noun })]
56698
+ });
56699
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56700
+ className: "mt-1 space-y-1.5",
56701
+ "data-testid": testId,
56702
+ children: [
56703
+ listState === "pending" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DevicesLoadingLine, { noun: plural }),
56704
+ listState === "failed" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DevicesFailedLine, { noun: noun === "camera" ? "devices" : plural }),
56705
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56706
+ className: "flex flex-wrap items-center gap-1",
56707
+ children: [
56708
+ chips.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56709
+ className: "text-[11px] text-foreground-subtle",
56710
+ children: emptyLabel ?? (emptyMeansAll ? `Every ${noun} — no restriction` : "None selected")
56711
+ }) : overflow.shown.map((chip) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SelectedChip, {
56712
+ testId: `picker-chip-${String(chip.id)}`,
56713
+ label: chip.label,
56714
+ warning: chip.removed,
56715
+ ...!disabled ? { onRemove: () => onChange(value.filter((id) => id !== chip.id)) } : {}
56716
+ }, chip.id)),
56717
+ overflow.hidden > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
56718
+ type: "button",
56719
+ className: "rounded-md border border-border px-1.5 py-0.5 text-[11px] text-foreground-subtle hover:bg-surface-hover",
56720
+ onClick: () => setChipsExpanded(true),
56721
+ children: [
56722
+ "+",
56723
+ overflow.hidden,
56724
+ " more"
56725
+ ]
56726
+ }),
56727
+ chipsExpanded && chips.length > 6 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
56728
+ type: "button",
56729
+ className: "text-[11px] text-foreground-subtle underline",
56730
+ onClick: () => setChipsExpanded(false),
56731
+ children: "Show fewer"
56732
+ }),
56733
+ listState !== "pending" && !showAll && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
56734
+ type: "button",
56735
+ disabled,
56736
+ "aria-expanded": open,
56737
+ onClick: () => setOpen((o) => !o),
56738
+ className: "rounded-md border border-dashed border-border px-1.5 py-0.5 text-[11px] text-foreground hover:bg-surface-hover disabled:opacity-50",
56739
+ children: open ? "Done" : value.length === 0 ? `Add ${plural}…` : "Edit…"
56740
+ })
56741
+ ]
56742
+ }),
56743
+ listOpen && listState === "answered" && (isDeviceNoun(noun) ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GroupedPickerPanel, {
56744
+ devices,
56745
+ value,
56746
+ onChange,
56747
+ annotate,
56748
+ groupTitle,
56749
+ noun,
56750
+ disabled,
56751
+ showAll
56752
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PickerPanel, {
56753
+ devices,
56754
+ value,
56755
+ onChange,
56756
+ annotate,
56757
+ groupTitle,
56758
+ noun,
56759
+ disabled,
56760
+ showAll
56761
+ })),
56762
+ widening && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DevicesWidenWarning, { noun })
56763
+ ]
56764
+ });
56765
+ }
56766
+ /**
56767
+ * The device picker's panel: mounted only while the list is open, so the zone
56768
+ * read below happens only then (for Zone grouping and the Zone filter).
56769
+ */
56770
+ function GroupedPickerPanel(props) {
56771
+ const { groupBy, setGroupBy } = useStoredGroupBy();
56772
+ const zones = usePickerSiteZones(true);
56773
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PickerPanel, {
56774
+ ...props,
56775
+ grouping: {
56776
+ groupBy,
56777
+ onGroupByChange: setGroupBy,
56778
+ zones
56779
+ }
56780
+ });
56781
+ }
56782
+ /** The search box's hint: a device has a room and a type, a class a category, a node or package an id. */
56783
+ function searchPlaceholder(noun) {
56784
+ if (noun === "camera" || noun === "device") return "Search by name, room or type…";
56785
+ if (noun === "class") return "Search by class or category…";
56786
+ if (noun === "zone") return "Search by camera or zone…";
56787
+ return "Search by name or id…";
56788
+ }
56789
+ /** An answered read with no zones: what the pickers without "Group by" are judged against. */
56790
+ var NO_ZONES = {
56791
+ state: "answered",
56792
+ zones: []
56793
+ };
56794
+ /** The searchable, grouped list — rendered one page at a time. */
56795
+ function PickerPanel({ devices, value, onChange, annotate, groupTitle, noun, disabled, showAll, grouping }) {
56796
+ const [filters, setFilters] = (0, react$1.useState)(PICKER_NO_FILTERS);
56797
+ const [toggles, setToggles] = (0, react$1.useState)(/* @__PURE__ */ new Map());
56798
+ const [shown, setShown] = (0, react$1.useState)(60);
56799
+ const searching = showAll || pickerQueryTokens(filters.query).length > 0;
56800
+ const groupBy = grouping?.groupBy;
56801
+ const zones = grouping?.zones ?? NO_ZONES;
56802
+ const matches = (0, react$1.useMemo)(() => applyPickerFilters(devices, filters, zones), [
56803
+ devices,
56804
+ filters,
56805
+ zones
56806
+ ]);
56807
+ const grouped = (0, react$1.useMemo)(() => groupBy !== void 0 ? groupPickerDevicesBy(matches, value, groupBy, zones) : null, [
56808
+ matches,
56809
+ value,
56810
+ groupBy,
56811
+ zones
56812
+ ]);
56813
+ const rows = (0, react$1.useMemo)(() => grouped !== null ? flattenPickerGrouping(grouped, value, toggles, searching) : flattenPickerRows(groupPickerDevices(matches, value), value, toggles, searching), [
56814
+ grouped,
56815
+ matches,
56816
+ value,
56817
+ toggles,
56818
+ searching
56819
+ ]);
56820
+ const shownBy = grouped?.groupBy ?? null;
56821
+ const typeOptions = (0, react$1.useMemo)(() => pickerFacetOptions(devices, "type", filters.types, zones), [
56822
+ devices,
56823
+ filters.types,
56824
+ zones
56825
+ ]);
56826
+ const zoneOptions = (0, react$1.useMemo)(() => pickerFacetOptions(devices, "zone", filters.zones, zones), [
56827
+ devices,
56828
+ filters.zones,
56829
+ zones
56830
+ ]);
56831
+ const locationOptions = (0, react$1.useMemo)(() => grouping === void 0 ? [] : pickerFacetOptions(devices, "location", filters.locations, zones), [
56832
+ devices,
56833
+ filters.locations,
56834
+ zones,
56835
+ grouping
56836
+ ]);
56837
+ const hiddenSelected = (0, react$1.useMemo)(() => pickerHiddenSelectedCount(value, devices, matches), [
56838
+ devices,
56839
+ value,
56840
+ matches
56841
+ ]);
56842
+ const page = pageRows(rows, showAll ? rows.length : shown);
56843
+ const toggleGroup = (group, expanded) => setToggles((current) => new Map([...current, [group.key, !expanded]]));
56844
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56845
+ className: "rounded-md border border-border bg-surface p-2",
56846
+ "data-testid": "device-picker-panel",
56847
+ children: [
56848
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DeviceFilterBar, {
56849
+ filters,
56850
+ onFiltersChange: (next) => {
56851
+ setFilters(next);
56852
+ setShown(60);
56853
+ },
56854
+ typeOptions,
56855
+ zoneOptions,
56856
+ locationOptions,
56857
+ zones,
56858
+ ...grouping !== void 0 ? { grouping: {
56859
+ groupBy: grouping.groupBy,
56860
+ onGroupByChange: grouping.onGroupByChange,
56861
+ fallback: grouped?.fallback ?? null
56862
+ } } : {},
56863
+ searchPlaceholder: searchPlaceholder(noun),
56864
+ searchLabel: noun === "camera" || noun === "device" ? "Search devices" : `Search ${PICKER_NOUN_PLURAL[noun]}`,
56865
+ disabled
56866
+ }),
56867
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
56868
+ className: "mt-1 text-[10px] text-foreground-subtle",
56869
+ children: [
56870
+ matches.length,
56871
+ " of ",
56872
+ devices.length,
56873
+ " shown · ",
56874
+ value.length,
56875
+ " selected"
56876
+ ]
56877
+ }),
56878
+ rows.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
56879
+ className: "py-2 text-[11px] text-foreground-subtle italic",
56880
+ children: noun === "camera" || noun === "device" ? "No device matches." : `No ${noun} matches.`
56881
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56882
+ className: showAll ? "mt-1" : "mt-1 max-h-72 overflow-y-auto",
56883
+ "data-testid": "device-picker-rows",
56884
+ children: [page.rows.map((row) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PickerRowView, {
56885
+ row,
56886
+ value,
56887
+ onChange,
56888
+ onToggleGroup: toggleGroup,
56889
+ annotate,
56890
+ groupTitle,
56891
+ groupBy: shownBy,
56892
+ disabled
56893
+ }, row.key)), page.remaining > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
56894
+ type: "button",
56895
+ className: "mt-1 w-full rounded border border-border py-1 text-[11px] text-foreground-subtle hover:bg-surface-hover",
56896
+ onClick: () => setShown((n) => n + 60),
56897
+ children: [
56898
+ "Show ",
56899
+ Math.min(page.remaining, 60),
56900
+ " more (",
56901
+ page.remaining,
56902
+ " left)"
56903
+ ]
56904
+ })]
56905
+ }),
56906
+ hiddenSelected > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
56907
+ className: "mt-1 text-[10px] text-foreground-subtle",
56908
+ "data-testid": "picker-hidden-selected",
56909
+ children: [hiddenSelected, " selected hidden by filters"]
56910
+ })
56911
+ ]
56912
+ });
56913
+ }
56914
+ /** The second line: under "Group by" the room (where the header lacks it) and type, then the container. */
56915
+ function secondLine(device, groupBy) {
56916
+ if (groupBy === null) return device.detail;
56917
+ const facts = pickerRowFacts(device, groupBy);
56918
+ const parts = [
56919
+ facts.location,
56920
+ facts.type === null ? null : typeLabel(facts.type),
56921
+ device.detail ?? null
56922
+ ].filter((part) => part !== null);
56923
+ return parts.length > 0 ? parts.join(" · ") : void 0;
56924
+ }
56925
+ function PickerRowView({ row, value, onChange, onToggleGroup, annotate, groupTitle, groupBy, disabled }) {
56926
+ if (row.kind === "header") {
56927
+ const { group, expanded } = row;
56928
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56929
+ className: "flex items-center gap-2 border-b border-border px-1 py-1",
56930
+ "data-testid": "device-picker-group",
56931
+ children: [
56932
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
56933
+ type: "button",
56934
+ "aria-expanded": expanded,
56935
+ onClick: () => onToggleGroup(group, expanded),
56936
+ className: "flex min-w-0 flex-1 items-center gap-1 text-left text-[10px] font-semibold uppercase tracking-wide text-foreground-subtle",
56937
+ children: [
56938
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: expanded ? "▾" : "▸" }),
56939
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56940
+ className: "truncate",
56941
+ children: groupTitle(group)
56942
+ }),
56943
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
56944
+ className: "font-normal normal-case",
56945
+ children: [
56946
+ "(",
56947
+ group.selectedCount,
56948
+ "/",
56949
+ group.devices.length,
56950
+ ")"
56951
+ ]
56952
+ })
56953
+ ]
56954
+ }),
56955
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
56956
+ type: "button",
56957
+ disabled,
56958
+ className: "text-[10px] text-primary hover:underline disabled:opacity-50",
56959
+ onClick: () => onChange(selectPickerGroup(value, group)),
56960
+ children: "All"
56961
+ }),
56962
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
56963
+ type: "button",
56964
+ disabled,
56965
+ className: "text-[10px] text-foreground-subtle hover:underline disabled:opacity-50",
56966
+ onClick: () => onChange(clearPickerGroup(value, group)),
56967
+ children: "Clear"
56968
+ })
56969
+ ]
56970
+ });
56971
+ }
56972
+ const note = annotate?.(row.device.id);
56973
+ const second = secondLine(row.device, groupBy);
56974
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
56975
+ className: "flex cursor-pointer items-center gap-2.5 px-3 py-1 text-xs text-foreground hover:bg-surface-hover",
56976
+ "data-testid": "device-picker-row",
56977
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
56978
+ type: "checkbox",
56979
+ checked: row.selected,
56980
+ disabled,
56981
+ onChange: () => onChange(togglePickerDevice(value, row.device.id)),
56982
+ className: "accent-primary",
56983
+ "data-device-id": String(row.device.id)
56984
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
56985
+ className: "flex min-w-0 flex-1 flex-col",
56986
+ children: [
56987
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56988
+ className: "truncate",
56989
+ children: row.device.name
56990
+ }),
56991
+ second !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56992
+ className: "truncate text-[10px] text-foreground-subtle",
56993
+ children: second
56994
+ }),
56995
+ note !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56996
+ className: "text-[10px] text-warning",
56997
+ children: note
56998
+ })
56999
+ ]
57000
+ })]
57001
+ });
57002
+ }
57003
+ //#endregion
57004
+ //#region src/composites/unit-picker/UnitPicker.tsx
57005
+ /**
57006
+ * Same-dimension unit picker. Lists only `unitsForDimension(dimension)`; an
57007
+ * empty dimension disables the control and shows `emptyReason` so the operator
57008
+ * never sees a refused conversion as a pickable option.
57009
+ */
57010
+ var FIELD_CLASS = "w-full min-w-0 px-1.5 py-0.5 text-[11px] bg-background border border-border rounded focus:outline-none focus:border-primary disabled:opacity-50 disabled:cursor-not-allowed";
57011
+ function UnitPicker({ dimension, value, onChange, disabled = false, emptyReason, inheritLabel, className, id, "aria-label": ariaLabel }) {
57012
+ const choices = dimension !== void 0 ? (0, _camstack_types.unitsForDimension)(dimension) : [];
57013
+ const isEmpty = dimension === void 0;
57014
+ const selectDisabled = disabled || isEmpty;
57015
+ const handleChange = (event) => {
57016
+ const raw = event.target.value;
57017
+ if (raw === "") {
57018
+ onChange("");
57019
+ return;
57020
+ }
57021
+ onChange((0, _camstack_types.normalizeUnit)(raw) ?? raw);
57022
+ };
57023
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
57024
+ className: "flex flex-col gap-0.5 min-w-0 w-full",
57025
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
57026
+ id,
57027
+ className: className ?? FIELD_CLASS,
57028
+ "aria-label": ariaLabel,
57029
+ value,
57030
+ onChange: handleChange,
57031
+ disabled: selectDisabled,
57032
+ children: [inheritLabel !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
57033
+ value: "",
57034
+ children: inheritLabel
57035
+ }), choices.map((unit) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
57036
+ value: unit,
57037
+ children: unit
57038
+ }, unit))]
57039
+ }), isEmpty && emptyReason !== void 0 && emptyReason !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
57040
+ className: "text-[10px] text-foreground-subtle",
57041
+ children: emptyReason
57042
+ })]
57043
+ });
57044
+ }
57045
+ //#endregion
57046
+ //#region src/composites/placeholder-field/PlaceholderChipField.tsx
57047
+ /**
57048
+ * Text field with insertable placeholder chips — the same interaction model
57049
+ * as Notification Center's TemplateField, without the NC variable catalog.
57050
+ */
57051
+ var LABEL_CLASS = "block text-[11px] font-medium text-foreground mb-1";
57052
+ var TEXTAREA_CLASS = "w-full rounded-md border border-border bg-surface px-2.5 py-1.5 text-xs text-foreground placeholder:text-foreground-subtle focus:outline-none focus:ring-1 focus:ring-primary";
57053
+ function insertAtSelection(value, selection, token) {
57054
+ const before = value.slice(0, selection.start);
57055
+ return {
57056
+ value: `${before}${token}${value.slice(selection.end)}`,
57057
+ cursor: before.length + token.length
57058
+ };
57059
+ }
57060
+ function PlaceholderChipField({ label, value, onChange, chips, multiline = false, placeholder, disabled = false, testId, maxLength }) {
57061
+ const inputRef = (0, react$1.useRef)(null);
57062
+ const areaRef = (0, react$1.useRef)(null);
57063
+ const insert = (token) => {
57064
+ const element = multiline ? areaRef.current : inputRef.current;
57065
+ const result = insertAtSelection(value, {
57066
+ start: element?.selectionStart ?? value.length,
57067
+ end: element?.selectionEnd ?? value.length
57068
+ }, token);
57069
+ onChange(result.value);
57070
+ requestAnimationFrame(() => {
57071
+ element?.focus();
57072
+ element?.setSelectionRange(result.cursor, result.cursor);
57073
+ });
57074
+ };
57075
+ const handle = (e) => onChange(e.target.value);
57076
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
57077
+ className: "space-y-1",
57078
+ "data-testid": testId,
57079
+ children: [
57080
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
57081
+ className: LABEL_CLASS,
57082
+ children: label
57083
+ }),
57084
+ multiline ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
57085
+ ref: areaRef,
57086
+ value,
57087
+ rows: 2,
57088
+ disabled,
57089
+ placeholder,
57090
+ maxLength,
57091
+ onChange: handle,
57092
+ className: TEXTAREA_CLASS,
57093
+ "data-testid": `${testId}-input`
57094
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Input, {
57095
+ ref: inputRef,
57096
+ value,
57097
+ disabled,
57098
+ placeholder,
57099
+ maxLength,
57100
+ onChange: handle,
57101
+ "data-testid": `${testId}-input`
57102
+ }),
57103
+ chips !== void 0 && chips.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
57104
+ className: "flex flex-wrap gap-1 pt-0.5",
57105
+ "data-testid": `${testId}-chips`,
57106
+ children: chips.map((chip) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
57107
+ type: "button",
57108
+ disabled,
57109
+ className: "rounded-md bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary hover:bg-primary/20 disabled:opacity-50",
57110
+ onClick: () => insert(chip.token),
57111
+ "data-testid": `${testId}-chip-${chip.id}`,
57112
+ children: chip.label
57113
+ }, chip.id))
57114
+ })
57115
+ ]
57116
+ });
57117
+ }
57118
+ //#endregion
55747
57119
  //#region src/contexts/zone-editing.tsx
55748
57120
  /**
55749
57121
  * Per-device zone-editing state shared between the player overlay
@@ -56761,6 +58133,7 @@ exports.DEFAULT_COLOR = DEFAULT_COLOR;
56761
58133
  exports.DEVICE_COLUMNS = DEVICE_COLUMNS;
56762
58134
  exports.DEVICE_LIST_PAGE_SIZE_KEY = DEVICE_LIST_PAGE_SIZE_KEY;
56763
58135
  exports.DEVICE_LIST_PAGE_SIZE_OPTIONS = DEVICE_LIST_PAGE_SIZE_OPTIONS;
58136
+ exports.DEVICE_PICKER_GROUP_STORAGE_KEY = DEVICE_PICKER_GROUP_STORAGE_KEY;
56764
58137
  exports.DEVICE_ROLE_META = DEVICE_ROLE_META;
56765
58138
  exports.DEVICE_TYPE_CONTROL = DEVICE_TYPE_CONTROL;
56766
58139
  exports.DEVICE_TYPE_META = DEVICE_TYPE_META;
@@ -56776,13 +58149,18 @@ exports.DeviceBatchToolbar = DeviceBatchToolbar;
56776
58149
  exports.DeviceCard = DeviceCard;
56777
58150
  exports.DeviceContextProvider = DeviceContextProvider;
56778
58151
  exports.DeviceExportPanel = DeviceExportPanel;
58152
+ exports.DeviceFilterBar = DeviceFilterBar;
56779
58153
  exports.DeviceGrid = DeviceGrid;
56780
58154
  exports.DeviceItem = DeviceItem;
56781
58155
  exports.DeviceList = DeviceList;
56782
58156
  exports.DeviceMultiSelectField = DeviceMultiSelectField;
58157
+ exports.DevicePicker = DevicePicker;
56783
58158
  exports.DeviceSelectField = DeviceSelectField;
56784
58159
  exports.DeviceSelectorPicker = DeviceSelectorPicker;
56785
58160
  exports.DeviceStepMatrix = DeviceStepMatrix;
58161
+ exports.DevicesFailedLine = DevicesFailedLine;
58162
+ exports.DevicesLoadingLine = DevicesLoadingLine;
58163
+ exports.DevicesWidenWarning = DevicesWidenWarning;
56786
58164
  exports.Dialog = Dialog;
56787
58165
  exports.DialogBody = DialogBody;
56788
58166
  exports.DialogContent = DialogContent;
@@ -56831,6 +58209,7 @@ exports.HoverZoomImage = HoverZoomImage;
56831
58209
  exports.HumidifierHeroCard = HumidifierHeroCard;
56832
58210
  exports.HumidifierInlineControl = HumidifierInlineControl;
56833
58211
  exports.INPUT_COMPACT = INPUT_COMPACT;
58212
+ exports.INPUT_SHELL = INPUT_SHELL;
56834
58213
  exports.IconAction = IconAction;
56835
58214
  exports.IconButton = IconButton;
56836
58215
  exports.IconPicker = IconPicker;
@@ -56871,6 +58250,16 @@ exports.NodePicker = NodePicker;
56871
58250
  exports.NodeSelectField = NodeSelectField;
56872
58251
  exports.OfflineBadge = OfflineBadge;
56873
58252
  exports.PHASE_CONFIG = PHASE_CONFIG;
58253
+ exports.PICKER_CHIP_LIMIT = PICKER_CHIP_LIMIT;
58254
+ exports.PICKER_DEFAULT_GROUP_BY = PICKER_DEFAULT_GROUP_BY;
58255
+ exports.PICKER_GROUP_BY_MODES = PICKER_GROUP_BY_MODES;
58256
+ exports.PICKER_GROUP_COLLAPSE_AT = PICKER_GROUP_COLLAPSE_AT;
58257
+ exports.PICKER_NONE_KEY = PICKER_NONE_KEY;
58258
+ exports.PICKER_NOUN_PLURAL = PICKER_NOUN_PLURAL;
58259
+ exports.PICKER_NO_FILTERS = PICKER_NO_FILTERS;
58260
+ exports.PICKER_PAGE_SIZE = PICKER_PAGE_SIZE;
58261
+ exports.PICKER_SEARCH_DEBOUNCE_MS = PICKER_SEARCH_DEBOUNCE_MS;
58262
+ exports.PICKER_WINDOW = PICKER_WINDOW;
56874
58263
  exports.PRIORITY = PRIORITY;
56875
58264
  exports.PTZOverlay = PTZOverlay;
56876
58265
  exports.PageHeader = PageHeader;
@@ -56879,6 +58268,7 @@ exports.PipelineBuilder = PipelineBuilder;
56879
58268
  exports.PipelineRuntimeSelector = PipelineRuntimeSelector;
56880
58269
  exports.PipelineStep = PipelineStep;
56881
58270
  exports.PipelineTreeMatrix = PipelineTreeMatrix;
58271
+ exports.PlaceholderChipField = PlaceholderChipField;
56882
58272
  exports.PlayerOverlaysProvider = PlayerOverlaysProvider;
56883
58273
  exports.Popover = Popover;
56884
58274
  exports.PopoverContent = PopoverContent;
@@ -56922,6 +58312,7 @@ exports.ScriptHeroCard = ScriptHeroCard;
56922
58312
  exports.ScriptInlineControl = ScriptInlineControl;
56923
58313
  exports.ScrollArea = ScrollArea;
56924
58314
  exports.Select = Select;
58315
+ exports.SelectedChip = SelectedChip;
56925
58316
  exports.SemanticBadge = SemanticBadge;
56926
58317
  exports.SensorHeroCard = SensorHeroCard;
56927
58318
  exports.SensorInlineControl = SensorInlineControl;
@@ -56970,6 +58361,7 @@ exports.Tooltip = Tooltip;
56970
58361
  exports.TooltipContent = TooltipContent;
56971
58362
  exports.TooltipTrigger = TooltipTrigger;
56972
58363
  exports.Trash2 = Trash2;
58364
+ exports.UnitPicker = UnitPicker;
56973
58365
  exports.VacuumHeroCard = VacuumHeroCard;
56974
58366
  exports.VacuumInlineControl = VacuumInlineControl;
56975
58367
  exports.ValueReadout = ValueReadout;
@@ -56989,13 +58381,16 @@ exports.ZoneEditingProvider = ZoneEditingProvider;
56989
58381
  exports.agentColumnKey = agentColumnKey;
56990
58382
  exports.allDeviceTypeFilterOptions = allDeviceTypeFilterOptions;
56991
58383
  exports.applyFleetQueryDefaults = applyFleetQueryDefaults;
58384
+ exports.applyPickerFilters = applyPickerFilters;
56992
58385
  exports.ariaSortForColumn = ariaSortForColumn;
56993
58386
  exports.badgeFor = badgeFor;
56994
58387
  exports.badgeFor$1 = badgeFor;
56995
58388
  exports.buildStepTreeFromSchema = buildStepTreeFromSchema;
56996
58389
  exports.childEntityId = childEntityId;
56997
58390
  exports.childListName = childListName;
58391
+ exports.chipOverflow = chipOverflow;
56998
58392
  exports.classifyCapability = classifyCapability;
58393
+ exports.clearPickerGroup = clearPickerGroup;
56999
58394
  exports.cn = cn;
57000
58395
  exports.columnsForContext = columnsForContext;
57001
58396
  exports.containerChildToRef = containerChildToRef;
@@ -57014,6 +58409,7 @@ exports.deriveDeviceKind = deriveDeviceKind;
57014
58409
  exports.deriveWebrtcDebugStats = deriveWebrtcDebugStats;
57015
58410
  exports.describeDeviceSelector = describeDeviceSelector;
57016
58411
  exports.deviceIntegrationResolver = deviceIntegrationResolver;
58412
+ exports.deviceListStateOf = deviceListStateOf;
57017
58413
  exports.deviceMatchesFilter = deviceMatchesFilter;
57018
58414
  exports.deviceOptionLabel = deviceOptionLabel;
57019
58415
  exports.deviceRoleMeta = deviceRoleMeta;
@@ -57022,11 +58418,15 @@ exports.deviceTypeLabel = deviceTypeLabel;
57022
58418
  exports.deviceTypeMeta = deviceTypeMeta;
57023
58419
  exports.deviceTypeMetaOf = deviceTypeMetaOf;
57024
58420
  exports.devicesToOptions = devicesToOptions;
58421
+ exports.emptyDevicesWidens = emptyDevicesWidens;
57025
58422
  exports.ensureMfHostInit = ensureMfHostInit;
57026
58423
  exports.eventKindLabel = eventKindLabel;
57027
58424
  exports.filterDataTableRows = filterDataTableRows;
57028
58425
  exports.filterDeviceOptions = filterDeviceOptions;
58426
+ exports.filterPickerDevices = filterPickerDevices;
57029
58427
  exports.findTimezone = _camstack_types.findTimezone;
58428
+ exports.flattenPickerGrouping = flattenPickerGrouping;
58429
+ exports.flattenPickerRows = flattenPickerRows;
57030
58430
  exports.formatControlDateTime = formatControlDateTime;
57031
58431
  exports.formatLastSeen = formatLastSeen;
57032
58432
  exports.formatNumeric = formatNumeric;
@@ -57036,6 +58436,8 @@ exports.getClassColor = getClassColor;
57036
58436
  exports.getPhaseVisual = getPhaseVisual;
57037
58437
  exports.groupAgentColumns = groupAgentColumns;
57038
58438
  exports.groupChildrenByLayout = groupChildrenByLayout;
58439
+ exports.groupPickerDevices = groupPickerDevices;
58440
+ exports.groupPickerDevicesBy = groupPickerDevicesBy;
57039
58441
  exports.hardwareLabel = hardwareLabel;
57040
58442
  exports.hubOrigin = hubOrigin;
57041
58443
  exports.hubPath = hubPath;
@@ -57045,10 +58447,12 @@ exports.hubWsUrl = hubWsUrl;
57045
58447
  exports.humanDuration = humanDuration;
57046
58448
  exports.humidifierTint = humidifierTint;
57047
58449
  exports.initialScrubState = initialScrubState;
58450
+ exports.insertAtSelection = insertAtSelection;
57048
58451
  exports.isAbsentProvider = isAbsentProvider;
57049
58452
  exports.isAbsentProvider$1 = isAbsentProvider;
57050
58453
  exports.isDataTableFilterActive = isDataTableFilterActive;
57051
58454
  exports.isFieldVisible = isFieldVisible;
58455
+ exports.isGroupExpanded = isGroupExpanded;
57052
58456
  exports.lawnMowerActivityMeta = lawnMowerActivityMeta;
57053
58457
  exports.lightColors = require_theme_index.lightColors$1;
57054
58458
  exports.loadRemoteBundle = loadRemoteBundle;
@@ -57063,11 +58467,28 @@ exports.nextSort = nextSort;
57063
58467
  exports.nextSortDirection = nextSortDirection;
57064
58468
  exports.nextTableSort = nextTableSort;
57065
58469
  exports.normalizeForSearch = normalizeForSearch;
58470
+ exports.offListSelections = offListSelections;
57066
58471
  exports.openTopLayer = openTopLayer;
58472
+ exports.orphanDeviceLabel = orphanDeviceLabel;
57067
58473
  exports.overrideEntityIdFromLink = overrideEntityIdFromLink;
58474
+ exports.pageRows = pageRows;
58475
+ exports.parsePickerGroupBy = parsePickerGroupBy;
57068
58476
  exports.parseRecordedServerMessage = parseRecordedServerMessage;
58477
+ exports.pickerFacetFilterShown = pickerFacetFilterShown;
58478
+ exports.pickerFacetKey = pickerFacetKey;
58479
+ exports.pickerFacetOptions = pickerFacetOptions;
58480
+ exports.pickerFiltersActive = pickerFiltersActive;
58481
+ exports.pickerGroupKey = pickerGroupKey;
58482
+ exports.pickerHiddenSelectedCount = pickerHiddenSelectedCount;
58483
+ exports.pickerLocationKey = pickerLocationKey;
58484
+ exports.pickerQueryTokens = pickerQueryTokens;
58485
+ exports.pickerRowFacts = pickerRowFacts;
58486
+ exports.pickerSearchText = pickerSearchText;
58487
+ exports.pickerZoneFilterSuspended = pickerZoneFilterSuspended;
57069
58488
  exports.providerIcons = providerIcons;
58489
+ exports.readStoredGroupBy = readStoredGroupBy;
57070
58490
  exports.resetHubMountForTests = resetHubMountForTests;
58491
+ exports.resetPickerFilters = resetPickerFilters;
57071
58492
  exports.resolveContainerPrimary = resolveContainerPrimary;
57072
58493
  exports.resolveControlAlign = resolveControlAlign;
57073
58494
  exports.resolveDeviceControl = resolveDeviceControl;
@@ -57077,6 +58498,7 @@ exports.resolveEffectiveDefaultModel = resolveEffectiveDefaultModel;
57077
58498
  exports.resolveEffectiveStepModel = resolveEffectiveStepModel;
57078
58499
  exports.resolveEventKindIcon = resolveEventKindIcon;
57079
58500
  exports.resolvePrimaryChild = resolvePrimaryChild;
58501
+ exports.resolveSelectedDevices = resolveSelectedDevices;
57080
58502
  exports.resolveSensorDisplay = resolveSensorDisplay;
57081
58503
  exports.resolveStepDefaultModel = resolveStepDefaultModel;
57082
58504
  exports.resolveTableLayout = resolveTableLayout;
@@ -57084,11 +58506,13 @@ exports.roiTooLarge = roiTooLarge;
57084
58506
  exports.roiTooLarge$1 = roiTooLarge;
57085
58507
  exports.routerBasename = routerBasename;
57086
58508
  exports.scrubReducer = scrubReducer;
58509
+ exports.selectPickerGroup = selectPickerGroup;
57087
58510
  exports.selectedDeviceOptions = selectedDeviceOptions;
57088
58511
  exports.serializeRecordedCommand = serializeRecordedCommand;
57089
58512
  exports.shouldCommit = shouldCommit;
57090
58513
  exports.shouldEmit = shouldEmit;
57091
58514
  exports.shouldUseSingleNode = shouldUseSingleNode;
58515
+ exports.showsNoCamerasHint = showsNoCamerasHint;
57092
58516
  exports.sortRows = sortRows;
57093
58517
  exports.sortRowsByColumn = sortRowsByColumn;
57094
58518
  exports.statusIcons = statusIcons;
@@ -57102,7 +58526,10 @@ exports.tankAlert = tankAlert;
57102
58526
  exports.themeToCss = require_theme_index.themeToCss$1;
57103
58527
  exports.toStationaryOverlayBox = toStationaryOverlayBox;
57104
58528
  exports.toggleDataTableFacet = toggleDataTableFacet;
58529
+ exports.togglePickerDevice = togglePickerDevice;
58530
+ exports.togglePickerKey = togglePickerKey;
57105
58531
  exports.trpc = trpc;
58532
+ exports.typeLabel = typeLabel;
57106
58533
  exports.useAccessoriesGetStatus = useAccessoriesGetStatus;
57107
58534
  exports.useAccessoriesSetChildHidden = useAccessoriesSetChildHidden;
57108
58535
  exports.useAddonPagesListPages = useAddonPagesListPages;
@@ -57225,6 +58652,9 @@ exports.useBrokerTestConnection = useBrokerTestConnection;
57225
58652
  exports.useBrokerTestSettings = useBrokerTestSettings;
57226
58653
  exports.useBrokerUnsubscribe = useBrokerUnsubscribe;
57227
58654
  exports.useButtonPress = useButtonPress;
58655
+ exports.useCalendarsImportFromHomeAssistant = useCalendarsImportFromHomeAssistant;
58656
+ exports.useCalendarsListCalendars = useCalendarsListCalendars;
58657
+ exports.useCalendarsListEvents = useCalendarsListEvents;
57228
58658
  exports.useCameraCredentialsGetCredentials = useCameraCredentialsGetCredentials;
57229
58659
  exports.useCameraCredentialsGetStatus = useCameraCredentialsGetStatus;
57230
58660
  exports.useCameraGridLayoutApplyDeviceSettingsPatch = useCameraGridLayoutApplyDeviceSettingsPatch;
@@ -57715,12 +59145,15 @@ exports.useOptionalWidgetRegistry = useOptionalWidgetRegistry;
57715
59145
  exports.useOsdGetStatus = useOsdGetStatus;
57716
59146
  exports.useOsdManagerClearSlotBinding = useOsdManagerClearSlotBinding;
57717
59147
  exports.useOsdManagerCopyDeviceConfiguration = useOsdManagerCopyDeviceConfiguration;
59148
+ exports.useOsdManagerDeleteTemplate = useOsdManagerDeleteTemplate;
57718
59149
  exports.useOsdManagerGetConditionSupport = useOsdManagerGetConditionSupport;
57719
59150
  exports.useOsdManagerGetDeviceOsd = useOsdManagerGetDeviceOsd;
57720
59151
  exports.useOsdManagerGetSourceCatalog = useOsdManagerGetSourceCatalog;
59152
+ exports.useOsdManagerListTemplates = useOsdManagerListTemplates;
57721
59153
  exports.useOsdManagerPreviewSlot = useOsdManagerPreviewSlot;
57722
59154
  exports.useOsdManagerRenderDevice = useOsdManagerRenderDevice;
57723
59155
  exports.useOsdManagerSetSlotBinding = useOsdManagerSetSlotBinding;
59156
+ exports.useOsdManagerUpsertTemplate = useOsdManagerUpsertTemplate;
57724
59157
  exports.useOsdSetOverlay = useOsdSetOverlay;
57725
59158
  exports.usePTZ = usePTZ;
57726
59159
  exports.usePetFeederCallPet = usePetFeederCallPet;
@@ -57734,6 +59167,7 @@ exports.usePetFeederSetChildLock = usePetFeederSetChildLock;
57734
59167
  exports.usePetFeederSetFeedSound = usePetFeederSetFeedSound;
57735
59168
  exports.usePetFeederSetIndicatorLight = usePetFeederSetIndicatorLight;
57736
59169
  exports.usePetFeederSetVolume = usePetFeederSetVolume;
59170
+ exports.usePickerSiteZones = usePickerSiteZones;
57737
59171
  exports.usePipelineAnalyticsApplyDeviceSettingsPatch = usePipelineAnalyticsApplyDeviceSettingsPatch;
57738
59172
  exports.usePipelineAnalyticsCancelRelocateMedia = usePipelineAnalyticsCancelRelocateMedia;
57739
59173
  exports.usePipelineAnalyticsCancelStorageMigrationMove = usePipelineAnalyticsCancelStorageMigrationMove;
@@ -58093,6 +59527,7 @@ exports.useStorageTestLocation = useStorageTestLocation;
58093
59527
  exports.useStorageUpsertLocation = useStorageUpsertLocation;
58094
59528
  exports.useStorageWrite = useStorageWrite;
58095
59529
  exports.useStorageWriteChunk = useStorageWriteChunk;
59530
+ exports.useStoredGroupBy = useStoredGroupBy;
58096
59531
  exports.useStreamBrokerAcquireEgressTranscode = useStreamBrokerAcquireEgressTranscode;
58097
59532
  exports.useStreamBrokerApplyDeviceSettingsPatch = useStreamBrokerApplyDeviceSettingsPatch;
58098
59533
  exports.useStreamBrokerAssignProfile = useStreamBrokerAssignProfile;
@@ -58267,3 +59702,4 @@ exports.waterHeaterPhase = waterHeaterPhase;
58267
59702
  exports.waterHeaterTint = waterHeaterTint;
58268
59703
  exports.weatherConditionMeta = weatherConditionMeta;
58269
59704
  exports.weatherTint = weatherTint;
59705
+ exports.writeStoredGroupBy = writeStoredGroupBy;