@camstack/ui-library 1.1.7 → 1.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -17210,6 +17210,8 @@ var useDeviceManagerAdoptionRefresh = trpc.deviceManager.adoptionRefresh.useMuta
17210
17210
  var useDeviceManagerAdoptionAdopt = trpc.deviceManager.adoptionAdopt.useMutation;
17211
17211
  /** Generated alias around `trpc.deviceManager.adoptionRelease.useMutation`. */
17212
17212
  var useDeviceManagerAdoptionRelease = trpc.deviceManager.adoptionRelease.useMutation;
17213
+ /** Generated alias around `trpc.deviceManager.adoptionResync.useMutation`. */
17214
+ var useDeviceManagerAdoptionResync = trpc.deviceManager.adoptionResync.useMutation;
17213
17215
  /** Generated alias around `trpc.deviceManager.testField.useMutation`. */
17214
17216
  var useDeviceManagerTestField = trpc.deviceManager.testField.useMutation;
17215
17217
  /** Generated alias around `trpc.deviceManager.getDeviceStatusAggregate.useQuery`. */
@@ -18257,6 +18259,31 @@ var ROLE_DESCRIPTOR = {
18257
18259
  field: "value",
18258
18260
  getSliceHandle: (s) => s.numericSensor
18259
18261
  },
18262
+ "wind-sensor": {
18263
+ kind: "numeric",
18264
+ field: "value",
18265
+ getSliceHandle: (s) => s.numericSensor
18266
+ },
18267
+ "rain-sensor": {
18268
+ kind: "numeric",
18269
+ field: "value",
18270
+ getSliceHandle: (s) => s.numericSensor
18271
+ },
18272
+ "uv-sensor": {
18273
+ kind: "numeric",
18274
+ field: "value",
18275
+ getSliceHandle: (s) => s.numericSensor
18276
+ },
18277
+ "solar-radiation-sensor": {
18278
+ kind: "numeric",
18279
+ field: "value",
18280
+ getSliceHandle: (s) => s.numericSensor
18281
+ },
18282
+ "soil-moisture-sensor": {
18283
+ kind: "numeric",
18284
+ field: "value",
18285
+ getSliceHandle: (s) => s.numericSensor
18286
+ },
18260
18287
  "power-sensor": {
18261
18288
  kind: "numeric",
18262
18289
  field: "watts",
@@ -18770,6 +18797,10 @@ function vacuumStateMeta(state) {
18770
18797
  label: "Docked",
18771
18798
  tone: "text-success"
18772
18799
  };
18800
+ case "drying": return {
18801
+ label: "Drying",
18802
+ tone: "text-warning"
18803
+ };
18773
18804
  case "idle": return {
18774
18805
  label: "Idle",
18775
18806
  tone: "text-foreground-subtle"
@@ -18826,6 +18857,12 @@ function vacuumGraphicTint(state) {
18826
18857
  docked: true,
18827
18858
  active: false
18828
18859
  };
18860
+ case "drying": return {
18861
+ glow: "bg-warning/10",
18862
+ accent: "text-warning",
18863
+ docked: true,
18864
+ active: false
18865
+ };
18829
18866
  case "paused": return {
18830
18867
  glow: "bg-warning/10",
18831
18868
  accent: "text-warning",
@@ -18996,8 +19033,10 @@ function tankMeta(kind) {
18996
19033
  /**
18997
19034
  * TankIndicator — one tank row: label + glyph, then a fill bar (numeric
18998
19035
  * level) or a status pill (discrete), tinted by `tankAlert`. Renders
18999
- * nothing when the tank is absent (null) or its reading is unknown
19000
- * (both fields null).
19036
+ * nothing only when the tank is absent (null). A PRESENT tank whose
19037
+ * reading is unknown (both fields null) still renders a neutral row with
19038
+ * an em-dash placeholder, so the user always sees which tanks the robot
19039
+ * has (Dreame reports presence but no fill telemetry).
19001
19040
  */
19002
19041
  function TankIndicator({ kind, tank }) {
19003
19042
  if (tank === null) return null;
@@ -19033,15 +19072,22 @@ function TankIndicator({ kind, tank }) {
19033
19072
  children: tank.status
19034
19073
  })]
19035
19074
  });
19036
- return null;
19075
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19076
+ className: "flex items-center gap-2 text-xs",
19077
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
19078
+ className: "inline-flex items-center gap-1 w-24 shrink-0 text-foreground-subtle",
19079
+ children: [meta.icon, meta.label]
19080
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19081
+ className: "inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium bg-surface-hover text-foreground-subtle",
19082
+ children: "—"
19083
+ })]
19084
+ });
19037
19085
  }
