@camstack/ui-library 1.2.112 → 1.2.114

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.
@@ -1,5 +1,4 @@
1
1
  import { ReactElement } from 'react';
2
- import { UseDeviceProxyTrpc } from '../hooks/use-device-proxy';
3
2
  export interface DeviceExportPanelProps {
4
3
  /**
5
4
  * Addon id of the export addon whose surface is rendered. Used for
@@ -8,16 +7,18 @@ export interface DeviceExportPanelProps {
8
7
  */
9
8
  readonly addonId: string;
10
9
  /**
11
- * tRPC client passed straight to the shared `<DeviceList>` so its
12
- * per-row capability atoms (snapshot, switch, …) work. Supply
13
- * `system.trpcClient` from `useSystem()` at the mount site.
10
+ * Optional callback to open a device-details page.
11
+ *
12
+ * There is no `trpc` prop. It existed to feed the device-mode list's per-row
13
+ * capability atoms (snapshot, switch, live control); this surface renders
14
+ * type + name + export and asks a device nothing, so the prop was removed
15
+ * rather than left as a parameter every caller still threads and nothing
16
+ * reads.
14
17
  */
15
- readonly trpc: UseDeviceProxyTrpc;
16
- /** Optional callback to open a device-details page. */
17
18
  readonly onOpenDevice?: (deviceId: string) => void;
18
19
  }
19
20
  /**
20
21
  * Generic device-export panel. Addon-agnostic — only the `device-export`
21
22
  * cap drives it.
22
23
  */
23
- export declare function DeviceExportPanel({ addonId, trpc, onOpenDevice, }: DeviceExportPanelProps): ReactElement;
24
+ export declare function DeviceExportPanel({ addonId, onOpenDevice }: DeviceExportPanelProps): ReactElement;
@@ -20,6 +20,12 @@ export interface DeviceSelectCandidate {
20
20
  readonly name: string;
21
21
  readonly location?: string | null;
22
22
  readonly features?: readonly string[];
23
+ /** Name of the container this device lives in, when it has one. */
24
+ readonly parentName?: string | null;
25
+ }
26
+ /** A picker option that still knows where its device is. */
27
+ export interface DeviceOption extends ConfigOption {
28
+ readonly location: string | null;
23
29
  }
24
30
  /**
25
31
  * Does a device satisfy the filter?
@@ -37,13 +43,33 @@ export declare function deviceOptionLabel(device: DeviceSelectCandidate): string
37
43
  * (value = the device id as a string). Sorted case-insensitively by label so
38
44
  * the picker order is stable.
39
45
  */
40
- export declare function devicesToOptions(devices: readonly DeviceSelectCandidate[], filter?: DeviceSelectFilter): ConfigOption[];
46
+ /** `Location · type · in Container` — the parts a name alone does not say. */
47
+ export declare function deviceOptionDescription(device: DeviceSelectCandidate): string;
48
+ export declare function devicesToOptions(devices: readonly DeviceSelectCandidate[], filter?: DeviceSelectFilter): DeviceOption[];
49
+ export interface DeviceFacets {
50
+ readonly locations: readonly string[];
51
+ readonly types: readonly string[];
52
+ }
53
+ /** The distinct locations and types on offer — the chips above the search. */
54
+ export declare function deviceFacets(devices: readonly DeviceSelectCandidate[]): DeviceFacets;
55
+ export interface FacetSelection {
56
+ readonly location?: string;
57
+ readonly type?: string;
58
+ }
59
+ /** Every chosen facet must match; an unchosen one matches everything. */
60
+ export declare function matchesFacets(device: DeviceSelectCandidate, facets: FacetSelection): boolean;
61
+ export interface SuggestedPartition<T extends DeviceOption> {
62
+ readonly suggested: readonly T[];
63
+ readonly others: readonly T[];
64
+ }
65
+ /** The devices sharing `location` come first — suggested, never pre-linked. */
66
+ export declare function partitionSuggested<T extends DeviceOption>(options: readonly T[], location: string | null | undefined): SuggestedPartition<T>;
41
67
  /**
42
68
  * Free-text option filter — case-insensitive AND over whitespace tokens, each
43
69
  * matched against the option's `value` + `label` (so a device is found by name,
44
70
  * id, or location). An empty query returns every option in order.
45
71
  */
46
- export declare function filterDeviceOptions(options: readonly ConfigOption[], query: string): ConfigOption[];
72
+ export declare function filterDeviceOptions<T extends ConfigOption>(options: readonly T[], query: string): T[];
47
73
  /**
48
74
  * Resolve the currently-selected values into options IN SELECTION ORDER (so
49
75
  * chips stay stable while searching). A selected value with no matching option
package/dist/index.cjs CHANGED
@@ -6903,6 +6903,7 @@ function intersects(a, b) {
6903
6903
  */
