@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.
- package/dist/composites/device-export-panel.d.ts +8 -7
- package/dist/composites/device-select-filter.d.ts +28 -2
- package/dist/index.cjs +391 -52
- package/dist/index.js +391 -52
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
7163
|
-
className: "
|
|
7164
|
-
children:
|
|
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>`
|
|
43817
|
-
*
|
|
43818
|
-
*
|
|
43819
|
-
*
|
|
43820
|
-
*
|
|
43821
|
-
*
|
|
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
|
|
43937
|
-
*
|
|
43938
|
-
*
|
|
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,
|
|
44089
|
+
function ExportRowToggle({ addonId, device, exposed, busy, onToggle }) {
|
|
43941
44090
|
const handleClick = (e) => {
|
|
43942
44091
|
e.stopPropagation();
|
|
43943
44092
|
if (busy) return;
|
|
43944
|
-
|
|
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,
|
|
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
|
|
44023
|
-
|
|
44024
|
-
|
|
44025
|
-
|
|
44026
|
-
|
|
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
|
-
}, [
|
|
44029
|
-
const
|
|
44030
|
-
|
|
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
|
-
|
|
44036
|
-
|
|
44037
|
-
|
|
44038
|
-
|
|
44039
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44051
|
-
|
|
44350
|
+
exposedIds,
|
|
44351
|
+
onRowToggle,
|
|
44352
|
+
selectedIds,
|
|
44353
|
+
toggleSelection
|
|
44052
44354
|
]);
|
|
44053
|
-
const
|
|
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
|
-
|
|
44091
|
-
|
|
44092
|
-
|
|
44093
|
-
|
|
44094
|
-
|
|
44095
|
-
|
|
44096
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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__ */
|
|
7139
|
-
className: "
|
|
7140
|
-
children:
|
|
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>`
|
|
43793
|
-
*
|
|
43794
|
-
*
|
|
43795
|
-
*
|
|
43796
|
-
*
|
|
43797
|
-
*
|
|
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
|
|
43913
|
-
*
|
|
43914
|
-
*
|
|
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,
|
|
44065
|
+
function ExportRowToggle({ addonId, device, exposed, busy, onToggle }) {
|
|
43917
44066
|
const handleClick = (e) => {
|
|
43918
44067
|
e.stopPropagation();
|
|
43919
44068
|
if (busy) return;
|
|
43920
|
-
|
|
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,
|
|
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
|
|
43999
|
-
|
|
44000
|
-
|
|
44001
|
-
|
|
44002
|
-
|
|
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
|
-
}, [
|
|
44005
|
-
const
|
|
44006
|
-
|
|
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
|
-
|
|
44012
|
-
|
|
44013
|
-
|
|
44014
|
-
|
|
44015
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44027
|
-
|
|
44326
|
+
exposedIds,
|
|
44327
|
+
onRowToggle,
|
|
44328
|
+
selectedIds,
|
|
44329
|
+
toggleSelection
|
|
44028
44330
|
]);
|
|
44029
|
-
const
|
|
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
|
-
|
|
44067
|
-
|
|
44068
|
-
|
|
44069
|
-
|
|
44070
|
-
|
|
44071
|
-
|
|
44072
|
-
|
|
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",
|