19038
19086
  /**
19039
19087
  * TanksRow — compact 2-column grid of the present tanks for the vacuum
19040
- * hero. Absent tanks (null) are skipped; the row renders nothing unless at
19041
- * least one tank is actually renderablepresent AND with a known reading
19042
- * (level or status). A tank that is present but all-unknown
19043
- * (`{level:null,status:null}`) renders nothing, so a hero of only such
19044
- * tanks would otherwise show an empty bordered box.
19088
+ * hero. Absent tanks (null) are skipped; every PRESENT tank is shown, even
19089
+ * when its reading is unknown (`{level:null,status:null}`)it renders a
19090
+ * neutral em-dash row. The row renders iff the robot has at least one tank.
19045
19091
  */
19046
19092
  function TanksRow({ cleanWater, dirtyWater, detergent, dustBin }) {
19047
19093
  const tanks = [
@@ -19050,7 +19096,7 @@ function TanksRow({ cleanWater, dirtyWater, detergent, dustBin }) {
19050
19096
  ["detergent", detergent],
19051
19097
  ["dustBin", dustBin]
19052
19098
  ];
19053
- if (!tanks.some(([, t]) => t !== null && (t.level !== null || t.status !== null))) return null;
19099
+ if (!tanks.some(([, t]) => t !== null)) return null;
19054
19100
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19055
19101
  className: "grid grid-cols-1 gap-x-6 gap-y-2 rounded-md border border-border bg-surface-hover/40 p-3 sm:grid-cols-2",
19056
19102
  children: tanks.map(([kind, tank]) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TankIndicator, {
@@ -19144,52 +19190,72 @@ function VacuumHeroCard({ trpc, deviceId }) {
19144
19190
  className: "flex items-center gap-6",
19145
19191
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(VacuumGraphic, { state }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19146
19192
  className: "flex flex-col gap-3 flex-1",
19147
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19148
- className: "flex items-center justify-between",
19149
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19150
- className: cn("text-3xl font-semibold", meta.tone),
19151
- children: meta.label
19152
- }), slice?.batteryLevel != null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BatteryBadge$2, { level: slice.batteryLevel })]
19153
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19154
- className: "flex items-center gap-2",
19155
- children: [
19156
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VacuumHeroButton, {
19157
- label: "Start",
19158
- busy,
19159
- active: state === "cleaning",
19160
- onClick: () => press(cap.start),
19161
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Play, { className: "h-5 w-5" })
19162
- }),
19163
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VacuumHeroButton, {
19164
- label: "Pause",
19165
- busy,
19166
- active: state === "paused",
19167
- onClick: () => press(cap.pause),
19168
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pause, { className: "h-5 w-5" })
19169
- }),
19170
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VacuumHeroButton, {
19171
- label: "Stop",
19172
- busy,
19173
- active: false,
19174
- onClick: () => press(cap.stop),
19175
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Square, { className: "h-4 w-4" })
19176
- }),
19177
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VacuumHeroButton, {
19178
- label: "Return to base",
19179
- busy,
19180
- active: state === "returning",
19181
- onClick: () => press(cap.returnToBase),
19182
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(House, { className: "h-5 w-5" })
19183
- }),
19184
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VacuumHeroButton, {
19185
- label: "Locate",
19186
- busy,
19187
- active: false,
19188
- onClick: () => press(cap.locate),
19189
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Megaphone, { className: "h-5 w-5" })
19190
- })
19191
- ]
19192
- })]
19193
+ children: [
19194
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19195
+ className: "flex items-center justify-between",
19196
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19197
+ className: cn("text-3xl font-semibold", meta.tone),
19198
+ children: meta.label
19199
+ }), slice?.batteryLevel != null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BatteryBadge$2, { level: slice.batteryLevel })]
19200
+ }),
19201
+ slice?.progressPercent != null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19202
+ className: "flex items-center gap-2",
19203
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19204
+ className: "h-1.5 flex-1 rounded bg-surface-hover overflow-hidden",
19205
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19206
+ className: "h-full bg-primary transition-[width] duration-500",
19207
+ style: { width: `${Math.max(0, Math.min(100, slice.progressPercent))}%` }
19208
+ })
19209
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
19210
+ className: "tabular-nums text-xs text-foreground-subtle",
19211
+ children: [Math.round(slice.progressPercent), "%"]
19212
+ })]
19213
+ }),
19214
+ slice?.errorCode != null && slice.errorCode !== 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19215
+ className: "inline-flex items-center gap-1 text-xs text-destructive",
19216
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TriangleAlert, { className: "h-3.5 w-3.5" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: slice.errorLabel ?? `Error ${slice.errorCode}` })]
19217
+ }),
19218
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19219
+ className: "flex items-center gap-2",
19220
+ children: [
19221
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VacuumHeroButton, {
19222
+ label: "Start",
19223
+ busy,
19224
+ active: state === "cleaning",
19225
+ onClick: () => press(cap.start),
19226
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Play, { className: "h-5 w-5" })
19227
+ }),
19228
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VacuumHeroButton, {
19229
+ label: "Pause",
19230
+ busy,
19231
+ active: state === "paused",
19232
+ onClick: () => press(cap.pause),
19233
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pause, { className: "h-5 w-5" })
19234
+ }),
19235
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VacuumHeroButton, {
19236
+ label: "Stop",
19237
+ busy,
19238
+ active: false,
19239
+ onClick: () => press(cap.stop),
19240
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Square, { className: "h-4 w-4" })
19241
+ }),
19242
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VacuumHeroButton, {
19243
+ label: "Return to base",
19244
+ busy,
19245
+ active: state === "returning",
19246
+ onClick: () => press(cap.returnToBase),
19247
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(House, { className: "h-5 w-5" })
19248
+ }),
19249
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VacuumHeroButton, {
19250
+ label: "Locate",
19251
+ busy,
19252
+ active: false,
19253
+ onClick: () => press(cap.locate),
19254
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Megaphone, { className: "h-5 w-5" })
19255
+ })
19256
+ ]
19257
+ })
19258
+ ]
19193
19259
  })]