6904
6904
  function deviceMatchesFilter(device, filter) {
6905
6905
  if (filter?.excludeDeviceId !== void 0 && device.id === filter.excludeDeviceId) return false;
6906
+ if (filter?.excludeTypes !== void 0 && filter.excludeTypes.includes(device.type)) return false;
6906
6907
  const hasCaps = filter?.caps !== void 0 && filter.caps.length > 0;
6907
6908
  const hasTypes = filter?.types !== void 0 && filter.types.length > 0;
6908
6909
  if (!hasCaps && !hasTypes) return true;
@@ -6919,12 +6920,52 @@ function deviceOptionLabel(device) {
6919
6920
  * (value = the device id as a string). Sorted case-insensitively by label so
6920
6921
  * the picker order is stable.
6921
6922
  */
6923
+ /** `Location · type · in Container` — the parts a name alone does not say. */
6924
+ function deviceOptionDescription(device) {
6925
+ const parts = [];
6926
+ if (typeof device.location === "string" && device.location.length > 0) parts.push(device.location);
6927
+ parts.push(device.type);
6928
+ if (typeof device.parentName === "string" && device.parentName.length > 0) parts.push(`in ${device.parentName}`);
6929
+ return parts.join(" · ");
6930
+ }
6922
6931
  function devicesToOptions(devices, filter) {
6923
6932
  return devices.filter((d) => deviceMatchesFilter(d, filter)).map((d) => ({
6924
6933
  value: String(d.id),
6925
- label: deviceOptionLabel(d)
6934
+ label: deviceOptionLabel(d),
6935
+ description: deviceOptionDescription(d),
6936
+ location: typeof d.location === "string" && d.location.length > 0 ? d.location : null
6926
6937
  })).toSorted((a, b) => a.label.localeCompare(b.label));
6927
6938
  }
6939
+ /** The distinct locations and types on offer — the chips above the search. */
6940
+ function deviceFacets(devices) {
6941
+ const locations = /* @__PURE__ */ new Set();
6942
+ const types = /* @__PURE__ */ new Set();
6943
+ for (const d of devices) {
6944
+ if (typeof d.location === "string" && d.location.length > 0) locations.add(d.location);
6945
+ types.add(d.type);
6946
+ }
6947
+ return {
6948
+ locations: [...locations].toSorted((a, b) => a.localeCompare(b)),
6949
+ types: [...types].toSorted((a, b) => a.localeCompare(b))
6950
+ };
6951
+ }
6952
+ /** Every chosen facet must match; an unchosen one matches everything. */
6953
+ function matchesFacets(device, facets) {
6954
+ if (facets.location !== void 0 && device.location !== facets.location) return false;
6955
+ if (facets.type !== void 0 && device.type !== facets.type) return false;
6956
+ return true;
6957
+ }
6958
+ /** The devices sharing `location` come first — suggested, never pre-linked. */
6959
+ function partitionSuggested(options, location) {
6960
+ if (location === null || location === void 0 || location.length === 0) return {
6961
+ suggested: [],
6962
+ others: [...options]
6963
+ };
6964
+ return {
6965
+ suggested: options.filter((o) => o.location === location),
6966
+ others: options.filter((o) => o.location !== location)
6967
+ };
6968
+ }
6928
6969
  /**
6929
6970
  * Free-text option filter — case-insensitive AND over whitespace tokens, each
6930
6971
  * matched against the option's `value` + `label` (so a device is found by name,
@@ -6934,7 +6975,7 @@ function filterDeviceOptions(options, query) {
6934
6975
  const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
6935
6976
  if (tokens.length === 0) return [...options];
6936
6977
  return options.filter((opt) => {
6937
- const hay = `${opt.value} ${opt.label}`.toLowerCase();
6978
+ const hay = `${opt.value} ${opt.label} ${opt.description ?? ""}`.toLowerCase();
6938
6979
  return tokens.every((t) => hay.includes(t));
6939
6980
  });
6940
6981
  }
@@ -6973,21 +7014,61 @@ var DESC_CLASS$1 = "text-[10px] text-foreground-subtle mt-0.5 break-words";
6973
7014
  /** The compact projection of the listAll rows the device fields consume. */
6974
7015
  function toCandidates(rows) {
6975
7016
  if (!Array.isArray(rows)) return [];
7017
+ const nameById = /* @__PURE__ */ new Map();
7018
+ for (const row of rows) {
7019
+ if (typeof row !== "object" || row === null) continue;
7020
+ const r = row;
7021
+ if (typeof r.id === "number" && typeof r.name === "string") nameById.set(r.id, r.name);
7022
+ }
6976
7023
  const out = [];
6977
7024
  for (const row of rows) {
6978
7025
  if (typeof row !== "object" || row === null) continue;
6979
7026
  const r = row;
6980
7027
  if (typeof r.id !== "number" || typeof r.type !== "string" || typeof r.name !== "string") continue;
7028
+ const parentName = typeof r.parentDeviceId === "number" ? nameById.get(r.parentDeviceId) ?? null : null;
6981
7029
  out.push({
6982
7030
  id: r.id,
6983
7031
  type: r.type,
6984
7032
  name: r.name,
6985
7033
  location: typeof r.location === "string" ? r.location : null,
6986
- features: Array.isArray(r.features) ? r.features.filter((f) => typeof f === "string") : []
7034
+ features: Array.isArray(r.features) ? r.features.filter((f) => typeof f === "string") : [],
7035
+ parentName
6987
7036
  });
6988
7037
  }
6989
7038
  return out;
6990
7039
  }
7040
+ var FACET_CHIP = "rounded-full border px-1.5 py-0.5 text-[10px] leading-4 transition-colors cursor-pointer";
7041
+ var FACET_CHIP_ON = `${FACET_CHIP} border-primary bg-primary/10 text-primary`;
7042
+ var FACET_CHIP_OFF = `${FACET_CHIP} border-border text-foreground-subtle hover:bg-surface-hover`;
7043
+ var GROUP_HEADING = "px-2.5 pt-1.5 pb-0.5 text-[10px] font-medium uppercase tracking-wide text-foreground-subtle";
7044
+ /** One row of facet chips: `All` plus every distinct value. */
7045
+ function FacetRow({ label, values, active, onPick }) {
7046
+ if (values.length < 2) return null;
7047
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7048
+ className: "flex flex-wrap items-center gap-1 mb-1",
7049
+ role: "group",
7050
+ "aria-label": label,
7051
+ children: [
7052
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7053
+ className: "text-[10px] text-foreground-subtle mr-0.5",
7054
+ children: label
7055
+ }),
7056
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
7057
+ type: "button",
7058
+ className: active === void 0 ? FACET_CHIP_ON : FACET_CHIP_OFF,
7059
+ onClick: () => onPick(void 0),
7060
+ children: "All"
7061
+ }),
7062
+ values.map((v) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
7063
+ type: "button",
7064
+ className: active === v ? FACET_CHIP_ON : FACET_CHIP_OFF,
7065
+ "aria-pressed": active === v,
7066
+ onClick: () => onPick(active === v ? void 0 : v),
7067
+ children: v
7068
+ }, v))
7069
+ ]
7070
+ });
7071
+ }
6991
7072
  function FieldShell({ label, description, required, translationFn, children }) {
6992
7073
  const resolvedLabel = resolveLabel(label, translationFn);
6993
7074
  const resolvedDescription = resolveLabel(description, translationFn);
@@ -7035,7 +7116,16 @@ function DeviceSelectField({ field, value, onChange, disabled, translationFn })
7035
7116
  }
7036
7117
  function DeviceMultiSelectField({ field, value, onChange, disabled, translationFn }) {
7037
7118
  const { data } = useDeviceManagerListAll({});
7038
- const options = (0, react$1.useMemo)(() => devicesToOptions(toCandidates(data), field.deviceFilter), [data, field.deviceFilter]);
7119
+ const smart = field.suggestLocation !== void 0;
7120
+ const [facets, setFacets] = (0, react$1.useState)({});
7121
+ const candidates = (0, react$1.useMemo)(() => toCandidates(data), [data]);
7122
+ const facetValues = (0, react$1.useMemo)(() => deviceFacets(candidates.filter((c) => c.id !== field.deviceFilter?.excludeDeviceId)), [candidates, field.deviceFilter?.excludeDeviceId]);
7123
+ const candidatesInScope = (0, react$1.useMemo)(() => smart ? candidates.filter((c) => matchesFacets(c, facets)) : candidates, [
7124
+ candidates,
7125
+ facets,
7126
+ smart
7127
+ ]);
7128
+ const options = (0, react$1.useMemo)(() => devicesToOptions(candidatesInScope, field.deviceFilter), [candidatesInScope, field.deviceFilter]);
7039
7129
  const selected = Array.isArray(value) ? value.map(String) : [];
7040
7130
  const isDisabled = disabled === true || field.disabled === true;
7041
7131
  const atMax = field.maxItems !== void 0 && selected.length >= field.maxItems;
@@ -7045,7 +7135,13 @@ function DeviceMultiSelectField({ field, value, onChange, disabled, translationF
7045
7135
  const inputRef = (0, react$1.useRef)(null);
7046
7136
  const blurTimer = (0, react$1.useRef)(null);
7047
7137
  const selectedOptions = (0, react$1.useMemo)(() => selectedDeviceOptions(options, selected), [options, selected]);
7048
- const filtered = (0, react$1.useMemo)(() => filterDeviceOptions(options, query), [options, query]);
7138
+ const searched = (0, react$1.useMemo)(() => filterDeviceOptions(options, query), [options, query]);
7139
+ const partition = (0, react$1.useMemo)(() => partitionSuggested(searched, smart ? field.suggestLocation : null), [
7140
+ searched,
7141
+ smart,
7142
+ field.suggestLocation
7143
+ ]);
7144
+ const filtered = (0, react$1.useMemo)(() => [...partition.suggested, ...partition.others], [partition]);
7049
7145
  const toggle = (optValue) => {
7050
7146
  if (isDisabled) return;
7051
7147
  if (selected.includes(optValue)) onChange(selected.filter((v) => v !== optValue));
@@ -7095,6 +7191,23 @@ function DeviceMultiSelectField({ field, value, onChange, disabled, translationF
7095
7191
  })]
7096
7192
  }, opt.value))
7097
7193
  }),
7194
+ smart && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(FacetRow, {
7195
+ label: "Location",
7196
+ values: facetValues.locations,
7197
+ active: facets.location,
7198
+ onPick: (location) => setFacets((f) => ({
7199
+ ...f,
7200
+ location
7201
+ }))
7202
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FacetRow, {
7203
+ label: "Type",
7204
+ values: facetValues.types,
7205
+ active: facets.type,
7206
+ onPick: (type) => setFacets((f) => ({
7207
+ ...f,
7208
+ type
7209
+ }))
7210
+ })] }),
7098
7211
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7099
7212
  className: INPUT_SHELL + (isDisabled ? " opacity-50" : ""),
7100
7213
  children: [
@@ -7144,7 +7257,12 @@ function DeviceMultiSelectField({ field, value, onChange, disabled, translationF
7144
7257
  }) : filtered.map((opt, i) => {
7145
7258
  const checked = selected.includes(opt.value);
7146
7259
  const blockedByMax = !checked && atMax;
7147
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
7260
+ const suggestedCount = partition.suggested.length;
7261
+ const heading = !smart ? null : i === 0 && suggestedCount > 0 ? `Suggested · ${field.suggestLocation ?? ""}` : i === suggestedCount && suggestedCount > 0 ? "Other devices" : null;
7262
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [heading !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
7263
+ className: GROUP_HEADING,
7264
+ children: heading
7265
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
7148
7266
  type: "button",
7149
7267
  role: "option",
7150
7268
  "aria-selected": checked,
@@ -7159,11 +7277,17 @@ function DeviceMultiSelectField({ field, value, onChange, disabled, translationF
7159
7277
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7160
7278
  className: ["flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border", checked ? "border-primary bg-primary text-white" : "border-border"].join(" "),
7161
7279
  children: checked && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Check, { className: "h-2.5 w-2.5" })
7162
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7163
- className: "truncate text-foreground",
7164
- children: opt.label
7280
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
7281
+ className: "min-w-0 flex-1",
7282
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7283
+ className: "block truncate text-foreground",
7284
+ children: opt.label
7285
+ }), opt.description !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7286
+ className: "block truncate text-[10px] text-foreground-subtle",
7287
+ children: opt.description
7288
+ })]
7165
7289
  })]