19194
19260
  }),
19195
19261
  fanSpeeds.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
@@ -19417,6 +19483,37 @@ function LawnMowerGraphic({ activity }) {
19417
19483
  })]
19418
19484
  });
19419
19485
  }
19486
+ /** Tailwind text tone for a device-code severity. */
19487
+ function severityTone(severity) {
19488
+ switch (severity) {
19489
+ case "error": return "text-destructive";
19490
+ case "warning": return "text-warning";
19491
+ case "info": return "text-foreground-subtle";
19492
+ }
19493
+ }
19494
+ /** Mowing-progress bar + % — shown only when a progress value is present. */
19495
+ function ProgressBar({ percent }) {
19496
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19497
+ className: "flex items-center gap-2",
19498
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19499
+ className: "h-1.5 flex-1 rounded bg-surface-hover overflow-hidden",
19500
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19501
+ className: "h-full bg-primary transition-[width] duration-500",
19502
+ style: { width: `${Math.max(0, Math.min(100, percent))}%` }
19503
+ })
19504
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
19505
+ className: "tabular-nums text-xs text-foreground-subtle",
19506
+ children: [Math.round(percent), "%"]
19507
+ })]
19508
+ });
19509
+ }
19510
+ /** Current device/event code line — tinted by severity; an icon for warn/error. */
19511
+ function CodeLine({ code, label, severity }) {
19512
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19513
+ className: cn("inline-flex items-center gap-1 text-xs", severityTone(severity)),
19514
+ children: [severity !== "info" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TriangleAlert, { className: "h-3.5 w-3.5" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label ?? `Code ${code}` })]
19515
+ });
19516
+ }
19420
19517
  /** Battery % badge — shared between inline + hero. */
19421
19518
  function BatteryBadge$1({ level }) {
19422
19519
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
@@ -19520,38 +19617,47 @@ function LawnMowerHeroCard({ trpc, deviceId }) {
19520
19617
  className: "flex items-center gap-6",
19521
19618
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LawnMowerGraphic, { activity }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19522
19619
  className: "flex flex-col gap-3 flex-1",
19523
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19524
- className: "flex items-center justify-between",
19525
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19526
- className: cn("text-3xl font-semibold", meta.tone),
19527
- children: meta.label
19528
- }), slice?.batteryLevel != null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BatteryBadge$1, { level: slice.batteryLevel })]
19529
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19530
- className: "flex items-center gap-2",
19531
- children: [
19532
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LawnMowerHeroButton, {
19533
- label: "Start mowing",
19534
- busy,
19535
- active: activity === "mowing",
19536
- onClick: () => press(cap.startMowing),
19537
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Play, { className: "h-5 w-5" })
19538
- }),
19539
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LawnMowerHeroButton, {
19540
- label: "Pause",
19541
- busy,
19542
- active: activity === "paused",
19543
- onClick: () => press(cap.pause),
19544
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pause, { className: "h-5 w-5" })
19545
- }),
19546
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LawnMowerHeroButton, {
19547
- label: "Dock",
19548
- busy,
19549
- active: activity === "docked",
19550
- onClick: () => press(cap.dock),
19551
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(House, { className: "h-5 w-5" })
19552
- })
19553
- ]
19554
- })]
19620
+ children: [
19621
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19622
+ className: "flex items-center justify-between",
19623
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19624
+ className: cn("text-3xl font-semibold", meta.tone),
19625
+ children: meta.label
19626
+ }), slice?.batteryLevel != null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BatteryBadge$1, { level: slice.batteryLevel })]
19627
+ }),
19628
+ slice?.progressPercent != null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ProgressBar, { percent: slice.progressPercent }),
19629
+ slice?.currentCode != null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CodeLine, {
19630
+ code: slice.currentCode,
19631
+ label: slice.currentCodeLabel,
19632
+ severity: slice.severity
19633
+ }),
19634
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19635
+ className: "flex items-center gap-2",
19636
+ children: [
19637
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LawnMowerHeroButton, {
19638
+ label: "Start mowing",
19639
+ busy,
19640
+ active: activity === "mowing",
19641
+ onClick: () => press(cap.startMowing),
19642
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Play, { className: "h-5 w-5" })
19643
+ }),
19644
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LawnMowerHeroButton, {
19645
+ label: "Pause",
19646
+ busy,
19647
+ active: activity === "paused",
19648
+ onClick: () => press(cap.pause),
19649
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pause, { className: "h-5 w-5" })
19650
+ }),
19651
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LawnMowerHeroButton, {
19652
+ label: "Dock",
19653
+ busy,
19654
+ active: activity === "docked",
19655
+ onClick: () => press(cap.dock),
19656
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(House, { className: "h-5 w-5" })
19657
+ })
19658
+ ]
19659
+ })
19660
+ ]
19555
19661
  })]
19556
19662
  })
19557
19663
  });
@@ -21285,103 +21391,6 @@ function WeatherHeroCard({ trpc, deviceId }) {
21285
21391
  });
21286
21392
  }
21287
21393
  //#endregion