7166
- }, opt.value);
7290
+ })] }, opt.value);
7167
7291
  })
7168
7292
  }),
7169
7293
  description && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
@@ -43813,12 +43937,33 @@ function DeviceList(props) {
43813
43937
  * - a link-state status badge + exposed-device count from `getStatus`,
43814
43938
  * - an optional "Setup" section (pairing QR + copyable fields) from
43815
43939
  * the cap's `setup` block,
43816
- * - ONE unified devices overview — the shared `<DeviceList>` table
43817
- * restricted to this exporter's `listSupportedDeviceKinds`, with a
43818
- * per-row Expose/Exposed toggle wired through the list's
43819
- * `renderRowAction` extension point. Each row reflects whether the
43820
- * device is exposed to THIS integration and lets the operator
43821
- * toggle it directly from the integration page.
43940
+ * - ONE unified devices overview — the shared `<DeviceList>` in its
43941
+ * GENERIC mode (the same table `/integrations` renders), restricted
43942
+ * to this exporter's `listSupportedDeviceKinds`.
43943
+ *
43944
+ * ── What an entry shows, and what it deliberately does not ───────────
43945
+ *
43946
+ * Three columns plus a selection box: device TYPE, NAME, EXPORT. That is
43947
+ * the whole row.
43948
+ *
43949
+ * It used to be the full device-mode row — Hardware (manufacturer ·
43950
+ * model), Features, and the merged Preview cell carrying the per-type
43951
+ * live control, the battery/charging pills, the snapshot eye, the reboot
43952
+ * quick-action, the PTZ button and the reachability dot. Every one of
43953
+ * those is reachable on `/devices` and on the device's own page; none of
43954
+ * them existed only here, which is the ONLY reason they were removable.
43955
+ * The Expose toggle is the affordance this surface owns, so it stays —
43956
+ * and the row click still opens the device page.
43957
+ *
43958
+ * ── Multi-select, and why every run reports ──────────────────────────
43959
+ *
43960
+ * Selecting rows exposes (or unexposes) them in one go. The toolbar
43961
+ * always names the COUNT the buttons will act on, and every run — bulk
43962
+ * or single-row — ends in a summary that names the devices on both
43963
+ * sides. A generic "export failed" would leave the operator re-running
43964
+ * the whole set to discover which half landed; and the previous
43965
+ * single-row toggle used `mutate` with no `onError`, so a refused
43966
+ * accessory changed nothing on screen at all.
43822
43967
  *
43823
43968
  * This composite is the reusable replacement for the per-addon
43824
43969
  * Module-Federation overview tables that each export addon used to
@@ -43933,31 +44078,84 @@ function SetupSection({ setup }) {
43933
44078
  });
43934
44079
  }
43935
44080
  /**
43936
- * Per-row Expose/Exposed toggle — the `renderRowAction` injected into
43937
- * `<DeviceList>`. Reflects whether the row's device is exposed to THIS
43938
- * exporter and flips it via expose/unexpose, routed by `addonId`.
44081
+ * Per-row Expose/Exposed toggle. Reflects whether the row's device is exposed
44082
+ * to THIS exporter and flips it through the SAME runner the bulk buttons use —
44083
+ * so a single-row refusal is reported by name instead of vanishing, which is
44084
+ * what the previous fire-and-forget `mutate` did.
44085
+ *
44086
+ * The accessible name carries the device name (`Expose Garden cam`): several
44087
+ * of these live in one table, and "Expose" alone names none of them.
43939
44088
  */
43940
- function ExportRowToggle({ addonId, deviceId, exposed, busy, onExpose, onUnexpose }) {
44089
+ function ExportRowToggle({ addonId, device, exposed, busy, onToggle }) {
43941
44090
  const handleClick = (e) => {
43942
44091
  e.stopPropagation();
43943
44092
  if (busy) return;
43944
- if (exposed) onUnexpose(deviceId);
43945
- else onExpose(deviceId);
44093
+ onToggle(device, exposed ? "unexpose" : "expose");
43946
44094
  };
43947
44095
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
43948
44096
  size: "sm",
43949
44097
  variant: exposed ? "ghost" : "primary",
43950
44098
  disabled: busy,
43951
44099
  onClick: handleClick,
44100
+ "aria-label": `${exposed ? "Unexpose" : "Expose"} ${device.name}`,
43952
44101
  title: exposed ? `Stop exposing to "${addonId}"` : `Expose to "${addonId}"`,
43953
44102
  children: exposed ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Unlink2, { className: "mr-1 h-3.5 w-3.5" }), "Exposed"] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Share2, { className: "mr-1 h-3.5 w-3.5" }), "Expose"] })
43954
44103
  });
43955
44104
  }
43956
44105
  /**
44106
+ * The result of the last run, named device by device.
44107
+ *
44108
+ * Rendered for a CLEAN run too. "It probably worked" is not something an
44109
+ * operator should have to infer from silence, and a summary that appears only
44110
+ * on failure trains them to read no news as good news right up until the one
44111
+ * time the render itself was what failed.
44112
+ */
44113
+ function BulkSummary({ outcome, onDismiss }) {
44114
+ const verb = outcome.action === "expose" ? "Exposed" : "Unexposed";
44115
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
44116
+ "data-testid": "export-bulk-summary",
44117
+ className: `flex flex-col gap-1 px-4 py-2 border-b border-border text-[11px] ${outcome.failed.length === 0 ? "text-foreground-subtle" : "text-danger"}`,
44118
+ children: [
44119
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
44120
+ className: "flex items-center justify-between gap-2",
44121
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
44122
+ className: "font-medium",
44123
+ children: [
44124
+ verb,
44125
+ " ",
44126
+ outcome.succeeded.length,
44127
+ " of ",
44128
+ outcome.total,
44129
+ " selected device",
44130
+ outcome.total === 1 ? "" : "s",
44131
+ "."
44132
+ ]
44133
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
44134
+ size: "sm",
44135
+ variant: "ghost",
44136
+ onClick: onDismiss,
44137
+ "aria-label": "Dismiss export result",
44138
+ children: "Dismiss"
44139
+ })]
44140
+ }),
44141
+ outcome.succeeded.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
44142
+ className: "text-foreground-subtle",
44143
+ children: ["Succeeded: ", outcome.succeeded.join(", ")]
44144
+ }),
44145
+ outcome.failed.map((failure) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
44146
+ "Failed: ",
44147
+ failure.label,
44148
+ " — ",
44149
+ failure.message
44150
+ ] }, failure.label))
44151
+ ]
44152
+ });
44153
+ }
44154
+ /**
43957
44155
  * Generic device-export panel. Addon-agnostic — only the `device-export`
43958
44156
  * cap drives it.
43959
44157
  */
43960
- function DeviceExportPanel({ addonId, trpc, onOpenDevice }) {
44158
+ function DeviceExportPanel({ addonId, onOpenDevice }) {
43961
44159
  const queryClient = (0, _tanstack_react_query.useQueryClient)();
43962
44160
  const statusQuery = useDeviceExportGetStatus({
43963
44161
  nodeId: "hub",
@@ -44019,38 +44217,171 @@ function DeviceExportPanel({ addonId, trpc, onOpenDevice }) {
44019
44217
  const loading = statusQuery.isLoading || exposedQuery.isLoading || kindsQuery.isLoading || devicesQuery.isLoading;
44020
44218
  const exposedCount = status?.exposedDeviceCount ?? exposedIds.size;
44021
44219
  const busy = exposeMutation.isPending || unexposeMutation.isPending;
44022
- const onExpose = (0, react$1.useCallback)((deviceId) => {
44023
- exposeMutation.mutate({
44024
- deviceId,
44025
- nodeId: "hub",
44026
- addonId
44220
+ const [selectedIds, setSelectedIds] = (0, react$1.useState)(() => /* @__PURE__ */ new Set());
44221
+ const [outcome, setOutcome] = (0, react$1.useState)(null);
44222
+ const [running, setRunning] = (0, react$1.useState)(false);
44223
+ const toggleSelection = (0, react$1.useCallback)((deviceId) => {
44224
+ setSelectedIds((current) => {
44225
+ const next = new Set(current);
44226
+ if (next.has(deviceId)) next.delete(deviceId);
44227
+ else next.add(deviceId);
44228
+ return next;
44027
44229
  });
44028
- }, [exposeMutation, addonId]);
44029
- const onUnexpose = (0, react$1.useCallback)((deviceId) => {
44030
- unexposeMutation.mutate({
44230
+ }, []);
44231
+ const clearSelection = (0, react$1.useCallback)(() => setSelectedIds(/* @__PURE__ */ new Set()), []);
44232
+ const dismissOutcome = (0, react$1.useCallback)(() => setOutcome(null), []);
44233
+ /** Display label for a device id — its name, or the raw id if it left the fleet. */
44234
+ const labelFor = (0, react$1.useCallback)((deviceId) => candidateDevices.find((d) => String(d.id) === deviceId)?.name ?? `#${deviceId}`, [candidateDevices]);
44235
+ /**
44236
+ * The ONE path an expose/unexpose takes — a single row's toggle is a run of
44237
+ * one. Every device is dispatched, every result is recorded, and a failure is
44238
+ * both logged (with `deviceId`, so it can be asked about per-camera) and
44239
+ * named on screen. Nothing here can drop a device quietly: the loop visits
44240
+ * the whole input and each visit lands in `succeeded` or in `failed`.
44241
+ */
44242
+ const runExport = (0, react$1.useCallback)(async (action, deviceIds) => {
44243
+ if (deviceIds.length === 0 || running) return;
44244
+ setRunning(true);
44245
+ setOutcome(null);
44246
+ const succeeded = [];
44247
+ const failed = [];
44248
+ const mutation = action === "expose" ? exposeMutation : unexposeMutation;
44249
+ (await Promise.allSettled(deviceIds.map((deviceId) => mutation.mutateAsync({
44031
44250
  deviceId,
44032
44251
  nodeId: "hub",
44033
44252
  addonId
44253
+ })))).forEach((result, index) => {
44254
+ const deviceId = deviceIds[index] ?? "";
44255
+ const label = labelFor(deviceId);
44256
+ if (result.status === "fulfilled") {
44257
+ succeeded.push(label);
44258
+ return;
44259
+ }
44260
+ const message = result.reason instanceof Error ? result.reason.message : String(result.reason);
44261
+ console.error(`[device-export] ${action} failed`, {
44262
+ tags: { deviceId: Number.parseInt(deviceId, 10) },
44263
+ addonId,
44264
+ error: message
44265
+ });
44266
+ failed.push({
44267
+ label,
44268
+ message
44269
+ });
44034
44270
  });
44035
- }, [unexposeMutation, addonId]);
44036
- const renderRowAction = (0, react$1.useCallback)((device) => {
44037
- const id = String(device.id);
44038
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ExportRowToggle, {
44039
- addonId,
44040
- deviceId: id,
44041
- exposed: exposedIds.has(id),
44042
- busy,
44043
- onExpose,
44044
- onUnexpose
44271
+ setOutcome({
44272
+ action,
44273
+ total: deviceIds.length,
44274
+ succeeded,
44275
+ failed
44045
44276
  });
44277
+ setSelectedIds(/* @__PURE__ */ new Set());
44278
+ setRunning(false);
44046
44279
  }, [
44047
44280
  addonId,
44048
- exposedIds,
44281
+ exposeMutation,
44282
+ unexposeMutation,
44283
+ labelFor,
44284
+ running
44285
+ ]);
44286
+ const selectedList = (0, react$1.useMemo)(() => [...selectedIds], [selectedIds]);
44287
+ const exposeSelected = (0, react$1.useCallback)(() => {
44288
+ runExport("expose", selectedList);
44289
+ }, [runExport, selectedList]);
44290
+ const unexposeSelected = (0, react$1.useCallback)(() => {
44291
+ runExport("unexpose", selectedList);
44292
+ }, [runExport, selectedList]);
44293
+ const onRowToggle = (0, react$1.useCallback)((device, action) => {
44294
+ runExport(action, [String(device.id)]);
44295
+ }, [runExport]);
44296
+ const onRowClick = (0, react$1.useCallback)((device) => onOpenDevice?.(String(device.id)), [onOpenDevice]);
44297
+ const getKey = (0, react$1.useCallback)((device) => String(device.id), []);
44298
+ const getSearchText = (0, react$1.useCallback)((device) => `${device.name} ${device.stableId}`, []);
44299
+ const getFilterType = (0, react$1.useCallback)((device) => device.type, []);
44300
+ const columns = (0, react$1.useMemo)(() => [
44301
+ {
44302
+ key: "select",
44303
+ header: "",
44304
+ headerClassName: "w-8",
44305
+ cell: (device) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
44306
+ type: "checkbox",
44307
+ checked: selectedIds.has(String(device.id)),
44308
+ onChange: () => toggleSelection(String(device.id)),
44309
+ onClick: (e) => e.stopPropagation(),
44310
+ "aria-label": `Select ${device.name}`
44311
+ })
44312
+ },
44313
+ {
44314
+ key: "type",
44315
+ header: "Type",
44316
+ headerClassName: "w-32",
44317
+ cell: (device) => {
44318
+ const meta = deviceTypeMetaOf(device.type);
44319
+ const TypeIcon = meta.icon;
44320
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
44321
+ className: "inline-flex items-center gap-1.5 text-[11px] text-foreground-subtle",
44322
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TypeIcon, { className: "h-3.5 w-3.5 shrink-0" }), meta.label]
44323
+ });
44324
+ }
44325
+ },
44326
+ {
44327
+ key: "name",
44328
+ header: "Name",
44329
+ cell: (device) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
44330
+ className: "text-xs font-medium text-foreground",
44331
+ children: device.name
44332
+ })
44333
+ },
44334
+ {
44335
+ key: "export",
44336
+ header: "Export",
44337
+ headerClassName: "w-32 text-right",
44338
+ cellClassName: "text-right",
44339
+ cell: (device) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ExportRowToggle, {
44340
+ addonId,
44341
+ device,
44342
+ exposed: exposedIds.has(String(device.id)),
44343
+ busy,
44344
+ onToggle: onRowToggle
44345
+ })
44346
+ }
44347
+ ], [
44348
+ addonId,
44049
44349
  busy,
44050
- onExpose,
44051
- onUnexpose
44350
+ exposedIds,
44351
+ onRowToggle,
44352
+ selectedIds,
44353
+ toggleSelection
44052
44354
  ]);
44053
- const onNavigate = (0, react$1.useCallback)((deviceId) => onOpenDevice?.(String(deviceId)), [onOpenDevice]);
44355
+ const selectionToolbar = selectedIds.size === 0 ? void 0 : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
44356
+ className: "inline-flex items-center gap-1.5",
44357
+ children: [
44358
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
44359
+ "data-testid": "export-selection-count",
44360
+ className: "text-[11px] text-foreground-subtle",
44361
+ children: [selectedIds.size, " selected"]
44362
+ }),
44363
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
44364
+ size: "sm",
44365
+ variant: "primary",
44366
+ disabled: busy,
44367
+ onClick: exposeSelected,
44368
+ children: ["Expose ", selectedIds.size]
44369
+ }),
44370
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
44371
+ size: "sm",
44372
+ variant: "secondary",
44373
+ disabled: busy,
44374
+ onClick: unexposeSelected,
44375
+ children: ["Unexpose ", selectedIds.size]
44376
+ }),
44377
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
44378
+ size: "sm",
44379
+ variant: "ghost",
44380
+ onClick: clearSelection,
44381
+ children: "Clear"
44382
+ })
44383
+ ]
44384
+ });
44054
44385
  const hasSupportedKinds = supportedKindList.length > 0;
44055
44386
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
44056
44387
  className: "rounded-lg border border-border bg-surface overflow-hidden",
@@ -44081,19 +44412,27 @@ function DeviceExportPanel({ addonId, trpc, onOpenDevice }) {
44081
44412
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorBox, { message: status.error })
44082
44413
  }),