21288
- //#region src/composites/hover-zoom-image.tsx
21289
- /**
21290
- * <HoverZoomImage> — a small thumbnail that expands into a floating
21291
- * enlarged preview on hover/focus.
21292
- *
21293
- * One responsibility: render a compact thumbnail (image when `src` is
21294
- * present, a muted `ImageOff` placeholder box when null) and, while the
21295
- * thumbnail is hovered or focused, portal a larger preview to
21296
- * `document.body` positioned with FLIP-ON-COLLISION:
21297
- * - the position is computed from the thumbnail's `getBoundingClientRect`;
21298
- * - placed ABOVE the thumbnail unless there is insufficient space (then
21299
- * below); left-clamped to the viewport;
21300
- * - `position:fixed`, `pointer-events:none`, `z-[9999]`.
21301
- *
21302
- * The portal is what lets this work inside the device grid, whose overflow
21303
- * ancestors would otherwise clip a normally-positioned popover.
21304
- *
21305
- * Reused by BOTH the Image DeviceType inline control (signed image url) and
21306
- * the camera-snapshot cell in device rows (snapshot blob/object url).
21307
- */
21308
- var DEFAULT_EXPANDED_WIDTH = 288;
21309
- var DEFAULT_EXPANDED_HEIGHT = 196;
21310
- var DEFAULT_THUMB_CLASS = "h-7 w-7 rounded";
21311
- var TRIGGER_GAP = 8;
21312
- var VIEWPORT_MARGIN = 8;
21313
- function HoverZoomImage({ src, alt, caption, placeholder = "No image", thumbClassName = DEFAULT_THUMB_CLASS, expandedWidth = DEFAULT_EXPANDED_WIDTH, expandedHeight = DEFAULT_EXPANDED_HEIGHT }) {
21314
- const triggerRef = (0, react$1.useRef)(null);
21315
- const [position, setPosition] = (0, react$1.useState)(null);
21316
- const [errored, setErrored] = (0, react$1.useState)(false);
21317
- (0, react$1.useEffect)(() => {
21318
- setErrored(false);
21319
- }, [src]);
21320
- const hasImage = src !== null && !errored;
21321
- const safeSrc = src ?? void 0;
21322
- const open = (0, react$1.useCallback)(() => {
21323
- const el = triggerRef.current;
21324
- if (!el) return;
21325
- const rect = el.getBoundingClientRect();
21326
- const spaceAbove = rect.top;
21327
- const spaceBelow = window.innerHeight - rect.bottom;
21328
- const top = spaceAbove < expandedHeight + TRIGGER_GAP && spaceBelow > spaceAbove ? rect.bottom + TRIGGER_GAP : rect.top - expandedHeight - TRIGGER_GAP;
21329
- const desiredLeft = rect.right - expandedWidth;
21330
- setPosition({
21331
- top,
21332
- left: Math.max(VIEWPORT_MARGIN, Math.min(desiredLeft, window.innerWidth - expandedWidth - VIEWPORT_MARGIN))
21333
- });
21334
- }, [expandedWidth, expandedHeight]);
21335
- const close = (0, react$1.useCallback)(() => setPosition(null), []);
21336
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
21337
- ref: triggerRef,
21338
- type: "button",
21339
- onClick: (e) => e.stopPropagation(),
21340
- onMouseEnter: open,
21341
- onMouseLeave: close,
21342
- onFocus: open,
21343
- onBlur: close,
21344
- "aria-label": alt,
21345
- className: `flex flex-shrink-0 items-center justify-center overflow-hidden border border-border bg-background text-foreground-subtle transition-colors hover:bg-surface-hover ${thumbClassName}`,
21346
- children: hasImage ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
21347
- src: safeSrc,
21348
- alt: "",
21349
- onError: () => setErrored(true),
21350
- className: "h-full w-full object-cover"
21351
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ImageOff, {
21352
- className: "h-3.5 w-3.5",
21353
- "aria-hidden": "true"
21354
- })
21355
- }), position && (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
21356
- "aria-hidden": "true",
21357
- style: {
21358
- position: "fixed",
21359
- top: position.top,
21360
- left: position.left,
21361
- width: expandedWidth,
21362
- pointerEvents: "none"
21363
- },
21364
- className: "z-[9999] overflow-hidden rounded-lg border border-border bg-surface shadow-lg shadow-black/30",
21365
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
21366
- className: "flex h-40 items-center justify-center bg-background",
21367
- children: hasImage ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
21368
- src: safeSrc,
21369
- alt,
21370
- className: "h-full w-full object-cover"
21371
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
21372
- className: "text-xs text-foreground-subtle",
21373
- children: placeholder
21374
- })
21375
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
21376
- className: "px-3 py-2",
21377
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
21378
- className: "truncate text-xs font-semibold text-foreground",
21379
- children: caption ?? alt
21380
- })
21381
- })]
21382
- }), document.body)] });
21383
- }
21384
- //#endregion
21385
21394
  //#region src/composites/device-controls/image-control.tsx