44083
44414
  status?.setup && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SetupSection, { setup: status.setup }),
44415
+ outcome !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BulkSummary, {
44416
+ outcome,
44417
+ onDismiss: dismissOutcome
44418
+ }),
44084
44419
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
44085
44420
  className: "p-3",
44086
44421
  children: loading && candidateDevices.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
44087
44422
  className: "flex items-center gap-2 px-1 py-6 text-xs text-foreground-subtle",
44088
44423
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoaderCircle, { className: "h-3.5 w-3.5 animate-spin" }), "Loading exportable devices…"]
44089
44424
  }) : hasSupportedKinds && candidateDevices.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DeviceList, {
44090
- devices: candidateDevices,
44091
- trpc,
44092
- filters: { typeOptions: supportedTypeOptions },
44093
- urlScope: "nested",
44094
- lsKey: `device-export:${addonId}`,
44095
- onNavigate: onOpenDevice ? onNavigate : void 0,
44096
- renderRowAction
44425
+ mode: "generic",
44426
+ items: candidateDevices,
44427
+ getKey,
44428
+ getSearchText,
44429
+ getFilterType,
44430
+ filterOptions: supportedTypeOptions,
44431
+ columns,
44432
+ onRowClick: onOpenDevice ? onRowClick : void 0,
44433
+ toolbar: selectionToolbar,
44434
+ searchPlaceholder: "Search by name or stableId",
44435
+ emptyLabel: "No device matches this search."
44097
44436
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EmptyState, {
44098
44437
  icon: Share2,
44099
44438
  title: "No exportable devices",
package/dist/index.js CHANGED
@@ -6879,6 +6879,7 @@ function intersects(a, b) {
6879
6879
  */
6880
6880
  function deviceMatchesFilter(device, filter) {
6881
6881
  if (filter?.excludeDeviceId !== void 0 && device.id === filter.excludeDeviceId) return false;
6882
+ if (filter?.excludeTypes !== void 0 && filter.excludeTypes.includes(device.type)) return false;
6882
6883
  const hasCaps = filter?.caps !== void 0 && filter.caps.length > 0;
6883
6884
  const hasTypes = filter?.types !== void 0 && filter.types.length > 0;
6884
6885
  if (!hasCaps && !hasTypes) return true;
@@ -6895,12 +6896,52 @@ function deviceOptionLabel(device) {
6895
6896
  * (value = the device id as a string). Sorted case-insensitively by label so
6896
6897
  * the picker order is stable.
6897
6898
  */
6899
+ /** `Location · type · in Container` — the parts a name alone does not say. */
6900
+ function deviceOptionDescription(device) {
6901
+ const parts = [];
6902
+ if (typeof device.location === "string" && device.location.length > 0) parts.push(device.location);
6903
+ parts.push(device.type);
6904
+ if (typeof device.parentName === "string" && device.parentName.length > 0) parts.push(`in ${device.parentName}`);
6905
+ return parts.join(" · ");
6906
+ }
6898
6907
  function devicesToOptions(devices, filter) {
6899
6908
  return devices.filter((d) => deviceMatchesFilter(d, filter)).map((d) => ({
6900
6909
  value: String(d.id),
6901
- label: deviceOptionLabel(d)
6910
+ label: deviceOptionLabel(d),
6911
+ description: deviceOptionDescription(d),
6912
+ location: typeof d.location === "string" && d.location.length > 0 ? d.location : null
6902
6913
  })).toSorted((a, b) => a.label.localeCompare(b.label));
6903
6914
  }
6915
+ /** The distinct locations and types on offer — the chips above the search. */
6916
+ function deviceFacets(devices) {
6917
+ const locations = /* @__PURE__ */ new Set();
6918
+ const types = /* @__PURE__ */ new Set();
6919
+ for (const d of devices) {
6920
+ if (typeof d.location === "string" && d.location.length > 0) locations.add(d.location);
6921
+ types.add(d.type);
6922
+ }
6923
+ return {
6924
+ locations: [...locations].toSorted((a, b) => a.localeCompare(b)),
6925
+ types: [...types].toSorted((a, b) => a.localeCompare(b))
6926
+ };
6927
+ }
6928
+ /** Every chosen facet must match; an unchosen one matches everything. */
6929
+ function matchesFacets(device, facets) {
6930
+ if (facets.location !== void 0 && device.location !== facets.location) return false;
6931
+ if (facets.type !== void 0 && device.type !== facets.type) return false;
6932
+ return true;
6933
+ }
6934
+ /** The devices sharing `location` come first — suggested, never pre-linked. */
6935
+ function partitionSuggested(options, location) {
6936
+ if (location === null || location === void 0 || location.length === 0) return {
6937
+ suggested: [],
6938
+ others: [...options]
6939
+ };
6940
+ return {
6941
+ suggested: options.filter((o) => o.location === location),
6942
+ others: options.filter((o) => o.location !== location)
6943
+ };
6944
+ }
6904
6945
  /**
6905
6946
  * Free-text option filter — case-insensitive AND over whitespace tokens, each
6906
6947
  * matched against the option's `value` + `label` (so a device is found by name,
@@ -6910,7 +6951,7 @@ function filterDeviceOptions(options, query) {
6910
6951
  const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
6911
6952
  if (tokens.length === 0) return [...options];
6912
6953
  return options.filter((opt) => {
6913
- const hay = `${opt.value} ${opt.label}`.toLowerCase();
6954
+ const hay = `${opt.value} ${opt.label} ${opt.description ?? ""}`.toLowerCase();
6914
6955
  return tokens.every((t) => hay.includes(t));
6915
6956
  });
6916
6957
  }
@@ -6949,21 +6990,61 @@ var DESC_CLASS$1 = "text-[10px] text-foreground-subtle mt-0.5 break-words";
6949
6990
  /** The compact projection of the listAll rows the device fields consume. */
6950
6991
  function toCandidates(rows) {
6951
6992
  if (!Array.isArray(rows)) return [];
6993
+ const nameById = /* @__PURE__ */ new Map();
6994
+ for (const row of rows) {
6995
+ if (typeof row !== "object" || row === null) continue;
6996
+ const r = row;
6997
+ if (typeof r.id === "number" && typeof r.name === "string") nameById.set(r.id, r.name);
6998
+ }
6952
6999
  const out = [];
6953
7000
  for (const row of rows) {
6954
7001
  if (typeof row !== "object" || row === null) continue;
6955
7002
  const r = row;
6956
7003
  if (typeof r.id !== "number" || typeof r.type !== "string" || typeof r.name !== "string") continue;
7004
+ const parentName = typeof r.parentDeviceId === "number" ? nameById.get(r.parentDeviceId) ?? null : null;
6957
7005
  out.push({
6958
7006
  id: r.id,
6959
7007
  type: r.type,
6960
7008
  name: r.name,
6961
7009
  location: typeof r.location === "string" ? r.location : null,
6962
- features: Array.isArray(r.features) ? r.features.filter((f) => typeof f === "string") : []
7010
+ features: Array.isArray(r.features) ? r.features.filter((f) => typeof f === "string") : [],
7011
+ parentName
6963
7012
  });
6964
7013
  }
6965
7014
  return out;
6966
7015
  }
7016
+ var FACET_CHIP = "rounded-full border px-1.5 py-0.5 text-[10px] leading-4 transition-colors cursor-pointer";
7017
+ var FACET_CHIP_ON = `${FACET_CHIP} border-primary bg-primary/10 text-primary`;
7018
+ var FACET_CHIP_OFF = `${FACET_CHIP} border-border text-foreground-subtle hover:bg-surface-hover`;
7019
+ var GROUP_HEADING = "px-2.5 pt-1.5 pb-0.5 text-[10px] font-medium uppercase tracking-wide text-foreground-subtle";
7020
+ /** One row of facet chips: `All` plus every distinct value. */
7021
+ function FacetRow({ label, values, active, onPick }) {
7022
+ if (values.length < 2) return null;
7023
+ return /* @__PURE__ */ jsxs("div", {
7024
+ className: "flex flex-wrap items-center gap-1 mb-1",
7025
+ role: "group",
7026
+ "aria-label": label,
7027
+ children: [
7028
+ /* @__PURE__ */ jsx("span", {
7029
+ className: "text-[10px] text-foreground-subtle mr-0.5",
7030
+ children: label
7031
+ }),
7032
+ /* @__PURE__ */ jsx("button", {
7033
+ type: "button",
7034
+ className: active === void 0 ? FACET_CHIP_ON : FACET_CHIP_OFF,
7035
+ onClick: () => onPick(void 0),
7036
+ children: "All"
7037
+ }),
7038
+ values.map((v) => /* @__PURE__ */ jsx("button", {
7039
+ type: "button",
7040
+ className: active === v ? FACET_CHIP_ON : FACET_CHIP_OFF,
7041
+ "aria-pressed": active === v,
7042
+ onClick: () => onPick(active === v ? void 0 : v),
7043
+ children: v
7044
+ }, v))
7045
+ ]
7046
+ });
7047
+ }
6967
7048
  function FieldShell({ label, description, required, translationFn, children }) {
6968
7049
  const resolvedLabel = resolveLabel(label, translationFn);
6969
7050
  const resolvedDescription = resolveLabel(description, translationFn);
@@ -7011,7 +7092,16 @@ function DeviceSelectField({ field, value, onChange, disabled, translationFn })
7011
7092
  }
7012
7093
  function DeviceMultiSelectField({ field, value, onChange, disabled, translationFn }) {
7013
7094
  const { data } = useDeviceManagerListAll({});
7014
- const options = useMemo(() => devicesToOptions(toCandidates(data), field.deviceFilter), [data, field.deviceFilter]);
7095
+ const smart = field.suggestLocation !== void 0;
7096
+ const [facets, setFacets] = useState({});
7097
+ const candidates = useMemo(() => toCandidates(data), [data]);
7098
+ const facetValues = useMemo(() => deviceFacets(candidates.filter((c) => c.id !== field.deviceFilter?.excludeDeviceId)), [candidates, field.deviceFilter?.excludeDeviceId]);
7099
+ const candidatesInScope = useMemo(() => smart ? candidates.filter((c) => matchesFacets(c, facets)) : candidates, [
7100
+ candidates,
7101
+ facets,
7102
+ smart
7103
+ ]);
7104
+ const options = useMemo(() => devicesToOptions(candidatesInScope, field.deviceFilter), [candidatesInScope, field.deviceFilter]);
7015
7105
  const selected = Array.isArray(value) ? value.map(String) : [];
7016
7106
  const isDisabled = disabled === true || field.disabled === true;
7017
7107
  const atMax = field.maxItems !== void 0 && selected.length >= field.maxItems;
@@ -7021,7 +7111,13 @@ function DeviceMultiSelectField({ field, value, onChange, disabled, translationF
7021
7111
  const inputRef = useRef(null);
7022
7112
  const blurTimer = useRef(null);
7023
7113
  const selectedOptions = useMemo(() => selectedDeviceOptions(options, selected), [options, selected]);
7024
- const filtered = useMemo(() => filterDeviceOptions(options, query), [options, query]);
7114
+ const searched = useMemo(() => filterDeviceOptions(options, query), [options, query]);
7115
+ const partition = useMemo(() => partitionSuggested(searched, smart ? field.suggestLocation : null), [
7116
+ searched,
7117
+ smart,
7118
+ field.suggestLocation
7119
+ ]);
7120
+ const filtered = useMemo(() => [...partition.suggested, ...partition.others], [partition]);
7025
7121
  const toggle = (optValue) => {
7026
7122
  if (isDisabled) return;
7027
7123
  if (selected.includes(optValue)) onChange(selected.filter((v) => v !== optValue));
@@ -7071,6 +7167,23 @@ function DeviceMultiSelectField({ field, value, onChange, disabled, translationF
7071
7167
  })]
7072
7168
  }, opt.value))
7073
7169
  }),
7170
+ smart && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(FacetRow, {
7171
+ label: "Location",
7172
+ values: facetValues.locations,
7173
+ active: facets.location,
7174
+ onPick: (location) => setFacets((f) => ({
7175
+ ...f,
7176
+ location
7177
+ }))
7178
+ }), /* @__PURE__ */ jsx(FacetRow, {
7179
+ label: "Type",
7180
+ values: facetValues.types,
7181
+ active: facets.type,
7182
+ onPick: (type) => setFacets((f) => ({
7183
+ ...f,
7184
+ type
7185
+ }))
7186
+ })] }),
7074
7187
  /* @__PURE__ */ jsxs("div", {
7075
7188
  className: INPUT_SHELL + (isDisabled ? " opacity-50" : ""),
7076
7189
  children: [
@@ -7120,7 +7233,12 @@ function DeviceMultiSelectField({ field, value, onChange, disabled, translationF
7120
7233
  }) : filtered.map((opt, i) => {
7121
7234
  const checked = selected.includes(opt.value);
7122
7235
  const blockedByMax = !checked && atMax;
7123
- return /* @__PURE__ */ jsxs("button", {
7236
+ const suggestedCount = partition.suggested.length;
7237
+ const heading = !smart ? null : i === 0 && suggestedCount > 0 ? `Suggested · ${field.suggestLocation ?? ""}` : i === suggestedCount && suggestedCount > 0 ? "Other devices" : null;
7238
+ return /* @__PURE__ */ jsxs("div", { children: [heading !== null && /* @__PURE__ */ jsx("div", {
7239
+ className: GROUP_HEADING,
7240
+ children: heading
7241
+ }), /* @__PURE__ */ jsxs("button", {
7124
7242
  type: "button",
7125
7243
  role: "option",
7126
7244
  "aria-selected": checked,
@@ -7135,11 +7253,17 @@ function DeviceMultiSelectField({ field, value, onChange, disabled, translationF
7135
7253
  children: [/* @__PURE__ */ jsx("span", {
7136
7254
  className: ["flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border", checked ? "border-primary bg-primary text-white" : "border-border"].join(" "),
7137
7255
  children: checked && /* @__PURE__ */ jsx(Check, { className: "h-2.5 w-2.5" })
7138
- }), /* @__PURE__ */ jsx("span", {
7139
- className: "truncate text-foreground",
7140
- children: opt.label
7256
+ }), /* @__PURE__ */ jsxs("span", {
7257
+ className: "min-w-0 flex-1",
7258
+ children: [/* @__PURE__ */ jsx("span", {
7259
+ className: "block truncate text-foreground",
7260
+ children: opt.label
7261
+ }), opt.description !== void 0 && /* @__PURE__ */ jsx("span", {
7262
+ className: "block truncate text-[10px] text-foreground-subtle",
7263
+ children: opt.description
7264
+ })]
7141
7265
  })]
7142
- }, opt.value);
7266
+ })] }, opt.value);
7143
7267
  })
7144
7268
  }),
7145
7269
  description && /* @__PURE__ */ jsx("p", {
@@ -43789,12 +43913,33 @@ function DeviceList(props) {
43789
43913
  * - a link-state status badge + exposed-device count from `getStatus`,
43790
43914
  * - an optional "Setup" section (pairing QR + copyable fields) from
43791
43915
  * the cap's `setup` block,
43792
- * - ONE unified devices overview — the shared `<DeviceList>` table
43793
- * restricted to this exporter's `listSupportedDeviceKinds`, with a
43794
- * per-row Expose/Exposed toggle wired through the list's
43795
- * `renderRowAction` extension point. Each row reflects whether the
43796
- * device is exposed to THIS integration and lets the operator
43797
- * toggle it directly from the integration page.
43916
+ * - ONE unified devices overview — the shared `<DeviceList>` in its
43917
+ * GENERIC mode (the same table `/integrations` renders), restricted
43918
+ * to this exporter's `listSupportedDeviceKinds`.
43919
+ *
43920
+ * ── What an entry shows, and what it deliberately does not ───────────
43921
+ *
43922
+ * Three columns plus a selection box: device TYPE, NAME, EXPORT. That is
43923
+ * the whole row.
43924
+ *
43925
+ * It used to be the full device-mode row — Hardware (manufacturer ·
43926
+ * model), Features, and the merged Preview cell carrying the per-type
43927
+ * live control, the battery/charging pills, the snapshot eye, the reboot
43928
+ * quick-action, the PTZ button and the reachability dot. Every one of
43929
+ * those is reachable on `/devices` and on the device's own page; none of
43930
+ * them existed only here, which is the ONLY reason they were removable.
43931
+ * The Expose toggle is the affordance this surface owns, so it stays —
43932
+ * and the row click still opens the device page.
43933
+ *
43934
+ * ── Multi-select, and why every run reports ──────────────────────────
43935
+ *
43936
+ * Selecting rows exposes (or unexposes) them in one go. The toolbar
43937
+ * always names the COUNT the buttons will act on, and every run — bulk
43938
+ * or single-row — ends in a summary that names the devices on both
43939
+ * sides. A generic "export failed" would leave the operator re-running
43940
+ * the whole set to discover which half landed; and the previous
43941
+ * single-row toggle used `mutate` with no `onError`, so a refused
43942
+ * accessory changed nothing on screen at all.
43798
43943
  *
43799
43944
  * This composite is the reusable replacement for the per-addon
43800
43945
  * Module-Federation overview tables that each export addon used to
@@ -43909,31 +44054,84 @@ function SetupSection({ setup }) {
43909
44054
  });
43910
44055
  }
43911
44056
  /**
43912
- * Per-row Expose/Exposed toggle — the `renderRowAction` injected into
43913
- * `<DeviceList>`. Reflects whether the row's device is exposed to THIS
43914
- * exporter and flips it via expose/unexpose, routed by `addonId`.
44057
+ * Per-row Expose/Exposed toggle. Reflects whether the row's device is exposed
44058
+ * to THIS exporter and flips it through the SAME runner the bulk buttons use —
44059
+ * so a single-row refusal is reported by name instead of vanishing, which is
44060
+ * what the previous fire-and-forget `mutate` did.
44061
+ *
44062
+ * The accessible name carries the device name (`Expose Garden cam`): several
44063
+ * of these live in one table, and "Expose" alone names none of them.
43915
44064
  */
43916
- function ExportRowToggle({ addonId, deviceId, exposed, busy, onExpose, onUnexpose }) {
44065
+ function ExportRowToggle({ addonId, device, exposed, busy, onToggle }) {
43917
44066
  const handleClick = (e) => {
43918
44067
  e.stopPropagation();
43919
44068
  if (busy) return;
43920
- if (exposed) onUnexpose(deviceId);
43921
- else onExpose(deviceId);
44069
+ onToggle(device, exposed ? "unexpose" : "expose");
43922
44070
  };
43923
44071
  return /* @__PURE__ */ jsx(Button, {
43924
44072
  size: "sm",
43925
44073
  variant: exposed ? "ghost" : "primary",
43926
44074
  disabled: busy,
43927
44075
  onClick: handleClick,
44076
+ "aria-label": `${exposed ? "Unexpose" : "Expose"} ${device.name}`,
43928
44077
  title: exposed ? `Stop exposing to "${addonId}"` : `Expose to "${addonId}"`,
43929
44078
  children: exposed ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Unlink2, { className: "mr-1 h-3.5 w-3.5" }), "Exposed"] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Share2, { className: "mr-1 h-3.5 w-3.5" }), "Expose"] })
43930
44079
  });
43931
44080
  }
43932
44081
  /**
44082
+ * The result of the last run, named device by device.
44083
+ *
44084
+ * Rendered for a CLEAN run too. "It probably worked" is not something an
44085
+ * operator should have to infer from silence, and a summary that appears only
44086
+ * on failure trains them to read no news as good news right up until the one
44087
+ * time the render itself was what failed.
44088
+ */
44089
+ function BulkSummary({ outcome, onDismiss }) {
44090
+ const verb = outcome.action === "expose" ? "Exposed" : "Unexposed";
44091
+ return /* @__PURE__ */ jsxs("div", {
44092
+ "data-testid": "export-bulk-summary",
44093
+ className: `flex flex-col gap-1 px-4 py-2 border-b border-border text-[11px] ${outcome.failed.length === 0 ? "text-foreground-subtle" : "text-danger"}`,
44094
+ children: [
44095
+ /* @__PURE__ */ jsxs("div", {
44096
+ className: "flex items-center justify-between gap-2",
44097
+ children: [/* @__PURE__ */ jsxs("span", {
44098
+ className: "font-medium",
44099
+ children: [
44100
+ verb,
44101
+ " ",
44102
+ outcome.succeeded.length,
44103
+ " of ",
44104
+ outcome.total,
44105
+ " selected device",
44106
+ outcome.total === 1 ? "" : "s",
44107
+ "."
44108
+ ]
44109
+ }), /* @__PURE__ */ jsx(Button, {
44110
+ size: "sm",
44111
+ variant: "ghost",
44112
+ onClick: onDismiss,
44113
+ "aria-label": "Dismiss export result",
44114
+ children: "Dismiss"
44115
+ })]
44116
+ }),
44117
+ outcome.succeeded.length > 0 && /* @__PURE__ */ jsxs("span", {
44118
+ className: "text-foreground-subtle",
44119
+ children: ["Succeeded: ", outcome.succeeded.join(", ")]
44120
+ }),
44121
+ outcome.failed.map((failure) => /* @__PURE__ */ jsxs("span", { children: [
44122
+ "Failed: ",
44123
+ failure.label,
44124
+ " — ",
44125
+ failure.message
44126
+ ] }, failure.label))
44127
+ ]
44128
+ });
44129
+ }
44130
+ /**
43933
44131
  * Generic device-export panel. Addon-agnostic — only the `device-export`
43934
44132
  * cap drives it.
43935
44133
  */