21386
21395
  /**
21387
21396
  * Image controls for a `DeviceType.Image` device.
@@ -21405,6 +21414,91 @@ function useImage(trpc, deviceId) {
21405
21414
  slice: (d) => d.state.image
21406
21415
  });
21407
21416
  }
21417
+ var ZOOM_MIN = 1;
21418
+ var ZOOM_MAX = 6;
21419
+ var ZOOM_STEP = .5;
21420
+ /**
21421
+ * ImageDetailModal — a full-view image dialog with zoom in/out/reset and pan
21422
+ * (scroll when zoomed). Generous padding frames the image. Opened by clicking
21423
+ * the inline thumbnail or the hero image. Controlled via `open`/`onOpenChange`.
21424
+ */
21425
+ function ImageDetailModal({ url, alt, open, onOpenChange }) {
21426
+ const [scale, setScale] = (0, react$1.useState)(1);
21427
+ (0, react$1.useEffect)(() => {
21428
+ if (open) setScale(1);
21429
+ }, [open, url]);
21430
+ const zoomIn = (0, react$1.useCallback)(() => setScale((s) => Math.min(ZOOM_MAX, s + ZOOM_STEP)), []);
21431
+ const zoomOut = (0, react$1.useCallback)(() => setScale((s) => Math.max(ZOOM_MIN, s - ZOOM_STEP)), []);
21432
+ const reset = (0, react$1.useCallback)(() => setScale(1), []);
21433
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Dialog, {
21434
+ open,
21435
+ onOpenChange,
21436
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogContent, {
21437
+ width: "lg",
21438
+ className: "max-w-5xl p-6",
21439
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
21440
+ className: "flex items-center justify-between gap-2 mb-3",
21441
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
21442
+ className: "truncate text-sm font-semibold text-foreground",
21443
+ children: alt
21444
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
21445
+ className: "flex items-center gap-1",
21446
+ children: [
21447
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModalIconButton, {
21448
+ label: "Zoom out",
21449
+ disabled: scale <= ZOOM_MIN,
21450
+ onClick: zoomOut,
21451
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoomOut, { className: "h-4 w-4" })
21452
+ }),
21453
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
21454
+ className: "tabular-nums text-xs text-foreground-subtle w-10 text-center",
21455
+ children: [Math.round(scale * 100), "%"]
21456
+ }),
21457
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModalIconButton, {
21458
+ label: "Zoom in",
21459
+ disabled: scale >= ZOOM_MAX,
21460
+ onClick: zoomIn,
21461
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoomIn, { className: "h-4 w-4" })
21462
+ }),
21463
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModalIconButton, {
21464
+ label: "Reset zoom",
21465
+ disabled: scale === 1,
21466
+ onClick: reset,
21467
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RotateCcw, { className: "h-4 w-4" })
21468
+ }),
21469
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModalIconButton, {
21470
+ label: "Close",
21471
+ onClick: () => onOpenChange(false),
21472
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(X, { className: "h-4 w-4" })
21473
+ })
21474
+ ]
21475
+ })]
21476
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
21477
+ className: "flex max-h-[75vh] items-center justify-center overflow-auto rounded border border-border bg-gradient-to-b from-surface-hover to-surface p-6",
21478
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
21479
+ src: url,
21480
+ alt,
21481
+ style: {
21482
+ transform: `scale(${scale})`,
21483
+ transformOrigin: "center"
21484
+ },
21485
+ className: "max-h-[65vh] w-auto object-contain transition-transform duration-150"
21486
+ })
21487
+ })]
21488
+ })
21489
+ });
21490
+ }
21491
+ function ModalIconButton({ label, disabled, onClick, children }) {
21492
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
21493
+ type: "button",
21494
+ "aria-label": label,
21495
+ title: label,
21496
+ disabled,
21497
+ onClick,
21498
+ className: "inline-flex h-7 w-7 items-center justify-center rounded text-foreground-subtle hover:text-foreground hover:bg-surface-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed",
21499
+ children
21500
+ });
21501
+ }
21408
21502
  /**
21409
21503
  * ImageInlineControl — compact thumbnail for a device-list row that expands
21410
21504
  * into a floating preview on hover/focus (shared `HoverZoomImage`). Shows a
@@ -21415,13 +21509,36 @@ function useImage(trpc, deviceId) {
21415
21509
  */
21416
21510
  function ImageInlineControl({ trpc, deviceId }) {
21417
21511
  const { slice } = useImage(trpc, deviceId);
21512
+ const [open, setOpen] = (0, react$1.useState)(false);
21418
21513
  if (slice === void 0) return null;
21419
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HoverZoomImage, {
21420
- src: slice.url,
21421
- alt: "Image",
21422
- placeholder: "No image",
21423
- thumbClassName: "h-7 w-7 rounded"
21514
+ const url = slice.url;
21515
+ if (url === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
21516
+ className: "inline-flex h-7 w-7 items-center justify-center rounded bg-surface-hover text-foreground-subtle",
21517
+ title: "No image",
21518
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ImageOff, {
21519
+ className: "h-4 w-4",
21520
+ "aria-hidden": "true"
21521
+ })
21424
21522
  });
21523
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
21524
+ type: "button",
21525
+ title: "View image",
21526
+ onClick: (e) => {
21527
+ e.stopPropagation();
21528
+ setOpen(true);
21529
+ },
21530
+ className: "inline-flex items-center justify-center rounded ring-offset-1 hover:ring-2 hover:ring-primary/50 transition",
21531
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
21532
+ src: url,
21533
+ alt: "Image",
21534
+ className: "h-7 w-7 rounded object-cover"
21535
+ })
21536
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ImageDetailModal, {
21537
+ url,
21538
+ alt: "Image",
21539
+ open,
21540
+ onOpenChange: setOpen
21541
+ })] });
21425
21542
  }
21426
21543
  /**
21427
21544
  * ImageHeroCard — read-only framed image for the device-detail hero.
@@ -21434,19 +21551,27 @@ function ImageInlineControl({ trpc, deviceId }) {
21434
21551
  function ImageHeroCard({ trpc, deviceId, deviceName }) {
21435
21552
  const { slice } = useImage(trpc, deviceId);
21436
21553
  const [errored, setErrored] = (0, react$1.useState)(false);
21554
+ const [open, setOpen] = (0, react$1.useState)(false);
21555
+ const url = slice?.url ?? null;
21437
21556
  (0, react$1.useEffect)(() => {
21438
21557
  setErrored(false);
21439
- }, [slice?.url ?? null]);
21558
+ }, [url]);
21440
21559
  if (slice === void 0) return null;
21441
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
21560
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
21442
21561
  className: "flex flex-col items-stretch gap-3 rounded-lg border border-border bg-surface p-4",
21443
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
21562
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
21444
21563
  className: "flex w-full items-center justify-center overflow-hidden rounded border-2 border-border bg-gradient-to-b from-surface-hover to-surface",
21445
- children: slice.url !== null && !errored ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
21446
- src: slice.url,
21447
- alt: deviceName ?? "Image",
21448
- onError: () => setErrored(true),
21449
- className: "max-h-72 w-full object-contain"
21564
+ children: slice.url !== null && !errored ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
21565
+ type: "button",
21566
+ title: "View image",
21567
+ onClick: () => setOpen(true),
21568
+ className: "flex w-full cursor-zoom-in items-center justify-center",
21569
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
21570
+ src: slice.url,
21571
+ alt: deviceName ?? "Image",
21572
+ onError: () => setErrored(true),
21573
+ className: "max-h-72 w-full object-contain"
21574
+ })
21450
21575
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
21451
21576
  "data-testid": "image-placeholder",
21452
21577
  className: "flex h-48 w-full flex-col items-center justify-center gap-2 text-foreground-subtle",
@@ -21458,7 +21583,12 @@ function ImageHeroCard({ trpc, deviceId, deviceName }) {
21458
21583
  children: "No image"
21459
21584
  })]
21460
21585
  })
21461
- })
21586
+ }), url !== null && !errored && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ImageDetailModal, {
21587
+ url,
21588
+ alt: deviceName ?? "Image",
21589
+ open,
21590
+ onOpenChange: setOpen
21591
+ })]
21462
21592
  });
21463
21593
  }
21464
21594
  //#endregion
@@ -25017,6 +25147,103 @@ function BatteryBadge({ status, variant = "compact", className }) {
25017
25147
  });
25018
25148
  }
25019
25149
  //#endregion
25150
+ //#region src/composites/hover-zoom-image.tsx
25151
+ /**
25152
+ * <HoverZoomImage> — a small thumbnail that expands into a floating
25153
+ * enlarged preview on hover/focus.
25154
+ *
25155
+ * One responsibility: render a compact thumbnail (image when `src` is
25156
+ * present, a muted `ImageOff` placeholder box when null) and, while the
25157
+ * thumbnail is hovered or focused, portal a larger preview to
25158
+ * `document.body` positioned with FLIP-ON-COLLISION:
25159
+ * - the position is computed from the thumbnail's `getBoundingClientRect`;
25160
+ * - placed ABOVE the thumbnail unless there is insufficient space (then
25161
+ * below); left-clamped to the viewport;
25162
+ * - `position:fixed`, `pointer-events:none`, `z-[9999]`.
25163
+ *
25164
+ * The portal is what lets this work inside the device grid, whose overflow
25165
+ * ancestors would otherwise clip a normally-positioned popover.
25166
+ *
25167
+ * Reused by BOTH the Image DeviceType inline control (signed image url) and
25168
+ * the camera-snapshot cell in device rows (snapshot blob/object url).
25169
+ */
25170
+ var DEFAULT_EXPANDED_WIDTH = 288;
25171
+ var DEFAULT_EXPANDED_HEIGHT = 196;
25172
+ var DEFAULT_THUMB_CLASS = "h-7 w-7 rounded";
25173
+ var TRIGGER_GAP = 8;
25174
+ var VIEWPORT_MARGIN = 8;
25175
+ function HoverZoomImage({ src, alt, caption, placeholder = "No image", thumbClassName = DEFAULT_THUMB_CLASS, expandedWidth = DEFAULT_EXPANDED_WIDTH, expandedHeight = DEFAULT_EXPANDED_HEIGHT }) {
25176
+ const triggerRef = (0, react$1.useRef)(null);
25177
+ const [position, setPosition] = (0, react$1.useState)(null);
25178
+ const [errored, setErrored] = (0, react$1.useState)(false);
25179
+ (0, react$1.useEffect)(() => {
25180
+ setErrored(false);
25181
+ }, [src]);
25182
+ const hasImage = src !== null && !errored;
25183
+ const safeSrc = src ?? void 0;
25184
+ const open = (0, react$1.useCallback)(() => {
25185
+ const el = triggerRef.current;
25186
+ if (!el) return;
25187
+ const rect = el.getBoundingClientRect();
25188
+ const spaceAbove = rect.top;
25189
+ const spaceBelow = window.innerHeight - rect.bottom;
25190
+ const top = spaceAbove < expandedHeight + TRIGGER_GAP && spaceBelow > spaceAbove ? rect.bottom + TRIGGER_GAP : rect.top - expandedHeight - TRIGGER_GAP;
25191
+ const desiredLeft = rect.right - expandedWidth;
25192
+ setPosition({
25193
+ top,
25194
+ left: Math.max(VIEWPORT_MARGIN, Math.min(desiredLeft, window.innerWidth - expandedWidth - VIEWPORT_MARGIN))
25195
+ });
25196
+ }, [expandedWidth, expandedHeight]);
25197
+ const close = (0, react$1.useCallback)(() => setPosition(null), []);
25198
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
25199
+ ref: triggerRef,
25200
+ type: "button",
25201
+ onClick: (e) => e.stopPropagation(),
25202
+ onMouseEnter: open,
25203
+ onMouseLeave: close,
25204
+ onFocus: open,
25205
+ onBlur: close,
25206
+ "aria-label": alt,
25207
+ className: `flex flex-shrink-0 items-center justify-center overflow-hidden border border-border bg-background text-foreground-subtle transition-colors hover:bg-surface-hover ${thumbClassName}`,
25208
+ children: hasImage ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
25209
+ src: safeSrc,
25210
+ alt: "",
25211
+ onError: () => setErrored(true),
25212
+ className: "h-full w-full object-cover"
25213
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ImageOff, {
25214
+ className: "h-3.5 w-3.5",
25215
+ "aria-hidden": "true"
25216
+ })
25217
+ }), position && (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
25218
+ "aria-hidden": "true",
25219
+ style: {
25220
+ position: "fixed",
25221
+ top: position.top,
25222
+ left: position.left,
25223
+ width: expandedWidth,
25224
+ pointerEvents: "none"
25225
+ },
25226
+ className: "z-[9999] overflow-hidden rounded-lg border border-border bg-surface shadow-lg shadow-black/30",
25227
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
25228
+ className: "flex h-40 items-center justify-center bg-background",
25229
+ children: hasImage ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
25230
+ src: safeSrc,
25231
+ alt,
25232
+ className: "h-full w-full object-cover"
25233
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
25234
+ className: "text-xs text-foreground-subtle",
25235
+ children: placeholder
25236
+ })
25237
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
25238
+ className: "px-3 py-2",
25239
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
25240
+ className: "truncate text-xs font-semibold text-foreground",
25241
+ children: caption ?? alt
25242
+ })
25243
+ })]
25244
+ }), document.body)] });
25245
+ }
25246
+ //#endregion
25020
25247
  //#region src/composites/device-item/preview.tsx
25021
25248
  function DeviceItemPreview({ trpc, device, status, enabled, showStatusPills = false }) {
25022
25249
  const snapshot = useDeviceSnapshotImage(trpc, enabled && device.online ? device.id : null);
@@ -42847,6 +43074,7 @@ exports.useDeviceManagerAdoptionAdopt = useDeviceManagerAdoptionAdopt;
42847
43074
  exports.useDeviceManagerAdoptionListCandidates = useDeviceManagerAdoptionListCandidates;
42848
43075
  exports.useDeviceManagerAdoptionRefresh = useDeviceManagerAdoptionRefresh;
42849
43076
  exports.useDeviceManagerAdoptionRelease = useDeviceManagerAdoptionRelease;
43077
+ exports.useDeviceManagerAdoptionResync = useDeviceManagerAdoptionResync;
42850
43078
  exports.useDeviceManagerAllocateDeviceId = useDeviceManagerAllocateDeviceId;
42851
43079
  exports.useDeviceManagerApplyInitialMeta = useDeviceManagerApplyInitialMeta;
42852
43080
  exports.useDeviceManagerCreateDevice = useDeviceManagerCreateDevice;