43936
- function DeviceExportPanel({ addonId, trpc, onOpenDevice }) {
44134
+ function DeviceExportPanel({ addonId, onOpenDevice }) {
43937
44135
  const queryClient = useQueryClient();
43938
44136
  const statusQuery = useDeviceExportGetStatus({
43939
44137
  nodeId: "hub",
@@ -43995,38 +44193,171 @@ function DeviceExportPanel({ addonId, trpc, onOpenDevice }) {
43995
44193
  const loading = statusQuery.isLoading || exposedQuery.isLoading || kindsQuery.isLoading || devicesQuery.isLoading;
43996
44194
  const exposedCount = status?.exposedDeviceCount ?? exposedIds.size;
43997
44195
  const busy = exposeMutation.isPending || unexposeMutation.isPending;
43998
- const onExpose = useCallback((deviceId) => {
43999
- exposeMutation.mutate({
44000
- deviceId,
44001
- nodeId: "hub",
44002
- addonId
44196
+ const [selectedIds, setSelectedIds] = useState(() => /* @__PURE__ */ new Set());
44197
+ const [outcome, setOutcome] = useState(null);
44198
+ const [running, setRunning] = useState(false);
44199
+ const toggleSelection = useCallback((deviceId) => {
44200
+ setSelectedIds((current) => {
44201
+ const next = new Set(current);
44202
+ if (next.has(deviceId)) next.delete(deviceId);
44203
+ else next.add(deviceId);
44204
+ return next;
44003
44205
  });
44004
- }, [exposeMutation, addonId]);
44005
- const onUnexpose = useCallback((deviceId) => {
44006
- unexposeMutation.mutate({
44206
+ }, []);
44207
+ const clearSelection = useCallback(() => setSelectedIds(/* @__PURE__ */ new Set()), []);
44208
+ const dismissOutcome = useCallback(() => setOutcome(null), []);
44209
+ /** Display label for a device id — its name, or the raw id if it left the fleet. */
44210
+ const labelFor = useCallback((deviceId) => candidateDevices.find((d) => String(d.id) === deviceId)?.name ?? `#${deviceId}`, [candidateDevices]);
44211
+ /**
44212
+ * The ONE path an expose/unexpose takes — a single row's toggle is a run of
44213
+ * one. Every device is dispatched, every result is recorded, and a failure is
44214
+ * both logged (with `deviceId`, so it can be asked about per-camera) and
44215
+ * named on screen. Nothing here can drop a device quietly: the loop visits
44216
+ * the whole input and each visit lands in `succeeded` or in `failed`.
44217
+ */
44218
+ const runExport = useCallback(async (action, deviceIds) => {
44219
+ if (deviceIds.length === 0 || running) return;
44220
+ setRunning(true);
44221
+ setOutcome(null);
44222
+ const succeeded = [];
44223
+ const failed = [];
44224
+ const mutation = action === "expose" ? exposeMutation : unexposeMutation;
44225
+ (await Promise.allSettled(deviceIds.map((deviceId) => mutation.mutateAsync({
44007
44226
  deviceId,
44008
44227
  nodeId: "hub",
44009
44228
  addonId
44229
+ })))).forEach((result, index) => {
44230
+ const deviceId = deviceIds[index] ?? "";
44231
+ const label = labelFor(deviceId);
44232
+ if (result.status === "fulfilled") {
44233
+ succeeded.push(label);
44234
+ return;
44235
+ }
44236
+ const message = result.reason instanceof Error ? result.reason.message : String(result.reason);
44237
+ console.error(`[device-export] ${action} failed`, {
44238
+ tags: { deviceId: Number.parseInt(deviceId, 10) },
44239
+ addonId,
44240
+ error: message
44241
+ });
44242
+ failed.push({
44243
+ label,
44244
+ message
44245
+ });
44010
44246
  });
44011
- }, [unexposeMutation, addonId]);
44012
- const renderRowAction = useCallback((device) => {
44013
- const id = String(device.id);
44014
- return /* @__PURE__ */ jsx(ExportRowToggle, {
44015
- addonId,
44016
- deviceId: id,
44017
- exposed: exposedIds.has(id),
44018
- busy,
44019
- onExpose,
44020
- onUnexpose
44247
+ setOutcome({
44248
+ action,
44249
+ total: deviceIds.length,
44250
+ succeeded,
44251
+ failed
44021
44252
  });
44253
+ setSelectedIds(/* @__PURE__ */ new Set());
44254
+ setRunning(false);
44022
44255
  }, [
44023
44256
  addonId,
44024
- exposedIds,
44257
+ exposeMutation,
44258
+ unexposeMutation,
44259
+ labelFor,
44260
+ running
44261
+ ]);
44262
+ const selectedList = useMemo(() => [...selectedIds], [selectedIds]);
44263
+ const exposeSelected = useCallback(() => {
44264
+ runExport("expose", selectedList);
44265
+ }, [runExport, selectedList]);
44266
+ const unexposeSelected = useCallback(() => {
44267
+ runExport("unexpose", selectedList);
44268
+ }, [runExport, selectedList]);
44269
+ const onRowToggle = useCallback((device, action) => {
44270
+ runExport(action, [String(device.id)]);
44271
+ }, [runExport]);
44272
+ const onRowClick = useCallback((device) => onOpenDevice?.(String(device.id)), [onOpenDevice]);
44273
+ const getKey = useCallback((device) => String(device.id), []);
44274
+ const getSearchText = useCallback((device) => `${device.name} ${device.stableId}`, []);
44275
+ const getFilterType = useCallback((device) => device.type, []);
44276
+ const columns = useMemo(() => [
44277
+ {
44278
+ key: "select",
44279
+ header: "",
44280
+ headerClassName: "w-8",
44281
+ cell: (device) => /* @__PURE__ */ jsx("input", {
44282
+ type: "checkbox",
44283
+ checked: selectedIds.has(String(device.id)),
44284
+ onChange: () => toggleSelection(String(device.id)),
44285
+ onClick: (e) => e.stopPropagation(),
44286
+ "aria-label": `Select ${device.name}`
44287
+ })
44288
+ },
44289
+ {
44290
+ key: "type",
44291
+ header: "Type",
44292
+ headerClassName: "w-32",
44293
+ cell: (device) => {
44294
+ const meta = deviceTypeMetaOf(device.type);
44295
+ const TypeIcon = meta.icon;
44296
+ return /* @__PURE__ */ jsxs("span", {
44297
+ className: "inline-flex items-center gap-1.5 text-[11px] text-foreground-subtle",
44298
+ children: [/* @__PURE__ */ jsx(TypeIcon, { className: "h-3.5 w-3.5 shrink-0" }), meta.label]
44299
+ });
44300
+ }
44301
+ },
44302
+ {
44303
+ key: "name",
44304
+ header: "Name",
44305
+ cell: (device) => /* @__PURE__ */ jsx("span", {
44306
+ className: "text-xs font-medium text-foreground",
44307
+ children: device.name
44308
+ })
44309
+ },
44310
+ {
44311
+ key: "export",
44312
+ header: "Export",
44313
+ headerClassName: "w-32 text-right",
44314
+ cellClassName: "text-right",
44315
+ cell: (device) => /* @__PURE__ */ jsx(ExportRowToggle, {
44316
+ addonId,
44317
+ device,
44318
+ exposed: exposedIds.has(String(device.id)),
44319
+ busy,
44320
+ onToggle: onRowToggle
44321
+ })
44322
+ }
44323
+ ], [
44324
+ addonId,
44025
44325
  busy,
44026
- onExpose,
44027
- onUnexpose
44326
+ exposedIds,
44327
+ onRowToggle,
44328
+ selectedIds,
44329
+ toggleSelection
44028
44330
  ]);
44029
- const onNavigate = useCallback((deviceId) => onOpenDevice?.(String(deviceId)), [onOpenDevice]);
44331
+ const selectionToolbar = selectedIds.size === 0 ? void 0 : /* @__PURE__ */ jsxs("span", {
44332
+ className: "inline-flex items-center gap-1.5",
44333
+ children: [
44334
+ /* @__PURE__ */ jsxs("span", {
44335
+ "data-testid": "export-selection-count",
44336
+ className: "text-[11px] text-foreground-subtle",
44337
+ children: [selectedIds.size, " selected"]
44338
+ }),
44339
+ /* @__PURE__ */ jsxs(Button, {
44340
+ size: "sm",
44341
+ variant: "primary",
44342
+ disabled: busy,
44343
+ onClick: exposeSelected,
44344
+ children: ["Expose ", selectedIds.size]
44345
+ }),
44346
+ /* @__PURE__ */ jsxs(Button, {
44347
+ size: "sm",
44348
+ variant: "secondary",
44349
+ disabled: busy,
44350
+ onClick: unexposeSelected,
44351
+ children: ["Unexpose ", selectedIds.size]
44352
+ }),
44353
+ /* @__PURE__ */ jsx(Button, {
44354
+ size: "sm",
44355
+ variant: "ghost",
44356
+ onClick: clearSelection,
44357
+ children: "Clear"
44358
+ })
44359
+ ]
44360
+ });
44030
44361
  const hasSupportedKinds = supportedKindList.length > 0;
44031
44362
  return /* @__PURE__ */ jsxs("div", {
44032
44363
  className: "rounded-lg border border-border bg-surface overflow-hidden",
@@ -44057,19 +44388,27 @@ function DeviceExportPanel({ addonId, trpc, onOpenDevice }) {
44057
44388
  children: /* @__PURE__ */ jsx(ErrorBox, { message: status.error })
44058
44389
  }),
44059
44390
  status?.setup && /* @__PURE__ */ jsx(SetupSection, { setup: status.setup }),
44391
+ outcome !== null && /* @__PURE__ */ jsx(BulkSummary, {
44392
+ outcome,
44393
+ onDismiss: dismissOutcome
44394
+ }),
44060
44395
  /* @__PURE__ */ jsx("div", {
44061
44396
  className: "p-3",
44062
44397
  children: loading && candidateDevices.length === 0 ? /* @__PURE__ */ jsxs("div", {
44063
44398
  className: "flex items-center gap-2 px-1 py-6 text-xs text-foreground-subtle",
44064
44399
  children: [/* @__PURE__ */ jsx(LoaderCircle, { className: "h-3.5 w-3.5 animate-spin" }), "Loading exportable devices…"]
44065
44400
  }) : hasSupportedKinds && candidateDevices.length > 0 ? /* @__PURE__ */ jsx(DeviceList, {
44066
- devices: candidateDevices,
44067
- trpc,
44068
- filters: { typeOptions: supportedTypeOptions },
44069
- urlScope: "nested",
44070
- lsKey: `device-export:${addonId}`,
44071
- onNavigate: onOpenDevice ? onNavigate : void 0,
44072
- renderRowAction
44401
+ mode: "generic",
44402
+ items: candidateDevices,
44403
+ getKey,
44404
+ getSearchText,
44405
+ getFilterType,
44406
+ filterOptions: supportedTypeOptions,
44407
+ columns,
44408
+ onRowClick: onOpenDevice ? onRowClick : void 0,
44409
+ toolbar: selectionToolbar,
44410
+ searchPlaceholder: "Search by name or stableId",
44411
+ emptyLabel: "No device matches this search."
44073
44412
  }) : /* @__PURE__ */ jsx(EmptyState, {
44074
44413
  icon: Share2,
44075
44414
  title: "No exportable devices",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/ui-library",
3
- "version": "1.2.112",
3
+ "version": "1.2.114",
4
4
  "type": "module",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",